├── .editorconfig ├── .eslintrc.js ├── .gitattributes ├── .github ├── FUNDING.yml ├── contributing.md └── workflows │ └── test.yml ├── .gitignore ├── .verb.md ├── LICENSE ├── README.md ├── examples ├── cache │ └── partials.js ├── custom-tags │ ├── front-matter │ │ ├── Lexer.js │ │ ├── extends.js │ │ ├── index.js │ │ ├── nodes │ │ │ └── Root.js │ │ └── tags │ │ │ └── FrontMatter.js │ └── google-analytics │ │ └── GoogleAnalytics.js ├── django.js ├── examples.js ├── filters.js ├── floats.js ├── parse.js ├── render.js ├── tags │ ├── apply.js │ ├── blanks.js │ ├── case.js │ ├── cycle.js │ ├── embed.js │ ├── extends-block-function.js │ ├── extends-block-super.js │ ├── extends-with-parent.js │ ├── extends.js │ ├── for.js │ ├── ga.js │ ├── if.js │ ├── layout.js │ ├── macro │ │ ├── _fields.html │ │ ├── _fields.liquid │ │ ├── _macros.liquid │ │ ├── _signup.liquid │ │ ├── _test.liquid │ │ ├── from-as.js │ │ ├── from.js │ │ ├── import.js │ │ ├── macro-with-dot-param.js │ │ └── macro.js │ └── markdown.js ├── ten-million-dots.js └── variables.js ├── index.js ├── lib ├── Condition.js ├── Context.js ├── Dry.js ├── Expression.js ├── FileSystem.js ├── I18n.js ├── Lexer.js ├── Location.js ├── ParseTreeVisitor.js ├── Parser.js ├── PartialCache.js ├── Profiler.js ├── RangeLookup.js ├── ResourceLimits.js ├── StandardFilters.js ├── State.js ├── StaticRegisters.js ├── StrainerFactory.js ├── StrainerTemplate.js ├── Template.js ├── TemplateFactory.js ├── Tokenizer.js ├── Usage.js ├── VariableLookup.js ├── constants │ ├── characters.js │ ├── index.js │ ├── regex.js │ └── symbols.js ├── drops │ ├── Drop.js │ ├── ForLoopDrop.js │ ├── TableRowLoopDrop.js │ └── index.js ├── expressions │ ├── Lexer.js │ ├── Parser.js │ ├── Scanner.js │ └── index.js ├── locales │ └── en.yml ├── nodes │ ├── BlockBody.js │ ├── BlockNode.js │ ├── BlockTag.js │ ├── Branch.js │ ├── Close.js │ ├── Node.js │ ├── Open.js │ ├── Root.js │ ├── Tag.js │ ├── Text.js │ ├── Token.js │ ├── Variable.js │ └── index.js ├── profiler │ └── hooks.js ├── shared │ ├── errors.js │ ├── hash.js │ ├── helpers │ │ ├── content_for.js │ │ ├── create_error_context.js │ │ ├── find_template.js │ │ ├── index.js │ │ ├── methods.js │ │ ├── raise.js │ │ ├── render.js │ │ ├── ternary.js │ │ └── variable.js │ ├── index.js │ ├── parse_with_selected_parser.js │ ├── select-parser.js │ ├── strftime.js │ └── utils.js ├── tag │ ├── disableable.js │ └── disabler.js ├── tags │ ├── Apply.js │ ├── Assign.js │ ├── Block.js │ ├── Break.js │ ├── Capture.js │ ├── Case.js │ ├── Comment.js │ ├── Content.js │ ├── Continue.js │ ├── Cycle.js │ ├── Decrement.js │ ├── Echo.js │ ├── Else.js │ ├── Elsif.js │ ├── Embed.js │ ├── Extends.js │ ├── For.js │ ├── From.js │ ├── If.js │ ├── Ifchanged.js │ ├── Import.js │ ├── Include.js │ ├── Increment.js │ ├── Interrupts.js │ ├── Layout.js │ ├── Liquid.js │ ├── Macro.js │ ├── Paginate.js │ ├── Raw.js │ ├── Render.js │ ├── Set.js │ ├── Switch.js │ ├── TableRow.js │ ├── Unless.js │ ├── Verbatim.js │ ├── When.js │ ├── With.js │ └── index.js └── version.js ├── package.json ├── pnpm-lock.yaml ├── prettier.config.js ├── test ├── .eslintrc.json ├── expected │ ├── accessors.html │ ├── append-block-multiple.html │ ├── append-block.html │ ├── block-body.html │ ├── block-file-extends.html │ ├── block-indent.html │ ├── block-multiple.html │ ├── block.html │ ├── blocks-missing.html │ ├── body-tag.html │ ├── filter.html │ ├── helpers-extends-args.html │ ├── helpers-extends.html │ ├── helpers.html │ ├── layout-block-and-text-node.html │ ├── layout-block-outside-body.html │ ├── layout-block.html │ ├── layout-file-property.html │ ├── layout-tag-nested.html │ ├── layout-tag-replace.html │ ├── layout-text-node.html │ ├── merge-blocks.html │ ├── mixed-multiple.html │ ├── nested-blocks-1.html │ ├── nested-blocks-append-repeat.html │ ├── nested-blocks-append.html │ ├── nested-blocks-prepend.html │ ├── nested-blocks.html │ ├── nested-extends-append-stacked.html │ ├── nested-extends-append.html │ ├── nested-extends-mixed.html │ ├── nested-extends-mixed2.html │ ├── nested-extends-prepend.html │ ├── nested-extends.html │ ├── options-trim.html │ ├── other-blocks.html │ ├── prepend-block-multiple.html │ ├── prepend-block.html │ ├── repeat.html │ ├── replace-block-multiple.html │ ├── replace-block.html │ └── text-nodes.html ├── fixtures │ ├── _macros │ │ ├── fields.html │ │ ├── multiple.html │ │ ├── signup.html │ │ ├── simple.html │ │ ├── test.html │ │ └── textarea.html │ └── en_locale.yml ├── integration │ ├── assign_test.js │ ├── blank_test.js │ ├── block_test.js │ ├── context_test.js │ ├── drop_test.js │ ├── error_handling_test.js │ ├── expression_test.js │ ├── filter_test.js │ ├── hash_ordering_test.js │ ├── output_test.js │ ├── profiler_test.js │ ├── set_test.js │ ├── standard_filter_exceptions.js │ ├── standard_filter_test.js │ ├── tag_test.js │ ├── tags │ │ ├── break_tag_test.js │ │ ├── capture_tag_test.js │ │ ├── case_tag_test.js │ │ ├── comment_tag.js │ │ ├── content_tag_test.js │ │ ├── continue_tag_test.js │ │ ├── cycle_tag_test.js │ │ ├── echo_test.js │ │ ├── embed_tag_test.js │ │ ├── extends_tag_test.js │ │ ├── for_tag_test.js │ │ ├── if_else_tag_test.js │ │ ├── include_tag_test.js │ │ ├── increment_tag_test.js │ │ ├── layout_tag_test.js │ │ ├── liquid_tag_test.js │ │ ├── macro_tag_test.js │ │ ├── raw_tag_test.js │ │ ├── render_tag_test.js │ │ ├── standard_tag_test.js │ │ ├── statements_test.js │ │ ├── switch_tag_test.js │ │ ├── table_row_test.js │ │ ├── unless_else_tag_test.js │ │ └── with_tag_test.js │ ├── template_test.js │ ├── trim_mode_test.js │ ├── variable_async_test.js │ └── variable_test.js ├── support │ └── themes.js ├── test_helpers.js └── unit │ ├── block_unit_test.js │ ├── condition_unit_test.js │ ├── expression_lexer_unit_test.js │ ├── expression_parser_unit_test.js │ ├── file_system_unit_test.js │ ├── i18n_unit_test.js │ ├── layout_cache_unit_test.js │ ├── lexer_unit_test.js │ ├── parse_append_unit_test.js │ ├── parse_tree_visitor_test.js │ ├── parser_unit_test.js │ ├── partial_cache_unit_test.js │ ├── regexp_unit_test.js │ ├── static_registers_unit_test.js │ ├── strainer_factory_unit_test.js │ ├── strainer_template_unit_test.js │ ├── tag_unit_test.js │ ├── tags │ ├── case_tag_unit_test.js │ ├── for_tag_unit_test.js │ └── if_tag_unit_test.js │ ├── template_factory_unit_test.js │ ├── template_unit_test.js │ ├── tokenizer_unit_test.js │ └── variable_unit_test.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Enforce Unix newlines 2 | * text eol=lf 3 | 4 | # binaries 5 | *.ai binary 6 | *.psd binary 7 | *.jpg binary 8 | *.gif binary 9 | *.png binary 10 | *.jpeg binary 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [jonschlinkert] 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request] 3 | 4 | env: 5 | CI: true 6 | 7 | jobs: 8 | test: 9 | name: Node.js ${{ matrix.node-version }} @ ${{ matrix.os }} 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | os: [ubuntu-latest, windows-latest, macos-latest] 15 | node-version: [16, 18, 20, 22] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - run: npm install 23 | - run: npm test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # always ignore files 2 | *.sublime-* 3 | *.code-* 4 | *.log 5 | .DS_Store 6 | .env 7 | .env.* 8 | 9 | # always ignore dirs 10 | temp 11 | tmp 12 | vendor 13 | 14 | # test related, or directories generated by tests 15 | test/actual 16 | actual 17 | coverage 18 | .nyc* 19 | 20 | # package managers 21 | node_modules 22 | package-lock.json 23 | yarn.lock 24 | 25 | # misc 26 | _gh_pages 27 | _draft 28 | _drafts 29 | bower_components 30 | vendor 31 | temp 32 | tmp 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present, Jon Schlinkert. 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/cache/partials.js: -------------------------------------------------------------------------------- 1 | 2 | require('time-require'); 3 | 4 | const start = Date.now(); 5 | process.on('exit', () => console.log(`Time: ${Date.now() - start}ms`)); 6 | 7 | const assert = require('assert'); 8 | const Dry = require('../..'); 9 | const { Context, PartialCache, State } = Dry; 10 | const { StubFileSystem } = require('../../test/test_helpers'); 11 | 12 | const context = Context.build({ 13 | registers: { 14 | file_system: new StubFileSystem({ my_partial: 'my partial body' }) 15 | } 16 | }); 17 | 18 | const partial = PartialCache.load('my_partial', { 19 | context, 20 | state: new State() 21 | }); 22 | 23 | partial.render() 24 | .then(output => { 25 | console.log({ output }); 26 | assert.equal('my partial body', output); 27 | }) 28 | .catch(err => { 29 | console.error(err); 30 | }); 31 | -------------------------------------------------------------------------------- /examples/custom-tags/front-matter/Lexer.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | 4 | class Lexer extends Dry.Lexer { 5 | captureFence() { 6 | if (this.prev && this.prev.value !== '\n') return; 7 | const token = this.capture(/^---\n?/, 'fence'); 8 | if (token && (token.value === '---\n' || this.remaining === '')) { 9 | return token; 10 | } 11 | } 12 | 13 | advance() { 14 | return this.captureFence() || super.advance(); 15 | } 16 | } 17 | 18 | module.exports = Lexer; 19 | -------------------------------------------------------------------------------- /examples/custom-tags/front-matter/extends.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | 4 | class FileSystem { 5 | constructor(files) { 6 | this.files = files; 7 | } 8 | 9 | read_template_file(path) { 10 | return this.files[path]; 11 | } 12 | } 13 | 14 | // register custom lexer and tag 15 | Dry.Lexer = require('./Lexer'); 16 | Dry.Template.register_tag('frontmatter', require('./tags/FrontMatter')); 17 | Dry.nodes.Root = require('./nodes/Root'); 18 | 19 | const registers = Dry.Template.file_system = new FileSystem({ 20 | 'parent.html': ` 21 | {% block "content" %} 22 | The main block. 23 | {% endblock %} 24 | ` 25 | }); 26 | 27 | Dry.Template.layouts = new FileSystem({ 28 | 'base.html': ` 29 | 30 | 31 | {% content %} 32 | 33 | `, 34 | 'default.html': ` 35 | {% layout "base.html" -%} 36 | {% extends "parent.html" -%} 37 | {% block "content" %} 38 | 39 | {{ title }} 40 | 41 | 42 | {% content %} 43 | 44 | {% endblock %}` 45 | }); 46 | 47 | const source = `--- 48 | title: Content 49 | layout: default.html 50 | --- 51 | This is {{title}}.`; 52 | 53 | Dry.Template.render_strict(source, undefined, { registers }) 54 | .then(console.log) 55 | .catch(console.error); 56 | -------------------------------------------------------------------------------- /examples/custom-tags/front-matter/index.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | 4 | class StubFileSystem { 5 | constructor(files) { 6 | this.files = files; 7 | } 8 | 9 | read_template_file(path) { 10 | return this.files[path]; 11 | } 12 | } 13 | 14 | // register custom lexer and tag 15 | Dry.Lexer = require('./Lexer'); 16 | Dry.Template.register_tag('frontmatter', require('./tags/FrontMatter')); 17 | Dry.nodes.Root = require('./nodes/Root'); 18 | 19 | Dry.Template.layouts = new StubFileSystem({ 20 | 'base.html': ` 21 | 22 | 23 | {% content %} 24 | 25 | `, 26 | 'default.html': ` 27 | {% layout "base.html" -%} 28 | 29 | {{ title }} 30 | 31 | 32 | {% content %} 33 | ` 34 | }); 35 | 36 | const source = `--- 37 | title: Content 38 | layout: default.html 39 | --- 40 | This is {{title}}.`; 41 | 42 | Dry.Template.render_strict(source) 43 | .then(console.log) 44 | .catch(console.error); 45 | -------------------------------------------------------------------------------- /examples/custom-tags/front-matter/nodes/Root.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-case-declarations */ 2 | 3 | const Dry = require('../../..'); 4 | 5 | class Root extends Dry.BlockTag { 6 | constructor(node, state, parent) { 7 | super(node, state, parent); 8 | this.type = this.name = 'root'; 9 | this.blank = true; 10 | this.depth = 0; 11 | } 12 | 13 | first_node() { 14 | return this.nodes.find(node => node.type !== 'text' || node.value.trim() !== ''); 15 | } 16 | 17 | async render_with_layout(layout_name, node, nodes = [], context) { 18 | const layout_string = Dry.tags.Layout.toString(layout_name); 19 | const template = Dry.Template.parse(layout_string); 20 | const layout = template.root.ast.nodes.find(node => node.name === 'layout'); 21 | return layout.render_content(nodes, context); 22 | } 23 | 24 | async render_with_front_matter(node, nodes = [], context) { 25 | const data = node.parse(); 26 | const name = data.layout; 27 | 28 | return context.stack(data, () => { 29 | return name ? this.render_with_layout(name, node, nodes, context) : super.render(context); 30 | }); 31 | } 32 | 33 | async render(context, output) { 34 | const node = this.first_node(); 35 | const nodes = this.nodes.slice(this.nodes.indexOf(node)); 36 | 37 | switch (node?.name) { 38 | case 'front_matter': 39 | return this.render_with_front_matter(node, nodes, context); 40 | case 'layout': 41 | return node.render_content(nodes, context); 42 | default: { 43 | return super.render(context, output); 44 | } 45 | } 46 | } 47 | 48 | get nodelist() { 49 | return this.nodes; 50 | } 51 | 52 | static get ParseTreeVisitor() { 53 | return ParseTreeVisitor; 54 | } 55 | } 56 | 57 | class ParseTreeVisitor extends Dry.ParseTreeVisitor { 58 | Parent = Root; 59 | get children() { 60 | return this.node.nodes.slice(); 61 | } 62 | } 63 | 64 | module.exports = Root; 65 | -------------------------------------------------------------------------------- /examples/custom-tags/front-matter/tags/FrontMatter.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../../..'); 3 | 4 | class FrontMatter extends Dry.BlockTag { 5 | constructor(node, state) { 6 | super(node, state); 7 | this.type = this.name = 'front_matter'; 8 | } 9 | 10 | parse() { 11 | const lines = this.nodes.slice(1, -1).map(n => n.value).join('\n').split('\n'); 12 | const data = {}; 13 | 14 | for (const line of lines) { 15 | if (line !== '' && line !== '\n') { 16 | const [key, ...value] = line.split(/: */); 17 | data[key.trim()] = value.join(': ').trim(); 18 | } 19 | } 20 | 21 | return data; 22 | } 23 | 24 | render(context) { 25 | context.merge({ page: this.parse() }); 26 | return ''; 27 | } 28 | } 29 | 30 | module.exports = FrontMatter; 31 | -------------------------------------------------------------------------------- /examples/custom-tags/google-analytics/GoogleAnalytics.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../../..'); 3 | const { utils } = Dry; 4 | 5 | const kScript = Symbol(':script'); 6 | 7 | /** 8 | * Returns a Google Analytics tag. 9 | * 10 | * == Basic Usage: 11 | * 12 | * {% google_analytics 'UA-XXXXX-X' %} 13 | */ 14 | 15 | class GoogleAnalytics extends Dry.Tag { 16 | static Syntax = utils.r`^(?:(["'\`])((?:G|UA|YT|MO)-[a-zA-Z0-9-]+)\\1|${Dry.regex.QuotedFragment})(.*)$`; 17 | 18 | constructor(node, state) { 19 | super(node, state); 20 | 21 | if (this.Syntax(this.match[3], GoogleAnalytics.Syntax)) { 22 | const [m, quoted, inner] = this.last_match; 23 | this.variable = quoted ? inner : Dry.VariableLookup.parse(m); 24 | } else { 25 | throw new Dry.SyntaxError('Syntax Error in "ga" tag - Valid syntax: {% ga "account_id" %}'); 26 | } 27 | } 28 | 29 | render(context) { 30 | const account_id = context.evaluate(this.variable) || this.variable.name; 31 | 32 | if (account_id === this.variable.name && this.variable.name === this.variable.markup) { 33 | throw new Dry.SyntaxError('Syntax Error in "ga" tag - Valid syntax: {% ga "account_id" %}'); 34 | } 35 | 36 | return this.constructor.script(account_id); 37 | } 38 | 39 | static set script(value) { 40 | this[kScript] = value; 41 | } 42 | static get script() { 43 | if (this[kScript]) return this[kScript]; 44 | 45 | return account_id => ` 46 | 54 | `; 55 | } 56 | } 57 | 58 | module.exports = GoogleAnalytics; 59 | -------------------------------------------------------------------------------- /examples/django.js: -------------------------------------------------------------------------------- 1 | 2 | const { Template } = require('..'); 3 | 4 | class FileSystem { 5 | constructor(values) { 6 | this.values = values; 7 | } 8 | read_template_file(name) { 9 | return this.values[name]; 10 | } 11 | } 12 | 13 | const templates = { 14 | 'base.html': ` 15 | 16 | 17 | 18 | 19 | {% block title %}My amazing site{% endblock %} 20 | 21 | 22 | 30 |
31 | {% block content %}{% endblock %} 32 |
33 | 34 | 35 | ` 36 | }; 37 | 38 | const source = ` 39 | {% extends "base.html" %} 40 | {% block title %}My amazing blog{% endblock %} 41 | {%- block content -%} 42 | {% if blog_entries.length >= 2 %} 43 | 51 | {% endif -%} 52 | {% endblock %} 53 | `; 54 | 55 | Template.file_system = new FileSystem(templates); 56 | 57 | const locals = { 58 | blog_entries: [ 59 | { title: 'Entry one', body: 'This is my first entry.' }, 60 | { title: 'Entry two', body: 'This is my second entry.' } 61 | ] 62 | }; 63 | 64 | Template.render_strict(source, locals, { strict_variables: true, strict_filters: true }) 65 | .then(console.log) 66 | .catch(console.error); 67 | -------------------------------------------------------------------------------- /examples/filters.js: -------------------------------------------------------------------------------- 1 | const { render_strict, Template } = require('..'); 2 | 3 | // Template.register_filter('append', append); 4 | Template.register_filter('map', (data, params) => { 5 | return data.map(item => ({ [params.label]: item[params.value] })); 6 | }); 7 | 8 | (async () => { 9 | 10 | const actual = await render_strict('{% assign options = steps.fetch_labels.records | map: "value"=id, "label"=name %}', { 11 | steps: { 12 | fetch_labels: { 13 | records: [ 14 | { id: 1, name: 'doowb' }, 15 | { id: 2, name: 'jonschlinkert' }, 16 | { id: 3, name: 'foo' }, 17 | { id: 4, name: 'bar' }, 18 | { id: 5, name: 'baz' } 19 | ] 20 | } 21 | } 22 | }, { 23 | output: { type: 'array' } 24 | }); 25 | 26 | console.log({ 27 | expected: '', 28 | actual 29 | }); 30 | console.log(actual); 31 | 32 | console.log({ 33 | expected: '', 34 | actual: await render_strict('{{ steps.fetch_labels.records | map: "value"=id, "label"=name }}', { 35 | steps: { 36 | fetch_labels: { 37 | records: [ 38 | { id: 1, name: 'doowb' }, 39 | { id: 2, name: 'jonschlinkert' }, 40 | { id: 3, name: 'foo' }, 41 | { id: 4, name: 'bar' }, 42 | { id: 5, name: 'baz' } 43 | ] 44 | } 45 | } 46 | }, { 47 | output: { type: 'array' } 48 | }) 49 | }); 50 | 51 | })(); 52 | -------------------------------------------------------------------------------- /examples/floats.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | 3 | const Dry = require('..'); 4 | const { Template } = Dry; 5 | 6 | // const locals = { 0: { 0: { 0: 'It worked!' } } }; 7 | const locals = { 0: [['foo', 'bar']] }; 8 | 9 | Template.render(` 10 | 11 | {% assign arr = ["a", "b"] %} 12 | "0.0.0" => {{ "0.0.0" }}, 13 | 0[0] => {{ 0[0] }}, 14 | 0["0"] => {{ 0["0"] }} 15 | 0[0].1 => {{ 0[0].1 }} 16 | 0.0.1 => {{ 0.0.1 }} 17 | 0."0".1 => {{ 0."0".1 }} 18 | "0.0.0" => {{ "0.0.0" }} 19 | "arr[0]" => {{ arr[0] }} 20 | 21 | `, locals) 22 | .then(console.log) 23 | .catch(console.error); 24 | -------------------------------------------------------------------------------- /examples/render.js: -------------------------------------------------------------------------------- 1 | const start = Date.now(); 2 | const { Template } = require('..'); 3 | 4 | console.log('Elapsed:', Date.now() - start); 5 | 6 | const template = new Template(); 7 | template.parse('Hello {{ name }}!'); 8 | 9 | console.log('Elapsed:', Date.now() - start); 10 | 11 | template.render({ name: 'Unai' }) 12 | .then(output => { 13 | console.log(output); 14 | console.log('Total:', Date.now() - start); 15 | }); 16 | -------------------------------------------------------------------------------- /examples/tags/apply.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | 4 | const source = ` 5 | {%- apply upcase -%} 6 | This is inner 7 | {% endapply %} 8 | 9 | {%- apply split: '' | join: '-' -%} 10 | {% apply upcase -%} 11 | This is inner 12 | {%- endapply %} 13 | This is outer 14 | {%- endapply %} 15 | 16 | This should not render:{{ apply }} 17 | `; 18 | 19 | const template = Dry.Template.parse(source); 20 | template.render({}).then(console.log).catch(console.error); 21 | -------------------------------------------------------------------------------- /examples/tags/blanks.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | 4 | const source = ' {%- assign foo = "bar" -%} {%- case foo -%} {%- when "bar" -%} {%- when "whatever" -%} {% else %} {%- endcase -%} '; 5 | 6 | const template = Dry.Template.parse(source); 7 | 8 | (async () => { 9 | 10 | console.log({ 11 | expected: '', 12 | actual: await template.render() 13 | }); 14 | 15 | })(); 16 | -------------------------------------------------------------------------------- /examples/tags/case.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | 4 | const source = `{% case shipping_method.title %} 5 | {% when 'International Shipping' %} 6 | You're shipping internationally. Your order should arrive in 2–3 weeks. 7 | {% when 'Domestic Shipping' %} 8 | Your order should arrive in 3–4 days. 9 | {% when 'Local Pick-Up' %} 10 | Your order will be ready for pick-up tomorrow. 11 | {% else %} 12 | Thank you for your order! 13 | {% endcase %}`; 14 | 15 | const template = Dry.Template.parse(source); 16 | 17 | template.render_strict({ shipping_method: { title: 'Domestic Shipping' } }) 18 | .then(console.log) 19 | .catch(console.error); 20 | 21 | template.render_strict({ shipping_method: { title: 'Local Pick-Up' } }) 22 | .then(console.log) 23 | .catch(console.error); 24 | -------------------------------------------------------------------------------- /examples/tags/cycle.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | const { Template } = Dry; 4 | 5 | const source = ` 6 | {%- for i in (1..10) -%} 7 | {% cycle 1,2,3 -%}{%- assign n = i % 2 -%} 8 | {%- if n == 0 %} 9 | A 10 | {% else %} 11 | B 12 | {% endif %} 13 | {%- endfor -%} 14 | `; 15 | 16 | const template = Template.parse(source, { path: 'source.html' }); 17 | template.render({}).then(console.log).catch(console.error); 18 | 19 | -------------------------------------------------------------------------------- /examples/tags/embed.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | 4 | class FileSystem { 5 | constructor(values) { 6 | this.values = values; 7 | } 8 | read_template_file(template_path) { 9 | return this.values[template_path]; 10 | } 11 | } 12 | 13 | Dry.Template.file_system = new FileSystem({ 14 | 'vertical_boxes_skeleton.liquid': ` 15 | {{ foo }} 16 | {{ bar }} 17 |
18 | {%- block top %}Top box default content{% endblock %} 19 |
20 | 21 |
22 | {% block middle %}Middle box default content{% endblock %} 23 |
24 | 25 |
26 | {% block bottom %}Bottom box default content{% endblock %} 27 |
28 | 29 | {% block footer -%} 30 |
This is the footer
31 | {% endblock %} 32 | ` 33 | }); 34 | 35 | const source = ` 36 | Before 37 | {% embed "vertical_boxes_skeleton.liquid" with data %} 38 | {% block top %} 39 | Some content for the top box 40 | {%- endblock %} 41 | 42 | {% block bottom -%} 43 | Some content for the bottom box 44 | {%- endblock %} 45 | {% endembed %} 46 | After 47 | `; 48 | 49 | const template = Dry.Template.parse(source, { path: 'source.html' }); 50 | template.render({ data: { foo: 'one', bar: 'two' } }).then(console.log).catch(console.error); 51 | 52 | -------------------------------------------------------------------------------- /examples/tags/extends-block-function.js: -------------------------------------------------------------------------------- 1 | 2 | const { Template } = require('../..'); 3 | 4 | class FileSystem { 5 | constructor(values) { 6 | this.values = values; 7 | } 8 | read_template_file(name) { 9 | return this.values[name]; 10 | } 11 | } 12 | 13 | const templates = { 14 | 'common_blocks.liquid': ` 15 | {% block 'title' %} 16 | This is from comment_blocks.liquid 17 | {% endblock %} 18 | ` 19 | }; 20 | 21 | const source = ` 22 | {% extends "common_blocks.liquid" %} 23 | {% block 'title' %}This is a title{% endblock %} 24 | {% block 'footer' %}This is a footer{% endblock %} 25 | {{ block('title') }} 26 |

