├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ └── feature-request.yml └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── LICENSE ├── README.md ├── jest.config.ts ├── package-lock.json ├── package.json ├── scripts └── build-grammar.ts ├── src ├── index.ts ├── parser │ ├── 1-source-to-cst │ │ ├── __tests__ │ │ │ ├── element-node.test.ts │ │ │ ├── index.ts │ │ │ ├── text-node.test.ts │ │ │ └── utils.ts │ │ └── index.ts │ ├── 2-cst-to-ast │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ └── index.ts.snap │ │ │ ├── element-node.test.ts │ │ │ ├── index.ts │ │ │ ├── text-node.test.ts │ │ │ └── utils.ts │ │ ├── ast-builder.ts │ │ ├── index.ts │ │ └── utils.ts │ ├── errors.ts │ └── grammar │ │ ├── __tests__ │ │ ├── grammar.test.ts │ │ └── index.ts │ │ ├── index.ts │ │ └── liquidx.ohm ├── renderer │ ├── __tests__ │ │ ├── attributes.test.ts │ │ ├── formatting.test.ts │ │ ├── index.ts │ │ ├── nested.test.ts │ │ ├── self-closing.test.ts │ │ ├── simple.test.ts │ │ ├── source.test.ts │ │ └── utils.ts │ └── index.ts └── utils │ └── tests │ ├── get-relative-dirname-from-absolute-dirname.ts │ ├── get-root-suite-name.ts │ ├── get-suite-name.ts │ ├── index.ts │ └── require-all.ts ├── tsconfig.json └── tsup.config.ts /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug 3 | title: '' 4 | labels: 5 | - 'bug' 6 | body: 7 | - type: textarea 8 | id: steps_to_reproduce 9 | attributes: 10 | label: Steps to reproduce 11 | description: Let us know the exact steps required to reproduce the bug. The more details, the better! 12 | value: |- 13 | 1. 14 | 2. 15 | 3. 16 | validations: 17 | required: true 18 | - type: textarea 19 | id: expected_behavior 20 | attributes: 21 | label: Expected Behavior 22 | description: What do you think should have happened? 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: actual_behavior 27 | attributes: 28 | label: Actual Behavior 29 | description: What actually happened? 30 | validations: 31 | required: true 32 | - type: markdown 33 | attributes: 34 | value: | 35 | ## Environment Details 36 | - type: input 37 | id: os 38 | attributes: 39 | label: Operating System 40 | placeholder: Windows 11, Mac OS Monterey, Ubuntu 20.04... 41 | validations: 42 | required: true 43 | - type: input 44 | id: version 45 | attributes: 46 | label: LiquidX version (check your project's `package.json` if you're not sure) 47 | validations: 48 | required: true 49 | - type: input 50 | id: shell 51 | attributes: 52 | label: Shell 53 | placeholder: Cygwin, Git Bash, iTerm2, bash, zsh... 54 | - type: input 55 | id: node_version 56 | attributes: 57 | label: Node version (run `node -v` if you're not sure) 58 | placeholder: v18.0.0 59 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Twitter 4 | url: https://twitter.com/unshopable 5 | about: Connect with us and the LiquidX community, and get notified about updates 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature, or changes to an existing one 3 | title: '' 4 | labels: 5 | - 'enhancement' 6 | body: 7 | - type: dropdown 8 | id: type 9 | attributes: 10 | label: What type of change do you want to see? 11 | options: 12 | - New feature 13 | - Change to an existing feature 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: overview 18 | attributes: 19 | label: Overview 20 | description: Please describe the feature you'd like to be added to LiquidX. 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: motivation 25 | attributes: 26 | label: Motivation 27 | description: What inspired this feature request? What problems were you facing? 28 | validations: 29 | required: true 30 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | ### Summary 10 | 11 | 15 | 16 | ### Why are these changes introduced? 17 | 18 | 22 | 23 | ### What approach did you take? 24 | 25 | 29 | 30 | ### How did you test the change(s) to make sure everything is working properly? 31 | 32 | 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | dist/ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 100 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 thorborn GmbH & Brand Boosting GmbH 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LiquidX 2 | 3 | LiquidX is a XML-like syntax extension to Shopify's Liquid template language. It's not intended to run on Shopify's servers, thus needs to be used by preprocessors (transpilers) to transform it into standard Liquid. 4 | 5 | ```liquid 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Product 1 15 | 16 | 17 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt 18 | ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud 19 | exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ``` 28 | 29 | ![liquidx-preview](https://github.com/unshopable/liquidx/assets/64148345/1a4fcdc3-bae2-4f64-85c7-5056a4795db9) 30 | 31 | ## Table of Contents 32 | 33 | - [Motivation](#motivation) 34 | - [Getting started](#getting-started) 35 | - [Components](#components) 36 | - [Props](#props) 37 | - [Contributing](#contributing) 38 | - [License](#license) 39 | 40 | ## Motivation 41 | 42 | The purpose of LiquidX is to improve the developer experience and speed up the development process tremendously – we're talking 10x here. It achieves this goal by making it almost trivial to implement design systems and component libraries. 43 | 44 | Out of the box, Liquid does not support nested structures for components (aka snippets) which makes it hard – or even impossible in some cases – to create really reusable components. LiquidX introduces a concise and familiar syntax for defining tree structures with attributes while adding almost no syntactic footprint. 45 | 46 | ## Getting started 47 | 48 | > **Note** 49 | > If you're not using any build tools yet, the fastest way to implement LiquidX is [Melter](https://github.com/unshopable/melter) with the [LiquidX Melter Plugin](https://github.com/unshopable/melter-plugin-liquidx). 50 | 51 | This package exports a `render` function which expects a string. If this string contains LiquidX syntax than it's rendered to Shopify-compatible code. 52 | 53 | To illustrate how easy it is to implement LiquidX yourself in your Shopify theme projects, we'll do a quick implementation with [Melter](https://github.com/unshopable/melter). 54 | 55 | Assuming that you already installed Melter, create a new file: 56 | 57 | ```diff 58 | melter-liquidx 59 | ├── node_modules 60 | ├── src 61 | │ └── ... 62 | ├── melter.config.js 63 | + ├── liquidx-plugin.js 64 | ├── package-lock.json 65 | └── package.json 66 | ``` 67 | 68 | **liquidx-plugin.js** 69 | 70 | ```js 71 | const { render } = require('@unshopable/liquidx'); 72 | const { Plugin } = require('@unshopable/melter'); 73 | 74 | class LiquidXPlugin extends Plugin { 75 | apply(compiler): void { 76 | compiler.hooks.emitter.tap('LiquidXPlugin', (emitter) => { 77 | emitter.hooks.beforeAssetAction.tap('LiquidXPlugin', (asset) => { 78 | if (asset.action !== 'remove') { 79 | asset.content = Buffer.from(render(asset.content.toString())); 80 | } 81 | }); 82 | }); 83 | } 84 | } 85 | 86 | module.exports = LiquidXPlugin; 87 | ``` 88 | 89 | Now add this to your melter config: 90 | 91 | ```diff 92 | + const LiquidXPlugin = require('./liquidx-plugin.js'); 93 | 94 | /** @type {import("@unshopable/melter").MelterConfig} */ 95 | const melterConfig = { 96 | + plugins: [ 97 | + new LiquidXPlugin(), 98 | + ], 99 | }; 100 | 101 | module.exports = melterConfig; 102 | ``` 103 | 104 | ## Components 105 | 106 | Now that LiquidX is ready to be transpiled, let's talk about how to create components. Let's take a look at an example. 107 | 108 | First, create some new files: 109 | 110 | ```diff 111 | melter-liquidx 112 | ├── node_modules 113 | ├── src 114 | + │ ├── snippets 115 | + │ │ └── button.liquid 116 | + │ └── sections 117 | + │ └── section.liquid 118 | ├── melter.config.js 119 | ├── liquidx-plugin.js 120 | ├── package-lock.json 121 | └── package.json 122 | ``` 123 | 124 | > **Note** 125 | > We recommend creating a dedicated directory for components to have a clear distinction between "snippets" and "components". This can easily be configured with the `paths` option in Melter. 126 | 127 | **components/button.liquid** 128 | 129 | ```liquid 130 | 131 | ``` 132 | 133 | **sections/section.liquid** 134 | 135 | ```liquid 136 | 137 | ``` 138 | 139 | In this example `{{ children }}` will render "Click me!". 140 | 141 | It's important to understand, that components are basically just native Shopify snippets that give access to an optional `children` property. 142 | 143 | You could also rewrite the example above: 144 | 145 | ```diff 146 | - 147 | + 169 | - 170 | ``` 171 | 172 | **sections/section.liquid** 173 | 174 | ```liquid 175 | 176 | 177 | ``` 178 | 179 | This renders: 180 | 181 | ```liquid 182 | 183 | I'm a Link! 184 | ``` 185 | 186 | ### Props 187 | 188 | Props can be of any type Liquid supports: 189 | 190 | ```liquid 191 | 202 | ``` 203 | 204 | For a smooth developer experience make sure to document all available props in your component: 205 | 206 | ```diff 207 | + {% comment %} 208 | + Renders a button component. 209 | + 210 | + @param {string} [url] - A destination to link to, rendered in the href attribute of a link. 211 | + @param {any} children 212 | + 213 | + @example 214 | + 215 | + 216 | + {% endcomment %} 217 | + 218 | {%- liquid 219 | # Determine tag name and optional attributes of the underlying element (button or anchor). 220 | 221 | assign tag_name = 'button' 222 | assign inner_attrs = null 223 | 224 | if url 225 | assign tag_name = 'a' 226 | assign href_attr = 'href="' | append: url | append: '"' | sort 227 | assign inner_attrs = inner_attrs | concat: href_attr 228 | endif 229 | -%} 230 | 231 | <{{ tag_name }}{{ inner_attrs | join: ' ' }}>{{ children }} 232 | ``` 233 | 234 | > **Note** 235 | > This "LiquidDoc" is also what will be used to power intellisense/autocompletion in VSCode in a later release. 236 | 237 | ## Contributing 238 | 239 | TODO 240 | 241 | ## License 242 | 243 | [MIT](LICENSE) 244 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { JestConfigWithTsJest } from 'ts-jest'; 2 | 3 | const config: JestConfigWithTsJest = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | 7 | moduleNameMapper: { 8 | '^@/(.*)': '/src/$1', 9 | }, 10 | 11 | testMatch: [`/src/**/__tests__/index.ts`], 12 | }; 13 | 14 | export default config; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unshopable/liquidx", 3 | "version": "0.1.0-alpha.9", 4 | "description": "XML-like syntax extension to Shopify's Liquid template language", 5 | "author": "", 6 | "keywords": [ 7 | "xml", 8 | "liquid", 9 | "shopify", 10 | "shopify-theme" 11 | ], 12 | "license": "MIT", 13 | "repository": "unshopable/liquidx", 14 | "homepage": "https://github.com/unshopable/liquidx", 15 | "bugs": "https://github.com/unshopable/liquidx/issues", 16 | "private": false, 17 | "main": "dist/index.js", 18 | "module": "dist/index.mjs", 19 | "types": "dist/index.d.ts", 20 | "exports": { 21 | ".": { 22 | "require": "./dist/index.js", 23 | "import": "./dist/index.mjs", 24 | "types": "./dist/index.d.ts" 25 | } 26 | }, 27 | "files": [ 28 | "dist" 29 | ], 30 | "scripts": { 31 | "test": "jest", 32 | "build": "tsup", 33 | "build:grammar": "esno scripts/build-grammar.ts", 34 | "prepublishOnly": "npm run build" 35 | }, 36 | "dependencies": { 37 | "@babel/code-frame": "^7.21.4", 38 | "line-column": "^1.0.2", 39 | "ohm-js": "^17.1.0" 40 | }, 41 | "devDependencies": { 42 | "@tsconfig/recommended": "^1.0.2", 43 | "@types/babel__code-frame": "^7.0.3", 44 | "@types/jest": "^29.5.1", 45 | "@types/line-column": "^1.0.0", 46 | "dedent-js": "^1.0.1", 47 | "esno": "^0.16.3", 48 | "husky": "^8.0.0", 49 | "jest": "^29.5.0", 50 | "prettier": "^2.8.8", 51 | "ts-jest": "^29.1.0", 52 | "ts-node": "^10.9.1", 53 | "tsup": "^6.7.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /scripts/build-grammar.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | async function run() { 5 | const source = path.resolve(process.cwd(), 'src/parser/grammar/liquidx.ohm'); 6 | const target = path.resolve(process.cwd(), 'dist/liquidx.ohm'); 7 | 8 | fs.copyFileSync(source, target); 9 | } 10 | 11 | run(); 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import render from './renderer'; 2 | 3 | export { render }; 4 | -------------------------------------------------------------------------------- /src/parser/1-source-to-cst/__tests__/element-node.test.ts: -------------------------------------------------------------------------------- 1 | import { expectOutput } from './utils'; 2 | 3 | describe('opening tag', () => { 4 | it('should parse without attributes', () => { 5 | const input = ''; 35 | 36 | expectOutput(input).toHaveProperty('0.type', 'ElementClosingTag'); 37 | expectOutput(input).toHaveProperty('0.name', 'Button'); 38 | }); 39 | }); 40 | 41 | describe('self-closing tag', () => { 42 | it('should parse without attributes', () => { 43 | const input = ''; 44 | 45 | expectOutput(input).toHaveProperty('0.type', 'ElementSelfClosingTag'); 46 | expectOutput(input).toHaveProperty('0.name', 'Icon'); 47 | expectOutput(input).toHaveProperty('0.attributes.length', 0); 48 | }); 49 | 50 | it('should parse with single attribute', () => { 51 | const input = ''; 52 | 53 | expectOutput(input).toHaveProperty('0.type', 'ElementSelfClosingTag'); 54 | expectOutput(input).toHaveProperty('0.name', 'Icon'); 55 | expectOutput(input).toHaveProperty('0.attributes.0.name.value', 'icon'); 56 | expectOutput(input).toHaveProperty('0.attributes.length', 1); 57 | }); 58 | 59 | it('should parse with multiple attributes', () => { 60 | const input = ''; 61 | 62 | expectOutput(input).toHaveProperty('0.type', 'ElementSelfClosingTag'); 63 | expectOutput(input).toHaveProperty('0.name', 'Icon'); 64 | expectOutput(input).toHaveProperty('0.attributes.0.name.value', 'icon'); 65 | expectOutput(input).toHaveProperty('0.attributes.1.name.value', 'size'); 66 | expectOutput(input).toHaveProperty('0.attributes.length', 2); 67 | }); 68 | }); 69 | 70 | describe('attributes', () => { 71 | it('should parse double quoted attribute', () => { 72 | const input = ''; 13 | 14 | expectOutput(input).toHaveProperty('0.type', 'TextNode'); 15 | expectOutput(input).toHaveProperty('0.value', ''); 16 | }); 17 | 18 | it('should parse custom HTML elements', () => { 19 | const input = ''; 20 | 21 | expectOutput(input).toHaveProperty('0.type', 'TextNode'); 22 | expectOutput(input).toHaveProperty('0.value', ''); 23 | }); 24 | 25 | it('regression #1', () => { 26 | const input = dedent` 27 | A > B 28 | `; 29 | 30 | expectOutput(input).toHaveProperty('0.type', 'TextNode'); 31 | expectOutput(input).toHaveProperty('0.value', 'A > B'); 32 | }); 33 | -------------------------------------------------------------------------------- /src/parser/1-source-to-cst/__tests__/utils.ts: -------------------------------------------------------------------------------- 1 | import sourceToCST from '../'; 2 | 3 | export function expectOutput(input: string) { 4 | const output = sourceToCST(input); 5 | 6 | return expect(output); 7 | } 8 | -------------------------------------------------------------------------------- /src/parser/1-source-to-cst/index.ts: -------------------------------------------------------------------------------- 1 | import { Node } from 'ohm-js'; 2 | import { toAST } from 'ohm-js/extras'; 3 | import { CSTParsingError } from '../errors'; 4 | import grammar from '../grammar'; 5 | 6 | export enum ConcreteNodeTypes { 7 | TextNode = 'TextNode', 8 | 9 | LiquidDropNode = 'LiquidDropNode', 10 | 11 | ElementOpeningTag = 'ElementOpeningTag', 12 | ElementClosingTag = 'ElementClosingTag', 13 | ElementSelfClosingTag = 'ElementSelfClosingTag', 14 | 15 | AttributeDoubleQuoted = 'AttributeDoubleQuoted', 16 | AttributeSingleQuoted = 'AttributeSingleQuoted', 17 | AttributeUnquoted = 'AttributeUnquoted', 18 | AttributeEmpty = 'AttributeEmpty', 19 | } 20 | 21 | export type ConcreteNode = 22 | | ConcreteTextNode 23 | | ConcreteLiquidDropNode 24 | | ConcreteElementOpeningTagNode 25 | | ConcreteElementClosingTagNode 26 | | ConcreteElementSelfClosingTagNode; 27 | 28 | export type ConcreteBasicNode = { 29 | type: T; 30 | locStart: number; 31 | locEnd: number; 32 | source: string; 33 | }; 34 | 35 | export type ConcreteTextNode = { 36 | value: string; 37 | } & ConcreteBasicNode; 38 | 39 | export type ConcreteLiquidDropNode = { 40 | value: string; 41 | } & ConcreteBasicNode; 42 | 43 | export type ConcreteElementOpeningTagNode = { 44 | name: string; 45 | attributes: ConcreteAttributeNode[]; 46 | } & ConcreteBasicNode; 47 | 48 | export type ConcreteElementClosingTagNode = { 49 | name: string; 50 | } & ConcreteBasicNode; 51 | 52 | export type ConcreteElementSelfClosingTagNode = { 53 | name: string; 54 | attributes: ConcreteAttributeNode[]; 55 | } & ConcreteBasicNode; 56 | 57 | export type ConcreteAttributeNodeBase = { 58 | name: ConcreteTextNode; 59 | value: ConcreteTextNode; 60 | } & ConcreteBasicNode; 61 | 62 | export type ConcreteAttributeNode = 63 | | ConcreteAttributeDoubleQuoted 64 | | ConcreteAttributeSingleQuoted 65 | | ConcreteAttributeUnquoted 66 | | ConcreteAttributeEmpty; 67 | 68 | export type ConcreteAttributeDoubleQuoted = 69 | {} & ConcreteAttributeNodeBase; 70 | 71 | export type ConcreteAttributeSingleQuoted = 72 | {} & ConcreteAttributeNodeBase; 73 | 74 | export type ConcreteAttributeUnquoted = 75 | {} & ConcreteAttributeNodeBase; 76 | 77 | export type ConcreteAttributeEmpty = { 78 | name: ConcreteTextNode; 79 | } & ConcreteBasicNode; 80 | 81 | export type CST = ConcreteNode[]; 82 | 83 | export type TemplateMapping = { 84 | type: ConcreteNodeTypes; 85 | locStart: (node: Node[]) => number; 86 | locEnd: (node: Node[]) => number; 87 | source: string; 88 | [k: string]: string | number | boolean | object | null; 89 | }; 90 | 91 | export type TopLevelFunctionMapping = (...nodes: Node[]) => any; 92 | 93 | export type Mapping = { 94 | [k: string]: number | TemplateMapping | TopLevelFunctionMapping; 95 | }; 96 | 97 | function locStart(nodes: Node[]) { 98 | return nodes[0].source.startIdx; 99 | } 100 | 101 | function locEnd(nodes: Node[]) { 102 | return nodes[nodes.length - 1].source.endIdx; 103 | } 104 | 105 | export default function sourceToCST(source: string): ConcreteNode[] { 106 | const matchResult = grammar.match(source); 107 | 108 | if (matchResult.failed()) { 109 | throw new CSTParsingError(matchResult); 110 | } 111 | 112 | const textNode = { 113 | type: ConcreteNodeTypes.TextNode, 114 | locStart, 115 | locEnd, 116 | value: function (this: Node) { 117 | return this.sourceString; 118 | }, 119 | source, 120 | }; 121 | 122 | const mapping: Mapping = { 123 | Node: 0, 124 | 125 | TextNode: textNode, 126 | 127 | liquidDropNode: { 128 | type: ConcreteNodeTypes.LiquidDropNode, 129 | locStart, 130 | locEnd, 131 | source, 132 | value: 2, 133 | }, 134 | liquidDropValue: (node: Node) => node.sourceString.trimEnd(), 135 | 136 | ElementNode: 0, 137 | 138 | ElementOpeningTag: { 139 | type: ConcreteNodeTypes.ElementOpeningTag, 140 | locStart, 141 | locEnd, 142 | name: 1, 143 | attributes: 2, 144 | source, 145 | }, 146 | ElementClosingTag: { 147 | type: ConcreteNodeTypes.ElementClosingTag, 148 | locStart, 149 | locEnd, 150 | name: 1, 151 | source, 152 | }, 153 | ElementSelfClosingTag: { 154 | type: ConcreteNodeTypes.ElementSelfClosingTag, 155 | locStart, 156 | locEnd, 157 | name: 1, 158 | attributes: 2, 159 | source, 160 | }, 161 | 162 | AttributeDoubleQuoted: { 163 | type: ConcreteNodeTypes.AttributeDoubleQuoted, 164 | locStart, 165 | locEnd, 166 | source, 167 | name: 0, 168 | value: 3, 169 | }, 170 | AttributeSingleQuoted: { 171 | type: ConcreteNodeTypes.AttributeSingleQuoted, 172 | locStart, 173 | locEnd, 174 | source, 175 | name: 0, 176 | value: 3, 177 | }, 178 | AttributeUnquoted: { 179 | type: ConcreteNodeTypes.AttributeUnquoted, 180 | locStart, 181 | locEnd, 182 | source, 183 | name: 0, 184 | value: 2, 185 | }, 186 | AttributeEmpty: { 187 | type: ConcreteNodeTypes.AttributeEmpty, 188 | locStart, 189 | locEnd, 190 | source, 191 | name: 0, 192 | }, 193 | 194 | attributeName: textNode, 195 | 196 | attributeDoubleQuotedValue: 0, 197 | attributeSingleQuotedValue: 0, 198 | attributeUnquotedValue: 0, 199 | 200 | attributeDoubleQuotedTextNode: textNode, 201 | attributeSingleQuotedTextNode: textNode, 202 | attributeUnquotedTextNode: textNode, 203 | }; 204 | 205 | const cst = toAST(matchResult, mapping) as ConcreteNode[]; 206 | 207 | return cst; 208 | } 209 | -------------------------------------------------------------------------------- /src/parser/2-cst-to-ast/__tests__/__snapshots__/index.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`parser/2-cst-to-ast element-node should throw an error if corresponding closing tag is missing 1`] = ` 4 | "> 1 | 5 | | ^^^^ LiquidX element 'Box' has no corresponding closing tag." 6 | `; 7 | 8 | exports[`parser/2-cst-to-ast element-node should throw an error if corresponding opening tag is missing 1`] = ` 9 | "> 1 | 10 | | ^^^^^ LiquidX element 'Box' has no corresponding opening tag" 11 | `; 12 | -------------------------------------------------------------------------------- /src/parser/2-cst-to-ast/__tests__/element-node.test.ts: -------------------------------------------------------------------------------- 1 | import { NodeTypes } from '..'; 2 | import { expectErrorMessage, expectOutput } from './utils'; 3 | 4 | it('should parse without children', () => { 5 | const input = ''; 6 | 7 | expectOutput(input).toHaveProperty('0.type', NodeTypes.ElementNode); 8 | expectOutput(input).toHaveProperty('0.name', 'Button'); 9 | expectOutput(input).toHaveProperty('0.children', []); 10 | }); 11 | 12 | it('should parse with text children', () => { 13 | const input = ''; 14 | 15 | expectOutput(input).toHaveProperty('0.type', NodeTypes.ElementNode); 16 | expectOutput(input).toHaveProperty('0.name', 'Button'); 17 | expectOutput(input).toHaveProperty('0.children.0.type', NodeTypes.TextNode); 18 | }); 19 | 20 | it('should parse with element children', () => { 21 | const input = ''; 22 | 23 | expectOutput(input).toHaveProperty('0.type', NodeTypes.ElementNode); 24 | expectOutput(input).toHaveProperty('0.name', 'Box'); 25 | expectOutput(input).toHaveProperty('0.children.0.type', NodeTypes.ElementNode); 26 | expectOutput(input).toHaveProperty('0.children.0.name', 'Button'); 27 | expectOutput(input).toHaveProperty('0.children.0.children.0.type', NodeTypes.TextNode); 28 | }); 29 | 30 | it('should throw an error if corresponding closing tag is missing', () => { 31 | const input = ''; 32 | 33 | expectErrorMessage(input).toMatchSnapshot(); 34 | }); 35 | 36 | it('should throw an error if corresponding opening tag is missing', () => { 37 | const input = ''; 38 | 39 | expectErrorMessage(input).toMatchSnapshot(); 40 | }); 41 | -------------------------------------------------------------------------------- /src/parser/2-cst-to-ast/__tests__/index.ts: -------------------------------------------------------------------------------- 1 | import { getRootSuiteName, requireAll } from '@/utils/tests'; 2 | 3 | describe(getRootSuiteName(__dirname), () => { 4 | requireAll(__dirname); 5 | }); 6 | -------------------------------------------------------------------------------- /src/parser/2-cst-to-ast/__tests__/text-node.test.ts: -------------------------------------------------------------------------------- 1 | import { NodeTypes } from '..'; 2 | import { expectOutput } from './utils'; 3 | 4 | it('should parse plain text', () => { 5 | const input = 'Plain text'; 6 | 7 | expectOutput(input).toHaveProperty('0.type', NodeTypes.TextNode); 8 | expectOutput(input).toHaveProperty('0.value', 'Plain text'); 9 | }); 10 | 11 | it('should parse native HTML elements', () => { 12 | const input = ''; 13 | 14 | expectOutput(input).toHaveProperty('0.type', NodeTypes.TextNode); 15 | expectOutput(input).toHaveProperty('0.value', ''); 16 | }); 17 | 18 | it('should parse custom HTML elements', () => { 19 | const input = ''; 20 | 21 | expectOutput(input).toHaveProperty('0.type', NodeTypes.TextNode); 22 | expectOutput(input).toHaveProperty('0.value', ''); 23 | }); 24 | -------------------------------------------------------------------------------- /src/parser/2-cst-to-ast/__tests__/utils.ts: -------------------------------------------------------------------------------- 1 | import sourceToAST from '../'; 2 | 3 | export function expectOutput(input: string) { 4 | const output = sourceToAST(input); 5 | 6 | return expect(output); 7 | } 8 | 9 | export function expectErrorMessage(input: string) { 10 | let errorMessage = ''; 11 | 12 | try { 13 | sourceToAST(input); 14 | } catch (error: any) { 15 | errorMessage = error.message; 16 | } 17 | 18 | return expect(errorMessage); 19 | } 20 | -------------------------------------------------------------------------------- /src/parser/2-cst-to-ast/ast-builder.ts: -------------------------------------------------------------------------------- 1 | import { ElementNode, LiquidXNode, NodeTypes } from '.'; 2 | import { 3 | ConcreteElementClosingTagNode, 4 | ConcreteElementSelfClosingTagNode, 5 | } from '../1-source-to-cst'; 6 | import { ASTParsingError } from '../errors'; 7 | import { deepGet, dropLast } from './utils'; 8 | 9 | export default class ASTBuilder { 10 | ast: LiquidXNode[]; 11 | cursor: (string | number)[]; 12 | source: string; 13 | 14 | constructor(source: string) { 15 | this.ast = []; 16 | this.cursor = []; 17 | this.source = source; 18 | } 19 | 20 | get current(): LiquidXNode[] { 21 | return deepGet(this.cursor, this.ast); 22 | } 23 | 24 | get currentPosition(): number { 25 | return (this.current || []).length - 1; 26 | } 27 | 28 | get parent(): ElementNode | undefined { 29 | if (this.cursor.length == 0) return undefined; 30 | 31 | return deepGet(dropLast(1, this.cursor), this.ast); 32 | } 33 | 34 | open(node: ElementNode) { 35 | this.push(node); 36 | this.cursor.push(this.currentPosition); 37 | this.cursor.push('children'); 38 | } 39 | 40 | close( 41 | node: ConcreteElementClosingTagNode | ConcreteElementSelfClosingTagNode, 42 | nodeType: NodeTypes.ElementNode, 43 | ) { 44 | if (!this.parent || this.parent.name !== node.name || this.parent.type !== nodeType) { 45 | throw new ASTParsingError( 46 | `LiquidX element '${node.name}' has no corresponding opening tag`, 47 | this.source, 48 | node.locStart, 49 | node.locEnd, 50 | ); 51 | } 52 | 53 | this.parent.locEnd = node.locEnd; 54 | 55 | this.cursor.pop(); 56 | this.cursor.pop(); 57 | } 58 | 59 | push(node: LiquidXNode) { 60 | this.current.push(node); 61 | } 62 | 63 | finish() { 64 | if (this.cursor.length > 0) { 65 | throw new ASTParsingError( 66 | `LiquidX element '${this.parent?.name}' has no corresponding closing tag.`, 67 | this.source, 68 | this.parent?.locStart ?? this.source.length - 1, 69 | this.parent?.locEnd ?? this.source.length, 70 | ); 71 | } 72 | 73 | return this.ast; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/parser/2-cst-to-ast/index.ts: -------------------------------------------------------------------------------- 1 | import sourceToCST, { 2 | ConcreteAttributeNode, 3 | ConcreteElementOpeningTagNode, 4 | ConcreteElementSelfClosingTagNode, 5 | ConcreteLiquidDropNode, 6 | ConcreteNode, 7 | ConcreteNodeTypes, 8 | ConcreteTextNode, 9 | } from '../1-source-to-cst'; 10 | import { UnknownConcreteNodeTypeError } from '../errors'; 11 | import ASTBuilder from './ast-builder'; 12 | 13 | export type BasicNode = { 14 | type: T; 15 | locStart: number; 16 | locEnd: number; 17 | source: string; 18 | }; 19 | 20 | export enum NodeTypes { 21 | TextNode = 'TextNode', 22 | 23 | LiquidDropNode = 'LiquidDropNode', 24 | 25 | ElementNode = 'ElementNode', 26 | 27 | AttributeDoubleQuoted = 'AttributeDoubleQuoted', 28 | AttributeSingleQuoted = 'AttributeSingleQuoted', 29 | AttributeUnquoted = 'AttributeUnquoted', 30 | AttributeEmpty = 'AttributeEmpty', 31 | } 32 | 33 | export type TextNode = { 34 | value: string; 35 | } & BasicNode; 36 | 37 | export type LiquidDropNode = { 38 | value: string; 39 | } & BasicNode; 40 | 41 | export type LiquidXNode = TextNode | LiquidDropNode | ElementNode | AttributeNode; 42 | 43 | export type ElementNode = { 44 | name: string; 45 | source: string; 46 | attributes: AttributeNode[]; 47 | children: LiquidXNode[]; 48 | } & BasicNode; 49 | 50 | export type AttributeNode = 51 | | AttributeDoubleQuoted 52 | | AttributeSingleQuoted 53 | | AttributeUnquoted 54 | | AttributeEmpty; 55 | 56 | export type AttributeNodeBase = { 57 | name: TextNode; 58 | value: TextNode | LiquidDropNode; 59 | } & BasicNode; 60 | 61 | export type AttributeDoubleQuoted = {} & AttributeNodeBase; 62 | export type AttributeSingleQuoted = {} & AttributeNodeBase; 63 | export type AttributeUnquoted = {} & AttributeNodeBase; 64 | export type AttributeEmpty = { name: TextNode } & BasicNode; 65 | 66 | function toTextNode(node: ConcreteTextNode): TextNode { 67 | return { 68 | type: NodeTypes.TextNode, 69 | locStart: node.locStart, 70 | locEnd: node.locEnd, 71 | source: node.source, 72 | value: node.value, 73 | }; 74 | } 75 | 76 | function toLiquidDropNode(node: ConcreteLiquidDropNode): LiquidDropNode { 77 | return { 78 | type: NodeTypes.LiquidDropNode, 79 | locStart: node.locStart, 80 | locEnd: node.locEnd, 81 | source: node.source, 82 | value: node.value, 83 | }; 84 | } 85 | 86 | function toElementNode( 87 | node: ConcreteElementOpeningTagNode | ConcreteElementSelfClosingTagNode, 88 | ): ElementNode { 89 | return { 90 | type: NodeTypes.ElementNode, 91 | locStart: node.locStart, 92 | locEnd: node.locEnd, 93 | name: node.name, 94 | source: node.source, 95 | attributes: toAttributes(node.attributes), 96 | children: [], 97 | }; 98 | } 99 | 100 | function toAttributes(attributes: ConcreteAttributeNode[]) { 101 | return cstToAST(attributes) as AttributeNode[]; 102 | } 103 | 104 | function toAttributeValue(value: ConcreteTextNode | ConcreteLiquidDropNode) { 105 | return cstToAST([value])[0] as TextNode | LiquidDropNode; 106 | } 107 | 108 | function isAttributeNode(node: any): boolean { 109 | return ( 110 | node.type === ConcreteNodeTypes.AttributeDoubleQuoted || 111 | node.type === ConcreteNodeTypes.AttributeSingleQuoted || 112 | node.type === ConcreteNodeTypes.AttributeUnquoted || 113 | node.type === ConcreteNodeTypes.AttributeEmpty 114 | ); 115 | } 116 | 117 | function cstToAST(cst: ConcreteNode[] | ConcreteAttributeNode[]) { 118 | if (cst.length === 0) return []; 119 | 120 | const astBuilder = new ASTBuilder(cst[0].source); 121 | 122 | for (let i = 0; i < cst.length; i += 1) { 123 | const node = cst[i]; 124 | const prevNode = cst[i - 1]; 125 | 126 | // Add whitespaces and linebreaks that went missing after parsing. We don't need to do this 127 | // if the node is an attribute since whitespaces between attributes is not important to preserve. 128 | // In fact it would probably break the rendered output due to unexpected text nodes. 129 | // TODO: This should be handled in the grammar/source-to-cst part instead (if possible). 130 | if (prevNode?.source && !isAttributeNode(node)) { 131 | const diff = node.locStart - prevNode.locEnd; 132 | 133 | if (diff > 0) { 134 | astBuilder.push( 135 | toTextNode({ 136 | type: ConcreteNodeTypes.TextNode, 137 | locStart: prevNode.locEnd, 138 | locEnd: node.locStart, 139 | source: node.source, 140 | value: prevNode.source.slice(prevNode.locEnd, node.locStart), 141 | }), 142 | ); 143 | } 144 | } 145 | 146 | switch (node.type) { 147 | case ConcreteNodeTypes.TextNode: { 148 | astBuilder.push(toTextNode(node)); 149 | 150 | break; 151 | } 152 | 153 | case ConcreteNodeTypes.LiquidDropNode: { 154 | astBuilder.push(toLiquidDropNode(node)); 155 | break; 156 | } 157 | 158 | case ConcreteNodeTypes.ElementOpeningTag: { 159 | astBuilder.open(toElementNode(node)); 160 | 161 | break; 162 | } 163 | 164 | case ConcreteNodeTypes.ElementClosingTag: { 165 | astBuilder.close(node, NodeTypes.ElementNode); 166 | 167 | break; 168 | } 169 | 170 | case ConcreteNodeTypes.ElementSelfClosingTag: { 171 | astBuilder.open(toElementNode(node)); 172 | astBuilder.close(node, NodeTypes.ElementNode); 173 | 174 | break; 175 | } 176 | 177 | case ConcreteNodeTypes.AttributeDoubleQuoted: 178 | case ConcreteNodeTypes.AttributeSingleQuoted: 179 | case ConcreteNodeTypes.AttributeUnquoted: { 180 | const attributeNode: AttributeDoubleQuoted | AttributeSingleQuoted | AttributeUnquoted = { 181 | type: node.type as unknown as 182 | | NodeTypes.AttributeDoubleQuoted 183 | | NodeTypes.AttributeSingleQuoted 184 | | NodeTypes.AttributeUnquoted, 185 | locStart: node.locStart, 186 | locEnd: node.locEnd, 187 | source: node.source, 188 | name: cstToAST([node.name])[0] as TextNode, 189 | value: toAttributeValue(node.value), 190 | }; 191 | 192 | astBuilder.push(attributeNode); 193 | 194 | break; 195 | } 196 | 197 | case ConcreteNodeTypes.AttributeEmpty: { 198 | const attributeNode: AttributeEmpty = { 199 | type: NodeTypes.AttributeEmpty, 200 | locStart: node.locStart, 201 | locEnd: node.locEnd, 202 | source: node.source, 203 | name: cstToAST([node.name])[0] as TextNode, 204 | }; 205 | 206 | astBuilder.push(attributeNode); 207 | 208 | break; 209 | } 210 | 211 | default: { 212 | throw new UnknownConcreteNodeTypeError( 213 | '', 214 | (node as any)?.source, 215 | (node as any)?.locStart, 216 | (node as any)?.locEnd, 217 | ); 218 | } 219 | } 220 | } 221 | 222 | return astBuilder.finish(); 223 | } 224 | 225 | export default function sourceToAST(source: string): LiquidXNode[] { 226 | const cst = sourceToCST(source); 227 | const ast = cstToAST(cst); 228 | 229 | return ast; 230 | } 231 | -------------------------------------------------------------------------------- /src/parser/2-cst-to-ast/utils.ts: -------------------------------------------------------------------------------- 1 | export function deepGet(path: (string | number)[], obj: any): T { 2 | return path.reduce((curr: any, k: string | number) => { 3 | if (curr && curr[k] !== undefined) return curr[k]; 4 | 5 | return undefined; 6 | }, obj); 7 | } 8 | 9 | export function dropLast(num: number, xs: readonly T[]) { 10 | const result = [...xs]; 11 | 12 | for (let i = 0; i < num; i += 1) { 13 | result.pop(); 14 | } 15 | 16 | return result; 17 | } 18 | -------------------------------------------------------------------------------- /src/parser/errors.ts: -------------------------------------------------------------------------------- 1 | import { SourceLocation, codeFrameColumns } from '@babel/code-frame'; 2 | import lineColumn from 'line-column'; 3 | import * as ohm from 'ohm-js'; 4 | 5 | type ErrorResult = { 6 | result: string; 7 | }; 8 | 9 | type ErrorSource = { 10 | result: undefined; 11 | message: string; 12 | source: string; 13 | locStart: number; 14 | locEnd: number; 15 | }; 16 | 17 | class LoggableError extends Error { 18 | constructor(info: ErrorResult | ErrorSource) { 19 | let result = ''; 20 | 21 | if (typeof info.result === 'undefined') { 22 | const { message, source, locStart, locEnd } = info; 23 | 24 | const lc = lineColumn(source); 25 | const start = lc.fromIndex(locStart); 26 | const end = lc.fromIndex(Math.min(locEnd, source.length - 1)); 27 | 28 | const location: SourceLocation = { 29 | start: { 30 | line: start?.line ?? source.length - 1, 31 | column: start?.col, 32 | }, 33 | end: { 34 | line: end?.line ?? source.length, 35 | column: end?.col, 36 | }, 37 | }; 38 | 39 | result = codeFrameColumns(source, location, { 40 | message: message, 41 | }); 42 | } else { 43 | result = info.result; 44 | } 45 | 46 | super(result); 47 | 48 | this.name = 'BaseError'; 49 | } 50 | } 51 | 52 | export class CSTParsingError extends LoggableError { 53 | constructor(matchResult: ohm.MatchResult) { 54 | super({ result: matchResult.message ?? '' }); 55 | 56 | this.name = 'CSTParsingError'; 57 | } 58 | } 59 | 60 | export class UnknownConcreteNodeTypeError extends LoggableError { 61 | constructor(message: string, source: string, locStart: number, locEnd: number) { 62 | super({ result: undefined, message, source, locStart, locEnd }); 63 | 64 | this.name = 'UnknownConcreteNodeTypeError'; 65 | } 66 | } 67 | 68 | export class ASTParsingError extends LoggableError { 69 | constructor(message: string, source: string, locStart: number, locEnd: number) { 70 | super({ result: undefined, message, source, locStart, locEnd }); 71 | 72 | this.name = 'ASTParsingError'; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/parser/grammar/__tests__/grammar.test.ts: -------------------------------------------------------------------------------- 1 | import grammar from '..'; 2 | 3 | it('should parse plain text', () => { 4 | expectMatchSucceeded('Hello, World!').toBe(true); 5 | expectMatchSucceeded(' { 11 | expectMatchSucceeded('').toBe(true); 12 | expectMatchSucceeded('').toBe(true); 13 | expectMatchSucceeded('').toBe(true); 15 | expectMatchSucceeded('').toBe(true); 16 | expectMatchSucceeded('').toBe(true); 17 | }); 18 | 19 | it('should parse invalid HTML tags', () => { 20 | expectMatchSucceeded('< button>').toBe(true); 21 | expectMatchSucceeded('').toBe(true); 24 | expectMatchSucceeded('button attr>').toBe(true); 25 | expectMatchSucceeded('').toBe(true); 27 | expectMatchSucceeded('img attr />').toBe(true); 28 | }); 29 | 30 | it('should parse valid LiquidX tags', () => { 31 | expectMatchSucceeded('').toBe(true); 32 | expectMatchSucceeded('').toBe(true); 33 | expectMatchSucceeded('').toBe(true); 35 | expectMatchSucceeded('').toBe(true); 36 | expectMatchSucceeded('').toBe(true); 37 | }); 38 | 39 | function expectMatchSucceeded(source: string) { 40 | const match = grammar.match(source); 41 | 42 | return expect(match.succeeded()); 43 | } 44 | -------------------------------------------------------------------------------- /src/parser/grammar/__tests__/index.ts: -------------------------------------------------------------------------------- 1 | import { getRootSuiteName, requireAll } from '@/utils/tests'; 2 | 3 | describe(getRootSuiteName(__dirname), () => { 4 | requireAll(__dirname); 5 | }); 6 | -------------------------------------------------------------------------------- /src/parser/grammar/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as ohm from 'ohm-js'; 3 | import * as path from 'path'; 4 | 5 | export const grammars = ohm.grammars(fs.readFileSync(path.join(__dirname, 'liquidx.ohm'), 'utf8')); 6 | 7 | export default grammars['LiquidX']; 8 | -------------------------------------------------------------------------------- /src/parser/grammar/liquidx.ohm: -------------------------------------------------------------------------------- 1 | Helpers { 2 | Node = /* empty */ 3 | 4 | anyExcept = (~ lit any) 5 | anyExceptStar = (~ lit any)* 6 | anyExceptPlus = (~ lit any)+ 7 | AnyExcept = (~ lit any) 8 | AnyExceptPlus = (~ lit any)+ 9 | AnyExceptStar = (~ lit any)* 10 | 11 | quote = singleQuote | doubleQuote 12 | 13 | doubleQuote = "\"" | "“" | "”" 14 | singleQuote = "'" | "‘" | "’" 15 | 16 | tag = 17 | | openingTagStart 18 | | openingTagEnd 19 | | closingTagStart 20 | | closingTagEnd 21 | | selfClosingTagEnd 22 | 23 | openingTagStart = "<" 24 | openingTagEnd = ">" 25 | closingTagStart = "" 27 | selfClosingTagEnd = "/>" 28 | 29 | liquidDropStart = "{{" 30 | liquidDropEnd = "}}" 31 | } 32 | 33 | Liquid <: Helpers { 34 | liquidDropNode = liquidDropStart space* liquidDropValue liquidDropEnd 35 | liquidDropValue = anyExceptStar 36 | } 37 | 38 | LiquidX <: Liquid { 39 | Node := (ElementNode | TextNode)* 40 | 41 | TextNode = anyExceptPlus<(ElementNode)> 42 | 43 | ElementNode = 44 | | ElementOpeningTag 45 | | ElementClosingTag 46 | | ElementSelfClosingTag 47 | 48 | ElementOpeningTag = #(openingTagStart tagName) Attributes openingTagEnd 49 | ElementClosingTag = #(closingTagStart tagName) closingTagEnd 50 | ElementSelfClosingTag = #(openingTagStart tagName) Attributes selfClosingTagEnd 51 | 52 | tagName = leadingTagNamePart trailingTagNamePart* 53 | 54 | leadingTagNamePart = upper 55 | trailingTagNamePart = letter | digit 56 | 57 | Attributes = Attribute* 58 | Attribute = 59 | | AttributeDoubleQuoted 60 | | AttributeSingleQuoted 61 | | AttributeUnquoted 62 | | AttributeEmpty 63 | 64 | AttributeDoubleQuoted = attributeName "=" doubleQuote #(attributeDoubleQuotedValue doubleQuote) 65 | AttributeSingleQuoted = attributeName "=" singleQuote #(attributeSingleQuotedValue singleQuote) 66 | AttributeUnquoted = attributeName "=" attributeUnquotedValue 67 | AttributeEmpty = attributeName 68 | 69 | attributeName = anyExceptPlus<(space | quote | "=" | tag)> 70 | 71 | attributeDoubleQuotedValue = attributeDoubleQuotedTextNode | liquidDropNode 72 | attributeSingleQuotedValue = attributeSingleQuotedTextNode | liquidDropNode 73 | attributeUnquotedValue = attributeUnquotedTextNode | liquidDropNode 74 | 75 | attributeDoubleQuotedTextNode = anyExceptPlus<(doubleQuote | liquidDropStart)> 76 | attributeSingleQuotedTextNode = anyExceptPlus<(singleQuote | liquidDropStart)> 77 | attributeUnquotedTextNode = anyExceptPlus<(space | quote | "=" | tag | liquidDropStart)> 78 | } -------------------------------------------------------------------------------- /src/renderer/__tests__/attributes.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent-js'; 2 | import { testRender } from './utils'; 3 | 4 | it('should render string', () => { 5 | const input = dedent` 6 | 7 | `; 8 | 9 | const expected = dedent` 10 | {% render 'Icon', str: "STRING" %} 11 | `; 12 | 13 | testRender(input, expected); 14 | }); 15 | 16 | it('should render number', () => { 17 | const input = dedent` 18 | 19 | `; 20 | 21 | const expected = dedent` 22 | {% render 'Icon', num: 0 %} 23 | `; 24 | 25 | testRender(input, expected); 26 | }); 27 | 28 | it('should render float', () => { 29 | const input = dedent` 30 | 31 | `; 32 | 33 | const expected = dedent` 34 | {% render 'Icon', num: 1.1 %} 35 | `; 36 | 37 | testRender(input, expected); 38 | }); 39 | 40 | it('should render boolean (explicit)', () => { 41 | const input = dedent` 42 | 43 | `; 44 | 45 | const expected = dedent` 46 | {% render 'Icon', bool1: true, bool2: false %} 47 | `; 48 | 49 | testRender(input, expected); 50 | }); 51 | 52 | it('should render boolean (implicit)', () => { 53 | const input = dedent` 54 | 55 | `; 56 | 57 | const expected = dedent` 58 | {% render 'Icon', bool: true %} 59 | `; 60 | 61 | testRender(input, expected); 62 | }); 63 | 64 | it('should render null', () => { 65 | const input = dedent` 66 | 67 | `; 68 | 69 | const expected = dedent` 70 | {% render 'Icon', a: null %} 71 | `; 72 | 73 | testRender(input, expected); 74 | }); 75 | 76 | it('should render computed', () => { 77 | const input = dedent` 78 | 79 | `; 80 | 81 | const expected = dedent` 82 | {% render 'Icon', attr: foo %} 83 | `; 84 | 85 | testRender(input, expected); 86 | }); 87 | -------------------------------------------------------------------------------- /src/renderer/__tests__/formatting.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent-js'; 2 | import { testRender } from './utils'; 3 | 4 | it('should preserve formatting (single-line)', () => { 5 | const input = ''; 6 | 7 | const expected = dedent` 8 | {% capture ButtonChildren %}Hello, World!{% endcapture %}{% render 'Button', children: ButtonChildren %} 9 | `; 10 | 11 | testRender(input, expected); 12 | }); 13 | 14 | it('should preserve formatting (multi-line #1)', () => { 15 | const input = dedent` 16 | 18 | `; 19 | 20 | const expected = dedent` 21 | {% capture ButtonChildren %}Hello, World! 22 | {% endcapture %}{% render 'Button', children: ButtonChildren %} 23 | `; 24 | 25 | testRender(input, expected); 26 | }); 27 | 28 | it('should preserve formatting (multi-line #2)', () => { 29 | const input = dedent` 30 | 32 | `; 33 | 34 | const expected = dedent` 35 | {% capture ButtonChildren %} 36 | Hello, World!{% endcapture %}{% render 'Button', children: ButtonChildren %} 37 | `; 38 | 39 | testRender(input, expected); 40 | }); 41 | 42 | it('should preserve formatting (multi-line #3)', () => { 43 | const input = dedent` 44 | 47 | `; 48 | 49 | const expected = dedent` 50 | {% capture ButtonChildren %} 51 | Hello, World! 52 | {% endcapture %}{% render 'Button', children: ButtonChildren %} 53 | `; 54 | 55 | testRender(input, expected); 56 | }); 57 | 58 | it('should preserve formatting (deeply nested)', () => { 59 | const input = dedent` 60 |
61 |
62 | 67 |
68 |
69 | `; 70 | 71 | const expected = dedent` 72 |
73 |
74 | {% capture ButtonChildren %} 75 | {% capture TextChildren %} 76 | Hello, World! 77 | {% endcapture %}{% render 'Text', children: TextChildren %} 78 | {% endcapture %}{% render 'Button', children: ButtonChildren %} 79 |
80 |
81 | `; 82 | 83 | testRender(input, expected); 84 | }); 85 | -------------------------------------------------------------------------------- /src/renderer/__tests__/index.ts: -------------------------------------------------------------------------------- 1 | import { getRootSuiteName, requireAll } from '@/utils/tests'; 2 | 3 | describe(getRootSuiteName(__dirname), () => { 4 | requireAll(__dirname); 5 | }); 6 | -------------------------------------------------------------------------------- /src/renderer/__tests__/nested.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent-js'; 2 | import { testRender } from './utils'; 3 | 4 | it('should render text in element', () => { 5 | const input = dedent` 6 | 7 | `; 8 | 9 | const expected = dedent` 10 | {% capture ButtonChildren %}Hello, World!{% endcapture %}{% render 'Button', children: ButtonChildren %} 11 | `; 12 | 13 | testRender(input, expected); 14 | }); 15 | 16 | it('should render element in element', () => { 17 | const input = dedent` 18 | 21 | `; 22 | 23 | const expected = dedent` 24 | {% capture ButtonChildren %} 25 | {% capture TextChildren %}Hello, World!{% endcapture %}{% render 'Text', children: TextChildren %} 26 | {% endcapture %}{% render 'Button', children: ButtonChildren %} 27 | `; 28 | 29 | testRender(input, expected); 30 | }); 31 | 32 | it('should render element in text', () => { 33 | const input = dedent` 34 | 37 | `; 38 | 39 | const expected = dedent` 40 | 43 | `; 44 | 45 | testRender(input, expected); 46 | }); 47 | -------------------------------------------------------------------------------- /src/renderer/__tests__/self-closing.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent-js'; 2 | import { testRender } from './utils'; 3 | 4 | it('should render', () => { 5 | const input = dedent` 6 | 7 | 8 | `; 9 | 10 | const expected = dedent` 11 | {% render 'Icon' %} 12 | {% render 'Icon' %} 13 | `; 14 | 15 | testRender(input, expected); 16 | }); 17 | -------------------------------------------------------------------------------- /src/renderer/__tests__/simple.test.ts: -------------------------------------------------------------------------------- 1 | import { testRender } from './utils'; 2 | 3 | it('should render text', () => { 4 | const input = ''; 5 | 6 | const expected = ''; 7 | 8 | testRender(input, expected); 9 | }); 10 | 11 | it('should render element without children', () => { 12 | const input = ''; 13 | 14 | const expected = "{% render 'Button' %}"; 15 | 16 | testRender(input, expected); 17 | }); 18 | -------------------------------------------------------------------------------- /src/renderer/__tests__/source.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent-js'; 2 | import { testRender } from './utils'; 3 | 4 | it('should render source', () => { 5 | const input = dedent``; 6 | 7 | const expected = dedent` 8 | {% # LIQUIDX:START - EDITS MADE TO THE CODE BETWEEN \"LIQUIDX:START\" and \"LIQUIDX:END\" WILL BE OVERWRITTEN %}{% render 'Icon' %}{% # LIQUIDX:END - SOURCE \"\" %} 9 | `; 10 | 11 | testRender(input, expected, { withSource: true }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/renderer/__tests__/utils.ts: -------------------------------------------------------------------------------- 1 | import render from '..'; 2 | 3 | export function testRender(input: string, expected: string, { withSource = false } = {}) { 4 | const output = render(input, { withSource }); 5 | 6 | expect(output).toBe(expected); 7 | } 8 | -------------------------------------------------------------------------------- /src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | import sourceToAST, { 2 | AttributeNode, 3 | ElementNode, 4 | LiquidDropNode, 5 | LiquidXNode, 6 | NodeTypes, 7 | TextNode, 8 | } from '../parser/2-cst-to-ast'; 9 | 10 | function renderStartMarker() { 11 | return '{% # LIQUIDX:START - EDITS MADE TO THE CODE BETWEEN "LIQUIDX:START" and "LIQUIDX:END" WILL BE OVERWRITTEN %}'; 12 | } 13 | 14 | function renderEndMarker(node: ElementNode) { 15 | return `{% # LIQUIDX:END - SOURCE ${JSON.stringify( 16 | node.source.slice(node.locStart, node.locEnd), 17 | )} %}`; 18 | } 19 | 20 | function renderElement( 21 | node: ElementNode, 22 | { withSource = false, isChildOfElementNode = false } = {}, 23 | ) { 24 | let output = ''; 25 | 26 | const attributes = node.attributes; 27 | 28 | if (withSource && !isChildOfElementNode) { 29 | output += renderStartMarker(); 30 | } 31 | 32 | if (node.children.length > 0) { 33 | const captureName = `${node.name}Children`; 34 | 35 | output += `{% capture ${captureName} %}`; 36 | output += renderAST(node.children, { withSource, isChildOfElementNode: true }); 37 | output += '{% endcapture %}'; 38 | 39 | const childrenAttribute: AttributeNode = { 40 | type: NodeTypes.AttributeDoubleQuoted, 41 | locStart: 0, 42 | locEnd: 0, 43 | source: '', 44 | name: { 45 | type: NodeTypes.TextNode, 46 | locStart: 0, 47 | locEnd: 0, 48 | source: '', 49 | value: 'children', 50 | }, 51 | value: { 52 | type: NodeTypes.LiquidDropNode, 53 | locStart: 0, 54 | locEnd: 0, 55 | source: '', 56 | value: captureName, 57 | }, 58 | }; 59 | 60 | attributes.push(childrenAttribute); 61 | } 62 | 63 | const renderedAttributes = node.attributes.map((attribute) => renderAST([attribute])); 64 | const separator = ', '; 65 | 66 | const attributesString = 67 | renderedAttributes.length > 0 ? `${separator}${renderedAttributes.join(separator)}` : ''; 68 | 69 | output += `{% render '${node.name}'${attributesString} %}`; 70 | 71 | if (withSource && !isChildOfElementNode) { 72 | output += renderEndMarker(node); 73 | } 74 | 75 | return output; 76 | } 77 | 78 | function renderText(node: TextNode) { 79 | return node.value; 80 | } 81 | 82 | function renderLiquidDrop(node: LiquidDropNode) { 83 | return node.value; 84 | } 85 | 86 | function renderAST( 87 | ast: LiquidXNode[], 88 | { withSource = false, isChildOfElementNode = false } = {}, 89 | ): string { 90 | let output = ''; 91 | 92 | for (let i = 0; i < ast.length; i += 1) { 93 | const node = ast[i]; 94 | 95 | switch (node.type) { 96 | case NodeTypes.TextNode: { 97 | output += renderText(node); 98 | 99 | break; 100 | } 101 | 102 | case NodeTypes.ElementNode: { 103 | output += renderElement(node, { withSource, isChildOfElementNode }); 104 | 105 | break; 106 | } 107 | 108 | case NodeTypes.AttributeDoubleQuoted: 109 | case NodeTypes.AttributeSingleQuoted: 110 | case NodeTypes.AttributeUnquoted: { 111 | const name = renderText(node.name); 112 | let value = null; 113 | 114 | if (node.value.type === NodeTypes.TextNode) { 115 | value = JSON.stringify(renderText(node.value)); 116 | } else { 117 | value = renderLiquidDrop(node.value); 118 | } 119 | 120 | output += `${name}: ${value}`; 121 | 122 | break; 123 | } 124 | 125 | case NodeTypes.AttributeEmpty: { 126 | const name = renderText(node.name); 127 | const value = true; 128 | 129 | output += `${name}: ${value}`; 130 | 131 | break; 132 | } 133 | 134 | default: { 135 | console.log(node); 136 | 137 | // TODO 138 | throw new Error(''); 139 | } 140 | } 141 | } 142 | 143 | return output; 144 | } 145 | 146 | export default function render(source: string, { withSource = false } = {}) { 147 | const ast = sourceToAST(source); 148 | 149 | const ouput = renderAST(ast, { withSource }); 150 | 151 | return ouput; 152 | } 153 | -------------------------------------------------------------------------------- /src/utils/tests/get-relative-dirname-from-absolute-dirname.ts: -------------------------------------------------------------------------------- 1 | export default function getRelativeDirnameFromAbsoluteDirname(dirname: string) { 2 | return dirname.replace(process.cwd(), ''); 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/tests/get-root-suite-name.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import getRelativeDirnameFromAbsoluteDirname from './get-relative-dirname-from-absolute-dirname'; 3 | 4 | export default function getRootSuiteName(dirname: string) { 5 | const relativeDirname = getRelativeDirnameFromAbsoluteDirname(dirname); 6 | 7 | // Get rid of trailing slash, 'src', and '__tests__' 8 | const dirnameParts = relativeDirname.split(path.sep).slice(2, -1); 9 | 10 | return path.join(...dirnameParts); 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/tests/get-suite-name.ts: -------------------------------------------------------------------------------- 1 | import getRelativeDirnameFromAbsoluteDirname from './get-relative-dirname-from-absolute-dirname'; 2 | 3 | export default function getSuiteName(dirname: string, { isDir = false } = {}) { 4 | const relativeDirname = getRelativeDirnameFromAbsoluteDirname(dirname); 5 | 6 | if (isDir) { 7 | return relativeDirname.split('__tests__/')[1]; 8 | } 9 | 10 | const filename = dirname.split('/').at(-1); 11 | const filenameWithoutExtension = filename!.split('.')[0]; 12 | 13 | return filenameWithoutExtension; 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/tests/index.ts: -------------------------------------------------------------------------------- 1 | export { default as getRelativeDirnameFromAbsoluteDirname } from './get-relative-dirname-from-absolute-dirname'; 2 | export { default as getRootSuiteName } from './get-root-suite-name'; 3 | export { default as getSuiteName } from './get-suite-name'; 4 | export { default as requireAll } from './require-all'; 5 | -------------------------------------------------------------------------------- /src/utils/tests/require-all.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import getSuiteName from './get-suite-name'; 4 | 5 | const TEST_ONLY: string | undefined = process.env.TEST_ONLY; 6 | 7 | export default function requireAll(directory: string) { 8 | const files = fs.readdirSync(directory); 9 | 10 | files.forEach((file) => { 11 | const filePath = path.join(directory, file); 12 | 13 | if (filePath.endsWith('__snapshots__')) return; 14 | 15 | const filePathWithoutTestsPrefix = filePath.replace('__tests__/', ''); 16 | 17 | if (TEST_ONLY && !filePathWithoutTestsPrefix.match(new RegExp(TEST_ONLY, 'i'))) { 18 | test.skip('', () => { 19 | expect(true).toBe(true); 20 | }); 21 | 22 | return; 23 | } 24 | 25 | if (fs.statSync(filePath).isDirectory()) { 26 | describe(getSuiteName(filePath, { isDir: true }), () => { 27 | require(path.join(filePath, 'index.ts')); 28 | }); 29 | } else if (filePath.match(/.+\.test\.ts/)) { 30 | describe(getSuiteName(filePath), () => { 31 | require(filePath); 32 | }); 33 | } 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2017", 5 | "module": "esnext", 6 | "lib": ["esnext", "DOM"], 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "strict": true, 10 | "strictNullChecks": true, 11 | "resolveJsonModule": true, 12 | "baseUrl": ".", 13 | "paths": { 14 | "@/*": ["src/*"] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from 'tsup'; 2 | 3 | const tsupConfig: Options = { 4 | entryPoints: ['src/*.ts'], 5 | clean: true, 6 | format: ['cjs', 'esm'], 7 | dts: true, 8 | onSuccess: 'npm run build:grammar', 9 | }; 10 | 11 | export default tsupConfig; 12 | --------------------------------------------------------------------------------