├── tsconfig.json ├── jest.config.js ├── package.json ├── composer.json ├── LICENSE ├── index.php ├── src ├── index.ts └── lib │ ├── interpreter.test.ts │ └── interpreter.ts ├── .gitignore ├── README.md └── index.js /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": [ 4 | "jest" 5 | ], 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "\\.ts$": "esbuild-runner/jest", 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whenquery", 3 | "description": "", 4 | "main": "index.js", 5 | "scripts": { 6 | "watch": "esbuild ./src/index.ts --watch --bundle --outfile=index.js", 7 | "build": "esbuild ./src/index.ts --minify --bundle --outfile=index.js", 8 | "test": "jest --silent=false --noStackTrace" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "@types/jest": "^27.0.3", 14 | "esbuild": "^0.14.2", 15 | "esbuild-runner": "^2.2.1", 16 | "jest": "^27.4.3" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rasteiner/k3-whenquery", 3 | "description": "Conditionally show fields and sections. Better.", 4 | "type": "kirby-plugin", 5 | "version": "0.5.0", 6 | "license": "MIT", 7 | "homepage": "https://getkirby.com/plugins/rasteiner/whenquery", 8 | "authors": [ 9 | { 10 | "name": "Roman Steiner", 11 | "email": "roman@toastlab.ch", 12 | "homepage": "https://getkirby.com/plugins/rasteiner" 13 | } 14 | ], 15 | "require": { 16 | "getkirby/composer-installer": "^1.2", 17 | "getkirby/cms": ">=3.8" 18 | }, 19 | "extra": { 20 | "installer-name": "k3-whenquery" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Roman Steiner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | whenQuery = $query; 14 | } 15 | 16 | public function whenQuery(): ?string 17 | { 18 | return $this->whenQuery; 19 | } 20 | 21 | public function props(): array 22 | { 23 | return [ 24 | 'whenQuery' => $this->whenQuery(), 25 | ] + parent::props(); 26 | } 27 | } 28 | 29 | class QueryBlocksField extends BlocksField 30 | { 31 | use WhenQuery; 32 | 33 | public function __construct($params) 34 | { 35 | parent::__construct($params); 36 | $this->setWhenQuery($params['whenQuery'] ?? null); 37 | } 38 | 39 | public function type(): string 40 | { 41 | return 'blocks'; 42 | } 43 | } 44 | 45 | class QueryLayoutField extends LayoutField 46 | { 47 | use WhenQuery; 48 | 49 | public function __construct($params) 50 | { 51 | parent::__construct($params); 52 | $this->setWhenQuery($params['whenQuery'] ?? null); 53 | } 54 | 55 | public function type(): string 56 | { 57 | return 'layout'; 58 | } 59 | } 60 | 61 | Kirby::plugin('rasteiner/whenquery', [ 62 | 'fields' => [ 63 | 'blocks' => QueryBlocksField::class, 64 | 'layout' => QueryLayoutField::class, 65 | ], 66 | ]); -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import runQuery from "./lib/interpreter"; 2 | 3 | declare namespace panel { 4 | function plugin(name: string, options: any): void; 5 | const app: any; 6 | } 7 | 8 | panel.plugin('rasteiner/whenquery', { 9 | use: [ 10 | function(Vue) { 11 | const modelContainer = Vue.observable({}); 12 | 13 | function addModelWatch(type) { 14 | const options = Vue.component(type).options; 15 | Vue.component(type, { 16 | extends: options, 17 | created() { 18 | modelContainer.model = this.model; 19 | }, 20 | watch: { 21 | model: function(newValue) { 22 | modelContainer.model = newValue; 23 | } 24 | } 25 | }); 26 | } 27 | 28 | addModelWatch("k-page-view"); 29 | addModelWatch("k-site-view"); 30 | addModelWatch("k-file-view"); 31 | addModelWatch("k-user-view"); 32 | addModelWatch("k-account-view"); 33 | 34 | const orig = Vue.prototype.$helper.field.isVisible; 35 | 36 | Vue.prototype.$helper.field.isVisible = function(field, values) { 37 | if(!orig(field, values)) return false; 38 | 39 | if(field.whenQuery) { 40 | const context = (name) => { 41 | //variable names starting with _ refer to the model and not its content 42 | if(name[0] === '_') { 43 | return modelContainer?.model?.[name.substr(1)]; 44 | } 45 | 46 | if(values?.[name.toLowerCase()] !== undefined) { 47 | //first look if the name exists in a local "value" property 48 | return values[name.toLowerCase()]; 49 | } else { 50 | //otherwise look in the global context 51 | return panel?.app?.$store?.getters['content/values']()[name.toLowerCase()]; 52 | } 53 | }; 54 | 55 | return runQuery(context, field.whenQuery); 56 | } 57 | return true; 58 | }; 59 | } 60 | ] 61 | }); 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /src/lib/interpreter.test.ts: -------------------------------------------------------------------------------- 1 | import run from "./interpreter"; 2 | 3 | describe("run function", () => { 4 | it("should be defined", () => { 5 | expect(run).toBeDefined(); 6 | }); 7 | 8 | it("should be a function", () => { 9 | expect(typeof run).toBe("function"); 10 | }); 11 | 12 | const context = (s) => { 13 | switch(s) { 14 | case "a": 15 | return "Letter A"; 16 | case "b": 17 | return "Letter B"; 18 | case "nine": 19 | return 9; 20 | case "ten": 21 | return 10; 22 | case "yes": 23 | return true; 24 | case "no": 25 | return false; 26 | case "arrayOfNumbers": 27 | return [1, 2, 3, 4, 5]; 28 | case 'obj1': 29 | return { 30 | a: 1, 31 | b: 2, 32 | c: 3 33 | } 34 | case 'obj2': 35 | return { 36 | a: 'foo', 37 | b: 'bar', 38 | c: 'baz' 39 | } 40 | case 'obj3': 41 | return { 42 | a: 1, 43 | b: 'bar' 44 | } 45 | } 46 | }; 47 | 48 | test.each([ 49 | [ ``, true ], 50 | [ `a`, `Letter A` ], 51 | [ `b`, `Letter B` ], 52 | [ `nine`, 9 ], 53 | [ `ten`, 10 ], 54 | [ `yes`, true ], 55 | [ `no`, false ], 56 | [ `c`, undefined ], 57 | [ `nine < ten`, true ], 58 | [ `nine > ten`, false ], 59 | [ `nine + ten = 19`, true ], 60 | [ `[1,2,3]`, [1,2,3] ], 61 | [ `[1,[2,yes],4]`, [1,[2,true],4] ], 62 | [ `9 - 8`, 1 ], 63 | [ `9 - 4 * 2 `, 1 ], 64 | [ `(9 - 4) * 2 `, 10 ], 65 | [ `9 / 3`, 3 ], 66 | [ `(6 + 3) / 3`, 3 ], 67 | [ `13 % 5`, 3 ], 68 | [ `{ a: a }`, { a: `Letter A` } ], 69 | [ `{ a: a, b: 1-1 ? "Foo" : "Letter B" }`, { a: "Letter A", b: "Letter B" } ], 70 | [ `{ a: a, b: 1+1 ? "Foo" : "Letter B" }`, { a: "Letter A", b: "Foo" } ], 71 | [ `[1,2,3][0]`, 1 ], 72 | [ `[1,2,3][1]`, 2 ], 73 | [ `[].length > 0`, false ], 74 | [ `{yes: "foobar"}.yes`, "foobar" ], 75 | [ `{yes: [1,2,3,4]}.yes[3]`, 4 ], 76 | [ `{yes: [1,2,3,{a:a}]}["yes"][3].a`, 'Letter A' ], 77 | [ `undefined ?? a`, 'Letter A' ], 78 | [ `undefined ?? a ?? b`, 'Letter A' ], 79 | [ `undefined.a`, undefined ], 80 | [ `undefined.a.b[undefined] ?? a`, 'Letter A' ], 81 | [ `[a, b, c] =~ a`, true ], 82 | [ `a =~ [a, b, c]`, true ], 83 | [ `[b, c] =~ a`, false ], 84 | [ `a =~ [b, c]`, false ], 85 | [ `[b, c] =~ [b]`, true ], 86 | [ `[b, c] =~ [b, c]`, true ], 87 | [ `[b, c] =~ [a, b, c]`, false ], 88 | [ `a =~ "Letter"`, true ], 89 | [ `"ABC" =~ "D"`, false ], 90 | [ `12 =~ 6`, true ], 91 | [ `12 =~ 7`, false ], 92 | [ `ten =~ 2 ? ten + " is even" : ten + " is odd"`, "10 is even" ], 93 | [ `nine =~ 2 ? nine + " is even" : nine + " is odd"`, "9 is odd" ], 94 | [ `nine + ten =~ 2 ? nine + ten + " is even" : nine + ten + " is odd"`, "19 is odd" ], 95 | [ `"abc" =~ /b/`, true ], 96 | [ `"abc" =~ /c$/`, true ], 97 | [ `"abc" =~ /b$/`, false ], 98 | [ `"abc" =~ /^a/`, true ], 99 | [ `"abc" =~ /^b/`, false ], 100 | [ `"a/bc" =~ /a\\/b/`, true ], 101 | [ `"a/bc" =~ /a\\/c/`, false ], 102 | [ `"string" =~ /iNG$/`, false ], 103 | [ `"string" =~ /iNG$/i`, true ], 104 | [ `"one word" =~ /\\bw/`, true ], 105 | [ `"no word begins with letterO" =~ /\\bo/`, false ], 106 | [ `[{filename: "lol.svg"}, {filename: "lol.svg.txt"}]::count($.filename =~ /\\.svg$/)`, 1], 107 | [ `[10][0]/2`, 5], 108 | [ `[1, 2, 3, 4, 5, 6]::count()/2`, 3], 109 | [ `arrayOfNumbers::count()`, 5 ], 110 | [ `arrayOfNumbers::count(1)`, 5 ], 111 | [ `arrayOfNumbers::count(true)`, 5 ], 112 | [ `arrayOfNumbers::count(true = true)`, 5 ], 113 | [ `[obj1, obj2, obj3] ::count($.a = 1)`, 2], 114 | [ `[obj1, obj2, obj3] ::any($.a = 'foo')`, true], 115 | [ `[obj1, obj2, obj3] ::all($.a = 'foo')`, false], 116 | [ `[obj1, obj2, obj3] ::all($ != null)`, true], 117 | [ `[obj1, obj2, obj3, null] ::all($ != null)`, false], 118 | [ `[obj1, obj2, obj3, null] ::any($ = null)`, true], 119 | [ `[1,2,3,4,5] ::filter($ > 3)`, [4,5]], 120 | [ `[1,2,3,4,5] ::filter($ > 3) ::filter($ < 5)`, [4]], 121 | [ `[1,2,3,4,5] ::filter($ > 3) ::count($ < 5)`, 1], 122 | [ `[1,2,3,4,5] ::filter($ > 3) ::filter($ < 5)[0]`, 4], 123 | [ `[[1,2,3],[4,5],[8,19,20]] ::map($ ::filter($ =~ 2))`, [[2],[4],[8,20]]], 124 | [ `[1,2,3,4] ::reduce($1 + $)`, 10], 125 | [ `[1,2,3,4] ::reduce($1 + $, 5)`, 15], 126 | [ `['1','2','3','4'] ::reduce($1 + $)`, '1234'], 127 | ])("\"%s\" should return %j", (input, expected) => { 128 | expect(run(context, input)).toStrictEqual(expected); 129 | }); 130 | }) 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # k3-whenquery 2 | Conditionally show fields and sections. Better. 3 | 4 | ## Installation 5 | 6 | ### Download 7 | 8 | Download and copy this repository to `/site/plugins/k3-whenquery`. 9 | 10 | ### Git submodule 11 | 12 | ``` 13 | git submodule add https://github.com/rasteiner/k3-whenquery.git site/plugins/k3-whenquery 14 | ``` 15 | 16 | ### Composer 17 | 18 | ``` 19 | composer require rasteiner/k3-whenquery 20 | ``` 21 | 22 | ## Use 23 | 24 | Add a `whenQuery` property to your fields and sections. The expression in this query will be evaluated in real time in the browser to **hide** the section or field when it evaluates to a *falsy* value. 25 | 26 | ```yml 27 | title: Woofler page 28 | 29 | fields: 30 | foosables: 31 | type: select 32 | label: Foosables 33 | options: 34 | gerryconas: Gerryconas 35 | peterwands: Peterwands 36 | perlskippies: Perl Skippies 37 | 38 | barsters: 39 | type: range 40 | label: Barsters 41 | 42 | warning: 43 | type: info 44 | label: Woofling warning 45 | text: Having more than 30 Barsters is **not recommended** while wooffling Gerryconas. 46 | theme: negative 47 | whenQuery: foosables = 'gerryconas' && barsters > 30 48 | ``` 49 | 50 | ### Expression Language Syntax 51 | 52 | There are no assignments and function calls (it's meant to be as harmless as possible). 53 | Since there are no assignments, `=` is an equality comparison (there is no `==`). 54 | 55 | #### Supported operators 56 | - `... = ...`: Equals 57 | - `... != ...`: Not equals 58 | - `... < ...`: Less than 59 | - `... > ...`: Greater than 60 | - `... <= ...`: Less or equal 61 | - `... >= ...`: Greater or equal 62 | - `... =~ ...`: Search something in something (see below) 63 | - `... ?? ...`: Nullish coalescing operator 64 | - `... ? ... : ...`: Ternary operator 65 | - `... || ...`: Logical Or 66 | - `... && ...`: Logical And 67 | - `... + ...`: String concatenation or Number addition 68 | - `... - ...`: Number subtraction 69 | - `... * ...`: Number multiplication 70 | - `... / ...`: Number division 71 | - `... % ...`: Remainder operator 72 | - `(...)`: Precedence grouping 73 | - `...[...]`: Optional subscript operator (calculated member access) 74 | - `.`: Optional member access by identifier 75 | - `... ::map(...)`: Array `map` operator (see below) 76 | - `... ::filter(...)`: Array `filter` operator (see below) 77 | - `... ::count(...)`: Array `count` operator (see below) 78 | - `... ::any(...)`: Array `any` operator (see below) 79 | - `... ::all(...)`: Array `all` operator (see below) 80 | - `... ::reduce(...)`: Array Reducer (see below) 81 | 82 | Member Access is always "optional": it does never throw an error if you access a property of undefined; it just evaluates to `undefined`. In short, `.` behaves like javascript's `?.` and `a[something]` behaves like `a?.[something]`. 83 | 84 | The search operator `=~` is a multifunction tool: it behaves differently depending on the type of its operands: 85 | - `"string" =~ "tri"`: string on string, returns `true` if a string contains another 86 | - `[a,b,c] =~ a`: element on array, returns `true` if an element is present in an array. This case can optionally also be written in the reverse order: `a =~ [a,b,c]` 87 | - `[a,b,c] =~ [b,c]`: array on array, returns `true` if all elements of the right hand side are present in the left hand side array. 88 | - `12 =~ 2`: number on number, returns `true` if "left" is divisible by "right" (`left % right = 0`). 89 | - `"string" =~ /inG$/i`: regex on string, returns `true` if the string matches the regex. RegExes are useful only in combination with the search operator. 90 | 91 | There's also support for String, Number, Boolean (`true`, `false`), Object and Array literals. 92 | 93 | #### Array operators 94 | 95 | - `::map(expr)`: replaces each item in the array with the right hand side expression 96 | - `::filter(expr)`: filters the array by evaluating right hand side expression 97 | - `::count(expr)`: counts all items in the array that return true for the right hand side expression 98 | - `::any(expr)`: returns `true` if at least 1 item in the array returns true for the right hand side expression, `false` otherwise 99 | - `::all(expr)`: returns `true` if all items in the array return true for the right hand side expression, `false` otherwise 100 | 101 | ##### General syntax: 102 | 103 | Array operators are made of 3 parts: 104 | 1. In the left the array they operate on 105 | 2. after `::` the name of the operation 106 | 3. between the parentheses the expression that is evaluated for each array item. 107 | 108 | Inside of the parentheses, the symbol `$` represents the "current" item. 109 | 110 | ##### Example 111 | ```yml 112 | fields: 113 | blocks: 114 | type: blocks 115 | 116 | imagesEncouragement: 117 | type: info 118 | label: Nice job! 119 | text: Good! You have at least 13 images in this post. This will be a **great** post. 120 | theme: positive 121 | whenQuery: blocks ::count($.type = 'image') >= 13 122 | ``` 123 | 124 | #### Array reducers 125 | Array reducers are functions that take an array and return a single value. They can be used to aggregate values in an array. 126 | The syntax is similar to the array operators, but `::reduce(expr, ?initial)` accepts an optional initial value. If no initial value is provided, the first item of the array is used. 127 | Inside of the parentheses, the symbol `$` represents the "current" item, while `$1` represents the return value of the "previous" iteration (aka the "accumulator"). 128 | The array is always traversed left to right. 129 | 130 | ##### Example 131 | ```yml 132 | fields: 133 | percentages: 134 | type: structure 135 | fields: 136 | percent: 137 | type: number 138 | after: "%" 139 | 140 | percentagesEncouragement: 141 | type: info 142 | label: Math GENIUS! 143 | text: Great job! The sum of all percentages is exactly 100%. 144 | theme: positive 145 | whenQuery: percentages ::reduce($1 + $.percent, 0) = 100 146 | ``` 147 | An example usage without an initial value: 148 | ```yml 149 | whenQuery: percentages ::map($.percent) ::reduce($1 + $) = 100 150 | ``` 151 | 152 | ### Variables lookup 153 | #### Content variables 154 | By default, any valid identifier refers to the "current" fieldset. 155 | This means that page fields could be [shadowed](https://en.wikipedia.org/wiki/Variable_shadowing) 156 | by other fields with the same name in a structure or block, when the query is executed in such structure or block. 157 | If no field is found in the current fieldset, the query is evaluated in the page fields. 158 | 159 | #### Model variables 160 | The Site, Pages, Files and Users are "models". Other than content, they also have other properties that might be useful in a query. 161 | These properties are accessible by prepending an underscore (`_`) to their name, when (and only when) the query is being executed inside of their respective View. 162 | 163 | The accessible Site properties are: 164 | - `_title`: the title of the site 165 | 166 | The accessible Page properties are: 167 | - `_status`: the status of the page (one of 'draft', 'unlisted' or 'listed') 168 | - `_id`: the id of the page 169 | - `_title`: the title of the page 170 | 171 | The accessible User properties are: 172 | - `_id`: the id of the user 173 | - `_email`: the email of the user 174 | - `_name`: the name of the user 175 | - `_username`: the username of the user (either the name or the email as fallback) 176 | - `_language`: the language of the user 177 | - `_role`: the role name of the user (e.g. "Admin" with a capital "A") 178 | - `_avatar`: the url of the user's avatar 179 | - `_account`: boolean indicating if this is the current user's account 180 | 181 | The accessible File properties are: 182 | - `_dimensions.width`: If the file is an image, the width of the image 183 | - `_dimensions.height`: If the file is an image, the height of the image 184 | - `_dimensions.ratio`: If the file is an image, the ratio of the image 185 | - `_dimensions.orientation`: If the file is an image, one of "landscape", "square" or "portrait" 186 | - `_extension`: the extension of the file (e.g. "jpg", "png", "gif") 187 | - `_filename`: the filename of the file 188 | - `_mime`: the mime type of the file 189 | - `_niceSize`: the nice size of the file (e.g. "1.2 MB", "2.3 KB") 190 | - `_template`: the file template 191 | - `_type`: the file type, any of "archive", "audio", "code", "document", "image", "video" or null 192 | - `_url`: the media url to the file 193 | 194 | ##### Example 195 | ```yml 196 | fields: 197 | date: 198 | type: date 199 | 200 | dateInfo: 201 | type: info 202 | label: Heads up! 203 | text: This page is listed, its date will be ignored for sorting. 204 | theme: positive 205 | whenQuery: date && _status = 'listed' 206 | ``` 207 | 208 | ## Known issues 209 | - This plugin extends and replaces the default `Blocks` and `Layout` field types. This means that it is not compatible with other plugins that do the same. 210 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | (()=>{var p=class{constructor(e,t,r){this.type=e;this.lexeme=t;this.literal=r}toString(){return`Token(${this.type}, ${this.lexeme}, ${this.literal})`}},d=class{constructor(e){this.source=e}scanTokens(){for(this.tokens=[],this.start=0,this.current=0;!this.isAtEnd();)this.start=this.current,this.scanToken();return this.tokens.push(new p(36,"",null)),this.tokens}scanToken(){let e=this.advance();switch(e){case"(":this.addToken(0);break;case")":this.addToken(1);break;case"{":this.addToken(8);break;case"}":this.addToken(9);break;case"[":this.addToken(11);break;case"]":this.addToken(12);break;case",":this.addToken(2);break;case".":this.addToken(3);break;case"+":this.addToken(5);break;case"*":this.addToken(7);break;case"-":this.addToken(4);break;case"%":this.addToken(13);break;case"$":this.addToken(this.match("1")?23:22);break;case":":this.addToken(this.match(":")?28:10);break;case"=":this.addToken(this.match("~")?25:24);break;case"?":this.addToken(this.match("?")?21:20);break;case"!":this.addToken(this.match("=")?15:14);break;case">":this.addToken(this.match("=")?17:16);break;case"<":this.addToken(this.match("=")?19:18);break;case"|":this.match("|")?this.addToken(26):this.error("Invalid token");break;case"&":this.match("&")?this.addToken(27):this.error("Invalid token");break;case'"':this.string('"');break;case"'":this.string("'");break;case"/":let t=this.tokens[this.tokens.length-1];t&&(t.type==29||t.type==31||t.type==30||t.type==12||t.type==1||t.type==34||t.type==35||t.type==33?this.addToken(6):this.regex());break;case" ":case" ":break;case"\r":case` 2 | `:this.error("New line not allowed");break;case"\0":break;default:this.isDigit(e)?this.number():this.isAlpha(e)?this.identifier():this.error(`Unexpected character ${e}`)}}addToken(e,t){let r=this.source.substring(this.start,this.current);this.tokens.push(new p(e,r,t))}isAtEnd(){return this.current>=this.source.length}advance(){return this.current++,this.source.charAt(this.current-1)}match(e){return this.isAtEnd()||this.source.charAt(this.current)!=e?!1:(this.current++,!0)}peek(){return this.isAtEnd()?"\0":this.source.charAt(this.current)}peekNext(){return this.current+1>=this.source.length?"\0":this.source.charAt(this.current+1)}string(e){let t="",r="";for(;!this.isAtEnd();){let s=this.peek();if(r=="\\")switch(s){case"n":t+=` 3 | `;break;case"r":t+="\r";break;case"t":t+=" ";break;case"\\":t+="\\";break;case e:t+=e;break;default:this.error(`Invalid escape sequence \\${s}`)}else{if(s===e)break;t+=s}r=s,this.advance()}this.isAtEnd()&&this.error("Unterminated string"),this.advance(),this.addToken(30,t)}number(){for(;this.isDigit(this.peek());)this.advance();if(this.peek()=="."&&this.isDigit(this.peekNext()))for(this.advance();this.isDigit(this.peek());)this.advance();this.addToken(31,Number(this.source.substring(this.start,this.current)))}regex(){for(;;){let r=this.peek();if(this.isAtEnd())return this.error("Unterminated regex");if(r=="\\"&&this.peekNext()=="/")this.advance();else if(r=="/"){this.advance();break}this.advance()}let e=this.source.substring(this.start+1,this.current-1),t=[];for(;;){let r=this.peek();if(r=="i"||r=="g"||r=="s"||r=="m"||r=="u"||r=="y")t.push(this.advance());else break}this.addToken(32,new RegExp(e,t.join("")))}identifier(){for(;this.isAlphaNumeric(this.peek());)this.advance();let e=this.source.substring(this.start,this.current),t=29;e=="null"?t=33:e=="true"?t=34:e=="false"&&(t=35),this.addToken(t)}isDigit(e){return"0"<=e&&e<="9"}isAlpha(e){return"a"<=e&&e<="z"||"A"<=e&&e<="Z"||e=="_"}isAlphaNumeric(e){return this.isAlpha(e)||this.isDigit(e)}error(e){throw new Error(`[Syntax error] ${e}`)}},a=class{accept(e){return e.visit(this)}},E=class extends a{constructor(e,t){super();this.operator=e;this.right=t}accept(e){return e.visitUnary(this)}},c=class extends a{constructor(e,t,r){super();this.left=e;this.operator=t;this.right=r}accept(e){return e.visitBinary(this)}},y=class extends a{constructor(e){super();this.value=e}accept(e){return e.visitIdentifier(this)}},k=class extends a{constructor(e){super();this.properties=e}accept(e){return e.visitObjectExpression(this)}},A=class extends a{constructor(e,t){super();this.key=e;this.value=t}accept(e){return e.visitProperty(this)}},v=class extends a{constructor(e){super();this.elements=e}accept(e){return e.visitArrayExpression(this)}},o=class extends a{constructor(e){super();this.value=e}accept(e){return e.visitLiteral(this)}},f=class extends a{constructor(e,t,r){super();this.condition=e;this.ifTrue=t;this.ifFalse=r}accept(e){return e.visitTernary(this)}},m=class extends a{constructor(e,t){super();this.left=e;this.right=t}accept(e){return e.visitNullishCoalescing(this)}},b=class extends a{constructor(e,t){super();this.object=e;this.property=t}accept(e){return e.visitMemberAccess(this)}},x=class extends a{constructor(e,t){super();this.object=e;this.property=t}accept(e){return e.visitComputedMemberAccess(this)}},R=class extends a{constructor(e,t,r){super();this.left=e;this.operator=t;this.right=r}accept(e){return e.visitArrayOperation(this)}},N=class extends a{constructor(e,t,r){super();this.left=e;this.body=t;this.initialValue=r}accept(e){return e.visitArrayReducer(this)}},l=class extends a{constructor(e=0){super();this.index=e}accept(e){return e.visitCurrent(this)}},S=class{constructor(e){this.tokens=e,this.current=0}consume(e,t){if(this.check(e))return this.advance();this.error(t)}match(...e){return this.check(...e)?this.advance():!1}check(...e){return this.isAtEnd()?!1:e.some(t=>t===this.tokens[this.current].type)}advance(){return this.isAtEnd()||this.current++,this.previous()}isAtEnd(){return this.peek().type==36}peek(){return this.tokens[this.current]}peekNext(){return this.isAtEnd()?null:this.tokens[this.current+1]}previous(){return this.tokens[this.current-1]}error(e){throw new Error(e)}},g=class extends S{expression(){return this.ternary()}ternary(){let e=this.nullishCoalescing();if(this.match(20)){let t=this.ternary();this.consume(10,"Expected : after ternary");let r=this.ternary();return new f(e,t,r)}return e}nullishCoalescing(){let e=this.logicalOr();for(;this.match(21);){let t=this.nullishCoalescing();return new m(e,t)}return e}logicalOr(){let e=this.logicalAnd();for(;this.match(26);){let t=this.previous(),r=this.logicalAnd();e=new c(e,t,r)}return e}logicalAnd(){let e=this.equality();for(;this.match(27);){let t=this.previous(),r=this.equality();e=new c(e,t,r)}return e}equality(){let e=this.comparison();for(;this.match(15,24,25);){let t=this.previous(),r=this.comparison();e=new c(e,t,r)}return e}comparison(){let e=this.addition();for(;this.match(16,17,18,19);){let t=this.previous(),r=this.addition();e=new c(e,t,r)}return e}addition(){let e=this.multiplication();for(;this.match(4,5);){let t=this.previous(),r=this.multiplication();e=new c(e,t,r)}return e}multiplication(){let e=this.unary();for(;this.match(6,7,13);){let t=this.previous(),r=this.unary();e=new c(e,t,r)}return e}unary(){if(this.match(14,4)){let e=this.previous(),t=this.unary();return new E(e,t)}return this.memberAccess()}memberAccess(){let e=this.arrayOperation();for(;this.match(3,11);)if(this.previous().type==3){let t=this.consume(29,"Expected property name");e=new b(e,t.lexeme)}else{let t=this.expression();this.consume(12,"Expected ] after property access"),e=new x(e,t)}return e}arrayOperation(){let e=this.primary();for(;this.match(28);){let t=this.consume(29,"Expected array operator after ::");if(["any","all","count","filter","map","reduce"].indexOf(t.lexeme)!==-1){this.consume(0,"Expected ( after array operator type");let r=this.expression();if(t.lexeme==="reduce"){let s;this.match(2)&&(s=this.expression()),e=new N(e,r,s)}else e=new R(e,t.lexeme,r);this.consume(1,"Expected ) after array operator body")}else this.error(`Unknown array operator "${t.lexeme}"`)}return e}primary(){if(this.match(35))return new o(!1);if(this.match(34))return new o(!0);if(this.match(33))return new o(null);if(this.match(31))return new o(this.previous().literal);if(this.match(30))return new o(this.previous().literal);if(this.match(32))return new o(this.previous().literal);if(this.match(22))return new l;if(this.match(23))return new l(1);if(this.match(0)){let e=this.expression();return this.consume(1,"Expect ) after expression"),e}return this.match(11)?this.array():this.match(8)?this.object():this.match(29)?new y(this.previous().lexeme):new o(!0)}array(){let e=[];if(!this.check(12))for(;!this.check(12)&&(e.push(this.expression()),!!this.match(2)););return this.consume(12,"Expect ] after array"),new v(e)}object(){let e=[];if(!this.check(9))for(;!this.check(9);){let t;this.match(29)?t=this.previous().lexeme:this.match(30)?t=this.previous().literal:this.error("Expect property name"),this.consume(10,"Expect : after property name");let r=this.expression();if(e.push(new A(t,r)),!this.match(2))break}return this.consume(9,"Expect } after object"),new k(e)}},w=class{constructor(e){this.lookup=e;this.currentStack=[],this.previousStack=[]}visit(e){return e.accept(this)}visitCurrent(e){return e.index===0?this.currentStack[this.currentStack.length-1]:this.previousStack[this.previousStack.length-1]}visitArrayOperation(e){let t=this.visit(e.left);if(Array.isArray(t)){let r={any:"some",all:"every"};switch(e.operator){case"any":case"filter":case"map":case"all":return t[r[e.operator]??e.operator](s=>{this.currentStack.push(s);let n=this.visit(e.right);return this.currentStack.pop(),n});case"count":return e.right.value===!0?t.length:t.reduce((s,n)=>{this.currentStack.push(n);let h=this.visit(e.right);return this.currentStack.pop(),h?s+1:s},0)}}else return console.info("Array operation on non-array",e,"returning null"),null}visitArrayReducer(e){let t=this.visit(e.left);if(Array.isArray(t)){let r,s=0;e.initialValue!==void 0?r=this.visit(e.initialValue):(r=t[0],s=1);for(let n=s;nr;case 17:return t>=r;case 18:return te.indexOf(r)!==-1):e.indexOf(t)!==-1:Array.isArray(t)?t.indexOf(e)!==-1:!1}visitArrayExpression(e){return e.elements.map(t=>this.visit(t))}visitLiteral(e){return e.value}visitIdentifier(e){return this.lookup(e.value)}visitNullishCoalescing(e){let t=this.visit(e.left);return t||this.visit(e.right)}visitObjectExpression(e){let t={};return e.properties.forEach(r=>{t[r.key]=this.visit(r.value)}),t}visitProperty(e){return this.visit(e.value)}visitUnary(e){let t=this.visit(e.right);switch(e.operator.type){case 4:return-t;case 14:return!t;default:throw new Error("Unknown operator: "+e.operator.lexeme)}}visitTernary(e){return this.visit(e.condition)?this.visit(e.ifTrue):this.visit(e.ifFalse)}visitComputedMemberAccess(e){let t=this.visit(e.object),r=this.visit(e.property);if(t!==void 0)return t[r]}visitMemberAccess(e){let t=this.visit(e.object),r=e.property;if(t!==void 0)return t[r]}};function T(i,e){let r=new d(e).scanTokens(),n=new g(r).expression();return new w(i).visit(n)}panel.plugin("rasteiner/whenquery",{use:[function(i){let e=i.observable({});function t(s){let n=i.component(s).options;i.component(s,{extends:n,created(){e.model=this.model},watch:{model:function(h){e.model=h}}})}t("k-page-view"),t("k-site-view"),t("k-file-view"),t("k-user-view"),t("k-account-view");let r=i.prototype.$helper.field.isVisible;i.prototype.$helper.field.isVisible=function(s,n){return r(s,n)?s.whenQuery?T(u=>u[0]==="_"?e?.model?.[u.substr(1)]:n?.[u.toLowerCase()]!==void 0?n[u.toLowerCase()]:panel?.app?.$store?.getters["content/values"]()[u.toLowerCase()],s.whenQuery):!0:!1}}]});})(); 4 | -------------------------------------------------------------------------------- /src/lib/interpreter.ts: -------------------------------------------------------------------------------- 1 | enum TokenType { 2 | //Single Char tokens 3 | LEFT_PAREN, RIGHT_PAREN, COMMA, DOT, MINUS, PLUS, SLASH, STAR, 4 | LEFT_BRACE, RIGHT_BRACE, COLON, 5 | LEFT_BRACKET, RIGHT_BRACKET, MOD, 6 | 7 | //One or two char tokens 8 | BANG, BANG_EQUAL, 9 | GREATER, GREATER_EQUAL, 10 | LESS, LESS_EQUAL, 11 | QUESTION, DOUBLE_QUESTION, 12 | CURRENT, PREVIOUS, 13 | EQUAL, SEARCH, 14 | 15 | //Two char tokens 16 | OR, AND, DOUBLE_COLON, 17 | 18 | //Literals 19 | IDENTIFIER, STRING, NUMBER, REGEX, 20 | 21 | //KEYWORDS 22 | NULL, TRUE, FALSE, 23 | 24 | EOF, ILLEGAL 25 | } 26 | 27 | class Token { 28 | constructor(public type:TokenType, public lexeme:string, public literal:object) { 29 | } 30 | 31 | toString() { 32 | return `Token(${this.type}, ${this.lexeme}, ${this.literal})`; 33 | } 34 | } 35 | 36 | class Scanner { 37 | source:String; 38 | tokens:Token[]; 39 | start:number; 40 | current:number; 41 | 42 | constructor(source:String) { 43 | this.source = source; 44 | } 45 | 46 | public scanTokens():Token[] { 47 | this.tokens = []; 48 | this.start = 0; 49 | this.current = 0; 50 | 51 | while (!this.isAtEnd()) { 52 | this.start = this.current; 53 | this.scanToken(); 54 | } 55 | 56 | this.tokens.push(new Token(TokenType.EOF, '', null)); 57 | return this.tokens; 58 | } 59 | 60 | private scanToken() { 61 | let c = this.advance(); 62 | switch (c) { 63 | case '(': this.addToken(TokenType.LEFT_PAREN); break; 64 | case ')': this.addToken(TokenType.RIGHT_PAREN); break; 65 | case '{': this.addToken(TokenType.LEFT_BRACE); break; 66 | case '}': this.addToken(TokenType.RIGHT_BRACE); break; 67 | case '[': this.addToken(TokenType.LEFT_BRACKET); break; 68 | case ']': this.addToken(TokenType.RIGHT_BRACKET); break; 69 | case ',': this.addToken(TokenType.COMMA); break; 70 | case '.': this.addToken(TokenType.DOT); break; 71 | case '+': this.addToken(TokenType.PLUS); break; 72 | case '*': this.addToken(TokenType.STAR); break; 73 | case '-': this.addToken(TokenType.MINUS); break; 74 | case '%': this.addToken(TokenType.MOD); break; 75 | case '$': this.addToken(this.match('1') ? TokenType.PREVIOUS : TokenType.CURRENT); break; 76 | case ':': this.addToken(this.match(':') ? TokenType.DOUBLE_COLON : TokenType.COLON); break; 77 | case '=': this.addToken(this.match('~') ? TokenType.SEARCH : TokenType.EQUAL); break; 78 | case '?': this.addToken(this.match('?') ? TokenType.DOUBLE_QUESTION : TokenType.QUESTION); break; 79 | case '!': this.addToken(this.match('=') ? TokenType.BANG_EQUAL : TokenType.BANG); break; 80 | case '>': this.addToken(this.match('=') ? TokenType.GREATER_EQUAL : TokenType.GREATER); break; 81 | case '<': this.addToken(this.match('=') ? TokenType.LESS_EQUAL : TokenType.LESS); break; 82 | 83 | case '|': 84 | if (this.match('|')) { 85 | this.addToken(TokenType.OR); 86 | } else { 87 | this.error('Invalid token'); 88 | } 89 | break; 90 | case '&': 91 | if (this.match('&')) { 92 | this.addToken(TokenType.AND); 93 | } else { 94 | this.error('Invalid token'); 95 | } 96 | break; 97 | case '"': this.string('"'); break; 98 | case '\'': this.string('\''); break; 99 | case '/': 100 | // determine if this is a regex or a division operator 101 | // if the previous token is any kind of identifier, literal or closing bracket, then this is a division 102 | const prev = this.tokens[this.tokens.length - 1]; 103 | if(prev) { 104 | if(prev.type == TokenType.IDENTIFIER 105 | || prev.type == TokenType.NUMBER 106 | || prev.type == TokenType.STRING 107 | || prev.type == TokenType.RIGHT_BRACKET 108 | || prev.type == TokenType.RIGHT_PAREN 109 | || prev.type == TokenType.TRUE 110 | || prev.type == TokenType.FALSE 111 | || prev.type == TokenType.NULL) { 112 | this.addToken(TokenType.SLASH); 113 | } else { 114 | this.regex(); 115 | } 116 | } 117 | 118 | break; 119 | case ' ': 120 | case '\t': 121 | break; 122 | case '\r': 123 | case '\n': 124 | this.error('New line not allowed'); 125 | break; 126 | case '\0': 127 | break; 128 | default: 129 | if (this.isDigit(c)) { 130 | this.number(); 131 | } else if (this.isAlpha(c)) { 132 | this.identifier(); 133 | } else { 134 | this.error(`Unexpected character ${c}`); 135 | } 136 | } 137 | } 138 | 139 | private addToken(type:TokenType, literal?:Object) { 140 | let text = this.source.substring(this.start, this.current); 141 | this.tokens.push(new Token(type, text, literal)); 142 | } 143 | 144 | private isAtEnd() { 145 | return this.current >= this.source.length; 146 | } 147 | 148 | private advance() { 149 | this.current++; 150 | return this.source.charAt(this.current - 1); 151 | } 152 | 153 | private match(expected:String) { 154 | if (this.isAtEnd()) return false; 155 | if (this.source.charAt(this.current) != expected) return false; 156 | 157 | this.current++; 158 | return true; 159 | } 160 | 161 | private peek() { 162 | if (this.isAtEnd()) return '\0'; 163 | return this.source.charAt(this.current); 164 | } 165 | 166 | private peekNext() { 167 | if (this.current + 1 >= this.source.length) return '\0'; 168 | return this.source.charAt(this.current + 1); 169 | } 170 | 171 | private string(terminator:String) { 172 | let value = ''; 173 | let prev = ''; 174 | 175 | while (!this.isAtEnd()) { 176 | let c = this.peek(); 177 | if(prev == '\\') { 178 | switch(c) { 179 | case 'n': value += '\n'; break; 180 | case 'r': value += '\r'; break; 181 | case 't': value += '\t'; break; 182 | case '\\': value += '\\'; break; 183 | case terminator: value += terminator; break; 184 | default: 185 | this.error(`Invalid escape sequence \\${c}`); 186 | } 187 | } else { 188 | if (c === terminator) break; 189 | value += c; 190 | } 191 | prev = c; 192 | this.advance(); 193 | } 194 | 195 | if (this.isAtEnd()) { 196 | this.error('Unterminated string'); 197 | } 198 | 199 | this.advance(); 200 | this.addToken(TokenType.STRING, value); 201 | } 202 | 203 | private number() { 204 | while (this.isDigit(this.peek())) { 205 | this.advance(); 206 | } 207 | 208 | if (this.peek() == '.' && this.isDigit(this.peekNext())) { 209 | this.advance(); 210 | 211 | while (this.isDigit(this.peek())) { 212 | this.advance(); 213 | } 214 | } 215 | 216 | this.addToken(TokenType.NUMBER, Number(this.source.substring(this.start, this.current))); 217 | } 218 | 219 | private regex() { 220 | while (true) { 221 | const c = this.peek(); 222 | if (this.isAtEnd()) { 223 | return this.error('Unterminated regex'); 224 | } 225 | if (c == '\\' && this.peekNext() == '/') { 226 | this.advance(); 227 | } else if (c == '/') { 228 | this.advance(); 229 | break; 230 | } 231 | this.advance(); 232 | } 233 | 234 | const text = this.source.substring(this.start + 1, this.current - 1); 235 | 236 | // parse flags 237 | const flags = []; 238 | while (true) { 239 | const flag = this.peek(); 240 | if (flag == 'i' || flag == 'g' || flag == 's' || flag == 'm' || flag == 'u' || flag == 'y') { 241 | flags.push(this.advance()); 242 | } else { 243 | break; 244 | } 245 | } 246 | 247 | this.addToken(TokenType.REGEX, new RegExp(text, flags.join(''))); 248 | } 249 | 250 | private identifier() { 251 | while (this.isAlphaNumeric(this.peek())) { 252 | this.advance(); 253 | } 254 | 255 | let text = this.source.substring(this.start, this.current); 256 | let type = TokenType.IDENTIFIER; 257 | 258 | if (text == 'null') { 259 | type = TokenType.NULL; 260 | } else if (text == 'true') { 261 | type = TokenType.TRUE; 262 | } else if(text == 'false') { 263 | type = TokenType.FALSE; 264 | } 265 | this.addToken(type); 266 | } 267 | 268 | private isDigit(c:String) { 269 | return '0' <= c && c <= '9'; 270 | } 271 | 272 | private isAlpha(c:String) { 273 | return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || c == '_'; 274 | } 275 | 276 | private isAlphaNumeric(c:String) { 277 | return this.isAlpha(c) || this.isDigit(c); 278 | } 279 | 280 | private error(message:String) { 281 | throw new Error(`[Syntax error] ${message}`); 282 | } 283 | } 284 | 285 | interface Visitor { 286 | visit(node:ExpressionNode):any; 287 | visitUnary(node:Unary):any; 288 | visitBinary(node:Binary):any; 289 | visitIdentifier(node:Identifier):any; 290 | visitObjectExpression(node:ObjectExpression):any; 291 | visitArrayExpression(node:ArrayExpression):any; 292 | visitProperty(node:Property):any; 293 | visitTernary(node:Ternary):any; 294 | visitNullishCoalescing(node:NullishCoalescing):any; 295 | visitArrayExpression(node:ArrayExpression):any; 296 | visitLiteral(node:Literal):any; 297 | visitMemberAccess(node:MemberAccess):any; 298 | visitComputedMemberAccess(node:ComputedMemberAccess):any; 299 | visitArrayOperation(node:ArrayOperation):any; 300 | visitArrayReducer(node:ArrayReducer):any; 301 | visitCurrent(node:Current):any; 302 | } 303 | 304 | class ExpressionNode { 305 | accept(visitor: Visitor) { 306 | return visitor.visit(this); 307 | } 308 | } 309 | 310 | class Unary extends ExpressionNode { 311 | constructor(public operator:Token, public right:ExpressionNode) { 312 | super(); 313 | } 314 | 315 | accept(visitor: Visitor) { 316 | return visitor.visitUnary(this); 317 | } 318 | } 319 | 320 | class Binary extends ExpressionNode { 321 | constructor(public left:ExpressionNode, public operator:Token, public right:ExpressionNode) { 322 | super(); 323 | } 324 | 325 | accept(visitor: Visitor) { 326 | return visitor.visitBinary(this); 327 | } 328 | } 329 | 330 | class Identifier extends ExpressionNode { 331 | constructor(public value:string) { 332 | super(); 333 | } 334 | 335 | accept(visitor: Visitor) { 336 | return visitor.visitIdentifier(this); 337 | } 338 | } 339 | 340 | class ObjectExpression extends ExpressionNode { 341 | constructor(public properties:Array) { 342 | super(); 343 | } 344 | 345 | accept(visitor: Visitor) { 346 | return visitor.visitObjectExpression(this); 347 | } 348 | } 349 | 350 | class Property extends ExpressionNode { 351 | constructor(public key:string, public value:ExpressionNode) { 352 | super(); 353 | } 354 | 355 | accept(visitor: Visitor) { 356 | return visitor.visitProperty(this); 357 | } 358 | } 359 | 360 | class ArrayExpression extends ExpressionNode { 361 | constructor(public elements:Array) { 362 | super(); 363 | } 364 | 365 | accept(visitor: Visitor) { 366 | return visitor.visitArrayExpression(this); 367 | } 368 | } 369 | 370 | class Literal extends ExpressionNode { 371 | constructor(public value:any) { 372 | super(); 373 | } 374 | 375 | accept(visitor: Visitor) { 376 | return visitor.visitLiteral(this); 377 | } 378 | } 379 | 380 | class Ternary extends ExpressionNode { 381 | constructor(public condition:ExpressionNode, public ifTrue:ExpressionNode, public ifFalse:ExpressionNode) { 382 | super(); 383 | } 384 | 385 | accept(visitor: Visitor) { 386 | return visitor.visitTernary(this); 387 | } 388 | } 389 | 390 | class NullishCoalescing extends ExpressionNode { 391 | constructor(public left:ExpressionNode, public right:ExpressionNode) { 392 | super(); 393 | } 394 | 395 | accept(visitor: Visitor) { 396 | return visitor.visitNullishCoalescing(this); 397 | } 398 | } 399 | 400 | class MemberAccess extends ExpressionNode { 401 | constructor(public object:ExpressionNode, public property:string) { 402 | super(); 403 | } 404 | 405 | accept(visitor: Visitor) { 406 | return visitor.visitMemberAccess(this); 407 | } 408 | } 409 | 410 | class ComputedMemberAccess extends ExpressionNode { 411 | constructor(public object:ExpressionNode, public property:ExpressionNode) { 412 | super(); 413 | } 414 | 415 | accept(visitor: Visitor) { 416 | return visitor.visitComputedMemberAccess(this); 417 | } 418 | } 419 | 420 | class ArrayOperation extends ExpressionNode { 421 | constructor(public left:ExpressionNode, public operator:string, public right:ExpressionNode) { 422 | super(); 423 | } 424 | 425 | accept(visitor: Visitor) { 426 | return visitor.visitArrayOperation(this); 427 | } 428 | } 429 | 430 | class ArrayReducer extends ExpressionNode { 431 | constructor(public left:ExpressionNode, public body:ExpressionNode, public initialValue:ExpressionNode) { 432 | super(); 433 | } 434 | 435 | accept(visitor: Visitor) { 436 | return visitor.visitArrayReducer(this); 437 | } 438 | } 439 | 440 | class Current extends ExpressionNode { 441 | constructor(public index:number = 0) { 442 | super(); 443 | } 444 | 445 | accept(visitor: Visitor) { 446 | return visitor.visitCurrent(this); 447 | } 448 | } 449 | 450 | abstract class Parser { 451 | tokens:Token[]; 452 | current:number; 453 | 454 | constructor(tokens:Token[]) { 455 | this.tokens = tokens; 456 | this.current = 0; 457 | } 458 | 459 | consume(type:TokenType, message:string) { 460 | if (this.check(type)) { 461 | return this.advance(); 462 | } 463 | 464 | this.error(message); 465 | } 466 | 467 | match(...types:TokenType[]) { 468 | if(this.check(...types)) { 469 | return this.advance(); 470 | } 471 | 472 | return false; 473 | } 474 | 475 | check(...types:TokenType[]):boolean { 476 | if (this.isAtEnd()) return false; 477 | return types.some(type => type === this.tokens[this.current].type); 478 | } 479 | 480 | advance() { 481 | if (!this.isAtEnd()) { 482 | this.current++; 483 | } 484 | return this.previous(); 485 | } 486 | 487 | isAtEnd() { 488 | return this.peek().type == TokenType.EOF; 489 | } 490 | 491 | peek() { 492 | return this.tokens[this.current]; 493 | } 494 | 495 | peekNext() { 496 | if (this.isAtEnd()) return null; 497 | return this.tokens[this.current + 1]; 498 | } 499 | 500 | previous() { 501 | return this.tokens[this.current - 1]; 502 | } 503 | 504 | error(message:string) { 505 | throw new Error(message); 506 | } 507 | } 508 | 509 | class KXLParser extends Parser { 510 | expression() { 511 | return this.ternary(); 512 | } 513 | 514 | ternary() { 515 | let expr = this.nullishCoalescing(); 516 | 517 | if (this.match(TokenType.QUESTION)) { 518 | let trueExpr = this.ternary(); 519 | this.consume(TokenType.COLON, 'Expected : after ternary'); 520 | let falseExpr = this.ternary(); 521 | return new Ternary(expr, trueExpr, falseExpr); 522 | } 523 | 524 | return expr; 525 | } 526 | 527 | nullishCoalescing() { 528 | let expr = this.logicalOr(); 529 | 530 | while (this.match(TokenType.DOUBLE_QUESTION)) { 531 | let right = this.nullishCoalescing(); 532 | return new NullishCoalescing(expr, right); 533 | } 534 | 535 | return expr; 536 | } 537 | 538 | logicalOr() { 539 | let left = this.logicalAnd(); 540 | 541 | while (this.match(TokenType.OR)) { 542 | let operator = this.previous(); 543 | let right = this.logicalAnd(); 544 | left = new Binary(left, operator, right); 545 | } 546 | 547 | return left; 548 | } 549 | 550 | logicalAnd() { 551 | let left = this.equality(); 552 | 553 | while (this.match(TokenType.AND)) { 554 | let operator = this.previous(); 555 | let right = this.equality(); 556 | left = new Binary(left, operator, right); 557 | } 558 | 559 | return left; 560 | } 561 | 562 | equality() { 563 | let expr = this.comparison(); 564 | 565 | while (this.match(TokenType.BANG_EQUAL, TokenType.EQUAL, TokenType.SEARCH)) { 566 | let operator = this.previous(); 567 | let right = this.comparison(); 568 | expr = new Binary(expr, operator, right); 569 | } 570 | 571 | return expr; 572 | } 573 | 574 | comparison() { 575 | let expr = this.addition(); 576 | 577 | while (this.match(TokenType.GREATER, TokenType.GREATER_EQUAL, TokenType.LESS, TokenType.LESS_EQUAL)) { 578 | let operator = this.previous(); 579 | let right = this.addition(); 580 | expr = new Binary(expr, operator, right); 581 | } 582 | 583 | return expr; 584 | } 585 | 586 | addition() { 587 | let expr = this.multiplication(); 588 | 589 | while (this.match(TokenType.MINUS, TokenType.PLUS)) { 590 | let operator = this.previous(); 591 | let right = this.multiplication(); 592 | expr = new Binary(expr, operator, right); 593 | } 594 | 595 | return expr; 596 | } 597 | 598 | multiplication() { 599 | let expr = this.unary(); 600 | 601 | while (this.match(TokenType.SLASH, TokenType.STAR, TokenType.MOD)) { 602 | let operator = this.previous(); 603 | let right = this.unary(); 604 | expr = new Binary(expr, operator, right); 605 | } 606 | 607 | return expr; 608 | } 609 | 610 | unary() { 611 | if (this.match(TokenType.BANG, TokenType.MINUS)) { 612 | let operator = this.previous(); 613 | let right = this.unary(); 614 | return new Unary(operator, right); 615 | } 616 | 617 | return this.memberAccess(); 618 | } 619 | 620 | memberAccess() { 621 | let expr = this.arrayOperation(); 622 | 623 | while (this.match(TokenType.DOT, TokenType.LEFT_BRACKET)) { 624 | if (this.previous().type == TokenType.DOT) { 625 | let property = this.consume(TokenType.IDENTIFIER, 'Expected property name'); 626 | expr = new MemberAccess(expr, property.lexeme); 627 | } else { 628 | let property = this.expression(); 629 | this.consume(TokenType.RIGHT_BRACKET, 'Expected ] after property access'); 630 | expr = new ComputedMemberAccess(expr, property); 631 | } 632 | } 633 | 634 | return expr; 635 | } 636 | 637 | arrayOperation() { 638 | let expr = this.primary(); 639 | 640 | while(this.match(TokenType.DOUBLE_COLON)) { 641 | const operator = this.consume(TokenType.IDENTIFIER, 'Expected array operator after ::'); 642 | if (['any', 'all', 'count', 'filter', 'map', 'reduce'].indexOf(operator.lexeme) !== -1) { 643 | this.consume(TokenType.LEFT_PAREN, 'Expected ( after array operator type'); 644 | const body = this.expression(); 645 | if(operator.lexeme === 'reduce') { 646 | let initialValue = undefined; 647 | if (this.match(TokenType.COMMA)) { 648 | initialValue = this.expression(); 649 | } 650 | expr = new ArrayReducer(expr, body, initialValue); 651 | } else { 652 | expr = new ArrayOperation(expr, operator.lexeme, body); 653 | } 654 | 655 | this.consume(TokenType.RIGHT_PAREN, 'Expected ) after array operator body'); 656 | } else { 657 | this.error(`Unknown array operator "${operator.lexeme}"`); 658 | } 659 | } 660 | 661 | return expr; 662 | } 663 | 664 | primary() { 665 | 666 | if (this.match(TokenType.FALSE)) return new Literal(false); 667 | if (this.match(TokenType.TRUE)) return new Literal(true); 668 | if (this.match(TokenType.NULL)) return new Literal(null); 669 | if (this.match(TokenType.NUMBER)) return new Literal(this.previous().literal); 670 | if (this.match(TokenType.STRING)) return new Literal(this.previous().literal); 671 | if (this.match(TokenType.REGEX)) return new Literal(this.previous().literal); 672 | 673 | if (this.match(TokenType.CURRENT)) return new Current(); 674 | if (this.match(TokenType.PREVIOUS)) return new Current(1); 675 | 676 | if (this.match(TokenType.LEFT_PAREN)) { 677 | let expr = this.expression(); 678 | this.consume(TokenType.RIGHT_PAREN, 'Expect ) after expression'); 679 | return expr; 680 | } 681 | 682 | if (this.match(TokenType.LEFT_BRACKET)) { 683 | return this.array(); 684 | } 685 | 686 | if (this.match(TokenType.LEFT_BRACE)) { 687 | return this.object(); 688 | } 689 | 690 | if (this.match(TokenType.IDENTIFIER)) { 691 | return new Identifier(this.previous().lexeme); 692 | } 693 | 694 | return new Literal(true); 695 | } 696 | 697 | 698 | array() { 699 | let elements = []; 700 | 701 | if (!this.check(TokenType.RIGHT_BRACKET)) { 702 | while (!this.check(TokenType.RIGHT_BRACKET)) { 703 | elements.push(this.expression()); 704 | 705 | if (this.match(TokenType.COMMA)) { 706 | continue; 707 | } 708 | 709 | break; 710 | } 711 | } 712 | 713 | this.consume(TokenType.RIGHT_BRACKET, 'Expect ] after array'); 714 | return new ArrayExpression(elements); 715 | } 716 | 717 | object() { 718 | let properties = []; 719 | 720 | if (!this.check(TokenType.RIGHT_BRACE)) { 721 | while (!this.check(TokenType.RIGHT_BRACE)) { 722 | let name = undefined; 723 | if(this.match(TokenType.IDENTIFIER)) { 724 | name = this.previous().lexeme; 725 | } else if (this.match(TokenType.STRING)) { 726 | name = this.previous().literal; 727 | } else { 728 | this.error(`Expect property name`); 729 | } 730 | 731 | this.consume(TokenType.COLON, 'Expect : after property name'); 732 | 733 | let value = this.expression(); 734 | 735 | properties.push(new Property(name, value)); 736 | 737 | if (this.match(TokenType.COMMA)) { 738 | continue; 739 | } 740 | 741 | break; 742 | } 743 | } 744 | 745 | this.consume(TokenType.RIGHT_BRACE, 'Expect } after object'); 746 | 747 | return new ObjectExpression(properties); 748 | } 749 | } 750 | 751 | 752 | class Interpreter implements Visitor { 753 | private currentStack: any[]; 754 | private previousStack: any[]; 755 | 756 | constructor(public lookup:(variableName:string) => any) { 757 | this.currentStack = []; 758 | this.previousStack = []; 759 | } 760 | 761 | visit(node:ExpressionNode) { 762 | return node.accept(this); 763 | } 764 | 765 | visitCurrent(current:Current) { 766 | if(current.index === 0) { 767 | return this.currentStack[this.currentStack.length - 1]; 768 | } else { 769 | return this.previousStack[this.previousStack.length - 1]; 770 | } 771 | } 772 | 773 | visitArrayOperation(node: ArrayOperation) { 774 | const array = this.visit(node.left); 775 | if(Array.isArray(array)) { 776 | const jsname = { 777 | 'any': 'some', 778 | 'all': 'every' 779 | } 780 | 781 | switch(node.operator) { 782 | case 'any': 783 | case 'filter': 784 | case 'map': 785 | case 'all': 786 | return array[jsname[node.operator] ?? node.operator](item => { 787 | this.currentStack.push(item); 788 | const result = this.visit(node.right); 789 | this.currentStack.pop(); 790 | return result; 791 | }); 792 | case 'count': 793 | //shortcut for literal true 794 | if((node.right as Literal).value === true) { 795 | return array.length; 796 | } 797 | 798 | return array.reduce((count, item) => { 799 | this.currentStack.push(item); 800 | const result = this.visit(node.right); 801 | this.currentStack.pop(); 802 | return result ? count + 1 : count; 803 | }, 0); 804 | } 805 | } else { 806 | console.info('Array operation on non-array', node, 'returning null'); 807 | return null; 808 | } 809 | } 810 | 811 | visitArrayReducer(node: ArrayReducer) { 812 | const array = this.visit(node.left); 813 | if(Array.isArray(array)) { 814 | let value; 815 | let startIndex = 0; 816 | 817 | if(node.initialValue !== undefined) { 818 | //reduce with initial value 819 | value = this.visit(node.initialValue); 820 | } else { 821 | // reduce without initial value: initial value is first element 822 | value = array[0]; 823 | startIndex = 1; 824 | } 825 | 826 | for(let i = startIndex; i < array.length; i++) { 827 | this.currentStack.push(array[i]); 828 | this.previousStack.push(value); 829 | value = this.visit(node.body); 830 | this.previousStack.pop(); 831 | this.currentStack.pop(); 832 | } 833 | 834 | return value; 835 | } else { 836 | console.info('Array reducer on non-array', node, 'returning null'); 837 | return null; 838 | } 839 | } 840 | 841 | visitBinary(node:Binary) { 842 | let left = this.visit(node.left); 843 | let right = this.visit(node.right); 844 | 845 | switch (node.operator.type) { 846 | case TokenType.PLUS: 847 | return left + right; 848 | case TokenType.MINUS: 849 | return left - right; 850 | case TokenType.STAR: 851 | return left * right; 852 | case TokenType.SLASH: 853 | return left / right; 854 | case TokenType.GREATER: 855 | return left > right; 856 | case TokenType.GREATER_EQUAL: 857 | return left >= right; 858 | case TokenType.LESS: 859 | return left < right; 860 | case TokenType.LESS_EQUAL: 861 | return left <= right; 862 | case TokenType.BANG_EQUAL: 863 | return left != right; 864 | case TokenType.EQUAL: 865 | return left == right; 866 | case TokenType.AND: 867 | return left && right; 868 | case TokenType.OR: 869 | return left || right; 870 | case TokenType.MOD: 871 | return left % right; 872 | case TokenType.SEARCH: 873 | return this.search(left, right); 874 | default: 875 | throw new Error("Unknown operator: " + node.operator.lexeme); 876 | } 877 | } 878 | search(left:any, right:any) { 879 | if (typeof left === "string" && typeof right === "string") { 880 | return left.indexOf(right) !== -1; 881 | } 882 | 883 | if (typeof left === "number" && typeof right === "number") { 884 | return left % right == 0; 885 | } 886 | 887 | if (typeof left === "string" && right instanceof RegExp) { 888 | return right.test(left); 889 | } 890 | 891 | if (Array.isArray(left)) { 892 | if(Array.isArray(right)) { 893 | return right.every(x => left.indexOf(x) !== -1); 894 | } else { 895 | return left.indexOf(right) !== -1; 896 | } 897 | } else if (Array.isArray(right)) { 898 | return right.indexOf(left) !== -1; 899 | } 900 | 901 | return false; 902 | } 903 | visitArrayExpression(node:ArrayExpression) { 904 | return node.elements.map(e => this.visit(e)); 905 | } 906 | visitLiteral(node:Literal) { 907 | return node.value; 908 | } 909 | visitIdentifier(node:Identifier) { 910 | return this.lookup(node.value); 911 | } 912 | visitNullishCoalescing(node:NullishCoalescing) { 913 | let left = this.visit(node.left); 914 | if (left) return left; 915 | return this.visit(node.right); 916 | } 917 | visitObjectExpression(node:ObjectExpression) { 918 | let obj = {}; 919 | node.properties.forEach(p => { 920 | obj[p.key] = this.visit(p.value); 921 | }); 922 | return obj; 923 | } 924 | visitProperty(node:Property) { 925 | return this.visit(node.value); 926 | } 927 | visitUnary(node:Unary) { 928 | let right = this.visit(node.right); 929 | 930 | switch (node.operator.type) { 931 | case TokenType.MINUS: 932 | return -right; 933 | case TokenType.BANG: 934 | return !right; 935 | default: 936 | throw new Error("Unknown operator: " + node.operator.lexeme); 937 | } 938 | } 939 | visitTernary(node:Ternary) { 940 | let condition = this.visit(node.condition); 941 | if (condition) { 942 | return this.visit(node.ifTrue); 943 | } 944 | return this.visit(node.ifFalse); 945 | } 946 | visitComputedMemberAccess(node:ComputedMemberAccess) { 947 | let object = this.visit(node.object); 948 | let key = this.visit(node.property); 949 | if(object !== undefined) { 950 | return object[key]; 951 | } 952 | return undefined; 953 | } 954 | visitMemberAccess(node:MemberAccess) { 955 | let object = this.visit(node.object); 956 | let key = node.property; 957 | if(object !== undefined) { 958 | return object[key]; 959 | } 960 | return undefined; 961 | } 962 | } 963 | 964 | export default function run(context, code) { 965 | const scanner = new Scanner(code); 966 | const tokens = scanner.scanTokens(); 967 | const parser = new KXLParser(tokens); 968 | const dst = parser.expression(); 969 | const interpreter = new Interpreter(context); 970 | return interpreter.visit(dst); 971 | } 972 | --------------------------------------------------------------------------------