diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index 2e995a8922bc..c3aaac1397cc 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -12,6 +12,7 @@ "./src/packages/*" ], "dependencies": { + "@heximal/expressions": "^0.1.5", "@tiptap/core": "2.11.7", "@tiptap/extension-character-count": "2.11.7", "@tiptap/extension-image": "2.11.7", @@ -1024,6 +1025,15 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@heximal/expressions": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@heximal/expressions/-/expressions-0.1.5.tgz", + "integrity": "sha512-QdWz9vNrdzi24so9KGEM9w4UYLg1yk+LVvYBEDbw9EY1BzKHITWdtYc55xJ3Zuio0/9Naz/D1YtYlCnfsycNDQ==", + "license": "BSD 3-Clause", + "dependencies": { + "tslib": "^2.7.0" + } + }, "node_modules/@hey-api/json-schema-ref-parser": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.6.tgz", diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 3de46d0ca5e8..559a9565a18e 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -25,6 +25,7 @@ "./block-rte": "./dist-cms/packages/block/block-rte/index.js", "./block-type": "./dist-cms/packages/block/block-type/index.js", "./block": "./dist-cms/packages/block/block/index.js", + "./cache": "./dist-cms/packages/core/cache/index.js", "./clipboard": "./dist-cms/packages/clipboard/index.js", "./code-editor": "./dist-cms/packages/code-editor/index.js", "./collection": "./dist-cms/packages/core/collection/index.js", @@ -119,6 +120,7 @@ "./workspace": "./dist-cms/packages/core/workspace/index.js", "./external/backend-api": "./dist-cms/packages/core/backend-api/index.js", "./external/dompurify": "./dist-cms/external/dompurify/index.js", + "./external/heximal-expressions": "./dist-cms/external/heximal-expressions/index.js", "./external/lit": "./dist-cms/external/lit/index.js", "./external/marked": "./dist-cms/external/marked/index.js", "./external/monaco-editor": "./dist-cms/external/monaco-editor/index.js", @@ -200,6 +202,7 @@ "npm": ">=10.9" }, "dependencies": { + "@heximal/expressions": "^0.1.5", "@tiptap/core": "2.11.7", "@tiptap/extension-character-count": "2.11.7", "@tiptap/extension-image": "2.11.7", diff --git a/src/Umbraco.Web.UI.Client/src/external/heximal-expressions/index.ts b/src/Umbraco.Web.UI.Client/src/external/heximal-expressions/index.ts new file mode 100644 index 000000000000..ea9e367edb2a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/heximal-expressions/index.ts @@ -0,0 +1 @@ +export * from '@heximal/expressions'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/cache/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/cache/index.ts new file mode 100644 index 000000000000..0bd580e3d3bc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/cache/index.ts @@ -0,0 +1 @@ +export * from './lru-cache.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/cache/lru-cache.ts b/src/Umbraco.Web.UI.Client/src/packages/core/cache/lru-cache.ts new file mode 100644 index 000000000000..80d421cd707d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/cache/lru-cache.ts @@ -0,0 +1,40 @@ +/** + * @description + * This class provides a Least Recently Used (LRU) cache implementation. + * It is designed to store key-value pairs and automatically remove the least recently used items when the cache exceeds a maximum size. + */ +export class UmbLruCache { + #cache = new Map(); + + #maxSize: number; + + constructor(maxSize: number) { + this.#maxSize = maxSize; + } + + get(key: K): V | undefined { + if (!this.#cache.has(key)) return undefined; + const value = this.#cache.get(key)!; + this.#cache.delete(key); + this.#cache.set(key, value); + return value; + } + + set(key: K, value: V): void { + if (this.#cache.has(key)) { + this.#cache.delete(key); + } else if (this.#cache.size >= this.#maxSize) { + const oldestKey = this.#cache.keys().next().value; + if (oldestKey) { + this.#cache.delete(oldestKey); + } + } + this.#cache.set(key, value); + } + + has(key: K): boolean { + return this.#cache.has(key); + } +} + +export default UmbLruCache; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts index 643d45cda574..12bc4ff59ec0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ 'audit-log/index': './audit-log/index.ts', 'auth/index': './auth/index.ts', 'backend-api/index': './backend-api/index.ts', + 'cache/index': './cache/index.ts', 'collection/index': './collection/index.ts', 'components/index': './components/index.ts', 'const/index': './const/index.ts', diff --git a/src/Umbraco.Web.UI.Client/src/packages/ufm/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/ufm/components/index.ts index 7f1f30e7097e..93b54c533586 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/ufm/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/ufm/components/index.ts @@ -1,3 +1,4 @@ export * from './ufm-render/index.js'; export * from './ufm-component-base.js'; export * from './ufm-element-base.js'; +export * from './ufm-js-expression.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/ufm/components/ufm-js-expression.element.ts b/src/Umbraco.Web.UI.Client/src/packages/ufm/components/ufm-js-expression.element.ts new file mode 100644 index 000000000000..e27a8a54f94a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/ufm/components/ufm-js-expression.element.ts @@ -0,0 +1,67 @@ +import { UMB_UFM_CONTEXT } from '../contexts/ufm.context.js'; +import { UMB_UFM_RENDER_CONTEXT } from './ufm-render/ufm-render.context.js'; +import { customElement, state } from '@umbraco-cms/backoffice/external/lit'; +import { EvalAstFactory, Parser } from '@umbraco-cms/backoffice/external/heximal-expressions'; +import { UmbLruCache } from '@umbraco-cms/backoffice/cache'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { Expression, Scope } from '@umbraco-cms/backoffice/external/heximal-expressions'; + +const astFactory = new EvalAstFactory(); +const expressionCache = new UmbLruCache(1000); + +@customElement('umb-ufm-js-expression') +export class UmbUfmJsExpressionElement extends UmbLitElement { + #ufmContext?: typeof UMB_UFM_CONTEXT.TYPE; + + @state() + value?: unknown; + + constructor() { + super(); + + this.consumeContext(UMB_UFM_CONTEXT, (ufmContext) => { + this.#ufmContext = ufmContext; + }); + + this.consumeContext(UMB_UFM_RENDER_CONTEXT, (context) => { + this.observe( + context?.value, + (value) => { + this.value = this.#labelTemplate(this.textContent ?? '', value); + }, + 'observeValue', + ); + }); + } + + #labelTemplate(expression: string, model?: any): string { + const filters = this.#ufmContext?.getFilters() ?? []; + const functions = Object.fromEntries(filters.map((x) => [x.alias, x.filter])); + const scope: Scope = { ...model, ...functions }; + + let ast = expressionCache.get(expression); + + if (ast === undefined && !expressionCache.has(expression)) { + try { + ast = new Parser(expression, astFactory).parse(); + } catch { + console.error(`Error parsing expression: \`${expression}\``); + } + expressionCache.set(expression, ast); + } + + return ast?.evaluate(scope) ?? ''; + } + + override render() { + return (Array.isArray(this.value) ? this.value : [this.value]).join(', '); + } +} + +export default UmbUfmJsExpressionElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-ufm-js-expression': UmbUfmJsExpressionElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/ufm/contexts/ufm.context.ts b/src/Umbraco.Web.UI.Client/src/packages/ufm/contexts/ufm.context.ts index 8c8d3971f51e..4f21c0ffd686 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/ufm/contexts/ufm.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/ufm/contexts/ufm.context.ts @@ -36,9 +36,11 @@ export const UmbMarked = new Marked({ }, }); -type UmbUfmFilterType = { +export type UmbUfmFilterFunction = ((...args: Array) => string | undefined | null) | undefined; + +export type UmbUfmFilterType = { alias: string; - filter: ((...args: Array) => string | undefined | null) | undefined; + filter: UmbUfmFilterFunction; }; export class UmbUfmContext extends UmbContextBase { @@ -63,11 +65,30 @@ export class UmbUfmContext extends UmbContextBase { }); } - public getFilterByAlias(alias: string) { + /** + * Get the filters registered in the UFM context. + * @returns {Array} An array of filters with their aliases and filter functions. + */ + public getFilters(): Array { + return this.#filters.getValue(); + } + + /** + * Get a filter by its alias. + * @param alias The alias of the filter to retrieve. + * @returns {UmbUfmFilterFunction} The filter function associated with the alias, or undefined if not found. + */ + public getFilterByAlias(alias: string): UmbUfmFilterFunction { return this.#filters.getValue().find((x) => x.alias === alias)?.filter; } - public async parse(markdown: string, inline: boolean) { + /** + * Parse markdown content, optionally inline. + * @param markdown The markdown string to parse. + * @param inline If true, parse inline markdown; otherwise, parse block markdown. + * @returns {Promise} A promise that resolves to the parsed HTML string. + */ + public async parse(markdown: string, inline: boolean): Promise { return !inline ? await UmbMarked.parse(markdown) : await UmbMarked.parseInline(markdown); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/ufm/extensions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/ufm/extensions/manifests.ts index 04000dac8162..121ac2d69682 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/ufm/extensions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/ufm/extensions/manifests.ts @@ -10,4 +10,13 @@ export const manifests: Array = [ alias: 'ufm', }, }, + { + type: 'markedExtension', + alias: 'Umb.MarkedExtension.Ufmjs', + name: 'UFM JS Marked Extension', + api: () => import('./ufmjs-marked-extension.api.js'), + meta: { + alias: 'ufmjs', + }, + }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/ufm/extensions/types.ts b/src/Umbraco.Web.UI.Client/src/packages/ufm/extensions/types.ts index 581fc715b591..6a3f542c72b9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/ufm/extensions/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/ufm/extensions/types.ts @@ -1,5 +1,6 @@ -export type * from './marked-extension.extension.js'; -export type * from './ufm-filter.extension.js'; -export type * from './ufm-component.extension.js'; - -export type { UmbUfmMarkedExtensionApi } from './ufm-marked-extension.api.js'; +export type * from './marked-extension.extension.js'; +export type * from './ufm-filter.extension.js'; +export type * from './ufm-component.extension.js'; + +export type { UmbUfmMarkedExtensionApi } from './ufm-marked-extension.api.js'; +export type { UmbUfmJsMarkedExtensionApi } from './ufmjs-marked-extension.api.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/ufm/extensions/ufmjs-marked-extension.api.ts b/src/Umbraco.Web.UI.Client/src/packages/ufm/extensions/ufmjs-marked-extension.api.ts new file mode 100644 index 000000000000..92a3a8dbb1eb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/ufm/extensions/ufmjs-marked-extension.api.ts @@ -0,0 +1,14 @@ +import { ufmjs } from '../plugins/marked-ufmjs.plugin.js'; +import type { UmbMarkedExtensionApi } from './marked-extension.extension.js'; +import type { Marked } from '@umbraco-cms/backoffice/external/marked'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbUfmJsMarkedExtensionApi implements UmbMarkedExtensionApi { + constructor(_host: UmbControllerHost, marked: Marked) { + marked.use(ufmjs()); + } + + destroy() {} +} + +export default UmbUfmJsMarkedExtensionApi; diff --git a/src/Umbraco.Web.UI.Client/src/packages/ufm/plugins/index.ts b/src/Umbraco.Web.UI.Client/src/packages/ufm/plugins/index.ts index da53f2d205f8..542bb8b663c2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/ufm/plugins/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/ufm/plugins/index.ts @@ -1 +1,2 @@ export { ufm } from './marked-ufm.plugin.js'; +export { ufmjs } from './marked-ufmjs.plugin.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/ufm/plugins/marked-ufmjs.plugin.ts b/src/Umbraco.Web.UI.Client/src/packages/ufm/plugins/marked-ufmjs.plugin.ts new file mode 100644 index 000000000000..38e2cd45450b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/ufm/plugins/marked-ufmjs.plugin.ts @@ -0,0 +1,36 @@ +import type { MarkedExtension, Tokens } from '@umbraco-cms/backoffice/external/marked'; + +/** + * @returns {MarkedExtension} A Marked extension object. + */ +export function ufmjs(): MarkedExtension { + return { + extensions: [ + { + name: 'ufmjs', + level: 'inline', + start: (src: string) => src.search(/(? { + const pattern = /^\$\{((?:[^{}]|\{[^{}]*\})*)\}/; + const regex = new RegExp(pattern); + const match = src.match(regex); + + if (match) { + const [raw, text] = match; + return { + type: 'ufmjs', + raw: raw, + tokens: [], + text: text.trim(), + }; + } + + return undefined; + }, + renderer: (token: Tokens.Generic) => { + return `${token.text}`; + }, + }, + ], + }; +} diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json index 9f8747da52bc..4ac41978adae 100644 --- a/src/Umbraco.Web.UI.Client/tsconfig.json +++ b/src/Umbraco.Web.UI.Client/tsconfig.json @@ -52,6 +52,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/block-rte": ["./src/packages/block/block-rte/index.ts"], "@umbraco-cms/backoffice/block-type": ["./src/packages/block/block-type/index.ts"], "@umbraco-cms/backoffice/block": ["./src/packages/block/block/index.ts"], + "@umbraco-cms/backoffice/cache": ["./src/packages/core/cache/index.ts"], "@umbraco-cms/backoffice/clipboard": ["./src/packages/clipboard/index.ts"], "@umbraco-cms/backoffice/code-editor": ["./src/packages/code-editor/index.ts"], "@umbraco-cms/backoffice/collection": ["./src/packages/core/collection/index.ts"], @@ -148,6 +149,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/workspace": ["./src/packages/core/workspace/index.ts"], "@umbraco-cms/backoffice/external/backend-api": ["./src/packages/core/backend-api/index.ts"], "@umbraco-cms/backoffice/external/dompurify": ["./src/external/dompurify/index.ts"], + "@umbraco-cms/backoffice/external/heximal-expressions": ["./src/external/heximal-expressions/index.ts"], "@umbraco-cms/backoffice/external/lit": ["./src/external/lit/index.ts"], "@umbraco-cms/backoffice/external/marked": ["./src/external/marked/index.ts"], "@umbraco-cms/backoffice/external/monaco-editor": ["./src/external/monaco-editor/index.ts"],