{{ block('title') }}

27 | 28 | {{ block("title", "common_blocks.liquid") }} 29 | 30 | {{ block('title') }} 31 |

{{ block('title') }}

32 | 33 | {% if block("footer") is defined %} 34 | Footer is defined 35 | {% endif %} 36 | 37 | `; 38 | 39 | Template.file_system = new FileSystem(templates); 40 | 41 | Template.render_strict(source, {}, { path: 'source.html' }) 42 | .then(console.log) 43 | .catch(console.error); 44 | -------------------------------------------------------------------------------- /examples/tags/extends-block-super.js: -------------------------------------------------------------------------------- 1 | 2 | const { Template } = require('../..'); 3 | 4 | class FileSystem { 5 | constructor(values) { 6 | this.values = values; 7 | } 8 | read_template_file(name) { 9 | return this.values[name]; 10 | } 11 | } 12 | 13 | const templates = { 14 | 'base.html': ` 15 | {% block 'sidebar' %} 16 | This is from base.html 17 | {% endblock %} 18 | ` 19 | }; 20 | 21 | const source = ` 22 | {% extends "base.html" %} 23 | {% block 'sidebar' %} 24 |

Table Of Contents

25 | ... 26 | {{ super() }} 27 | {% endblock %} 28 | `; 29 | 30 | Template.file_system = new FileSystem(templates); 31 | 32 | Template 33 | .parse(source, { path: 'source.html' }) 34 | .render_strict() 35 | .then(console.log); 36 | -------------------------------------------------------------------------------- /examples/tags/extends-with-parent.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | 4 | class FileSystem { 5 | constructor(files) { 6 | this.files = files; 7 | } 8 | 9 | read_template_file(path) { 10 | return this.files[path]; 11 | } 12 | } 13 | 14 | const files = { 15 | 'base.html': ` 16 | 17 | 18 | 19 | {% block 'head' %} 20 | {% block 'title' %}Default Title{% endblock %} 21 | {% endblock %} 22 | 23 | 24 | {% block 'content' %}
{% endblock %} 25 | {% block 'footer' %}
Default footer
{% endblock %} 26 | 27 | 28 | ` 29 | }; 30 | 31 | const source = ` 32 | {% extends "base.html" %} 33 | {% block 'title' %}{{ page.title }}{% endblock %} 34 | {% block 'head' %} 35 | {{ super() }} 36 | 39 | {% endblock %} 40 | {% block 'content' %} 41 |

