diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
index 32c7c6b97..f89d23f31 100644
--- a/RELEASE-NOTES.md
+++ b/RELEASE-NOTES.md
@@ -4,6 +4,12 @@ This is a pre-release version and APIs will change quickly. Before `1.0` release
Please note after `1.0` Semver will be followed using normal protocols.
+
+# Version 0.4.0
+
+### Improvements
+* Templates now will evaluate javascript expressions like `{index + 1}`, {getThing(foo, bar)} and even nested objects like `{getValue { foo: 'baz'} }`
+
# Version 0.3.1
### Bugs
diff --git a/docs/src/components/Test/component.css b/docs/src/components/Test/component.css
index e69de29bb..7817a61ee 100644
--- a/docs/src/components/Test/component.css
+++ b/docs/src/components/Test/component.css
@@ -0,0 +1,10 @@
+table {
+ border-collapse: collapse;
+ th,
+ td {
+ border: var(--solid-border);
+ padding: 5px 10px;
+ text-align: left;
+ max-width: max-content;
+ }
+}
diff --git a/docs/src/components/Test/component.html b/docs/src/components/Test/component.html
index b30b5ff66..00306288f 100644
--- a/docs/src/components/Test/component.html
+++ b/docs/src/components/Test/component.html
@@ -1 +1,99 @@
-{firstSetting} {secondSetting}
+
Data Context
+
+
+ | Name |
+ Value |
+
+
+
+ | value |
+ {value} |
+
+
+ | now |
+ {now} |
+
+
+ | timezone |
+ {timezone} |
+
+
+
+
+Expressions
+
+
+ | Example |
+ Expression |
+ Result |
+ Should Match |
+
+
+
+ | Calling method in data context with object using spaced arguments |
+ {#html "formatDate now 'h:mm:ss a' { timezone: timezone }"} |
+ { formatDate now 'h:mm:ss a' { timezone: timezone } } |
+ Current time PST |
+
+
+ | Calling helper method in data context with js arguments |
+ {#html "formatDate(now, 'h:mm:ss a', { timezone: timezone })"} |
+ { formatDate(now, 'h:mm:ss a', { timezone: timezone }) } |
+ Current time PST |
+
+
+ | Calling helper method with inline object definition |
+ {#html 'classMap { one: true, two: true, three: now }' } |
+ { classMap { one: true, two: true, three: now } } |
+ one two three |
+
+
+ | Calling method with inline array definition |
+ join ['1', '2', '3'] ' and ' |
+ { join ['1', '2', '3'] ' and '} |
+ 1 and 2 and 3 |
+
+
+ | Outputting value from data context |
+ value |
+ {value} |
+ 0 |
+
+
+ | Inline addition |
+ value + 1 |
+ {value + 1} |
+ 2 |
+
+
+ | Inline arithmetic |
+ value + 2 * 5 |
+ {value + 2 * 5} |
+ 11 |
+
+
+ | Inline arithmetic with order of operations |
+ (value + 2) * 5 |
+ {(value + 2) * 5} |
+ 15 |
+
+
+ | Calling a method with spaced arguments |
+ addOne value |
+ {addOne value} |
+ 2 |
+
+
+ | Calling a method with js arguments and inline addition |
+ addOne(value + 1) |
+ {addOne(value + 1)} |
+ 3 |
+
+
+ | Calling a method with inline object |
+ getValue {one: 'two'} 'one' |
+ {getValue {one: 'two'} 'one'} |
+ two |
+
+
+
diff --git a/docs/src/components/Test/component.js b/docs/src/components/Test/component.js
index d86663be1..2d4cba197 100644
--- a/docs/src/components/Test/component.js
+++ b/docs/src/components/Test/component.js
@@ -1,24 +1,25 @@
-import { defineComponent } from '@semantic-ui/component';
-
-import css from './component.css?raw';
-import template from './component.html?raw';
+import { defineComponent, getText } from '@semantic-ui/component';
+const css = await getText('./component.css');
+const template = await getText('./component.html');
const settings = {
- firstSetting: true,
- secondSetting: false,
+ value: 1,
+ now: new Date(),
+ timezone: 'PST'
};
const createComponent = ({self, data, settings}) => ({
- initialize() {
- console.log('data is', data);
- console.log('first', settings.firstSetting);
- console.log('second', settings.secondSetting);
+ addOne(value, value2 = 0) {
+ return value + value2 + 1;
+ },
+ getValue(obj, prop) {
+ return obj[prop];
}
});
-export const SettingTest = defineComponent({
- tagName: 'setting-test',
+export const TestComponent = defineComponent({
+ tagName: 'test-component',
template,
css,
settings,
diff --git a/docs/src/components/Test/index.js b/docs/src/components/Test/index.js
new file mode 100644
index 000000000..2c9e28d75
--- /dev/null
+++ b/docs/src/components/Test/index.js
@@ -0,0 +1,4 @@
+import { TestComponent } from './component.js';
+
+
+export { TestComponent };
diff --git a/docs/src/components/Test/row.css b/docs/src/components/Test/row.css
deleted file mode 100755
index 411ebe02b..000000000
--- a/docs/src/components/Test/row.css
+++ /dev/null
@@ -1,4 +0,0 @@
-td {
- border: var(--solid-border);
- padding: 8px 10px;
-}
diff --git a/docs/src/components/Test/row.html b/docs/src/components/Test/row.html
deleted file mode 100755
index e92510361..000000000
--- a/docs/src/components/Test/row.html
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
Static content renders
-
{concat firstName lastName}
-
{age}
-
{gender}
-
diff --git a/docs/src/components/Test/row.js b/docs/src/components/Test/row.js
deleted file mode 100755
index f001b8450..000000000
--- a/docs/src/components/Test/row.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import { defineComponent } from '@semantic-ui/component';
-
-import css from './row.css?raw';
-import template from './row.html?raw';
-
-export const Row = defineComponent({
- template,
- css,
-});
diff --git a/docs/src/content/examples/expressions.mdx b/docs/src/content/examples/expressions.mdx
new file mode 100755
index 000000000..18941f352
--- /dev/null
+++ b/docs/src/content/examples/expressions.mdx
@@ -0,0 +1,9 @@
+---
+title: 'Expressions'
+exampleType: 'component'
+category: 'Components'
+subcategory: 'UI Components'
+tags: ['component', 'getting-started']
+description: An example of various expressions and their results
+selectedFile: 'component.html'
+---
diff --git a/docs/src/examples/expressions/component.css b/docs/src/examples/expressions/component.css
new file mode 100755
index 000000000..7817a61ee
--- /dev/null
+++ b/docs/src/examples/expressions/component.css
@@ -0,0 +1,10 @@
+table {
+ border-collapse: collapse;
+ th,
+ td {
+ border: var(--solid-border);
+ padding: 5px 10px;
+ text-align: left;
+ max-width: max-content;
+ }
+}
diff --git a/docs/src/examples/expressions/component.html b/docs/src/examples/expressions/component.html
new file mode 100755
index 000000000..00306288f
--- /dev/null
+++ b/docs/src/examples/expressions/component.html
@@ -0,0 +1,99 @@
+Data Context
+
+
+ | Name |
+ Value |
+
+
+
+ | value |
+ {value} |
+
+
+ | now |
+ {now} |
+
+
+ | timezone |
+ {timezone} |
+
+
+
+
+Expressions
+
+
+ | Example |
+ Expression |
+ Result |
+ Should Match |
+
+
+
+ | Calling method in data context with object using spaced arguments |
+ {#html "formatDate now 'h:mm:ss a' { timezone: timezone }"} |
+ { formatDate now 'h:mm:ss a' { timezone: timezone } } |
+ Current time PST |
+
+
+ | Calling helper method in data context with js arguments |
+ {#html "formatDate(now, 'h:mm:ss a', { timezone: timezone })"} |
+ { formatDate(now, 'h:mm:ss a', { timezone: timezone }) } |
+ Current time PST |
+
+
+ | Calling helper method with inline object definition |
+ {#html 'classMap { one: true, two: true, three: now }' } |
+ { classMap { one: true, two: true, three: now } } |
+ one two three |
+
+
+ | Calling method with inline array definition |
+ join ['1', '2', '3'] ' and ' |
+ { join ['1', '2', '3'] ' and '} |
+ 1 and 2 and 3 |
+
+
+ | Outputting value from data context |
+ value |
+ {value} |
+ 0 |
+
+
+ | Inline addition |
+ value + 1 |
+ {value + 1} |
+ 2 |
+
+
+ | Inline arithmetic |
+ value + 2 * 5 |
+ {value + 2 * 5} |
+ 11 |
+
+
+ | Inline arithmetic with order of operations |
+ (value + 2) * 5 |
+ {(value + 2) * 5} |
+ 15 |
+
+
+ | Calling a method with spaced arguments |
+ addOne value |
+ {addOne value} |
+ 2 |
+
+
+ | Calling a method with js arguments and inline addition |
+ addOne(value + 1) |
+ {addOne(value + 1)} |
+ 3 |
+
+
+ | Calling a method with inline object |
+ getValue {one: 'two'} 'one' |
+ {getValue {one: 'two'} 'one'} |
+ two |
+
+
+
diff --git a/docs/src/examples/expressions/component.js b/docs/src/examples/expressions/component.js
new file mode 100755
index 000000000..2d4cba197
--- /dev/null
+++ b/docs/src/examples/expressions/component.js
@@ -0,0 +1,27 @@
+import { defineComponent, getText } from '@semantic-ui/component';
+
+const css = await getText('./component.css');
+const template = await getText('./component.html');
+
+const settings = {
+ value: 1,
+ now: new Date(),
+ timezone: 'PST'
+};
+
+const createComponent = ({self, data, settings}) => ({
+ addOne(value, value2 = 0) {
+ return value + value2 + 1;
+ },
+ getValue(obj, prop) {
+ return obj[prop];
+ }
+});
+
+export const TestComponent = defineComponent({
+ tagName: 'test-component',
+ template,
+ css,
+ settings,
+ createComponent
+});
diff --git a/docs/src/pages/test.astro b/docs/src/pages/test.astro
index 22dd30a7a..af2354cd2 100644
--- a/docs/src/pages/test.astro
+++ b/docs/src/pages/test.astro
@@ -1,30 +1,12 @@
---
import Body from '../layouts/Body.astro';
+import TopBar from '@components/TopBar.astro';
-import NavMenu from '@components/NavMenu/NavMenu.js';
+import TopbarMenu from '@components/TopbarMenu/TopbarMenu.js';
+import {TestComponent} from '@components/Test/';
const menu = [];
---
-
+
+
diff --git a/packages/component/src/define-component.js b/packages/component/src/define-component.js
index 5540aee6d..5eb4b29b8 100644
--- a/packages/component/src/define-component.js
+++ b/packages/component/src/define-component.js
@@ -37,6 +37,7 @@ export const defineComponent = ({
renderingEngine,
} = {}) => {
+
// AST shared across instances
if(!ast) {
const compiler = new TemplateCompiler(template);
diff --git a/packages/component/src/engines/lit/renderer.js b/packages/component/src/engines/lit/renderer.js
index 26457f197..6eec4cf59 100644
--- a/packages/component/src/engines/lit/renderer.js
+++ b/packages/component/src/engines/lit/renderer.js
@@ -14,6 +14,7 @@ export class LitRenderer {
static PARENS_REGEXP = /('[^']*'|"[^"]*"|\(|\)|[^\s()]+)/g;
static STRING_REGEXP = /^\'(.*)\'$/;
+ static WRAPPED_EXPRESSION = /(\s|^)([\[{].*?[\]}])(\s|$)/g;
static useSubtreeCache = false; // experimental
@@ -293,14 +294,46 @@ export class LitRenderer {
return parse(tokens);
}
+ // evaluate javascript expressions
+ evaluateJavascript(code, context = {}, { includeHelpers = true } = {}) {
+ let result;
+ if(includeHelpers) {
+ context = {
+ ...context,
+ ...this.helpers
+ };
+ delete context.debugger; // this is a reserved word
+ }
+ try {
+ const keys = Object.keys(context);
+ const values = Object.values(context);
+ result = new Function(...keys, `return ${code}`)(...values);
+ }
+ catch(e) {
+ // nothing
+ }
+ return result;
+ }
+
// this evaluates an expression from right determining if something is an argument or a function
// then looking up the value
lookupExpressionValue(expression = '', data = {}) {
+
+ // wrap {} or [] in parens
+ expression = this.addParensToExpression(expression);
+
const expressionArray = isArray(expression)
? expression
: this.getExpressionArray(expression)
;
+ // check if whole expression is JS before tokenizing
+ const jsValue = this.evaluateJavascript(expression, data);
+ if(jsValue !== undefined) {
+ const value = this.getTokenValue(jsValue);
+ return wrapFunction(value)();
+ }
+
let funcArguments = [];
let result;
@@ -308,7 +341,7 @@ export class LitRenderer {
while(index--) {
const token = expressionArray[index];
if(isArray(token)) {
- result = this.lookupExpressionValue(token, data);
+ result = this.lookupExpressionValue(token.join(' '), data);
funcArguments.unshift(result);
}
else {
@@ -323,8 +356,8 @@ export class LitRenderer {
return result;
}
- lookupTokenValue(token = '', data) {
+ lookupTokenValue(token = '', data) {
if(isArray(token)) {
// Recursively evaluate nested expressions
return this.lookupExpressionValue(token, data);
@@ -378,16 +411,27 @@ export class LitRenderer {
dataValue = dataValue.bind(thisContext);
}
- // retrieve reactive value
- if(dataValue !== undefined) {
- return (dataValue instanceof ReactiveVar)
- ? dataValue.value
- : dataValue;
- }
+ return this.getTokenValue(dataValue);
+ }
+ // retrieve token value accessing getter for reactive vars
+ getTokenValue(tokenValue) {
+ if(tokenValue !== undefined) {
+ return (tokenValue instanceof ReactiveVar)
+ ? tokenValue.value
+ : tokenValue
+ ;
+ }
return undefined;
}
+ addParensToExpression(expression) {
+ // Match either an object {...} or array [...] at the start or after whitespace
+ return expression.replace(LitRenderer.WRAPPED_EXPRESSION, (match, before, brackets, after) => {
+ return `${before}(${brackets})${after}`;
+ });
+ }
+
getLiteralValue(token) {
// Check if this is a string literal (single or double quotes)
diff --git a/packages/templating/src/compiler/string-scanner.js b/packages/templating/src/compiler/string-scanner.js
index 5816aa027..be11aafc8 100644
--- a/packages/templating/src/compiler/string-scanner.js
+++ b/packages/templating/src/compiler/string-scanner.js
@@ -26,6 +26,18 @@ export class StringScanner {
return this.input.slice(this.pos);
}
+ step(step = 1) {
+ if(!this.isEOF()) {
+ this.pos = this.pos + step;
+ }
+ }
+
+ rewind(step = 1) {
+ if(this.pos !== 0) {
+ this.pos = this.pos - step;
+ }
+ }
+
isEOF() {
return this.pos >= this.input.length;
}
diff --git a/packages/templating/src/compiler/template-compiler.js b/packages/templating/src/compiler/template-compiler.js
index f6c1c2594..92d1cf6cf 100644
--- a/packages/templating/src/compiler/template-compiler.js
+++ b/packages/templating/src/compiler/template-compiler.js
@@ -101,26 +101,40 @@ class TemplateCompiler {
// if this expression contains nested expressions like { one { two } }
// we want tag content to include all nested expressions
- const getTagContent = (outerContent = '') => {
- let content = scanner.consumeUntil(parserRegExp.EXPRESSION_END);
-
- // nested expression found
- if(content.search(parserRegExp.EXPRESSION_START) >= 0) {
- content += scanner.consumeUntil(parserRegExp.EXPRESSION_START);
- content += scanner.consume(parserRegExp.EXPRESSION_START);
- content += getTagContent(content);
- content += scanner.consumeUntil(parserRegExp.EXPRESSION_END);
- content += scanner.consume(parserRegExp.EXPRESSION_END);
- return content;
+ let getTagContent = () => {
+
+ // break if we are already at the end of the expr
+ if(scanner.peek() == '}') {
+ scanner.consumeUntil(parserRegExp.EXPRESSION_END);
+ return;
}
- // if we have outer content we will need to return the closing bracket
- // otherwise we just eat it
- const bracket = scanner.consume(parserRegExp.EXPRESSION_END);
- if(outerContent) {
- content += bracket;
+ // step through expression evaluating sub expressions
+ // stopping when the final sub expression completes
+ let openTags = 1;
+ let content = scanner.peek();
+ while(openTags > 0 && !scanner.isEOF()) {
+ scanner.step();
+ if(scanner.peek() == '{') {
+ openTags++;
+ }
+ if(scanner.peek() == '}') {
+ openTags--;
+ }
+ if(openTags == 0) {
+ // we need to rewind as it is at '}'
+ scanner.rewind();
+ break;
+ }
+ content += scanner.peek();
}
+ // move pointer to the end of the expression
+ scanner.consumeUntil(parserRegExp.EXPRESSION_END);
+ scanner.consume(parserRegExp.EXPRESSION_END);
+
+ // remove whitespace
+ content = content.trim();
return content;
};
@@ -129,7 +143,7 @@ class TemplateCompiler {
if (scanner.matches(tagRegExp[type])) {
const context = scanner.getContext(); // context is used for better error handling
scanner.consume(tagRegExp[type]);
- const rawContent = getTagContent().trim();
+ const rawContent = getTagContent();
scanner.consume(parserRegExp.EXPRESSION_END);
const content = this.getValue(rawContent);
return { type, content, ...context }; // Include context in the return value
diff --git a/packages/templating/test/compiler.test.js b/packages/templating/test/compiler.test.js
index fce0ec2cf..1cd2cd9bb 100644
--- a/packages/templating/test/compiler.test.js
+++ b/packages/templating/test/compiler.test.js
@@ -525,6 +525,119 @@ describe('TemplateCompiler', () => {
expect(ast).toEqual(expectedAST);
});
});
+ describe('nested expressions', () => {
+ it('should handle single level of nesting in expressions', () => {
+ const compiler = new TemplateCompiler();
+ const template = `
+
+ {{ getValue { nested: value } }}
+
+ `;
+ const ast = compiler.compile(template);
+ const expectedAST = [
+ { type: 'html', html: '\n ' },
+ { type: 'expression', value: 'getValue { nested: value }' },
+ { type: 'html', html: '\n
' },
+ ];
+ expect(ast).toEqual(expectedAST);
+ });
+
+ it('should handle multiple levels of nesting in expressions', () => {
+ const compiler = new TemplateCompiler();
+ const template = `
+
+ {{ formatData { user: { name: userName, details: { age: userAge } } } }}
+
+ `;
+ const ast = compiler.compile(template);
+ const expectedAST = [
+ { type: 'html', html: '\n ' },
+ { type: 'expression', value: 'formatData { user: { name: userName, details: { age: userAge } } }' },
+ { type: 'html', html: '\n
' },
+ ];
+ expect(ast).toEqual(expectedAST);
+ });
+
+ it('should handle nested method calls with object parameters', () => {
+ const compiler = new TemplateCompiler();
+ const template = `
+
+ {{ processUser(getData({ id: userId })) }}
+
+ `;
+ const ast = compiler.compile(template);
+ const expectedAST = [
+ { type: 'html', html: '\n ' },
+ { type: 'expression', value: 'processUser(getData({ id: userId }))' },
+ { type: 'html', html: '\n
' },
+ ];
+ expect(ast).toEqual(expectedAST);
+ });
+
+ it('should handle mixed bracket types in nested expressions', () => {
+ const compiler = new TemplateCompiler();
+ const template = `
+
+ {{ formatList([{ id: 1 }, { id: 2 }]) }}
+
+ `;
+ const ast = compiler.compile(template);
+ const expectedAST = [
+ { type: 'html', html: '\n ' },
+ { type: 'expression', value: 'formatList([{ id: 1 }, { id: 2 }])' },
+ { type: 'html', html: '\n
' },
+ ];
+ expect(ast).toEqual(expectedAST);
+ });
+
+ it('should handle deeply nested conditional expressions', () => {
+ const compiler = new TemplateCompiler();
+ const template = `
+
+ {{ isValid({ user: { permissions: { admin: checkAdmin({ org: orgId }) } } }) }}
+
+ `;
+ const ast = compiler.compile(template);
+ const expectedAST = [
+ { type: 'html', html: '\n ' },
+ { type: 'expression', value: 'isValid({ user: { permissions: { admin: checkAdmin({ org: orgId }) } } })' },
+ { type: 'html', html: '\n
' },
+ ];
+ expect(ast).toEqual(expectedAST);
+ });
+
+ it('should handle nested array expressions with objects', () => {
+ const compiler = new TemplateCompiler();
+ const template = `
+
+ {{ processItems([ { type: 'user', data: { id: 1 } }, { type: 'admin', data: { id: 2 } } ]) }}
+
+ `;
+ const ast = compiler.compile(template);
+ const expectedAST = [
+ { type: 'html', html: '\n ' },
+ { type: 'expression', value: 'processItems([ { type: \'user\', data: { id: 1 } }, { type: \'admin\', data: { id: 2 } } ])' },
+ { type: 'html', html: '\n
' },
+ ];
+ expect(ast).toEqual(expectedAST);
+ });
+
+ it('should handle nested expressions in boolean attributes', () => {
+ const compiler = new TemplateCompiler();
+ const template = `
+
+ Content
+
+ `;
+ const ast = compiler.compile(template);
+ const expectedAST = [
+ { type: 'html', html: '\n Content\n
' },
+ ];
+ expect(ast).toEqual(expectedAST);
+ });
+ });
describe('each loops', () => {
it('should compile a template with an each loop', () => {