Skip to content

Commit 1fe29d9

Browse files
authored
UFM: JavaScript-like Expressions (#19685)
* Adds `markedExtension` extension-type * Relocates the Component and Filter extension-type interface code files to under "extensions" * Moves `UfmPlugin` type to its own referencable file * Adds UFM support for JS expressions making use of "@heximal/expressions" library. * Modified regex pattern to match nested braces * try/catch for invalid JS expressions * Capitalizing the JS in `UmbUfmJsMarkedExtensionApi` class name for consistency and improved readability. * Abstracted out `ufmjs()` to its own Marked extension file making it simpler to add unit-tests. * Fixed up types in UFM context added JSDocs for public methods * Adds a generic Least Recently Used (LRU) cache implementation
1 parent 91e19c4 commit 1fe29d9

File tree

15 files changed

+217
-9
lines changed

15 files changed

+217
-9
lines changed

src/Umbraco.Web.UI.Client/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Umbraco.Web.UI.Client/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"./block-rte": "./dist-cms/packages/block/block-rte/index.js",
2626
"./block-type": "./dist-cms/packages/block/block-type/index.js",
2727
"./block": "./dist-cms/packages/block/block/index.js",
28+
"./cache": "./dist-cms/packages/core/cache/index.js",
2829
"./clipboard": "./dist-cms/packages/clipboard/index.js",
2930
"./code-editor": "./dist-cms/packages/code-editor/index.js",
3031
"./collection": "./dist-cms/packages/core/collection/index.js",
@@ -119,6 +120,7 @@
119120
"./workspace": "./dist-cms/packages/core/workspace/index.js",
120121
"./external/backend-api": "./dist-cms/packages/core/backend-api/index.js",
121122
"./external/dompurify": "./dist-cms/external/dompurify/index.js",
123+
"./external/heximal-expressions": "./dist-cms/external/heximal-expressions/index.js",
122124
"./external/lit": "./dist-cms/external/lit/index.js",
123125
"./external/marked": "./dist-cms/external/marked/index.js",
124126
"./external/monaco-editor": "./dist-cms/external/monaco-editor/index.js",
@@ -200,6 +202,7 @@
200202
"npm": ">=10.9"
201203
},
202204
"dependencies": {
205+
"@heximal/expressions": "^0.1.5",
203206
"@tiptap/core": "2.11.7",
204207
"@tiptap/extension-character-count": "2.11.7",
205208
"@tiptap/extension-image": "2.11.7",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from '@heximal/expressions';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './lru-cache.js';
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* @description
3+
* This class provides a Least Recently Used (LRU) cache implementation.
4+
* It is designed to store key-value pairs and automatically remove the least recently used items when the cache exceeds a maximum size.
5+
*/
6+
export class UmbLruCache<K, V> {
7+
#cache = new Map<K, V>();
8+
9+
#maxSize: number;
10+
11+
constructor(maxSize: number) {
12+
this.#maxSize = maxSize;
13+
}
14+
15+
get(key: K): V | undefined {
16+
if (!this.#cache.has(key)) return undefined;
17+
const value = this.#cache.get(key)!;
18+
this.#cache.delete(key);
19+
this.#cache.set(key, value);
20+
return value;
21+
}
22+
23+
set(key: K, value: V): void {
24+
if (this.#cache.has(key)) {
25+
this.#cache.delete(key);
26+
} else if (this.#cache.size >= this.#maxSize) {
27+
const oldestKey = this.#cache.keys().next().value;
28+
if (oldestKey) {
29+
this.#cache.delete(oldestKey);
30+
}
31+
}
32+
this.#cache.set(key, value);
33+
}
34+
35+
has(key: K): boolean {
36+
return this.#cache.has(key);
37+
}
38+
}
39+
40+
export default UmbLruCache;

src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default defineConfig({
1515
'audit-log/index': './audit-log/index.ts',
1616
'auth/index': './auth/index.ts',
1717
'backend-api/index': './backend-api/index.ts',
18+
'cache/index': './cache/index.ts',
1819
'collection/index': './collection/index.ts',
1920
'components/index': './components/index.ts',
2021
'const/index': './const/index.ts',
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './ufm-render/index.js';
22
export * from './ufm-component-base.js';
33
export * from './ufm-element-base.js';
4+
export * from './ufm-js-expression.element.js';
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { UMB_UFM_CONTEXT } from '../contexts/ufm.context.js';
2+
import { UMB_UFM_RENDER_CONTEXT } from './ufm-render/ufm-render.context.js';
3+
import { customElement, state } from '@umbraco-cms/backoffice/external/lit';
4+
import { EvalAstFactory, Parser } from '@umbraco-cms/backoffice/external/heximal-expressions';
5+
import { UmbLruCache } from '@umbraco-cms/backoffice/cache';
6+
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
7+
import type { Expression, Scope } from '@umbraco-cms/backoffice/external/heximal-expressions';
8+
9+
const astFactory = new EvalAstFactory();
10+
const expressionCache = new UmbLruCache<string, Expression | undefined>(1000);
11+
12+
@customElement('umb-ufm-js-expression')
13+
export class UmbUfmJsExpressionElement extends UmbLitElement {
14+
#ufmContext?: typeof UMB_UFM_CONTEXT.TYPE;
15+
16+
@state()
17+
value?: unknown;
18+
19+
constructor() {
20+
super();
21+
22+
this.consumeContext(UMB_UFM_CONTEXT, (ufmContext) => {
23+
this.#ufmContext = ufmContext;
24+
});
25+
26+
this.consumeContext(UMB_UFM_RENDER_CONTEXT, (context) => {
27+
this.observe(
28+
context?.value,
29+
(value) => {
30+
this.value = this.#labelTemplate(this.textContent ?? '', value);
31+
},
32+
'observeValue',
33+
);
34+
});
35+
}
36+
37+
#labelTemplate(expression: string, model?: any): string {
38+
const filters = this.#ufmContext?.getFilters() ?? [];
39+
const functions = Object.fromEntries(filters.map((x) => [x.alias, x.filter]));
40+
const scope: Scope = { ...model, ...functions };
41+
42+
let ast = expressionCache.get(expression);
43+
44+
if (ast === undefined && !expressionCache.has(expression)) {
45+
try {
46+
ast = new Parser(expression, astFactory).parse();
47+
} catch {
48+
console.error(`Error parsing expression: \`${expression}\``);
49+
}
50+
expressionCache.set(expression, ast);
51+
}
52+
53+
return ast?.evaluate(scope) ?? '';
54+
}
55+
56+
override render() {
57+
return (Array.isArray(this.value) ? this.value : [this.value]).join(', ');
58+
}
59+
}
60+
61+
export default UmbUfmJsExpressionElement;
62+
63+
declare global {
64+
interface HTMLElementTagNameMap {
65+
'umb-ufm-js-expression': UmbUfmJsExpressionElement;
66+
}
67+
}

src/Umbraco.Web.UI.Client/src/packages/ufm/contexts/ufm.context.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ export const UmbMarked = new Marked({
3636
},
3737
});
3838

39-
type UmbUfmFilterType = {
39+
export type UmbUfmFilterFunction = ((...args: Array<unknown>) => string | undefined | null) | undefined;
40+
41+
export type UmbUfmFilterType = {
4042
alias: string;
41-
filter: ((...args: Array<unknown>) => string | undefined | null) | undefined;
43+
filter: UmbUfmFilterFunction;
4244
};
4345

4446
export class UmbUfmContext extends UmbContextBase {
@@ -63,11 +65,30 @@ export class UmbUfmContext extends UmbContextBase {
6365
});
6466
}
6567

66-
public getFilterByAlias(alias: string) {
68+
/**
69+
* Get the filters registered in the UFM context.
70+
* @returns {Array<UmbUfmFilterType>} An array of filters with their aliases and filter functions.
71+
*/
72+
public getFilters(): Array<UmbUfmFilterType> {
73+
return this.#filters.getValue();
74+
}
75+
76+
/**
77+
* Get a filter by its alias.
78+
* @param alias The alias of the filter to retrieve.
79+
* @returns {UmbUfmFilterFunction} The filter function associated with the alias, or undefined if not found.
80+
*/
81+
public getFilterByAlias(alias: string): UmbUfmFilterFunction {
6782
return this.#filters.getValue().find((x) => x.alias === alias)?.filter;
6883
}
6984

70-
public async parse(markdown: string, inline: boolean) {
85+
/**
86+
* Parse markdown content, optionally inline.
87+
* @param markdown The markdown string to parse.
88+
* @param inline If true, parse inline markdown; otherwise, parse block markdown.
89+
* @returns {Promise<string>} A promise that resolves to the parsed HTML string.
90+
*/
91+
public async parse(markdown: string, inline: boolean): Promise<string> {
7192
return !inline ? await UmbMarked.parse(markdown) : await UmbMarked.parseInline(markdown);
7293
}
7394
}

src/Umbraco.Web.UI.Client/src/packages/ufm/extensions/manifests.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,13 @@ export const manifests: Array<ManifestMarkedExtension> = [
1010
alias: 'ufm',
1111
},
1212
},
13+
{
14+
type: 'markedExtension',
15+
alias: 'Umb.MarkedExtension.Ufmjs',
16+
name: 'UFM JS Marked Extension',
17+
api: () => import('./ufmjs-marked-extension.api.js'),
18+
meta: {
19+
alias: 'ufmjs',
20+
},
21+
},
1322
];

0 commit comments

Comments
 (0)