Index

42 |

43 | Welcome on my awesome homepage. 44 |

45 | {% endblock %} 46 | `; 47 | 48 | const layouts = Dry.Template.file_system = new FileSystem(files); 49 | console.log(layouts); 50 | const template = Dry.Template.parse(source); 51 | template.render({ page: { title: 'Home' } }, { registers: { layouts } }) 52 | .then(v => console.log({ v })) 53 | .catch(console.error); 54 | -------------------------------------------------------------------------------- /examples/tags/extends.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | 4 | class FileSystem { 5 | constructor(files) { 6 | this.files = files; 7 | } 8 | 9 | read_template_file(path) { 10 | return this.files[path]; 11 | } 12 | } 13 | 14 | const templates = { 15 | base: ` 16 | 17 | 18 | 19 | {% block head %} 20 | {% block title %}Default Title{% endblock %} 21 | {% endblock %} 22 | 23 | 24 | {% block content %}
{% endblock %} 25 | {% block footer %}
Default footer
{% endblock %} 26 | 27 | 28 | ` 29 | }; 30 | 31 | const templates2 = { 32 | layouts: { 33 | 'base.html': ` 34 | 35 | 36 | 37 | {% block head %}{% endblock %} 38 | 39 | 40 | {% block content %}Default content{% endblock %} 41 | {% block footer %}
Default footer
{% endblock %} 42 | 43 | 44 | `, 45 | 'foo.html': ` 46 | {%- extends "layouts/base.html" -%} 47 | {% block content %}{{ parent() }}Foo content{% endblock %} 48 | {% block footer %}{{ parent() }}Foo footer{% endblock %} 49 | `, 50 | 'bar.html': ` 51 | {%- extends "layouts/foo.html" %} 52 | {% block content mode="append" %}{{ parent() }}Bar content{% endblock %} 53 | {% block footer mode="append" %}{{ parent() }}Bar footer{% endblock %} 54 | `, 55 | 'baz.html': ` 56 | {%- extends "layouts/bar.html" %} 57 | {% block content mode="append" %}Baz content{% endblock %} 58 | {% block footer mode="append" %}Baz footer{% endblock %} 59 | ` 60 | } 61 | }; 62 | 63 | Dry.Template.file_system = new FileSystem(templates2); 64 | 65 | const source = ` 66 | {%- extends "layouts/foo.html" -%} 67 | {%- block head %} Home {% endblock -%} 68 | {%- block content mode="append" %} New content {% endblock -%} 69 | `; 70 | 71 | // const block = ` 72 | // {% block content mode="append" %} New content {% endblock %} 73 | // `; 74 | 75 | const template = Dry.Template.parse(source, { path: 'source.html' }); 76 | template.render({}, { registers: templates2 }).then(console.log); 77 | -------------------------------------------------------------------------------- /examples/tags/for.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | const pkg = require('../../package'); 4 | 5 | const fixtures = { 6 | // key_value: ` 7 | // {% for key, user in users -%} 8 | //
  • {{ key }}: {{ user.username|e }}
  • 9 | // {%- endfor %} 10 | // `, 11 | 12 | // key_value2: ` 13 | // {% for key in pkg %} 14 | //
  • {{ key }}
  • 15 | // {% endfor %} 16 | // `, 17 | 18 | // key_value3: ` 19 | // {% for key, value in pkg -%} 20 | //
  • {{ key }}: {{ value }}
  • 21 | // {% endfor %} 22 | // `, 23 | 24 | // key_value4: ` 25 | // {%- for link in page.links %} 26 | //
  • {{ link[0] }}: {{ link[1].version | default: link[1] }}
  • 27 | // {%- endfor %} 28 | // `, 29 | 30 | // key_value5: ` 31 | // {% for item in pkg -%} 32 | //
  • {{ item[0] }}: {{ item[1] | json }}
  • 33 | // {% endfor %} 34 | // ` 35 | 36 | // for_loop_vars: ` 37 | // {% for a in (1..20) -%} 38 | // {{forloop.index0}} 39 | // {%- endfor %} 40 | // `, 41 | 42 | // for_loop_vars_at: ` 43 | // {% for letter in (1..20) %} 44 | // {{ @rindex }} 45 | // {%- endfor %} 46 | // `, 47 | 48 | // range: ` 49 | // {% for a in (1..10) -%} 50 | // {{ forloop.index0 }} 51 | // {%- endfor -%} 52 | 53 | // `, 54 | 55 | // range_filters: ` 56 | // {%- for letter in ('a'|upcase..'z'|upcase) %} 57 | // * {{ letter }} - {{ @index }} 58 | // {%- endfor -%} 59 | // `, 60 | 61 | kv: ` 62 | {%- for k, v in pkg -%} 63 | {%- assign val = v | typeof -%} 64 | {%- if val != 'object' %} 65 | - {{k}} = {{v}} 66 | {%- endif %} 67 | {%- endfor -%} 68 | ` 69 | 70 | // first: ` 71 | // {% for product in products %} 72 | // {% if forloop.first == true %} 73 | // First time through! 74 | // {% else %} 75 | // Not the first time. 76 | // {% endif %} 77 | // {% endfor %} 78 | // ` 79 | }; 80 | 81 | const locals = { 82 | pkg, 83 | users: [ 84 | { username: 'doowb' }, 85 | { username: 'jonschlinkert' } 86 | ], 87 | 88 | products: [1, 2, 3], 89 | 90 | page: { 91 | links: { 92 | demo: 'http://www.github.com/copperegg/mongo-scaling-demo', 93 | more: 'http://www.github.com/copperegg/mongo-scaling-more', 94 | deps: { 95 | version: 'v1.0.1' 96 | } 97 | } 98 | } 99 | }; 100 | 101 | (async () => { 102 | for (const [key, source] of Object.entries(fixtures)) { 103 | process.stdout.write(` --- ${key}`); 104 | // console.log({ source, locals }); 105 | const output = await Dry.Template.render_strict(source, locals) || ''; 106 | process.stdout.write(output); 107 | process.stdout.write('\n ---\n'); 108 | console.log(); 109 | } 110 | })(); 111 | 112 | -------------------------------------------------------------------------------- /examples/tags/ga.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | 4 | Dry.Template.register_tag('ga', require('./custom/GoogleAnalytics')); 5 | 6 | const source = ` 7 | {% ga "G-2RJ8P0I4GC" %} 8 | {% ga 'G-2RJ8P0I4GC' %} 9 | {% ga \`G-2RJ8P0I4GC\` %} 10 | {% ga google_analytics_id %} 11 | {% ga foo | default: "G-2RJ8P0I4GC" %} 12 | {{ foo | default: "G-2RJ8P0I4GC" }} 13 | `; 14 | 15 | const template = Dry.Template.parse(source); 16 | const output = template.render({ google_analytics_id: 'G-2RJ8P0I4GC' }); 17 | console.log(output); 18 | -------------------------------------------------------------------------------- /examples/tags/if.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | 4 | // {% if line_item.grams > 20000 or line_item.weight > 1000 and customer_address.city == 'Ottawa' %} 5 | // You're buying a heavy item, and live in the same city as our store. Choose local pick-up as a shipping option to avoid paying high shipping costs. 6 | // {% else %} 7 | // ... 8 | // {% endif %} 9 | const source = ` 10 | {% if linkpost -%} 11 | {%- capture title %}→ {{ post.title}}{% endcapture -%} 12 | {% else %} 13 | {%- capture title %}★ {{ post.title }}{% endcapture -%} 14 | {% endif -%} 15 | {{ title }} 16 | {% if linkpost %}→{% else %}★{% endif %} [{{ post.title }}](#{{ post.id }}) 17 | `; 18 | 19 | const template = Dry.Template.parse(source); 20 | const locals = { line_item: { grams: 19000, weight: 1001 }, customer_address: { city: 'Ottawa' } }; 21 | 22 | template.render_strict({ linkpost: false, post: { title: 'My Blog', id: 'abc' } }) 23 | .then(console.log) 24 | .catch(console.error); 25 | 26 | -------------------------------------------------------------------------------- /examples/tags/layout.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | 4 | class FileSystem { 5 | constructor(files) { 6 | this.files = files; 7 | } 8 | 9 | read_template_file(path) { 10 | return this.files[path]; 11 | } 12 | } 13 | 14 | const files = { 15 | base: ` 16 | 17 | 18 | 19 | {% block head %} 20 | {% block title %}Default Title{% endblock %} 21 | {% endblock %} 22 | 23 | 24 | {% block content %}
    {% endblock %} 25 | {% block footer %}
    Default footer
    {% endblock %} 26 | 27 | 28 | ` 29 | }; 30 | 31 | const source = ` 32 | {% extends "base.html" %} 33 | 34 | {% block title %}Home{% endblock %} 35 | {% block head %} 36 | {{ parent() }} 37 | 40 | {% endblock %} 41 | {% block content %} 42 |

    Index

    43 |

    44 | Welcome on my awesome homepage. 45 |

    46 | {% endblock %} 47 | `; 48 | 49 | const layouts = Dry.Template.layouts = new FileSystem(files); 50 | const template = Dry.Template.parse(source); 51 | 52 | template.render_strict({}, { registers: { layouts } }) 53 | .then(console.log) 54 | .catch(console.error); 55 | -------------------------------------------------------------------------------- /examples/tags/macro/_fields.html: -------------------------------------------------------------------------------- 1 | {% macro input(name, value, type = "text", size = 20) %} 2 | 3 | {% endmacro %} 4 | 5 | {% macro textarea(name, text, rows = 5, cols = 40) %} 6 | 7 | {% endmacro %} 8 | -------------------------------------------------------------------------------- /examples/tags/macro/_fields.liquid: -------------------------------------------------------------------------------- 1 | {% macro input(name, value, type = "text", size = 20) %} 2 | 3 | {% endmacro %} 4 | 5 | {% macro textarea(name, value, rows = 10, cols = 40) %} 6 | 7 | {% endmacro %} 8 | -------------------------------------------------------------------------------- /examples/tags/macro/_macros.liquid: -------------------------------------------------------------------------------- 1 | {% macro one() -%} 2 | This is macro "one" 3 | {% endmacro %} 4 | 5 | {% macro two() -%} 6 | This is macro "two" 7 | {% endmacro %} 8 | 9 | {% macro three() -%} 10 | This is macro "three" 11 | {% endmacro %} 12 | 13 | {% macro four(name="four") -%} 14 | This is macro "{{ name }}" 15 | {% endmacro %} 16 | 17 | {% macro hello(name="friend") -%} 18 | Hello, {{ name }}! 19 | {% endmacro %} 20 | -------------------------------------------------------------------------------- /examples/tags/macro/_signup.liquid: -------------------------------------------------------------------------------- 1 | {% macro input(name, value, type = "text", size = 20) %} 2 | 3 | {% endmacro %} 4 | 5 | {% macro textarea(name, value, rows = 10, cols = 40) %} 6 | 7 | {% endmacro %} 8 | -------------------------------------------------------------------------------- /examples/tags/macro/_test.liquid: -------------------------------------------------------------------------------- 1 | Test -------------------------------------------------------------------------------- /examples/tags/macro/from-as.js: -------------------------------------------------------------------------------- 1 | 2 | // const path = require('path'); 3 | const Dry = require('../../..'); 4 | const { FileSystem: { LocalFileSystem } } = Dry; 5 | const file_system = new LocalFileSystem(__dirname, '_%s.html'); 6 | 7 | const source = ` 8 | {% from 'fields' import input as input_field, textarea %} 9 | 10 |

    {{ input_field('password', '', 'password') }}

    11 |

    {{ textarea('comment', 'This is a comment') }}

    12 | `; 13 | 14 | const template = Dry.Template.parse(source); 15 | template.render({}, { registers: { file_system } }) 16 | .then(console.log) 17 | .catch(console.error); 18 | 19 | -------------------------------------------------------------------------------- /examples/tags/macro/from.js: -------------------------------------------------------------------------------- 1 | 2 | // const path = require('path'); 3 | const Dry = require('../../..'); 4 | const { FileSystem: { LocalFileSystem } } = Dry; 5 | const file_system = new LocalFileSystem(__dirname); 6 | 7 | const source = [ 8 | '{%- import "signup" as forms -%}', 9 | '{%- from "macros" import hello -%}', 10 | '{%- import "macros" as foo -%}', 11 | '', 12 | '{%- assign bar = foo -%}', 13 | 'Bar: {{ bar.one() }}', 14 | 'Bar: {{ bar.two() }}', 15 | 'Bar: {{ bar.three() }}', 16 | 'Bar: {{ bar.four() }}', 17 | '----', 18 | 'Foo: {{ foo.one() }}', 19 | 'Foo: {{ foo.two() }}', 20 | 'Foo: {{ foo.three() }}', 21 | 'Foo: {{ foo.four() }}', 22 | // '', 23 | '{%- if foo.hello is defined -%}', 24 | ' {{- foo.hello() -}}', 25 | ' {{- foo.hello("Jon") -}}', 26 | ' {%- for i in (1..3) -%}', 27 | ' {{- foo.hello(i) -}}', 28 | ' {%- endfor -%}', 29 | '{%- endif -%}', 30 | '', 31 | '{% if hello -%}', 32 | 'OK', 33 | '{% endif %}', 34 | '', 35 | '

    {{ forms.input("username") }}

    ', 36 | '

    {{ forms.input("password", null, "password") }}

    ', 37 | '', 38 | '
    ', 39 | '', 40 | '

    {{ forms.textarea("bio") }}

    ' 41 | ].join('\n'); 42 | 43 | const source2 = ` 44 | {% from 'forms.html' import input as input_field, textarea %} 45 | 46 |

    {{ input_field('password', '', 'password') }}

    47 |

    {{ textarea('comment') }}

    48 | `; 49 | 50 | const template = Dry.Template.parse(source); 51 | template.render({}, { registers: { file_system } }) 52 | .then(console.log) 53 | .catch(console.error); 54 | 55 | -------------------------------------------------------------------------------- /examples/tags/macro/import.js: -------------------------------------------------------------------------------- 1 | 2 | // const path = require('path'); 3 | const Dry = require('../../..'); 4 | const { FileSystem: { LocalFileSystem } } = Dry; 5 | const file_system = new LocalFileSystem(__dirname); 6 | 7 | const source = ` 8 | {% import "signup" as forms %} 9 | 10 | The above import call imports the forms.html file (which can contain only macros, or a template and some macros), and import the macros as items of the forms local variable. 11 | 12 | The macros can then be called at will in the current template: 13 | 14 |

    {{ forms.input('username') | trim }}

    15 |

    {{ forms.input('password', null, 'password') | trim }}

    16 | 17 |
    18 | 19 |

    {{ forms.textarea('bio') | trim }}

    20 | `; 21 | 22 | // const source2 = '{% import "signup" as forms %}

    {{ forms.input("username") | trim }}

    '; 23 | 24 | const template = Dry.Template.parse(source); 25 | template.render({}, { registers: { file_system } }) 26 | .then(console.log) 27 | .catch(console.error); 28 | -------------------------------------------------------------------------------- /examples/tags/macro/macro-with-dot-param.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../../..'); 3 | 4 | const source = ` 5 | {%- macro foo(a, b=true, c=foo.bar, d) %} 6 | a: {{a}} 7 | b: {{b}} 8 | c: {{c}} 9 | {% if d %}d: {{d}}{% endif %} 10 | {% endmacro %} 11 | 12 | {%- assign args1 = [undefined, foo.baz, 'gamma'] -%} 13 | {%- assign args = [...args1, "alpha", "beta"] -%} 14 | 15 |
    {{ foo('doowb', ...args, "whatever") }}
    16 | `; 17 | 18 | //
    {{ foo() }}
    19 | //
    {{ foo("one", false, foo.baz, 'd') }}
    20 | 21 | const template = Dry.Template.parse(source); 22 | template.render({ data: { foo: 'one', bar: 'two' }, foo: { bar: 'from context', baz: 'other from context' } }) 23 | .then(console.log) 24 | .catch(console.error); 25 | -------------------------------------------------------------------------------- /examples/tags/macro/macro.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../../..'); 3 | 4 | const source = ` 5 |

    {{ input('password', '', 'password') }}

    6 | 7 | {% macro input(name, value, type = "text", size = 20) %} 8 | 9 | {% endmacro %} 10 | 11 | {% macro foo(a, b=true, c=variable, d) %} 12 | a: {{a}} 13 | b: {{b}} 14 | c: {{c}} 15 | d: {{d}} 16 | {% endmacro %} 17 | 18 |
    {{ foo() }}
    19 | 20 | --- 21 | 22 |
    {{ foo("one", "
    Inside
    ", undefined, "ddd") }}
    23 | 24 | --- 25 | 26 |
    {{ input("username", "
    Inside
    ", undefined) }}
    27 | `; 28 | 29 | const template = Dry.Template.parse(source); 30 | template.render({ data: { foo: 'one', bar: 'two' }, variable: 'from context' }) 31 | .then(console.log) 32 | .catch(console.error); 33 | -------------------------------------------------------------------------------- /examples/tags/markdown.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../..'); 3 | const { Remarkable } = require('remarkable'); 4 | 5 | class Markdown extends Dry.Tag { 6 | async render(context) { 7 | const node = Dry.Expression.parse(this.match[3]); 8 | const value = await context.evaluate(node); 9 | const md = new Remarkable(); 10 | return md.render(value); 11 | } 12 | } 13 | 14 | Dry.Template.register_tag('markdown', Markdown); 15 | 16 | (async () => { 17 | const value = ` 18 | # Heading 19 | 20 | > This is markdown! 21 | 22 | Let's see if this works. 23 | `; 24 | 25 | console.log({ 26 | expected: '', 27 | actual: await Dry.Template.render_strict('foo {% markdown value %} bar', { value }) 28 | }); 29 | 30 | })(); 31 | -------------------------------------------------------------------------------- /examples/ten-million-dots.js: -------------------------------------------------------------------------------- 1 | const Template = require('../lib/Template'); 2 | 3 | // def test_for_dynamic_find_var 4 | // assert_template_result(' 1 2 3 ', '{%for item in (bar..[key]) %} {{item}} {%endfor%}', 'key' => 'foo', 'foo' => 3, 'bar' => 1) 5 | // end 6 | 7 | // # Regression test for old regex that has backtracking issues 8 | // def test_for_regular_expression_backtracking 9 | // with_error_mode(:strict) do 10 | // assert_raises(Liquid::SyntaxError) do 11 | // Template.parse("{%for item in (1#{'.' * 50000})! %} {{item}} {%endfor%}") 12 | // end 13 | // end 14 | // end 15 | 16 | const generateFixture = () => { 17 | const result = `{% for i in ('${'.'.repeat(10_000_000)}') %}\n{% endfor %}\n`; 18 | return result; 19 | }; 20 | 21 | const template = Template.parse(generateFixture()); 22 | 23 | template.render() 24 | .then(console.log) 25 | .catch(console.error); 26 | -------------------------------------------------------------------------------- /examples/variables.js: -------------------------------------------------------------------------------- 1 | 2 | const { render_strict, Template } = require('..'); 3 | const pause = (v, ms = 1000) => new Promise(res => setTimeout(() => res(v), ms)); 4 | const upper = v => v.toUpperCase(); 5 | const append = (a, b) => a + b; 6 | 7 | Template.register_filter('append', append); 8 | Template.register_filter('upper', upper); 9 | 10 | (async () => { 11 | const baz = () => pause('doowb', 10); 12 | // const foo = () => pause({ bar: { baz: 'doowb' } }, 10); 13 | const foo = () => pause({ bar: pause({ baz: 'doowb' }, 10) }, 10); 14 | 15 | // console.log({ 16 | // expected: '', 17 | // actual: await render('<{{ foo.bar.baz }}>', { foo: { bar: { baz } } }) 18 | // }); 19 | 20 | // console.log({ 21 | // expected: '', 22 | // actual: await render('<{{ foo.bar.baz }}>', { foo }) 23 | // }); 24 | 25 | console.log({ 26 | expected: '', 27 | actual: await render_strict('<{{ upper(append(foo.bar.baz, "-after")) }}>', { foo }) 28 | }); 29 | 30 | console.log({ 31 | expected: '', 32 | actual: await render_strict('<{{ foo.bar.baz | upper }}>', { foo }) 33 | }); 34 | })(); 35 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = require('./lib/Dry'); 3 | -------------------------------------------------------------------------------- /lib/Expression.js: -------------------------------------------------------------------------------- 1 | const Dry = require('./Dry'); 2 | const { regex, utils, RangeLookup, VariableLookup } = Dry; 3 | 4 | class Expression { 5 | static LITERALS = { 6 | 'nil': null, 'null': null, '': null, 7 | 'true': true, 8 | 'false': false, 9 | 'blank': '', 10 | 'empty': '' 11 | }; 12 | 13 | // static RANGES_REGEX = /^\(\s*([-+]?[0-9]+|[a-zA-Z])\s*\.\.\s*([-+]?[0-9]+|[a-zA-Z])\s*\)$/; // (1..10) 14 | static RANGES_REGEX = /^\(\s*(?:(\S+)\s*\.\.)\s*(\S+)\s*\)$/; // (1..10) 15 | // static RANGES_REGEX = /^\s*\(.*\)\s*$/; 16 | static INTEGERS_REGEX = /^([-+]?[0-9]+(?:[eE][-+]?[0-9]+)?|0x[0-9a-fA-F]+)$/; 17 | static FLOATS_REGEX = /^([-+]?[0-9]+(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?)$/; 18 | static SPREAD_REGEX = /^\.{3}([a-zA-Z_]\w*)/; 19 | static QUOTED_STRING = utils.r('m')`^\\s*(${regex.QuotedString.source.slice(1)})\\s*`; 20 | 21 | static parse(markup) { 22 | if (markup == null) return markup; 23 | if (typeof markup === 'number') return markup; 24 | if (typeof markup === 'symbol') return markup; 25 | if (typeof markup === 'string') markup = markup.trim(); 26 | 27 | if (hasOwnProperty.call(this.LITERALS, markup)) { 28 | return this.LITERALS[markup]; 29 | } 30 | 31 | const exec = regex => (this.match = regex.exec(markup)); 32 | 33 | if (exec(this.QUOTED_STRING)) return this.match[1].slice(1, -1); 34 | if (exec(this.INTEGERS_REGEX)) return Number(this.match[1]); 35 | if (exec(this.RANGES_REGEX)) { 36 | const parts = markup.trim().slice(1, -1).split(/\.{2,}/); 37 | if (parts.length === 2) { 38 | return RangeLookup.parse(parts[0], parts[1]); 39 | } 40 | } 41 | 42 | if (exec(this.FLOATS_REGEX)) return Number(this.match[0]); 43 | if (exec(this.SPREAD_REGEX)) { 44 | const output = VariableLookup.parse(this.match[1]); 45 | output.spread = true; 46 | return output; 47 | } 48 | 49 | return VariableLookup.parse(markup); 50 | } 51 | 52 | static isRange(markup) { 53 | return this.RANGES_REGEX.test(markup); 54 | } 55 | } 56 | 57 | module.exports = Expression; 58 | -------------------------------------------------------------------------------- /lib/I18n.js: -------------------------------------------------------------------------------- 1 | 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const { default: get } = require('get-value'); 5 | const { kLocale } = require('./constants/symbols'); 6 | const { isPlainObject } = require('./shared/utils'); 7 | 8 | const DEFAULT_LOCALE = path.join(__dirname, 'locales', 'en.yml'); 9 | 10 | class I18n { 11 | constructor(path = DEFAULT_LOCALE) { 12 | this.path = path; 13 | } 14 | 15 | translate(key, variables = {}) { 16 | return this.interpolate(this.get(key), variables); 17 | } 18 | 19 | t(...args) { 20 | return this.translate(...args); 21 | } 22 | 23 | interpolate(value, variables = {}) { 24 | if (Array.isArray(value)) { 25 | return value.map(v => this.interpolate(v, variables)); 26 | } 27 | 28 | if (isPlainObject(value)) { 29 | for (const key of Object.keys(value)) { 30 | value[key] = this.interpolate(value[key], variables); 31 | } 32 | return value; 33 | } 34 | 35 | if (typeof value === 'string') { 36 | return value.replace(/%\{(\w+)\}/g, (m, $1) => { 37 | if (!variables[$1]) { 38 | throw new Error(`Undefined key ${$1} for interpolation in translation ${value}`); 39 | } 40 | return variables[$1]; 41 | }); 42 | } 43 | return value; 44 | } 45 | 46 | get(key) { 47 | const value = get(this.locale, key); 48 | 49 | if (!value) { 50 | throw new Error(`Translation for ${key} does not exist in locale ${path}`); 51 | } 52 | 53 | return value; 54 | } 55 | 56 | set locale(value) { 57 | this[kLocale] = null; 58 | this.path = value; 59 | } 60 | get locale() { 61 | if (!this[kLocale]) { 62 | const yaml = require('yaml'); 63 | this[kLocale] = yaml.parse(fs.readFileSync(this.path, 'utf8')); 64 | } 65 | return this[kLocale]; 66 | } 67 | 68 | static get DEFAULT_LOCALE() { 69 | return DEFAULT_LOCALE; 70 | } 71 | } 72 | 73 | module.exports = I18n; 74 | -------------------------------------------------------------------------------- /lib/Location.js: -------------------------------------------------------------------------------- 1 | 2 | const { Newline } = require('./constants/regex'); 3 | 4 | class Position { 5 | constructor(loc) { 6 | this.index = loc.index; 7 | this.line = loc.line; 8 | this.col = loc.col; 9 | } 10 | } 11 | 12 | class Location { 13 | constructor(start, end) { 14 | this.start = start; 15 | this.end = end; 16 | } 17 | 18 | slice(input) { 19 | return input.slice(...this.range); 20 | } 21 | 22 | get range() { 23 | return [this.start.index, this.end.index]; 24 | } 25 | 26 | get lines() { 27 | return [this.start.line, this.end.line]; 28 | } 29 | 30 | static get Position() { 31 | return Position; 32 | } 33 | 34 | static get Location() { 35 | return Location; 36 | } 37 | 38 | static updateLocation(loc, value = '', length = value.length) { 39 | const lines = value.split(Newline); 40 | const last = lines[lines.length - 1]; 41 | loc.index += length; 42 | loc.col = lines.length > 1 ? last.length : loc.col + length; 43 | loc.line += Math.max(0, lines.length - 1); 44 | } 45 | 46 | static location(loc) { 47 | const start = new Position(loc); 48 | 49 | return node => { 50 | node.loc = new Location(start, new Position(loc)); 51 | return node; 52 | }; 53 | } 54 | } 55 | 56 | module.exports = Location; 57 | -------------------------------------------------------------------------------- /lib/ParseTreeVisitor.js: -------------------------------------------------------------------------------- 1 | 2 | class ParseTreeVisitor { 3 | static for(node, callbacks) { 4 | const Visitor = node.constructor.ParseTreeVisitor || this; 5 | const visitor = new Visitor(node, callbacks); 6 | return visitor; 7 | } 8 | 9 | constructor(node, callbacks = new Map()) { 10 | this.node = node; 11 | this.callbacks = callbacks; 12 | } 13 | 14 | add_callback_for(...classes) { 15 | const block = classes.pop(); 16 | const callback = node => block(node); 17 | classes.forEach(Node => this.callbacks.set(Node, callback)); 18 | return this; 19 | } 20 | 21 | visit(context = null) { 22 | return this.children.map(node => { 23 | const callback = this.callbacks.get(node.constructor); 24 | if (!callback) return [node.name || node]; 25 | 26 | const new_context = callback(node, context) || context; 27 | return [ 28 | ParseTreeVisitor.for(node, this.callbacks).visit(new_context) 29 | ]; 30 | }); 31 | } 32 | 33 | get children() { 34 | return this.node.nodes || []; 35 | } 36 | } 37 | 38 | module.exports = ParseTreeVisitor; 39 | -------------------------------------------------------------------------------- /lib/PartialCache.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('./Dry'); 3 | 4 | class PartialCache { 5 | static load_type(type, template_name, { context, state = {} } = {}) { 6 | const registers_key = type === 'partials' ? 'file_system' : type; 7 | const factory_key = type === 'partials' ? 'template_factory' : `${type}_factory`; 8 | 9 | try { 10 | const cached_partials = context.registers[`cached_${type}`] ||= {}; 11 | const cached = cached_partials[template_name]; 12 | if (cached) return cached; 13 | 14 | const file_system = context.registers[registers_key] ||= Dry.Template[registers_key]; 15 | const source = file_system.read_template_file(template_name); 16 | 17 | state.path = template_name; 18 | state.partial = true; 19 | 20 | const template_factory = context.registers[factory_key] ||= new Dry.TemplateFactory(); 21 | const template = template_factory.for(template_name); 22 | const partial = template.parse(source, state); 23 | 24 | cached_partials[template_name] = partial; 25 | return partial; 26 | } catch (err) { 27 | if (process.env.DEBUG) console.error(err); 28 | state.partial = false; 29 | } 30 | } 31 | 32 | static load(template_name, { context, state = {} } = {}) { 33 | return this.load_type('partials', template_name, { context, state }); 34 | } 35 | } 36 | 37 | module.exports = PartialCache; 38 | -------------------------------------------------------------------------------- /lib/RangeLookup.js: -------------------------------------------------------------------------------- 1 | const fill = require('fill-range'); 2 | const Dry = require('./Dry'); 3 | const { isPrimitive } = Dry.utils; 4 | 5 | const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; 6 | const MIN_SAFE_INTEGER = Number.MIN_SAFE_INTEGER; 7 | 8 | const toInteger = value => { 9 | if (typeof value === 'number') { 10 | if (value > MAX_SAFE_INTEGER || value < MIN_SAFE_INTEGER) { 11 | throw new Dry.RangeError('Integer value out of bounds'); 12 | } 13 | return value; 14 | } 15 | 16 | if (typeof value === 'bigint') { 17 | if (value > BigInt(MAX_SAFE_INTEGER) || value < BigInt(MIN_SAFE_INTEGER)) { 18 | throw new Dry.RangeError('Integer value out of bounds'); 19 | } 20 | return Number(value); 21 | } 22 | 23 | try { 24 | const number = Number(value); 25 | if (isNaN(number)) return 0; 26 | if (number > MAX_SAFE_INTEGER || number < MIN_SAFE_INTEGER) { 27 | throw new Dry.RangeError('Integer value out of bounds'); 28 | } 29 | return number; 30 | } catch { 31 | throw new Dry.SyntaxError('invalid integer'); 32 | } 33 | }; 34 | 35 | class RangeLookup { 36 | constructor(start, end) { 37 | this.type = 'range'; 38 | this.set('start', start); 39 | this.set('end', end); 40 | } 41 | 42 | set(key, value) { 43 | const markup = value?.markup || value; 44 | 45 | if (typeof markup === 'string' && markup.includes('|')) { 46 | value = new Dry.Variable(markup); 47 | } 48 | 49 | this[key] = value; 50 | } 51 | 52 | async evaluate(context) { 53 | const start = await context.evaluate(this.start); 54 | const end = await context.evaluate(this.end); 55 | 56 | try { 57 | if (!isPrimitive(start)) throw new Dry.RangeError(`Invalid range start: "${JSON.stringify(start)}"`); 58 | if (!isPrimitive(end)) throw new Dry.RangeError(`Invalid range end: "${JSON.stringify(end)}"`); 59 | } catch (error) { 60 | return context.handle_error(error); 61 | } 62 | 63 | return this.toString(this.toInteger(start), this.toInteger(end)); 64 | } 65 | 66 | toInteger(input) { 67 | return toInteger(input); 68 | } 69 | 70 | toString(start = this.start, end = this.end) { 71 | return this.constructor.toString(start, end); 72 | } 73 | 74 | static toString(start = this.start, end = this.end) { 75 | return `(${start}..${end})`; 76 | } 77 | 78 | static parse(startValue, endValue) { 79 | const start = Dry.Expression.parse(startValue); 80 | const end = Dry.Expression.parse(endValue); 81 | 82 | if (start?.evaluate || end?.evaluate) { 83 | return new RangeLookup(start, end); 84 | } 85 | 86 | return fill(start, end); 87 | } 88 | } 89 | 90 | module.exports = RangeLookup; 91 | -------------------------------------------------------------------------------- /lib/State.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('./Dry'); 3 | const { kOptions, kPartial } = require('./constants/symbols'); 4 | 5 | class State { 6 | static blocks = {}; 7 | 8 | constructor(options = {}, parent = {}) { 9 | this.parent = parent; 10 | this.parent.blocks ||= {}; 11 | 12 | this.path = options.path || 'unknown'; 13 | this.locale = options.locale || new Dry.I18n(); 14 | this.template_options = options; 15 | 16 | this.queue = new Set(); 17 | this.error_mode = this.template_options.error_mode || Dry.Template.error_mode; 18 | this.warnings = []; 19 | 20 | this.registry = { blocks: {}, imports: {}, macros: {}, scoped: [] }; 21 | this.depth = 0; 22 | this[kPartial] = false; 23 | 24 | return new Proxy(this, { 25 | get(target, key) { 26 | if (target.options && hasOwnProperty.call(target.options, key)) { 27 | return target?.options[key]; 28 | } 29 | return target[key]; 30 | } 31 | }); 32 | } 33 | 34 | new_tokenizer(markup, { start_line_number = null, for_liquid_tag = false }) { 35 | return new Dry.Tokenizer(markup, { line_number: start_line_number, for_liquid_tag }); 36 | } 37 | 38 | parse_expression(markup) { 39 | return Dry.Expression.parse(markup); 40 | } 41 | 42 | set_block(name, block) { 43 | if (this.path) { 44 | this.parent.blocks[this.path] ||= {}; 45 | this.parent.blocks[this.path][name] ||= block; 46 | } else { 47 | this.parent.blocks[name] = block; 48 | } 49 | return block; 50 | } 51 | 52 | get_block(name) { 53 | return this.blocks[name]; 54 | } 55 | 56 | get blocks() { 57 | return this.parent.blocks[this.path] || this.parent.blocks; 58 | } 59 | 60 | get line_number() { 61 | return this.loc && this.loc.line; 62 | } 63 | 64 | set partial(value) { 65 | this[kPartial] = value; 66 | this.options = value ? this.partial_options : this.template_options; 67 | this.error_mode = this.options.error_mode || Dry.Template.error_mode; 68 | } 69 | get partial() { 70 | return this[kPartial]; 71 | } 72 | 73 | create_partial_options() { 74 | const dont_pass = this.template_options.include_options_blacklist; 75 | 76 | if (dont_pass === true) { 77 | return { locale: this.locale }; 78 | } 79 | 80 | if (Array.isArray(dont_pass)) { 81 | const new_options = {}; 82 | for (const key of Object.keys(this.template_options)) { 83 | if (!dont_pass.includes(key)) { 84 | new_options[key] = this.template_options[key]; 85 | } 86 | } 87 | return new_options; 88 | } 89 | 90 | return this.template_options; 91 | } 92 | 93 | set partial_options(value) { 94 | this[kOptions] = value; 95 | } 96 | get partial_options() { 97 | return (this[kOptions] ||= this.create_partial_options()); 98 | } 99 | } 100 | 101 | module.exports = State; 102 | -------------------------------------------------------------------------------- /lib/StaticRegisters.js: -------------------------------------------------------------------------------- 1 | 2 | const { hasOwnProperty } = Reflect; 3 | const Dry = require('./Dry'); 4 | const { handlers } = require('./shared/utils'); 5 | 6 | class StaticRegisters { 7 | constructor(registers = {}) { 8 | this.static = registers.static || registers; 9 | this.registers = {}; 10 | return new Proxy(this, handlers); 11 | } 12 | 13 | set(key, value) { 14 | return (this.registers[key] = value); 15 | } 16 | 17 | get(key) { 18 | return hasOwnProperty.call(this.registers, key) ? this.registers[key] : this.static[key]; 19 | } 20 | 21 | delete(key) { 22 | const value = this.registers[key]; 23 | delete this.registers[key]; 24 | return value; 25 | } 26 | 27 | fetch(key, fallback, block) { 28 | if (hasOwnProperty.call(this.registers, key)) return this.registers[key]; 29 | if (hasOwnProperty.call(this.static, key)) return this.static[key]; 30 | if (fallback === undefined) { throw new Dry.KeyError(key); } 31 | if (block) return typeof block === 'function' ? block.call(this, key, fallback) : block; 32 | return fallback; 33 | } 34 | 35 | key(key) { 36 | return hasOwnProperty.call(this.registers, key) || hasOwnProperty.call(this.static, key); 37 | } 38 | } 39 | 40 | module.exports = StaticRegisters; 41 | -------------------------------------------------------------------------------- /lib/StrainerFactory.js: -------------------------------------------------------------------------------- 1 | 2 | const StrainerTemplate = require('./StrainerTemplate'); 3 | 4 | // StrainerFactory is the factory for the filters system. 5 | class StrainerFactory { 6 | static strainer_class_cache = new Map(); 7 | static global_filters = []; 8 | 9 | static create(context, filters = []) { 10 | const Strainer = this.strainer_from_cache(filters); 11 | return new Strainer(context); 12 | } 13 | 14 | static strainer_from_cache(filters) { 15 | let Strainer = this.strainer_class_cache.get(filters); 16 | if (!Strainer) { 17 | Strainer = class extends StrainerTemplate {}; 18 | Strainer.filter_methods.clear(); 19 | this.global_filters.forEach(f => Strainer.add_filter(f)); 20 | filters.forEach(f => Strainer.add_filter(f)); 21 | } 22 | this.strainer_class_cache.set(filters, Strainer); 23 | return Strainer; 24 | } 25 | 26 | static add_global_filter(filters) { 27 | this.strainer_class_cache.clear(); 28 | this.global_filters.push(filters); 29 | } 30 | } 31 | 32 | module.exports = StrainerFactory; 33 | -------------------------------------------------------------------------------- /lib/StrainerTemplate.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('./Dry'); 3 | const to_s = method => typeof method === 'string' ? method : method.name; 4 | const instance_cache = new Map(); 5 | 6 | const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v); 7 | 8 | /** 9 | * StrainerTemplate is the computed class for the filters system. 10 | * New filters are mixed into the strainer class which is then instantiated 11 | * for each liquid template render run. 12 | * 13 | * The Strainer only allows method calls defined in filters given to it via 14 | * StrainerFactory.add_global_filter, Context#add_filters or Template.register_filter 15 | */ 16 | 17 | class StrainerTemplate { 18 | static filter_methods = new Map(); 19 | 20 | static add_filter(filters) { 21 | if (Array.isArray(filters)) { 22 | filters.forEach(f => this.add_filter(f)); 23 | return; 24 | } 25 | 26 | StrainerTemplate.include(filters); 27 | } 28 | 29 | static include(filters) { 30 | if (typeof filters === 'function') { 31 | throw new Dry.TypeError('wrong argument type "function" (expected an object)'); 32 | } 33 | 34 | if (typeof filters.included === 'function') { 35 | const instances = instance_cache.get(filters) || new Set(); 36 | instance_cache.set(filters, instances); 37 | 38 | if (instances.has(this)) return; 39 | instances.add(this); 40 | filters.included(this); 41 | } 42 | 43 | for (const [key, filter] of Object.entries(filters)) { 44 | if (key === 'included') continue; 45 | if (key in Object) { 46 | throw new Dry.MethodOverrideError(`Dry error: Filter overrides registered public methods as non public: ${key}`); 47 | } 48 | 49 | StrainerTemplate.filter_methods.set(key, filter); 50 | } 51 | } 52 | 53 | static invokable(method) { 54 | return StrainerTemplate.filter_methods.has(to_s(method)); 55 | } 56 | 57 | constructor(context) { 58 | this.context = context; 59 | } 60 | 61 | send(method, ...args) { 62 | const filter = StrainerTemplate.filter_methods.get(method); 63 | const value = filter(...args); 64 | return value && value?.to_liquid ? value.to_liquid() : value; 65 | } 66 | 67 | invoke(method, ...args) { 68 | if (this.constructor.invokable(method)) { 69 | return this.send(method, ...args); 70 | } 71 | 72 | // console.log({ method, args }); 73 | const error = new Dry.UndefinedFilter(`undefined filter ${method}`); 74 | if (this.context.strict_filters) { 75 | throw error; 76 | } 77 | 78 | this.context.errors.push(error); 79 | return args[0]; 80 | } 81 | } 82 | 83 | module.exports = StrainerTemplate; 84 | -------------------------------------------------------------------------------- /lib/TemplateFactory.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('./Dry'); 3 | 4 | class TemplateFactory { 5 | for(_template_name) { 6 | return new Dry.Template(); 7 | } 8 | } 9 | 10 | module.exports = TemplateFactory; 11 | -------------------------------------------------------------------------------- /lib/Tokenizer.js: -------------------------------------------------------------------------------- 1 | 2 | const { regex: { Newline, TemplateRegex } } = require('./constants'); 3 | 4 | class Tokenizer { 5 | constructor(source, state = {}, { line_numbers = false } = {}) { 6 | const { line_number = null, for_liquid_tag = false } = state; 7 | this.loc = { index: 0, line: 0, col: 0 }; 8 | this.source = source.toString(); 9 | this.line_number = line_number || (line_numbers ? 1 : null); 10 | this.for_liquid_tag = for_liquid_tag; 11 | this.tokens = this.tokenize(); 12 | this.index = 0; 13 | } 14 | 15 | eos() { 16 | return this.index > this.tokens.length - 1; 17 | } 18 | 19 | shift() { 20 | const token = this.tokens[this.index]; 21 | this.index++; 22 | 23 | if (!token) return; 24 | if (this.line_number) { 25 | this.line_number += this.for_liquid_tag ? 1 : token.split(Newline).length - 1; 26 | } 27 | 28 | return token; 29 | } 30 | 31 | tokenize() { 32 | if (!this.source) return []; 33 | if (this.for_liquid_tag) return this.source.split(Newline); 34 | const tokens = this.source.split(TemplateRegex); 35 | 36 | // remove empty element at the beginning of the array 37 | if (!tokens[0]) tokens.shift(); 38 | return tokens; 39 | } 40 | } 41 | 42 | module.exports = Tokenizer; 43 | -------------------------------------------------------------------------------- /lib/Usage.js: -------------------------------------------------------------------------------- 1 | 2 | class Usage { 3 | static increment(name) {} 4 | } 5 | 6 | module.exports = Usage; 7 | -------------------------------------------------------------------------------- /lib/constants/characters.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | '.': 'dot', 4 | '|': 'pipe', 5 | ',': 'comma', 6 | ':': 'colon', 7 | '{': 'brace_open', 8 | '}': 'brace_close', 9 | '[': 'bracket_open', 10 | ']': 'bracket_close', 11 | '(': 'paren_open', 12 | ')': 'paren_close' 13 | }; 14 | -------------------------------------------------------------------------------- /lib/constants/index.js: -------------------------------------------------------------------------------- 1 | 2 | const { getOwnPropertyNames } = Object; 3 | const constants = require('export-files')(__dirname); 4 | const Dry = require('../Dry'); 5 | 6 | constants.REVERSE_OPERATOR = Object.freeze({ 7 | is: '===', 8 | isnt: '!==', 9 | '==': '!=', 10 | '===': '!==', 11 | '!=': '==', 12 | '!==': '===' 13 | }); 14 | 15 | constants.PROTECTED_KEYS = new Set(Object 16 | .getOwnPropertyNames(Object) 17 | .concat(['constructor', '__proto__', 'inspect', 'prototype'])); 18 | 19 | constants.DROP_KEYS = new Set(getOwnPropertyNames(Dry.Drop.prototype) 20 | .concat('each') 21 | .filter(k => k !== 'to_liquid')); 22 | 23 | module.exports = constants; 24 | -------------------------------------------------------------------------------- /lib/constants/regex.js: -------------------------------------------------------------------------------- 1 | 2 | const { r } = require('../shared/utils'); 3 | 4 | const FilterSeparator = /\|/; 5 | const ArgumentSeparator = ','; 6 | const FilterArgumentSeparator = ':'; 7 | const VarStart = '{{'; 8 | const WhitespaceControl = '-'; 9 | const TagStart = /{%/; 10 | const TagEnd = /%}/; 11 | const VariableSignature = /(?:\((@?[\w-.[\]]+)\)|(@?[\w-.[\]]+))/; 12 | const VariableSegment = /(?:[\w-]|,\s*|\.{2}\/)/; 13 | const VariableStart = /{{/; 14 | const VariableEnd = /}}/; 15 | const VariableIncompleteEnd = /}}?/; 16 | const QuotedString = /^`(?:\\.|[^`])*`|"(?:\\.|[^"])*"|'(?:\\.|[^'])*'/; 17 | const QuotedFragment = r('g')`(?:${QuotedString}|(?:[^\\s,(|'"]|${QuotedString})+)`; 18 | const TagAttributes = r`(\\w+)\\s*:\\s*(${QuotedFragment})`; 19 | const AnyStartingTag = r`${TagStart}|${VariableStart}`; 20 | const PartialTemplateParser = r('m')`${TagStart}[\\s\\S]*?${TagEnd}|${VariableStart}[\\s\\S]*?${VariableIncompleteEnd}`; 21 | const TemplateRegex = r`(${PartialTemplateParser}|${AnyStartingTag})`; 22 | const VariableParser = r('g')`(?:\\[[^\\]]+\\]|!*@?${VariableSegment}+\\??(?:\\([\\s\\S]*?\\))?)`; 23 | 24 | // custom 25 | const TernarySyntax = /(?.*?)\(\s*(?.+?)\s+\?\s+(?.+?)\s+:\s+(?.+?)\s*\)(?.+)?/; 26 | const FiltersSyntax = /(?.*?)(?(^|[^|])\|\s+.+)/; 27 | 28 | const regex = { 29 | Newline: /\r?\n/g, 30 | ArgumentSeparator, 31 | FilterArgumentSeparator, 32 | FilterSeparator, 33 | QuotedFragment, 34 | QuotedString, 35 | TagAttributes, 36 | TagEnd, 37 | TagStart, 38 | TemplateRegex, 39 | VarStart, 40 | VariableEnd, 41 | VariableParser, 42 | VariableSegment, 43 | VariableSignature, 44 | VariableStart, 45 | WhitespaceControl, 46 | FiltersSyntax, 47 | TernarySyntax 48 | }; 49 | 50 | module.exports = regex; 51 | -------------------------------------------------------------------------------- /lib/constants/symbols.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | RAISE_EXCEPTION_LAMBDA: Symbol('RAISE_EXCEPTION_LAMBDA'), 4 | kAssigns: Symbol(':assigns'), 5 | kBlank: Symbol(':blank'), 6 | kErrors: Symbol(':errors'), 7 | kInstanceAssigns: Symbol(':instance_assigns'), 8 | kInput: Symbol(':input'), 9 | kLocale: Symbol(':locale'), 10 | kName: Symbol(':name'), 11 | kParentContext: Symbol(':parent_context'), 12 | kParentTag: Symbol(':parent_tag'), 13 | kPartial: Symbol(':partial'), 14 | kReceiver: Symbol(':receiver'), 15 | kStrainer: Symbol(':strainer'), 16 | kTags: Symbol(':tags'), 17 | kToken: Symbol(':token'), 18 | kWithParent: Symbol(':with_parent') 19 | }; 20 | -------------------------------------------------------------------------------- /lib/drops/Drop.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../Dry'); 3 | const cache = new Map(); 4 | 5 | /** 6 | * A drop in liquid is a class which allows you to export DOM like things to liquid. 7 | * Methods of drops are callable. 8 | * 9 | * The main use for liquid drops is to implement lazy loaded objects. If you would 10 | * like to make data available to the web designers which you don't want loaded unless 11 | * needed then a drop is a great way to do that. 12 | * 13 | * Example: 14 | * 15 | * class ProductDrop extends Liquid.Drop 16 | * top_sales() { 17 | * return Shop.current.products.find({ all: true, order: 'sales', limit: 10 }); 18 | * } 19 | * } 20 | * 21 | * tmpl.render({ product: new ProductDrop() }) // will invoke top_sales query. 22 | * 23 | * Your drop can either implement the methods sans any parameters 24 | * or implement the liquid_method_missing(name) method which is a catch all. 25 | */ 26 | 27 | class Drop { 28 | constructor() { 29 | return new Proxy(this, { 30 | get(target, key, receiver) { 31 | return key in target ? target[key] : target.invoke_drop(key); 32 | } 33 | }); 34 | } 35 | 36 | // called by liquid to invoke a drop 37 | invoke_drop(method_or_key) { 38 | return this.liquid_method_missing(method_or_key); 39 | } 40 | 41 | liquid_method_missing(method) { 42 | if (this.context && this.context.strict_variables) { 43 | throw new Dry.UndefinedDropMethod(`Undefined drop method: "${method}"`); 44 | } 45 | } 46 | 47 | to_liquid() { 48 | return this; 49 | } 50 | 51 | to_s() { 52 | return this.toString(); 53 | } 54 | 55 | toString() { 56 | return this.constructor.name; 57 | } 58 | 59 | // Check for method existence 60 | static invokable(method_name) { 61 | return this.invokable_methods.has(method_name.toString()); 62 | } 63 | 64 | static get invokable_methods() { 65 | if (cache.has(this)) return cache.get(this); 66 | const blacklist = ['each', 'map', 'invoke_drop', 'liquid_method_missing', 'constructor', '__proto__', 'prototype', 'toString', 'to_s', 'inspect']; 67 | const methods = Reflect.ownKeys(this.prototype); 68 | const public_instance_methods = methods.filter(m => !blacklist.includes(m)); 69 | const whitelist = ['to_liquid'].concat(public_instance_methods); 70 | const invokable_methods = new Set(whitelist.filter(n => typeof n === 'string').sort()); 71 | cache.set(this, invokable_methods); 72 | return invokable_methods; 73 | } 74 | } 75 | 76 | module.exports = Drop; 77 | -------------------------------------------------------------------------------- /lib/drops/ForLoopDrop.js: -------------------------------------------------------------------------------- 1 | const Dry = require('../Dry'); 2 | 3 | const kIndex = Symbol(':index'); 4 | const kLocked = Symbol(':locked'); 5 | const kName = Symbol(':name'); 6 | const kVersion = Symbol(':version'); 7 | 8 | const MAX_SAFE_INDEX = Number.MAX_SAFE_INTEGER - 1; 9 | 10 | class ForLoopDrop extends Dry.Drop { 11 | constructor(name, length = 0, parentloop) { 12 | super(); 13 | this.name = name; 14 | this.length = Math.min(length, MAX_SAFE_INDEX); 15 | this[kIndex] = 0; 16 | this[kLocked] = false; 17 | this[kVersion] = 0; 18 | 19 | if (parentloop) { 20 | this.parentloop = parentloop; 21 | } 22 | } 23 | 24 | get index() { 25 | this.checkModification(); 26 | return Math.min(this[kIndex] + 1, MAX_SAFE_INDEX); 27 | } 28 | 29 | get index0() { 30 | this.checkModification(); 31 | return Math.min(this[kIndex], MAX_SAFE_INDEX); 32 | } 33 | 34 | get rindex() { 35 | this.checkModification(); 36 | return Math.max(0, Math.min(this.length - this[kIndex], MAX_SAFE_INDEX)); 37 | } 38 | 39 | get rindex0() { 40 | this.checkModification(); 41 | return Math.max(0, Math.min(this.length - this[kIndex] - 1, MAX_SAFE_INDEX)); 42 | } 43 | 44 | get first() { 45 | this.checkModification(); 46 | return this[kIndex] === 0; 47 | } 48 | 49 | get last() { 50 | this.checkModification(); 51 | return this[kIndex] === this.length - 1; 52 | } 53 | 54 | set name(value) { 55 | this[kName] = value; 56 | } 57 | 58 | get name() { 59 | Dry.Usage.increment('forloop_drop_name'); 60 | return this[kName]; 61 | } 62 | 63 | get parent() { 64 | return this.parentLoop; 65 | } 66 | 67 | increment() { 68 | if (this[kLocked]) { 69 | throw new Dry.Error('Cannot modify ForLoopDrop while iterating'); 70 | } 71 | 72 | if (this[kIndex] < MAX_SAFE_INDEX) { 73 | this[kLocked] = true; 74 | try { 75 | this[kIndex]++; 76 | this[kVersion]++; 77 | } finally { 78 | this[kLocked] = false; 79 | } 80 | } 81 | } 82 | 83 | checkModification() { 84 | if (this[kLocked]) { 85 | throw new Dry.Error('Cannot read ForLoopDrop while being modified'); 86 | } 87 | } 88 | 89 | static get kIndex() { 90 | return kIndex; 91 | } 92 | } 93 | 94 | module.exports = ForLoopDrop; 95 | -------------------------------------------------------------------------------- /lib/drops/TableRowLoopDrop.js: -------------------------------------------------------------------------------- 1 | const Dry = require('../Dry'); 2 | const Drop = require('./Drop'); 3 | 4 | const kIndex = Symbol('index'); 5 | const kLocked = Symbol('locked'); 6 | const kVersion = Symbol('version'); 7 | 8 | const MAX_SAFE_INDEX = Number.MAX_SAFE_INTEGER - 1; 9 | 10 | class TablerowloopDrop extends Drop { 11 | constructor(length, cols) { 12 | super(); 13 | this.length = Math.min(length, MAX_SAFE_INDEX); 14 | this.row = 1; 15 | this.col = 1; 16 | this.cols = Math.max(1, Math.min(cols, MAX_SAFE_INDEX)); 17 | this[kIndex] = 0; 18 | this[kLocked] = false; 19 | this[kVersion] = 0; 20 | } 21 | 22 | set index(index) { 23 | this.checkModification(); 24 | this[kIndex] = Math.min(index, MAX_SAFE_INDEX); 25 | this.recalculatePosition(); 26 | } 27 | 28 | get index() { 29 | this.checkModification(); 30 | return Math.min(this[kIndex] + 1, MAX_SAFE_INDEX); 31 | } 32 | 33 | get index0() { 34 | this.checkModification(); 35 | return Math.min(this[kIndex], MAX_SAFE_INDEX); 36 | } 37 | 38 | get col0() { 39 | this.checkModification(); 40 | return Math.max(0, this.col - 1); 41 | } 42 | 43 | get rindex() { 44 | this.checkModification(); 45 | return Math.max(0, Math.min(this.length - this[kIndex], MAX_SAFE_INDEX)); 46 | } 47 | 48 | get rindex0() { 49 | this.checkModification(); 50 | return Math.max(0, Math.min(this.length - this[kIndex] - 1, MAX_SAFE_INDEX)); 51 | } 52 | 53 | get first() { 54 | this.checkModification(); 55 | return this[kIndex] === 0; 56 | } 57 | 58 | get last() { 59 | this.checkModification(); 60 | return this[kIndex] === this.length - 1; 61 | } 62 | 63 | get col_first() { 64 | this.checkModification(); 65 | return this.col === 1; 66 | } 67 | 68 | get col_last() { 69 | this.checkModification(); 70 | return this.col === this.cols; 71 | } 72 | 73 | recalculatePosition() { 74 | if (this.cols > 0) { 75 | this.row = Math.floor(this[kIndex] / this.cols) + 1; 76 | this.col = (this[kIndex] % this.cols) + 1; 77 | } 78 | } 79 | 80 | increment() { 81 | if (this[kLocked]) { 82 | throw new Dry.Error('Cannot modify TableRowLoopDrop while iterating'); 83 | } 84 | 85 | if (this[kIndex] < MAX_SAFE_INDEX) { 86 | this[kLocked] = true; 87 | try { 88 | this[kIndex]++; 89 | this[kVersion]++; 90 | this.recalculatePosition(); 91 | } finally { 92 | this[kLocked] = false; 93 | } 94 | } 95 | } 96 | 97 | checkModification() { 98 | if (this[kLocked]) { 99 | throw new Dry.Error('Cannot read TableRowLoopDrop while being modified'); 100 | } 101 | } 102 | 103 | setCols(newCols) { 104 | this.checkModification(); 105 | this[kLocked] = true; 106 | try { 107 | this.cols = Math.max(1, Math.min(newCols, MAX_SAFE_INDEX)); 108 | this.recalculatePosition(); 109 | this[kVersion]++; 110 | } finally { 111 | this[kLocked] = false; 112 | } 113 | } 114 | } 115 | 116 | module.exports = TablerowloopDrop; 117 | -------------------------------------------------------------------------------- /lib/drops/index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = require('export-files')(__dirname); 3 | -------------------------------------------------------------------------------- /lib/expressions/Lexer.js: -------------------------------------------------------------------------------- 1 | 2 | const Dry = require('../Dry'); 3 | const Scanner = require('./Scanner'); 4 | 5 | const REPLACEMENTS = Object.freeze({ and: '&&', or: '||', is: '===', isnt: '!==' }); 6 | const SPECIALS = Object.freeze({ 7 | '|': 'pipe', 8 | '.': 'dot', 9 | ':': 'colon', 10 | ',': 'comma', 11 | '=': 'equal', 12 | '[': 'open_square', 13 | ']': 'close_square', 14 | '{': 'open_brace', 15 | '}': 'close_brace', 16 | '(': 'open_round', 17 | ')': 'close_round', 18 | '?': 'question', 19 | '-': 'dash' 20 | }); 21 | 22 | const IDENTIFIER = /^(!*)(\.{2}\/)*[a-zA-Z_][\w-]*\??/; 23 | const STRING_LITERAL = /^(?:`(\\.|[^`])*`|"(\\.|[^"])*"|'(\\.|[^'])*')/; 24 | const NUMBER_LITERAL = /^[-+]?[0-9]+(\.[0-9]+)?/; 25 | const SPREAD = /^\.{3}[a-zA-Z_][\w-]*/; 26 | const DOTDOT = /^\.\./; 27 | const WHITESPACE = /^\s+/; 28 | const MATH_OPERATOR = /^[%^&*~+/-]+/; 29 | const COMPARISON_OPERATOR = /^(?:<>|(?:==|!=)=?|[<>]=?|\?\?|\|\||&&|contains(?!\w))/; 30 | const THIS_EXPRESSION = /^\s*(this|\.)\s*$/; 31 | 32 | class Lexer { 33 | constructor(input, node) { 34 | this.scanner = new Scanner(input); 35 | this.node = node; 36 | } 37 | 38 | eos() { 39 | return !this.scanner.remaining; 40 | } 41 | 42 | scan(regex) { 43 | return this.scanner.scan(regex); 44 | } 45 | 46 | tokenize() { 47 | this.output = []; 48 | let tok; 49 | let t; 50 | 51 | while (!this.eos()) { 52 | if ((t = this.scan(THIS_EXPRESSION))) { 53 | this.output.push(['this', 'this']); 54 | continue; 55 | } 56 | 57 | this.scan(WHITESPACE); 58 | if (this.eos()) break; 59 | 60 | if ((t = this.scan(COMPARISON_OPERATOR))) { 61 | tok = ['comparison', REPLACEMENTS[t] || t]; 62 | } else if ((t = this.scan(STRING_LITERAL))) { 63 | tok = ['string', t]; 64 | } else if ((t = this.scan(NUMBER_LITERAL))) { 65 | tok = ['number', t]; 66 | } else if ((t = this.scan(IDENTIFIER))) { 67 | tok = ['id', t]; 68 | } else if ((t = this.scan(SPREAD))) { 69 | tok = ['spread', t]; 70 | } else if ((t = this.scan(DOTDOT))) { 71 | tok = ['dotdot', t]; 72 | } else if ((t = this.scan(MATH_OPERATOR))) { 73 | tok = ['operator', t]; 74 | } else { 75 | const c = this.scanner.consume(1); 76 | const s = SPECIALS[c]; 77 | 78 | if (s) { 79 | tok = [s, c]; 80 | } else { 81 | const v = this.node?.value || this.scanner.input; 82 | throw new Dry.SyntaxError(`Unexpected character ${c} in "${v}"`); 83 | } 84 | } 85 | 86 | this.output.push(tok); 87 | } 88 | 89 | this.output.push(['end_of_string']); 90 | return this.output; 91 | } 92 | } 93 | 94 | module.exports = Lexer; 95 | -------------------------------------------------------------------------------- /lib/expressions/Scanner.js: -------------------------------------------------------------------------------- 1 | 2 | class Scanner { 3 | constructor(input) { 4 | this.input = input; 5 | this.remaining = this.input; 6 | this.consumed = ''; 7 | } 8 | 9 | consume(n = 1) { 10 | const char = this.remaining[0]; 11 | this.remaining = this.remaining.slice(1); 12 | this.consumed += char; 13 | return char; 14 | } 15 | 16 | scan(pattern) { 17 | const match = this.remaining && pattern.exec(this.remaining); 18 | if (match) { 19 | const value = match[0]; 20 | this.remaining = this.remaining.slice(value.length); 21 | this.consumed += value; 22 | return value; 23 | } 24 | } 25 | } 26 | 27 | module.exports = Scanner; 28 | -------------------------------------------------------------------------------- /lib/expressions/index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | Lexer: require('./Lexer'), 4 | Parser: require('./Parser'), 5 | Scanner: require('./Scanner') 6 | }; 7 | -------------------------------------------------------------------------------- /lib/locales/en.yml: -------------------------------------------------------------------------------- 1 | --- 2 | errors: 3 | argument: 4 | extends: "Argument error in tag 'extends' - Illegal template name" 5 | include: "Argument error in tag 'include' - Illegal template name" 6 | layout: "Argument error in tag 'layout' - Illegal template name" 7 | template: "Argument error in tag '%{type}' - Illegal template name" 8 | 9 | disabled: 10 | tag: "usage is not allowed in this context" 11 | 12 | file_system: 13 | missing: "Cannot locate %{type}: '%{template_name}'" 14 | 15 | layout: 16 | exponential: 'Exponentially recursive layout defined: "%{expression}"' 17 | 18 | syntax: 19 | assign: "Syntax Error in 'assign' - Valid syntax: assign = [source]" 20 | block: "Syntax Error in 'block' - Valid syntax: block " 21 | capture: "Syntax Error in 'capture' - Valid syntax: capture " 22 | case_invalid_else: "Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) " 23 | case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [or condition2...] %}" 24 | case: "Syntax Error in 'case' - Valid syntax: case " 25 | conditional: "Syntax Error in tag '%{tag_name}' - Valid syntax: %{tag_name} " 26 | cycle: "Syntax Error in 'cycle' - Valid syntax: cycle [name :] var [, var2, var3 ...]" 27 | for_invalid_attribute: "Invalid attribute in for loop. Valid attributes are limit and offset" 28 | for_invalid_in: "For loops require an 'in' clause" 29 | for: "Syntax Error in 'for loop' - Valid syntax: for in " 30 | if: "Syntax Error in tag 'if' - Valid syntax: if " 31 | include: "Error in tag 'include' - Valid syntax: include