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

+ + + + + + + + + + + + + + + + + + + +
NameValue
value{value}
now{now}
timezone{timezone}
+ +

Expressions

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ExampleExpressionResultShould 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 definitionjoin ['1', '2', '3'] ' and '{ join ['1', '2', '3'] ' and '}1 and 2 and 3
Outputting value from data contextvalue{value}0
Inline additionvalue + 1{value + 1}2
Inline arithmeticvalue + 2 * 5{value + 2 * 5}11
Inline arithmetic with order of operations(value + 2) * 5{(value + 2) * 5}15
Calling a method with spaced argumentsaddOne value{addOne value}2
Calling a method with js arguments and inline additionaddOne(value + 1){addOne(value + 1)}3
Calling a method with inline objectgetValue {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

+ + + + + + + + + + + + + + + + + + + +
NameValue
value{value}
now{now}
timezone{timezone}
+ +

Expressions

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ExampleExpressionResultShould 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 definitionjoin ['1', '2', '3'] ' and '{ join ['1', '2', '3'] ' and '}1 and 2 and 3
Outputting value from data contextvalue{value}0
Inline additionvalue + 1{value + 1}2
Inline arithmeticvalue + 2 * 5{value + 2 * 5}11
Inline arithmetic with order of operations(value + 2) * 5{(value + 2) * 5}15
Calling a method with spaced argumentsaddOne value{addOne value}2
Calling a method with js arguments and inline additionaddOne(value + 1){addOne(value + 1)}3
Calling a method with inline objectgetValue {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', () => {