├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md ├── renovate.json └── workflows │ ├── erlang.yml │ ├── lint.yml │ ├── node-js.yml │ ├── priv-static-assets-js.yml │ └── rebar-lock.yml ├── .gitignore ├── .markdownlint.yml ├── .nvmrc ├── .prettierignore ├── .tool-versions ├── .yamllint.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── assets ├── counter_example.gif └── js │ ├── arizona-patch.mjs │ ├── arizona-patch.test.js │ ├── arizona-worker.mjs │ └── arizona.mjs ├── babel.config.json ├── elvis.config ├── esbuild.config.mjs ├── eslint.config.mjs ├── jest.config.mjs ├── package-lock.json ├── package.json ├── prettier.config.mjs ├── priv └── static │ └── assets │ └── js │ ├── arizona-worker.min.js │ ├── arizona-worker.min.js.map │ ├── arizona.min.js │ └── arizona.min.js.map ├── rebar.config ├── rebar.lock ├── src ├── arizona.app.src ├── arizona.erl ├── arizona_app.erl ├── arizona_component.erl ├── arizona_config.erl ├── arizona_diff.erl ├── arizona_js.erl ├── arizona_layout.erl ├── arizona_parser.erl ├── arizona_pubsub.erl ├── arizona_renderer.erl ├── arizona_scanner.erl ├── arizona_server.erl ├── arizona_socket.erl ├── arizona_static.erl ├── arizona_sup.erl ├── arizona_transform.erl ├── arizona_view.erl ├── arizona_view_handler.erl └── arizona_websocket.erl └── test ├── arizona_diff_SUITE.erl ├── arizona_parser_SUITE.erl ├── arizona_renderer_SUITE.erl ├── arizona_scanner_SUITE.erl ├── arizona_static_SUITE.erl ├── arizona_transform_SUITE.erl ├── arizona_view_SUITE.erl ├── arizona_view_handler_SUITE.erl ├── arizona_view_handler_SUITE_data └── layout.herl └── support ├── arizona_example_components.erl ├── arizona_example_counter.erl ├── arizona_example_ignore.erl ├── arizona_example_layout.erl ├── arizona_example_new_id.erl ├── arizona_example_template.erl └── arizona_example_template_new_id.erl /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # These are supported funding model platforms 3 | 4 | github: [williamthome] 5 | patreon: # Replace with a single Patreon username 6 | open_collective: # Replace with a single Open Collective username 7 | ko_fi: # Replace with a single Ko-fi username 8 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 9 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 10 | liberapay: # Replace with a single Liberapay username 11 | issuehunt: # Replace with a single IssueHunt username 12 | otechie: # Replace with a single Otechie username 13 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 14 | custom: ["https://www.buymeacoffee.com/williamthome"] 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug to improve Arizona 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### The bug 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | #### Software versions 15 | 16 | A list of software versions where the bug is apparent, as detailed as possible: 17 | 18 | * Erlang/OTP: ... 19 | * Arizona: ... 20 | * other (where applicable): ... 21 | 22 | #### How to replicate 23 | 24 | An ordered list of steps to replicate the bug: 25 | 26 | 1. run `rebar3 as test ci` 27 | 2. search for `...` in the error output 28 | 3. look at file `...` 29 | 30 | #### Expected behaviour 31 | 32 | What's expected to happen when you follow the steps listed above. 33 | 34 | #### Additional context 35 | 36 | Any other context about the bug. 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Request a feature for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Is your feature request related to a problem? 11 | 12 | A clear and concise description of what the problem is, e.g. "I'm always frustrated 13 | when ..." 14 | 15 | #### Describe the feature you'd like 16 | 17 | A clear and concise description of what you want to happen after the new feature 18 | is implemented. 19 | 20 | #### Describe alternatives you've considered 21 | 22 | A clear and concise description of any alternative solutions or features you've considered. 23 | 24 | #### Additional context 25 | 26 | Any other context about the feature request. 27 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | A brief description of your changes. 4 | 5 | Closes #<issue>. 6 | 7 | - [ ] I have performed a self-review of my changes 8 | - [ ] I have read and understood the [contributing guidelines](/arizona-framework/arizona/blob/main/CONTRIBUTING.md) 9 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "helpers:pinGitHubActionDigests", 6 | ":pinDevDependencies", 7 | ":pinDigestsDisabled" 8 | ], 9 | "packageRules": [ 10 | { 11 | "matchFileNames": [".github/**/*.yml", ".tool-versions"], 12 | "groupName": "dev tools" 13 | }, 14 | { 15 | "matchFileNames": ["rebar.config"], 16 | "groupName": "rebar.config deps" 17 | }, 18 | { 19 | "matchFileNames": ["package.json", ".nvmrc"], 20 | "groupName": "package.json + .nvmrc deps" 21 | }, 22 | { 23 | "matchFileNames": ["Dockerfile"], 24 | "groupName": "Docker deps" 25 | }, 26 | { 27 | "matchPackagePrefixes": ["minimum_otp_vsn"], 28 | "enabled": false 29 | } 30 | ], 31 | "customManagers": [ 32 | { 33 | "description": "Match versions (per datasource and depName) in .github/**/*.yml", 34 | "customType": "regex", 35 | "fileMatch": [".github/.*/.*\\.yml"], 36 | "matchStrings": [ 37 | "# renovate datasource: (?[^,]+), depName: (?[^\\n]+)\\n.+?(?v?\\d+(\\.\\d+(\\.\\d+)?)?(-[^\\n]+)?)\\n" 38 | ] 39 | }, 40 | { 41 | "description": "Match versions in rebar.config", 42 | "customType": "regex", 43 | "fileMatch": ["rebar.config"], 44 | "datasourceTemplate": "hex", 45 | "matchStrings": [ 46 | "{(?[^,]+), \"(?\\d+\\.\\d+(\\.\\d+)?)\"" 47 | ], 48 | "versioningTemplate": "semver" 49 | }, 50 | { 51 | "description": "Match versions (per datasource and depName) in Dockerfile", 52 | "customType": "regex", 53 | "fileMatch": ["Dockerfile"], 54 | "matchStrings": [ 55 | "# renovate datasource: (?[^,]+), depName: (?[^\\n]+)\\nENV .+?_VERSION=\"(?[^\"]+)\"" 56 | ], 57 | "versioningTemplate": "loose" 58 | } 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/erlang.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Erlang 3 | 4 | "on": 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - "*" 11 | workflow_dispatch: {} 12 | merge_group: 13 | 14 | concurrency: 15 | group: ${{github.workflow}}-${{github.ref}} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | cache: 20 | name: Cache 21 | 22 | runs-on: ubuntu-24.04 23 | 24 | outputs: 25 | build-cache-key: ${{ steps.set-build-key.outputs.key }} 26 | rebar-cache-key: ${{ steps.set-rebar-key.outputs.key }} 27 | 28 | steps: 29 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 30 | 31 | - uses: erlef/setup-beam@5304e04ea2b355f03681464e683d92e3b2f18451 # v1.18.2 32 | id: setup-beam 33 | with: 34 | version-type: strict 35 | version-file: .tool-versions 36 | 37 | - name: Set build cache key 38 | id: set-build-key 39 | run: | 40 | echo "key=\ 41 | _build-\ 42 | ${{ runner.os }}-\ 43 | otp-${{ steps.setup-beam.outputs.otp-version }}-\ 44 | rebar3-hash-${{ hashFiles('rebar.lock') }}" \ 45 | >> "${GITHUB_OUTPUT}" 46 | 47 | - name: Set rebar cache key 48 | id: set-rebar-key 49 | run: | 50 | echo "key=\ 51 | rebar3-\ 52 | ${{ runner.os }}-\ 53 | otp-${{ steps.setup-beam.outputs.otp-version }}-\ 54 | rebar3-${{ steps.setup-beam.outputs.rebar3-version }}" \ 55 | >> "${GITHUB_OUTPUT}" 56 | 57 | - name: Cache _build 58 | uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 59 | with: 60 | path: _build 61 | key: ${{ steps.set-build-key.outputs.key }} 62 | restore-keys: | 63 | _build-${{ runner.os }}-otp-${{ steps.setup-beam.outputs.otp-version }}- 64 | _build-${{ runner.os }}- 65 | 66 | - name: Cache rebar3 67 | uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 68 | with: 69 | path: ~/.cache/rebar3 70 | key: ${{ steps.set-rebar-key.outputs.key }} 71 | restore-keys: | 72 | rebar3-${{ runner.os }}-otp-${{ steps.setup-beam.outputs.otp-version }}- 73 | rebar3-${{ runner.os }}- 74 | 75 | - name: Compile 76 | run: | 77 | rebar3 as test compile 78 | 79 | check: 80 | name: Check 81 | 82 | needs: cache 83 | 84 | runs-on: ubuntu-24.04 85 | 86 | steps: 87 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 88 | 89 | - uses: erlef/setup-beam@5304e04ea2b355f03681464e683d92e3b2f18451 # v1.18.2 90 | id: setup-beam 91 | with: 92 | version-type: strict 93 | version-file: .tool-versions 94 | 95 | - name: Restore _build cache 96 | id: restore-build-cache 97 | uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 98 | with: 99 | path: _build 100 | key: ${{ needs.cache.outputs.build-cache-key }} 101 | restore-keys: | 102 | _build-${{ runner.os }}-otp-${{ steps.setup-beam.outputs.otp-version }}- 103 | _build-${{ runner.os }}- 104 | 105 | - name: Restore rebar3 cache 106 | id: restore-rebar3-cache 107 | uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 108 | with: 109 | path: ~/.cache/rebar3 110 | key: ${{ needs.cache.outputs.rebar-cache-key }} 111 | restore-keys: | 112 | rebar3-${{ runner.os }}-otp-${{ steps.setup-beam.outputs.otp-version }}- 113 | rebar3-${{ runner.os }}- 114 | 115 | - name: Check code 116 | run: | 117 | rebar3 as test check 118 | 119 | test: 120 | name: Test 121 | 122 | needs: cache 123 | 124 | runs-on: ubuntu-24.04 125 | 126 | steps: 127 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 128 | 129 | - uses: erlef/setup-beam@5304e04ea2b355f03681464e683d92e3b2f18451 # v1.18.2 130 | id: setup-beam 131 | with: 132 | version-type: strict 133 | version-file: .tool-versions 134 | 135 | - name: Restore _build cache 136 | id: restore-build-cache 137 | uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 138 | with: 139 | path: _build 140 | key: ${{ needs.cache.outputs.build-cache-key }} 141 | restore-keys: | 142 | _build-${{ runner.os }}-otp-${{ steps.setup-beam.outputs.otp-version }}- 143 | _build-${{ runner.os }}- 144 | 145 | - name: Restore rebar3 cache 146 | id: restore-rebar3-cache 147 | uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 148 | with: 149 | path: ~/.cache/rebar3 150 | key: ${{ needs.cache.outputs.rebar-cache-key }} 151 | restore-keys: | 152 | rebar3-${{ runner.os }}-otp-${{ steps.setup-beam.outputs.otp-version }}- 153 | rebar3-${{ runner.os }}- 154 | 155 | - name: Test 156 | run: | 157 | rebar3 as test test 158 | 159 | artifacts: 160 | name: Verify artifacts 161 | 162 | needs: cache 163 | 164 | runs-on: ubuntu-24.04 165 | 166 | steps: 167 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 168 | 169 | - uses: erlef/setup-beam@5304e04ea2b355f03681464e683d92e3b2f18451 # v1.18.2 170 | id: setup-beam 171 | with: 172 | version-type: strict 173 | version-file: .tool-versions 174 | 175 | - name: Restore _build cache 176 | id: restore-build-cache 177 | uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 178 | with: 179 | path: _build 180 | key: ${{ needs.cache.outputs.build-cache-key }} 181 | restore-keys: | 182 | _build-${{ runner.os }}-otp-${{ steps.setup-beam.outputs.otp-version }}- 183 | _build-${{ runner.os }}- 184 | 185 | - name: Restore rebar3 cache 186 | id: restore-rebar3-cache 187 | uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 188 | with: 189 | path: ~/.cache/rebar3 190 | key: ${{ needs.cache.outputs.rebar-cache-key }} 191 | restore-keys: | 192 | rebar3-${{ runner.os }}-otp-${{ steps.setup-beam.outputs.otp-version }}- 193 | rebar3-${{ runner.os }}- 194 | 195 | - name: Check if build left artifacts 196 | run: | 197 | rebar3 unlock --all 198 | rebar3 upgrade --all 199 | git diff --exit-code 200 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint 3 | 4 | "on": 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - "*" 11 | workflow_dispatch: {} 12 | merge_group: 13 | 14 | concurrency: 15 | group: ${{github.workflow}}-${{github.ref}} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | md-lint: 20 | name: Markdown Lint 21 | 22 | runs-on: ubuntu-24.04 23 | 24 | steps: 25 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | 27 | # uses .markdownlint.yml for configuration 28 | - name: markdownlint 29 | uses: DavidAnson/markdownlint-cli2-action@05f32210e84442804257b2a6f20b273450ec8265 # v19.1.0 30 | with: 31 | globs: | 32 | .github/**/*.md 33 | *.md 34 | 35 | yaml-lint: 36 | name: YAML Lint 37 | 38 | runs-on: ubuntu-24.04 39 | 40 | steps: 41 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 42 | 43 | - name: yamllint 44 | uses: ibiqlik/action-yamllint@2576378a8e339169678f9939646ee3ee325e845c # v3.1.1 45 | with: 46 | file_or_dir: | 47 | .github/**/*.yml 48 | .*.yml 49 | strict: true 50 | config_file: .yamllint.yml 51 | 52 | action-lint: 53 | name: Action Lint 54 | 55 | runs-on: ubuntu-24.04 56 | 57 | steps: 58 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 59 | 60 | - name: actionlint 61 | uses: reviewdog/action-actionlint@db58217885f9a6570da9c71be4e40ec33fe44a1f # v1.65.0 62 | env: 63 | SHELLCHECK_OPTS: -o all 64 | -------------------------------------------------------------------------------- /.github/workflows/node-js.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Node.js 3 | 4 | "on": 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - "*" 11 | workflow_dispatch: {} 12 | merge_group: 13 | 14 | concurrency: 15 | group: ${{github.workflow}}-${{github.ref}} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | check: 20 | name: Check 21 | 22 | runs-on: ubuntu-24.04 23 | 24 | steps: 25 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | 27 | - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 28 | with: 29 | node-version-file: .nvmrc 30 | cache: "npm" 31 | cache-dependency-path: "package-lock.json" 32 | 33 | - name: Install dependencies 34 | run: npm ci 35 | 36 | - name: Check code 37 | run: | 38 | npm run format:check 39 | npm run lint:check 40 | 41 | test: 42 | name: Test 43 | 44 | runs-on: ubuntu-24.04 45 | 46 | steps: 47 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 48 | 49 | - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 50 | with: 51 | node-version-file: .nvmrc 52 | cache: "npm" 53 | cache-dependency-path: "package-lock.json" 54 | 55 | - name: Install dependencies 56 | run: npm ci 57 | 58 | - name: Test 59 | run: npm test 60 | 61 | artifacts: 62 | name: Verify artifacts 63 | 64 | runs-on: ubuntu-24.04 65 | 66 | steps: 67 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 68 | 69 | - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 70 | with: 71 | node-version-file: .nvmrc 72 | cache: "npm" 73 | cache-dependency-path: "package-lock.json" 74 | 75 | - name: Install dependencies 76 | run: npm ci 77 | 78 | - name: Check if build left artifacts 79 | run: | 80 | npm run build 81 | git diff --exit-code 82 | -------------------------------------------------------------------------------- /.github/workflows/priv-static-assets-js.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Update priv/static/assets/js 3 | 4 | "on": 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - "*" 11 | workflow_dispatch: {} 12 | merge_group: 13 | 14 | concurrency: 15 | group: ${{github.workflow}}-${{github.ref}} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | branch: 20 | outputs: 21 | head_ref: ${{steps.branch.outputs.head_ref}} 22 | 23 | runs-on: ubuntu-24.04 24 | 25 | steps: 26 | - id: branch 27 | run: | 28 | head_ref=${GITHUB_HEAD_REF} 29 | echo "head_ref is ${head_ref}" 30 | echo "head_ref=${head_ref}" > "${GITHUB_OUTPUT}" 31 | 32 | update: 33 | name: Update priv/static/assets/js 34 | 35 | needs: [branch] 36 | 37 | if: endsWith(needs.branch.outputs.head_ref, 'package.json-+-.nvmrc-deps') 38 | 39 | runs-on: ubuntu-24.04 40 | 41 | steps: 42 | - uses: actions/create-github-app-token@3ff1caaa28b64c9cc276ce0a02e2ff584f3900c5 # v2.0.2 43 | id: app-token 44 | with: 45 | app-id: ${{vars.ARIZONA_BOT_APP_ID}} 46 | private-key: ${{secrets.ARIZONA_BOT_PRIVATE_KEY}} 47 | 48 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 49 | with: 50 | token: ${{steps.app-token.outputs.token}} 51 | ref: ${{needs.branch.outputs.head_ref}} 52 | 53 | - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 54 | with: 55 | node-version-file: .nvmrc 56 | cache: "npm" 57 | cache-dependency-path: "package-lock.json" 58 | 59 | - run: | 60 | npm run ci 61 | if ! git diff --exit-code >/dev/null; then 62 | # there's stuff to push 63 | git config user.name "arizona[bot]" 64 | git config user.email "arizona_bot_@user.noreply.github.com" 65 | git add priv/static/assets/js/* 66 | git commit -m "[automation] update \`priv/static/assets/js\` after Renovate" 67 | git push 68 | fi 69 | env: 70 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 71 | -------------------------------------------------------------------------------- /.github/workflows/rebar-lock.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Update rebar.lock 3 | 4 | "on": 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - "*" 11 | workflow_dispatch: {} 12 | merge_group: 13 | 14 | concurrency: 15 | group: ${{github.workflow}}-${{github.ref}} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | branch: 20 | outputs: 21 | head_ref: ${{steps.branch.outputs.head_ref}} 22 | 23 | runs-on: ubuntu-24.04 24 | 25 | steps: 26 | - id: branch 27 | run: | 28 | head_ref=${GITHUB_HEAD_REF} 29 | echo "head_ref is ${head_ref}" 30 | echo "head_ref=${head_ref}" > "${GITHUB_OUTPUT}" 31 | 32 | update: 33 | name: Update rebar.lock 34 | 35 | needs: [branch] 36 | 37 | if: endsWith(needs.branch.outputs.head_ref, 'rebar.config-deps') 38 | 39 | runs-on: ubuntu-24.04 40 | 41 | steps: 42 | - uses: actions/create-github-app-token@3ff1caaa28b64c9cc276ce0a02e2ff584f3900c5 # v2.0.2 43 | id: app-token 44 | with: 45 | app-id: ${{vars.ARIZONA_BOT_APP_ID}} 46 | private-key: ${{secrets.ARIZONA_BOT_PRIVATE_KEY}} 47 | 48 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 49 | with: 50 | token: ${{steps.app-token.outputs.token}} 51 | ref: ${{needs.branch.outputs.head_ref}} 52 | 53 | - uses: erlef/setup-beam@5304e04ea2b355f03681464e683d92e3b2f18451 # v1.18.2 54 | id: setup-beam 55 | with: 56 | version-type: strict 57 | version-file: .tool-versions 58 | 59 | - name: Restore _build 60 | uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 61 | with: 62 | path: _build 63 | key: "_build-cache-for\ 64 | -os-${{runner.os}}\ 65 | -otp-${{steps.setup-beam.outputs.otp-version}}\ 66 | -rebar3-${{steps.setup-beam.outputs.rebar3-version}}\ 67 | -hash-${{hashFiles('rebar.lock')}}-${{hashFiles('rebar.config')}}" 68 | 69 | - name: Restore rebar3's cache 70 | uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 71 | with: 72 | path: ~/.cache/rebar3 73 | key: "rebar3-cache-for\ 74 | -os-${{runner.os}}\ 75 | -otp-${{steps.setup-beam.outputs.otp-version}}\ 76 | -rebar3-${{steps.setup-beam.outputs.rebar3-version}}\ 77 | -hash-${{hashFiles('rebar.lock')}}" 78 | 79 | - run: | 80 | rebar3 upgrade --all 81 | if ! git diff --exit-code >/dev/null; then 82 | # there's stuff to push 83 | git config user.name "arizona[bot]" 84 | git config user.email "arizona_bot_@user.noreply.github.com" 85 | git add rebar.lock 86 | git commit -m "[automation] update \`rebar.lock\` after Renovate" 87 | git push 88 | fi 89 | env: 90 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build/ 2 | /_checkouts/ 3 | /doc/ 4 | /node_modules/ 5 | /static/ 6 | erl_crash.dump 7 | rebar3.crashdump 8 | -------------------------------------------------------------------------------- /.markdownlint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | default: true 3 | MD013: 4 | line_length: 100 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.14.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | **/*.min.{js,css} 3 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.0.1 2 | rebar 3.24.0 3 | -------------------------------------------------------------------------------- /.yamllint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | rules: 4 | line-length: 5 | max: 100 6 | comments: 7 | min-spaces-from-content: 1 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | See the [Releases](https://github.com/arizona-framework/arizona/releases) page. 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Arizona 2 | 3 | 1. [License](#license) 4 | 1. [Reporting a bug](#reporting-a-bug) 5 | 1. [Requesting or implementing a feature](#requesting-or-implementing-a-feature) 6 | 1. [Submitting your changes](#submitting-your-changes) 7 | 1. [Code Style](#code-style) 8 | 1. [Committing your changes](#committing-your-changes) 9 | 1. [Pull requests and branching](#pull-requests-and-branching) 10 | 1. [Credits](#credits) 11 | 12 | ## License 13 | 14 | Arizona is licensed under the [Apache License Version 2.0](LICENSE.md), for all code. 15 | 16 | ## Reporting a bug 17 | 18 | Arizona is not perfect software and will be buggy. 19 | 20 | Bugs can be reported via 21 | [GitHub issues: bug report](https://github.com/arizona-framework/arizona/issues/new?template=bug_report.md). 22 | 23 | Some contributors and maintainers may be unpaid developers working on Arizona, in their own time, 24 | with limited resources. We ask for respect and understanding, and will provide the same back. 25 | 26 | If your contribution is an actual bug fix, we ask you to include tests that, not only show the issue 27 | is solved, but help prevent future regressions related to it. 28 | 29 | ## Requesting or implementing a feature 30 | 31 | Before requesting or implementing a new feature, do the following: 32 | 33 | - search, in existing [issues](https://github.com/arizona-framework/arizona/issues) (open or closed), 34 | whether the feature might already be in the works, or has already been rejected, 35 | - make sure you're using the latest software release (or even the latest code, if you're going for 36 | _bleeding edge_). 37 | 38 | If this is done, open up a 39 | [GitHub issues: feature request](https://github.com/arizona-framework/arizona/issues/new?template=feature_request.md). 40 | 41 | We may discuss details with you regarding the implementation, and its inclusion within the project. 42 | 43 | We try to have as many of Arizona's features tested as possible. Everything that a user can do, 44 | and is repeatable in any way, should be tested, to guarantee backwards compatible. 45 | 46 | ## Submitting your changes 47 | 48 | ### Code Style 49 | 50 | - do not introduce trailing whitespace 51 | - indentation is 4 spaces, not tabs 52 | - try not to introduce lines longer than 100 characters 53 | - write small functions whenever possible, and use descriptive names for functions and variables 54 | - comment tricky or non-obvious decisions made to explain their rationale 55 | 56 | ### Committing your changes 57 | 58 | Merging to the `main` branch will usually be preceded by a squash. 59 | 60 | While it's Ok (and expected) your commit messages relate to why a given change was made, be aware 61 | that the final commit (the merge one) will be the issue title, so it's important it is as specific 62 | as possible. This will also help eventual automated changelog generation. 63 | 64 | ### Pull requests and branching 65 | 66 | All fixes to Arizona end up requiring a +1 from one or more of the project's maintainers. 67 | 68 | During the review process, you may be asked to correct or edit a few things before a final rebase 69 | to merge things. Do send edits as individual commits to allow for gradual and partial reviews to be 70 | done by reviewers. 71 | 72 | ### Credits 73 | 74 | Arizona has been improved by 75 | [many contributors](https://github.com/arizona-framework/arizona/graphs/contributors)! 76 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Apache License 2 | 3 | Version 2.0, January 2004 4 | 5 | 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | ## 1. Definitions 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, and 12 | distribution as defined by Sections 1 through 9 of this document. 13 | 14 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 15 | owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all other entities 18 | that control, are controlled by, or are under common control with that entity. 19 | For the purposes of this definition, "control" means (i) the power, direct or 20 | indirect, to cause the direction or management of such entity, whether by 21 | contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity exercising 25 | permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, including 28 | but not limited to software source code, documentation source, and configuration 29 | files. 30 | 31 | "Object" form shall mean any form resulting from mechanical transformation or 32 | translation of a Source form, including but not limited to compiled object code, 33 | generated documentation, and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or Object form, made 36 | available under the License, as indicated by a copyright notice that is included 37 | in or attached to the work (an example is provided in the Appendix below). 38 | 39 | "Derivative Works" shall mean any work, whether in Source or Object form, that 40 | is based on (or derived from) the Work and for which the editorial revisions, 41 | annotations, elaborations, or other modifications represent, as a whole, an 42 | original work of authorship. For the purposes of this License, Derivative Works 43 | shall not include works that remain separable from, or merely link (or bind by 44 | name) to the interfaces of, the Work and Derivative Works thereof. 45 | 46 | "Contribution" shall mean any work of authorship, including the original version 47 | of the Work and any modifications or additions to that Work or Derivative Works 48 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 49 | by the copyright owner or by an individual or Legal Entity authorized to submit 50 | on behalf of the copyright owner. For the purposes of this definition, 51 | "submitted" means any form of electronic, verbal, or written communication sent 52 | to the Licensor or its representatives, including but not limited to 53 | communication on electronic mailing lists, source code control systems, and 54 | issue tracking systems that are managed by, or on behalf of, the Licensor for 55 | the purpose of discussing and improving the Work, but excluding communication 56 | that is conspicuously marked or otherwise designated in writing by the copyright 57 | owner as "Not a Contribution." 58 | 59 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 60 | of whom a Contribution has been received by Licensor and subsequently 61 | incorporated within the Work. 62 | 63 | ## 2. Grant of Copyright License 64 | 65 | Subject to the terms and conditions of this License, each Contributor hereby 66 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 67 | irrevocable copyright license to reproduce, prepare Derivative Works of, 68 | publicly display, publicly perform, sublicense, and distribute the Work and such 69 | Derivative Works in Source or Object form. 70 | 71 | ## 3. Grant of Patent License 72 | 73 | Subject to the terms and conditions of this License, each Contributor hereby 74 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 75 | irrevocable (except as stated in this section) patent license to make, have 76 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 77 | such license applies only to those patent claims licensable by such Contributor 78 | that are necessarily infringed by their Contribution(s) alone or by combination 79 | of their Contribution(s) with the Work to which such Contribution(s) was 80 | submitted. If You institute patent litigation against any entity (including a 81 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 82 | Contribution incorporated within the Work constitutes direct or contributory 83 | patent infringement, then any patent licenses granted to You under this License 84 | for that Work shall terminate as of the date such litigation is filed. 85 | 86 | ## 4. Redistribution 87 | 88 | You may reproduce and distribute copies of the Work or Derivative Works thereof 89 | in any medium, with or without modifications, and in Source or Object form, 90 | provided that You meet the following conditions: 91 | 92 | 1. You must give any other recipients of the Work or Derivative Works a copy of 93 | this License; and 94 | 95 | 2. You must cause any modified files to carry prominent notices stating that 96 | You changed the files; and 97 | 98 | 3. You must retain, in the Source form of any Derivative Works that You 99 | distribute, all copyright, patent, trademark, and attribution notices from 100 | the Source form of the Work, excluding those notices that do not pertain to 101 | any part of the Derivative Works; and 102 | 103 | 4. If the Work includes a "NOTICE" text file as part of its distribution, then 104 | any Derivative Works that You distribute must include a readable copy of the 105 | attribution notices contained within such NOTICE file, excluding those 106 | notices that do not pertain to any part of the Derivative Works, in at least 107 | one of the following places: within a NOTICE text file distributed as part 108 | of the Derivative Works; within the Source form or documentation, if 109 | provided along with the Derivative Works; or, within a display generated by 110 | the Derivative Works, if and wherever such third-party notices normally 111 | appear. The contents of the NOTICE file are for informational purposes only 112 | and do not modify the License. You may add Your own attribution notices 113 | within Derivative Works that You distribute, alongside or as an addendum to 114 | the NOTICE text from the Work, provided that such additional attribution 115 | notices cannot be construed as modifying the License. 116 | 117 | You may add Your own copyright statement to Your modifications and may provide 118 | additional or different license terms and conditions for use, reproduction, or 119 | distribution of Your modifications, or for any such Derivative Works as a whole, 120 | provided Your use, reproduction, and distribution of the Work otherwise complies 121 | with the conditions stated in this License. 122 | 123 | ## 5. Submission of Contributions 124 | 125 | Unless You explicitly state otherwise, any Contribution intentionally submitted 126 | for inclusion in the Work by You to the Licensor shall be under the terms and 127 | conditions of this License, without any additional terms or conditions. 128 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 129 | any separate license agreement you may have executed with Licensor regarding 130 | such Contributions. 131 | 132 | ## 6. Trademarks 133 | 134 | This License does not grant permission to use the trade names, trademarks, 135 | service marks, or product names of the Licensor, except as required for 136 | reasonable and customary use in describing the origin of the Work and 137 | reproducing the content of the NOTICE file. 138 | 139 | ## 7. Disclaimer of Warranty 140 | 141 | Unless required by applicable law or agreed to in writing, Licensor provides the 142 | Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, 143 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 144 | including, without limitation, any warranties or conditions of TITLE, NON- 145 | INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 146 | solely responsible for determining the appropriateness of using or 147 | redistributing the Work and assume any risks associated with Your exercise of 148 | permissions under this License. 149 | 150 | ## 8. Limitation of Liability 151 | 152 | In no event and under no legal theory, whether in tort (including negligence), 153 | contract, or otherwise, unless required by applicable law (such as deliberate 154 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 155 | liable to You for damages, including any direct, indirect, special, incidental, 156 | or consequential damages of any character arising as a result of this License or 157 | out of the use or inability to use the Work (including but not limited to 158 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 159 | any and all other commercial damages or losses), even if such Contributor has 160 | been advised of the possibility of such damages. 161 | 162 | ## 9. Accepting Warranty or Additional Liability 163 | 164 | While redistributing the Work or Derivative Works thereof, You may choose to 165 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 166 | other liability obligations and/or rights consistent with this License. However, 167 | in accepting such obligations, You may act only on Your own behalf and on Your 168 | sole responsibility, not on behalf of any other Contributor, and only if You 169 | agree to indemnify, defend, and hold each Contributor harmless for any liability 170 | incurred by, or claims asserted against, such Contributor by reason of your 171 | accepting any such warranty or additional liability. 172 | 173 | END OF TERMS AND CONDITIONS 174 | 175 | Copyright 2023, William Fank Thomé . 176 | 177 | Licensed under the Apache License, Version 2.0 (the "License"); 178 | you may not use this file except in compliance with the License. 179 | You may obtain a copy of the License at 180 | 181 | 182 | 183 | Unless required by applicable law or agreed to in writing, software 184 | distributed under the License is distributed on an "AS IS" BASIS, 185 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 186 | See the License for the specific language governing permissions and 187 | limitations under the License. 188 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Arizona 2 | 3 | ![arizona_256x256](https://github.com/arizona-framework/arizona/assets/35941533/88b76a0c-0dfc-4f99-8608-b0ebd9c9fbd9) 4 | 5 | Arizona is a web framework for Erlang. 6 | 7 | ## ⚠️ Warning 8 | 9 | Work in progress. 10 | 11 | Use it at your own risk, as the API may change at any time. 12 | 13 | ## Template Syntax 14 | 15 | Arizona utilizes a templating approach where Erlang code is embedded within HTML 16 | using curly braces `{}`. This allows dynamic content generation by executing Erlang 17 | functions directly within the HTML structure. For example: 18 | 19 | ```erlang 20 |
    21 | {arizona:render_list(fun(Item) -> 22 | arizona:render_nested_template(~""" 23 |
  • {Item}
  • 24 | """) 25 | end, arizona:get_binding(list, View))} 26 |
27 | ``` 28 | 29 | No macros, no special syntaxes, just dynamic Erlang code embedded in static HTML. 30 | 31 | ## Basic Usage 32 | 33 | > [!NOTE] 34 | > 35 | > The example below is a simplified version of the code from the [example repository](https://github.com/arizona-framework/arizona_example). 36 | > Please refer to it for the complete code. 37 | 38 | Create a new rebar3 app: 39 | 40 | ```bash 41 | $ rebar3 new app arizona_example 42 | ===> Writing arizona_example/src/arizona_example_app.erl 43 | ===> Writing arizona_example/src/arizona_example_sup.erl 44 | ===> Writing arizona_example/src/arizona_example.app.src 45 | ===> Writing arizona_example/rebar.config 46 | ===> Writing arizona_example/.gitignore 47 | ===> Writing arizona_example/LICENSE.md 48 | ===> Writing arizona_example/README.md 49 | ``` 50 | 51 | Navigate to the project folder and compile it: 52 | 53 | ```bash 54 | $ cd arizona_example && rebar3 compile 55 | ===> Verifying dependencies... 56 | ===> Analyzing applications... 57 | ===> Compiling arizona_example 58 | ``` 59 | 60 | Add Arizona as a dependency in `rebar.config`: 61 | 62 | ```erlang 63 | {deps, [ 64 | {arizona, {git, "https://github.com/arizona-framework/arizona", {branch, "main"}}} 65 | ]}. 66 | ``` 67 | 68 | Include Arizona in the `src/arizona_example.app.src` file: 69 | 70 | ```erlang 71 | {application, arizona_example, [ 72 | % ... 73 | {applications, [ 74 | kernel, 75 | stdlib, 76 | arizona 77 | ]}, 78 | % ... 79 | ]}. 80 | ``` 81 | 82 | Update the dependencies: 83 | 84 | ```bash 85 | $ rebar3 get-deps 86 | ===> Verifying dependencies... 87 | ``` 88 | 89 | Create a `config/sys.config` file: 90 | 91 | ```erlang 92 | [ 93 | {arizona, [ 94 | {endpoint, #{ 95 | % Routes are plain Cowboy routes for now. 96 | routes => [ 97 | % Static files 98 | {"/assets/[...]", cowboy_static, {priv_dir, arizona_example, "assets"}}, 99 | % Views are stateful and keep their state in memory. 100 | % Use the 'arizona_view_handler' to render Arizona views. 101 | % The 'arizona_example_page' will be mounted with the bindings 'title' and 'id'. 102 | % The layout is optional and wraps the view. It does not have a state; 103 | % it simply places the view within its structure. 104 | {"/", arizona_view_handler, 105 | {arizona_example_page, #{title => ~"Arizona Example", id => ~"app"}, #{ 106 | layout => arizona_example_layout 107 | }}} 108 | ] 109 | }} 110 | ]} 111 | ]. 112 | ``` 113 | 114 | Set the config file in `rebar.config`: 115 | 116 | ```erlang 117 | {shell, [ 118 | {config, "config/sys.config"}, 119 | {apps, [arizona_example]} 120 | ]}. 121 | ``` 122 | 123 | Create the `src/arizona_example_page.erl` file: 124 | 125 | ```erlang 126 | -module(arizona_example_page). 127 | -compile({parse_transform, arizona_transform}). 128 | -behaviour(arizona_view). 129 | 130 | -export([mount/2]). 131 | -export([render/1]). 132 | -export([handle_event/4]). 133 | 134 | mount(Bindings, _Socket) -> 135 | View = arizona:new_view(?MODULE, Bindings), 136 | {ok, View}. 137 | 138 | render(View) -> 139 | arizona:render_view_template(View, ~""" 140 |
141 | {arizona:render_view(arizona_example_counter, #{ 142 | id => ~"counter", 143 | count => 0 144 | })} 145 |
146 | """). 147 | 148 | handle_event(_Event, _Payload, _From, View) -> 149 | {noreply, View}. 150 | ``` 151 | 152 | Create the `src/arizona_example_counter.erl` view, which is defined in the render function of the page: 153 | 154 | ```erlang 155 | -module(arizona_example_counter). 156 | -compile({parse_transform, arizona_transform}). 157 | -behaviour(arizona_view). 158 | 159 | -export([mount/2]). 160 | -export([render/1]). 161 | -export([handle_event/4]). 162 | 163 | mount(Bindings, _Socket) -> 164 | View = arizona:new_view(?MODULE, Bindings), 165 | {ok, View}. 166 | 167 | render(View) -> 168 | arizona:render_view_template(View, ~""" 169 |
170 | {integer_to_binary(arizona:get_binding(count, View))} 171 | {arizona:render_component(arizona_example_components, button, #{ 172 | handler => arizona:get_binding(id, View), 173 | event => ~"incr", 174 | payload => 1, 175 | text => ~"Increment" 176 | })} 177 |
178 | """). 179 | 180 | handle_event(~"incr", Incr, _From, View) -> 181 | Count = arizona:get_binding(count, View), 182 | arizona:put_binding(count, Count + Incr, View). 183 | ``` 184 | 185 | Create the button in `src/arizona_example_components.erl`, which is defined in the render 186 | function of the view: 187 | 188 | ```erlang 189 | -module(arizona_example_components). 190 | -export([button/1]). 191 | 192 | button(View) -> 193 | arizona:render_component_template(View, ~""" 194 | 204 | """). 205 | ``` 206 | 207 | Create the optional layout `src/arizona_example_layout.erl`, which is defined in the config file: 208 | 209 | ```erlang 210 | -module(arizona_example_layout). 211 | -compile({parse_transform, arizona_transform}). 212 | -behaviour(arizona_layout). 213 | 214 | -export([mount/2]). 215 | -export([render/1]). 216 | 217 | mount(Bindings, _Socket) -> 218 | arizona:new_view(?MODULE, Bindings). 219 | 220 | render(View) -> 221 | arizona:render_layout_template(View, ~"""" 222 | 223 | 224 | 225 | 226 | 227 | 228 | {arizona:get_binding(title, View)} 229 | 230 | 231 | 232 | 233 | {% The 'inner_content' binding is auto-binded by Arizona in the view. } 234 | {arizona:get_binding(inner_content, View)} 235 | 236 | 237 | """"). 238 | ``` 239 | 240 | Start the app: 241 | 242 | ```bash 243 | $ rebar3 shell 244 | ===> Verifying dependencies... 245 | ===> Analyzing applications... 246 | ===> Compiling arizona_example 247 | Erlang/OTP 27 [erts-15.2.2] [source] [64-bit] [smp:24:24] [ds:24:24:10] [async-threads:1] [jit:ns] 248 | 249 | Eshell V15.2.2 (press Ctrl+G to abort, type help(). for help) 250 | ===> Booted syntax_tools 251 | ===> Booted cowlib 252 | ===> Booted ranch 253 | ===> Booted cowboy 254 | ===> Booted arizona 255 | ===> Booted arizona_example 256 | ``` 257 | 258 | The server is up and running at , but it is not yet connected to the server. 259 | To establish the connection, create `priv/assets/main.js` in your static assets directory (matching 260 | the configured static route path in `config/sys.config` and matching the script added to the HTML of 261 | the layout file previously) and add the connection initialization code to it: 262 | 263 | ```js 264 | arizona.connect(); 265 | ``` 266 | 267 | Open the browser again, and the button click will now increase the count value by one. 268 | 269 | !["Counter Example"](./assets/counter_example.gif) 270 | 271 | The value is updated in `arizona_example_counter:handle_event/4` via WebSocket, and the DOM patch 272 | used the [morphdom library](https://github.com/patrick-steele-idem/morphdom) under the hood. 273 | Note that only the changed part is sent as a small payload from the server to the client. 274 | 275 | ## Sponsors 276 | 277 | If you like this tool, please consider [sponsoring me](https://github.com/sponsors/williamthome). 278 | I'm thankful for your never-ending support :heart: 279 | 280 | I also accept coffees :coffee: 281 | 282 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/williamthome) 283 | 284 | ## Contributing 285 | 286 | ### Issues 287 | 288 | Feel free to [submit an issue on Github](https://github.com/williamthome/arizona/issues/new). 289 | 290 | ## License 291 | 292 | Copyright (c) 2023-2025 [William Fank Thomé](https://github.com/williamthome) 293 | 294 | Arizona is 100% open-source and community-driven. All components are 295 | available under the Apache 2 License on [GitHub](https://github.com/williamthome/arizona). 296 | 297 | See [LICENSE.md](LICENSE.md) for more information. 298 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security policy 2 | 3 | Thanks for helping make Arizona safer for everyone. 4 | 5 | Find below updated information on our security policy. 6 | 7 | ## Security 8 | 9 | We take the security of this software seriously. 10 | 11 | We don't implement a bug bounty program or bounty rewards, but will work with 12 | you to ensure that your findings get the appropriate handling. 13 | 14 | ## Reporting Security Issues 15 | 16 | If you believe you have found a security vulnerability in this repository, 17 | please report it to . 18 | 19 | Please do not report security vulnerabilities through public channels, like 20 | GitHub issues, discussions, or pull requests. 21 | 22 | Please include as much of the information listed below as you can to help us 23 | better understand and resolve the issue: 24 | 25 | - the type of issue (e.g., buffer overflow, SQL injection, or cross-site 26 | scripting) 27 | - full paths of source file(s) related to the manifestation of the issue 28 | - the location of the affected source code (tag/branch/commit or direct URL) 29 | - any special configuration required to reproduce the issue 30 | - step-by-step instructions to reproduce the issue 31 | - proof-of-concept or exploit code (if possible) 32 | - impact of the issue, including how an attacker might exploit the issue 33 | 34 | This information will help us triage your report more quickly. 35 | -------------------------------------------------------------------------------- /assets/counter_example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arizona-framework/arizona/7bf1f6454a1ea85da0b29539c3dca5110d2d4515/assets/counter_example.gif -------------------------------------------------------------------------------- /assets/js/arizona-patch.mjs: -------------------------------------------------------------------------------- 1 | // -------------------------------------------------------------------- 2 | // API function definitions 3 | // -------------------------------------------------------------------- 4 | 5 | export function patch(rendered, diff) { 6 | if (rendered[0] === 'template' && rendered.length === 3) { 7 | const staticList = rendered[1]; 8 | const dynamicList = [...rendered[2]]; 9 | return patchTemplate(staticList, dynamicList, diff); 10 | } else if (rendered[0] === 'list' && rendered.length === 3) { 11 | const staticList = rendered[1]; 12 | const dynamicList = [...rendered[2]]; 13 | return patchList(staticList, dynamicList); 14 | } else { 15 | return rendered; 16 | } 17 | } 18 | 19 | // -------------------------------------------------------------------- 20 | // Private functions 21 | // -------------------------------------------------------------------- 22 | 23 | function patchTemplate(staticList, dynamicList, diff) { 24 | dynamicList = patchDynamic(dynamicList, diff); 25 | return zip(staticList, dynamicList, diff); 26 | } 27 | 28 | function patchList(staticList, dynamicList) { 29 | return dynamicList.forEach((d) => zip(staticList, d)); 30 | } 31 | 32 | function patchDynamic(dynamicList, diff) { 33 | if (!diff) return dynamicList; 34 | for (const [index, value] of Object.entries(diff)) { 35 | if (typeof value === 'object' && !Array.isArray(value)) { 36 | dynamicList[index] = patch(dynamicList[index], value); 37 | } else { 38 | dynamicList[index] = value; 39 | } 40 | } 41 | return dynamicList; 42 | } 43 | 44 | function zip(staticList, dynamicList, diff) { 45 | let str = ''; 46 | for (let i = 0; i < Math.max(staticList.length, dynamicList.length); i++) { 47 | str += `${staticList[i] ?? ''}${patch(dynamicList[i] ?? '', diff ? diff[i] : null)}`; 48 | } 49 | return str; 50 | } 51 | -------------------------------------------------------------------------------- /assets/js/arizona-patch.test.js: -------------------------------------------------------------------------------- 1 | import { patch } from './arizona-patch.mjs'; 2 | 3 | describe('Patch Test', () => { 4 | test('should patch diff', () => { 5 | const rendered = [ 6 | 'template', 7 | ['
\n ', ' ', '
'], 8 | [ 9 | 'counter', 10 | '0', 11 | [ 12 | 'template', 13 | [' ', ''], 14 | ['button', '\'arizona.send("counter", "incr", 1)\'', 'Increment'], 15 | ], 16 | ], 17 | ]; 18 | 19 | let diff = { 1: '1' }; 20 | expect(patch(rendered, diff)).toBe(`
21 | 1
`); 24 | 25 | diff = { 1: '2' }; 26 | expect(patch(rendered, diff)).toBe(`
27 | 2
`); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /assets/js/arizona-worker.mjs: -------------------------------------------------------------------------------- 1 | import { patch } from './arizona-patch.mjs'; 2 | 3 | const state = { 4 | queryParams: {}, 5 | socket: null, 6 | views: [], 7 | eventQueue: [], 8 | }; 9 | 10 | // Messages from client 11 | self.onmessage = function (e) { 12 | const { data: msg } = e; 13 | 14 | console.info('[WebWorker] client sent:', msg); 15 | 16 | if (typeof msg !== 'object' || !msg.subject) { 17 | console.error('[WebWorker] invalid message format:', msg); 18 | return; 19 | } 20 | 21 | switch (msg.subject) { 22 | case 'connect': { 23 | const { ref, queryParams } = msg.attachment; 24 | connect(ref, queryParams); 25 | break; 26 | } 27 | default: 28 | sendMsgToServer(msg); 29 | } 30 | }; 31 | 32 | function connect(ref, queryParams) { 33 | return new Promise((resolve) => { 34 | const url = genSocketUrl(queryParams); 35 | const socket = new WebSocket(url); 36 | 37 | state.queryParams = queryParams; 38 | state.socket = socket; 39 | 40 | socket.onopen = function () { 41 | console.info('[WebSocket] connected:', state); 42 | 43 | const queuedEvents = [...state.eventQueue]; 44 | state.eventQueue.length = 0; 45 | queuedEvents.forEach(sendMsgToServer); 46 | 47 | sendMsgToClient(ref, undefined, 'connected', true); 48 | 49 | resolve(); 50 | }; 51 | 52 | socket.onclose = function (e) { 53 | console.info('[WebSocket] disconnected:', e); 54 | sendMsgToClient(ref, undefined, 'connected', false); 55 | }; 56 | 57 | // Messages from server 58 | socket.onmessage = function (e) { 59 | console.info('[WebSocket] msg:', e.data); 60 | const data = JSON.parse(e.data); 61 | Array.isArray(data) ? data.forEach(handleEvent) : handleEvent(data); 62 | }; 63 | }); 64 | } 65 | 66 | function handleEvent(data) { 67 | const eventName = data[0]; 68 | const [ref, viewId, payload] = data[1]; 69 | switch (eventName) { 70 | case 'init': { 71 | state.views = payload; 72 | break; 73 | } 74 | case 'patch': { 75 | const rendered = state.views[viewId]; 76 | const html = patch(rendered, payload); 77 | sendMsgToClient(ref, viewId, 'patch', html); 78 | break; 79 | } 80 | default: { 81 | sendMsgToClient(ref, viewId, eventName, payload); 82 | break; 83 | } 84 | } 85 | } 86 | 87 | function sendMsgToClient(ref, viewId, eventName, payload) { 88 | self.postMessage({ ref, viewId, eventName, payload }); 89 | } 90 | 91 | function sendMsgToServer({ subject, attachment }) { 92 | if (isSocketOpen()) { 93 | state.socket.send(JSON.stringify([subject, attachment])); 94 | } else { 95 | state.eventQueue.push({ subject, attachment }); 96 | console.warn('[WebSocket] not ready to send messages'); 97 | } 98 | } 99 | 100 | function isSocketOpen() { 101 | return state.socket.readyState === WebSocket.OPEN; 102 | } 103 | 104 | function genSocketUrl(queryParams) { 105 | const proto = 'ws'; 106 | const host = location.host; 107 | const uri = '/websocket'; 108 | const queryString = `?${Object.keys(queryParams) 109 | .map((key) => `${key}=${encodeURIComponent(queryParams[key])}`) 110 | .join('&')}`; 111 | return `${proto}://${host}${uri}${queryString}`; 112 | } 113 | -------------------------------------------------------------------------------- /assets/js/arizona.mjs: -------------------------------------------------------------------------------- 1 | import morphdom from 'morphdom'; 2 | 3 | globalThis['arizona'] = (() => { 4 | // -------------------------------------------------------------------- 5 | // API function definitions 6 | // -------------------------------------------------------------------- 7 | 8 | function connect(params = {}, callback, opts) { 9 | const ref = generateRef(); 10 | if (typeof callback === 'function') { 11 | _subscribe(ref, 'connected', callback, opts); 12 | } 13 | const searchParams = Object.fromEntries([...new URLSearchParams(window.location.search)]); 14 | const queryParams = { 15 | ...searchParams, 16 | ...params, 17 | path: location.pathname, 18 | }; 19 | _sendMsgToWorker('connect', { ref, queryParams }); 20 | } 21 | 22 | function send(eventName, viewId, payload, callback, opts) { 23 | let ref; 24 | if (typeof callback === 'function') { 25 | ref = generateRef(); 26 | _subscribe(ref, eventName, callback, opts); 27 | } 28 | _sendMsgToWorker('event', [ref, viewId, eventName, payload]); 29 | } 30 | 31 | function event(eventName, viewId) { 32 | let joined = false; 33 | const _members = []; 34 | 35 | function join(payload) { 36 | return new Promise((resolve, reject) => { 37 | if (joined) reject('alreadyJoined'); 38 | 39 | const ref = generateRef(); 40 | _subscribe( 41 | ref, 42 | 'join', 43 | ([status, payload]) => { 44 | joined = status === 'ok'; 45 | joined ? resolve(payload) : reject(payload); 46 | }, 47 | { once: true } 48 | ); 49 | _sendMsgToWorker('join', [ref, viewId, eventName, payload]); 50 | }); 51 | } 52 | 53 | function handle(callback, opts) { 54 | const ref = generateRef(); 55 | const unsubscribe = _subscribe(ref, eventName, callback, opts); 56 | _members.push(unsubscribe); 57 | return this; 58 | } 59 | 60 | function leave() { 61 | _members.forEach((unsubscribe) => unsubscribe()); 62 | _members.length = 0; 63 | } 64 | 65 | return Object.freeze({ join, handle, leave }); 66 | } 67 | 68 | // -------------------------------------------------------------------- 69 | // Private functions 70 | // -------------------------------------------------------------------- 71 | 72 | function generateRef() { 73 | return Math.random().toString(36).substring(2, 9); 74 | } 75 | 76 | function _subscribe(ref, eventName, callback, opts = {}) { 77 | if (typeof callback !== 'function' || typeof opts !== 'object' || Array.isArray(opts)) { 78 | console.error('[Arizona] invalid subscribe data:', { 79 | eventName, 80 | callback, 81 | opts, 82 | }); 83 | return; 84 | } 85 | 86 | let eventSubs = subscribers.get(eventName); 87 | if (!eventSubs) eventSubs = new Map(); 88 | 89 | eventSubs.set(ref, { ref, callback, opts }); 90 | subscribers.set(eventName, eventSubs); 91 | unsubscribers.set(ref, eventName); 92 | 93 | console.table({ 94 | action: 'subscribed', 95 | eventName, 96 | ref, 97 | subscribers, 98 | unsubscribers, 99 | }); 100 | 101 | return function () { 102 | _unsubscribe(ref); 103 | }; 104 | } 105 | 106 | function _unsubscribe(ref) { 107 | const eventName = unsubscribers.get(ref); 108 | if (!eventName) return; 109 | const members = subscribers.get(eventName); 110 | if (!members) return; 111 | members.delete(ref); 112 | members.size ? subscribers.set(eventName, members) : subscribers.delete(eventName); 113 | unsubscribers.delete(ref); 114 | console.table({ 115 | action: 'unsubscribed', 116 | eventName, 117 | ref, 118 | subscribers, 119 | unsubscribers, 120 | }); 121 | } 122 | 123 | function _sendMsgToWorker(subject, attachment) { 124 | worker.postMessage({ subject, attachment }); 125 | } 126 | 127 | // -------------------------------------------------------------------- 128 | // Namespace initialization 129 | // -------------------------------------------------------------------- 130 | 131 | const worker = new Worker('assets/js/arizona/worker.js'); 132 | const subscribers = new Map(); 133 | const unsubscribers = new Map(); 134 | 135 | worker.addEventListener('message', function (e) { 136 | console.info('[WebWorker] msg:', e.data); 137 | 138 | const { ref, viewId, eventName, payload } = e.data; 139 | switch (eventName) { 140 | case 'patch': { 141 | const elem = document.getElementById(viewId); 142 | morphdom(elem, payload, { 143 | onBeforeElUpdated: (from, to) => !from.isEqualNode(to), 144 | }); 145 | break; 146 | } 147 | case 'leave': { 148 | _unsubscribe(payload); 149 | return; 150 | } 151 | } 152 | 153 | const members = subscribers.get(eventName); 154 | if (!(members instanceof Map)) return; 155 | 156 | if (typeof ref === 'string' && ref.length) { 157 | const member = members.get(ref); 158 | if (!member?.callback) return; 159 | member.callback(payload); 160 | } else { 161 | members.forEach(function ({ ref, callback, opts }) { 162 | callback(payload); 163 | opts.once && _unsubscribe(ref); 164 | }); 165 | } 166 | }); 167 | 168 | worker.addEventListener('error', function (e) { 169 | console.error('[WebWorker] error:', e); 170 | }); 171 | 172 | return Object.freeze({ connect, send, event }); 173 | })(); 174 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /elvis.config: -------------------------------------------------------------------------------- 1 | [ 2 | {elvis, [ 3 | {config, [ 4 | #{ 5 | dirs => ["src"], 6 | filter => "*.erl", 7 | ruleset => erl_files_strict, 8 | rules => [ 9 | {elvis_style, dont_repeat_yourself, #{min_complexity => 15}}, 10 | {elvis_style, atom_naming_convention, #{ 11 | regex => "^[a-z][a-z_@0-9]*(_SUITE)?$|^syntaxTree$" 12 | }}, 13 | {elvis_style, function_naming_convention, #{ 14 | regex => "^[a-z][a-z_0-9]*$" 15 | }}, 16 | {elvis_style, no_macros, #{ 17 | allow => [] 18 | }}, 19 | % TODO: Remove `max_module_length` when elvis 4.0 be released. 20 | % See https://github.com/inaka/elvis_core/pull/385. 21 | {elvis_style, max_module_length, #{max_length => 1000}}, 22 | {elvis_style, max_function_length, #{max_length => 65}}, 23 | {elvis_style, no_throw, disable}, 24 | {elvis_style, god_modules, #{limit => 30}} 25 | ] 26 | }, 27 | #{ 28 | dirs => ["test"], 29 | filter => "*.erl", 30 | ruleset => erl_files_strict, 31 | rules => [ 32 | {elvis_style, dont_repeat_yourself, disable}, 33 | {elvis_style, atom_naming_convention, #{ 34 | regex => "^[a-z][a-z_@0-9]*(_SUITE)?$" 35 | }}, 36 | {elvis_style, function_naming_convention, #{ 37 | regex => "^[a-z][a-z_0-9]*(_SUITE)?$" 38 | }}, 39 | {elvis_style, no_macros, #{ 40 | allow => [ 41 | 'assert', 42 | 'assertEqual', 43 | 'assertMatch', 44 | 'assertError' 45 | ] 46 | }}, 47 | {elvis_style, max_function_length, disable} 48 | ] 49 | }, 50 | #{ 51 | dirs => ["."], 52 | filter => "rebar.config", 53 | ruleset => rebar_config 54 | }, 55 | #{ 56 | dirs => ["."], 57 | filter => "elvis.config", 58 | ruleset => elvis_config 59 | } 60 | ]} 61 | ]} 62 | ]. 63 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | /* global process */ 2 | import * as esbuild from 'esbuild'; 3 | 4 | Promise.all([ 5 | esbuild.build({ 6 | entryPoints: ['./assets/js/arizona.mjs'], 7 | outfile: './priv/static/assets/js/arizona.min.js', 8 | bundle: true, 9 | minify: true, 10 | sourcemap: true, 11 | }), 12 | esbuild.build({ 13 | entryPoints: ['./assets/js/arizona-worker.mjs'], 14 | outfile: './priv/static/assets/js/arizona-worker.min.js', 15 | bundle: true, 16 | minify: true, 17 | sourcemap: true, 18 | }), 19 | ]).catch((error) => { 20 | console.error(error); 21 | process.exit(1); 22 | }); 23 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import pluginJs from '@eslint/js'; 2 | import globals from 'globals'; 3 | 4 | export default [ 5 | { 6 | files: [ 7 | './*.m?js', 8 | // Ignored files 9 | '!**/*.min.js', 10 | ], 11 | languageOptions: { 12 | sourceType: 'module', 13 | // Globals define variables that are automatically available in environment 14 | globals: { 15 | ...globals.browser, // Adds browser globals (window, document, etc.) 16 | ...globals.node, // Adds Node.js globals (process, require, __dirname) 17 | ...globals.es2021, // Modern JavaScript globals (Promise, Set, Map) 18 | }, 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | }, 22 | }, 23 | rules: { 24 | // Code Quality 25 | 'no-var': 'error', // Force `let`/`const` instead of `var` 26 | 'prefer-const': 'error', // Use `const` when variables aren’t reassigned 27 | eqeqeq: 'error', // Require `===` and `!==` (no loose equality) 28 | 'no-implicit-coercion': 'error', // Ban `+str`, `!!bool` coercions 29 | strict: ['error', 'global'], // Require `'use strict'` (for older JS) 30 | 'no-console': 'error', // Ban console.* functions 31 | 32 | // Error Prevention 33 | 'no-undef': 'error', // All variables must be defined 34 | 'no-unused-vars': 'error', // No unused variables/imports 35 | 'no-shadow': 'error', // Prevent variable shadowing 36 | 'no-param-reassign': 'error', // Don’t reassign function params 37 | 38 | // Async/Await Safety 39 | 'no-await-in-loop': 'error', // Avoid `await` inside loops 40 | 'require-atomic-updates': 'error', // Prevent race conditions in async code 41 | 42 | // Security 43 | 'no-eval': 'error', // Ban `eval()` (security risk) 44 | 'no-implied-eval': 'error', // Ban `setTimeout("code")` (indirect eval) 45 | 'no-script-url': 'error', // Ban `javascript:` URLs (XSS risk) 46 | 'no-multi-str': 'error', // Ban multi-line strings (can hide attacks) 47 | 48 | // Modern JavaScript 49 | 'arrow-body-style': ['error', 'always'], // Force arrow functions 50 | 'prefer-arrow-callback': 'error', // Prefer `() => {}` over `function` 51 | 'prefer-template': 'error', // Force template literals (`${var}`) 52 | 'object-shorthand': 'error', // Force `{ foo }` instead of `{ foo: foo }` 53 | }, 54 | }, 55 | pluginJs.configs.recommended, 56 | ]; 57 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | testRegex: 'assets/.*\\.test\\.js$', 3 | testPathIgnorePatterns: ['_build/', '.github'], 4 | modulePathIgnorePatterns: ['_build/', '.github'], 5 | collectCoverage: true, 6 | coveragePathIgnorePatterns: ['_build/', '.github'], 7 | coverageDirectory: '_build/test/cover/js', 8 | coverageProvider: 'v8', 9 | transform: { 10 | '^.+\\.(js|mjs)$': 'babel-jest', 11 | }, 12 | moduleFileExtensions: ['js', 'mjs', 'json'], 13 | }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Arizona JS", 3 | "type": "module", 4 | "scripts": { 5 | "build": "node ./esbuild.config.mjs", 6 | "format": "prettier --write \"./*.{js,mjs}\" \"./assets/js/*.mjs\" \"**/*.json\" \"**/*.yml\" --config prettier.config.mjs --ignore-path .prettierignore", 7 | "format:check": "prettier --check \"./*.{js,mjs}\" \"./assets/js/*.mjs\" \"**/*.json\" \"**/*.yml\" --config prettier.config.mjs --ignore-path .prettierignore", 8 | "lint": "eslint eslint.config.mjs --fix", 9 | "lint:check": "eslint eslint.config.mjs", 10 | "format:lint": "npm run format && npm run lint", 11 | "test": "NODE_OPTIONS='$NODE_OPTIONS --experimental-vm-modules' npx jest", 12 | "ci": "npm run format:check && npm run lint:check && npm test" 13 | }, 14 | "devDependencies": { 15 | "@babel/preset-env": "7.26.9", 16 | "@eslint/js": "9.24.0", 17 | "babel-jest": "29.7.0", 18 | "esbuild": "0.25.2", 19 | "eslint": "9.24.0", 20 | "globals": "16.0.0", 21 | "jest": "29.7.0", 22 | "prettier": "3.5.3", 23 | "uglify-js": "3.19.3" 24 | }, 25 | "dependencies": { 26 | "morphdom": "2.7.4" 27 | }, 28 | "overrides": { 29 | "glob": "^11.0.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://prettier.io/docs/configuration 3 | * @type {import("prettier").Config} 4 | */ 5 | export default { 6 | // Default configuration for all files 7 | printWidth: 100, 8 | tabWidth: 2, 9 | useTabs: false, 10 | semi: true, 11 | trailingComma: 'es5', 12 | bracketSpacing: true, 13 | arrowParens: 'always', 14 | 15 | overrides: [ 16 | // JavaScript files 17 | { 18 | files: ['*.{js,mjs}'], 19 | options: { 20 | parser: 'babel', 21 | singleQuote: true, 22 | }, 23 | }, 24 | // CSS files 25 | { 26 | files: ['*.css'], 27 | options: { 28 | parser: 'css', 29 | }, 30 | }, 31 | // JSON files 32 | { 33 | files: ['*.json'], 34 | options: { 35 | parser: 'json', 36 | tabWidth: 2, 37 | printWidth: 80, 38 | }, 39 | excludeFiles: ['package-lock.json'], 40 | }, 41 | // YAML files 42 | { 43 | files: ['*.yml'], 44 | options: { 45 | parser: 'yaml', 46 | proseWrap: 'preserve', 47 | }, 48 | }, 49 | ], 50 | }; 51 | -------------------------------------------------------------------------------- /priv/static/assets/js/arizona-worker.min.js: -------------------------------------------------------------------------------- 1 | (()=>{function u(e,t){if(e[0]==="template"&&e.length===3){let n=e[1],o=[...e[2]];return p(n,o,t)}else if(e[0]==="list"&&e.length===3){let n=e[1],o=[...e[2]];return k(n,o)}else return e}function p(e,t,n){return t=b(t,n),l(e,t,n)}function k(e,t){return t.forEach(n=>l(e,n))}function b(e,t){if(!t)return e;for(let[n,o]of Object.entries(t))typeof o=="object"&&!Array.isArray(o)?e[n]=u(e[n],o):e[n]=o;return e}function l(e,t,n){let o="";for(let c=0;c{let o=v(t),c=new WebSocket(o);r.queryParams=t,r.socket=c,c.onopen=function(){console.info("[WebSocket] connected:",r);let s=[...r.eventQueue];r.eventQueue.length=0,s.forEach(h),i(e,void 0,"connected",!0),n()},c.onclose=function(s){console.info("[WebSocket] disconnected:",s),i(e,void 0,"connected",!1)},c.onmessage=function(s){console.info("[WebSocket] msg:",s.data);let a=JSON.parse(s.data);Array.isArray(a)?a.forEach(f):f(a)}})}function f(e){let t=e[0],[n,o,c]=e[1];switch(t){case"init":{r.views=c;break}case"patch":{let s=r.views[o],a=u(s,c);i(n,o,"patch",a);break}default:{i(n,o,t,c);break}}}function i(e,t,n,o){self.postMessage({ref:e,viewId:t,eventName:n,payload:o})}function h({subject:e,attachment:t}){m()?r.socket.send(JSON.stringify([e,t])):(r.eventQueue.push({subject:e,attachment:t}),console.warn("[WebSocket] not ready to send messages"))}function m(){return r.socket.readyState===WebSocket.OPEN}function v(e){let t="ws",n=location.host,o="/websocket",c=`?${Object.keys(e).map(s=>`${s}=${encodeURIComponent(e[s])}`).join("&")}`;return`${t}://${n}${o}${c}`}})(); 2 | //# sourceMappingURL=arizona-worker.min.js.map 3 | -------------------------------------------------------------------------------- /priv/static/assets/js/arizona-worker.min.js.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "sources": ["../../../../assets/js/arizona-patch.mjs", "../../../../assets/js/arizona-worker.mjs"], 4 | "sourcesContent": ["// --------------------------------------------------------------------\n// API function definitions\n// --------------------------------------------------------------------\n\nexport function patch(rendered, diff) {\n if (rendered[0] === 'template' && rendered.length === 3) {\n const staticList = rendered[1];\n const dynamicList = [...rendered[2]];\n return patchTemplate(staticList, dynamicList, diff);\n } else if (rendered[0] === 'list' && rendered.length === 3) {\n const staticList = rendered[1];\n const dynamicList = [...rendered[2]];\n return patchList(staticList, dynamicList);\n } else {\n return rendered;\n }\n}\n\n// --------------------------------------------------------------------\n// Private functions\n// --------------------------------------------------------------------\n\nfunction patchTemplate(staticList, dynamicList, diff) {\n dynamicList = patchDynamic(dynamicList, diff);\n return zip(staticList, dynamicList, diff);\n}\n\nfunction patchList(staticList, dynamicList) {\n return dynamicList.forEach((d) => zip(staticList, d));\n}\n\nfunction patchDynamic(dynamicList, diff) {\n if (!diff) return dynamicList;\n for (const [index, value] of Object.entries(diff)) {\n if (typeof value === 'object' && !Array.isArray(value)) {\n dynamicList[index] = patch(dynamicList[index], value);\n } else {\n dynamicList[index] = value;\n }\n }\n return dynamicList;\n}\n\nfunction zip(staticList, dynamicList, diff) {\n let str = '';\n for (let i = 0; i < Math.max(staticList.length, dynamicList.length); i++) {\n str += `${staticList[i] ?? ''}${patch(dynamicList[i] ?? '', diff ? diff[i] : null)}`;\n }\n return str;\n}\n", "import { patch } from './arizona-patch.mjs';\n\nconst state = {\n queryParams: {},\n socket: null,\n views: [],\n eventQueue: [],\n};\n\n// Messages from client\nself.onmessage = function (e) {\n const { data: msg } = e;\n\n console.info('[WebWorker] client sent:', msg);\n\n if (typeof msg !== 'object' || !msg.subject) {\n console.error('[WebWorker] invalid message format:', msg);\n return;\n }\n\n switch (msg.subject) {\n case 'connect': {\n const { ref, queryParams } = msg.attachment;\n connect(ref, queryParams);\n break;\n }\n default:\n sendMsgToServer(msg);\n }\n};\n\nfunction connect(ref, queryParams) {\n return new Promise((resolve) => {\n const url = genSocketUrl(queryParams);\n const socket = new WebSocket(url);\n\n state.queryParams = queryParams;\n state.socket = socket;\n\n socket.onopen = function () {\n console.info('[WebSocket] connected:', state);\n\n const queuedEvents = [...state.eventQueue];\n state.eventQueue.length = 0;\n queuedEvents.forEach(sendMsgToServer);\n\n sendMsgToClient(ref, undefined, 'connected', true);\n\n resolve();\n };\n\n socket.onclose = function (e) {\n console.info('[WebSocket] disconnected:', e);\n sendMsgToClient(ref, undefined, 'connected', false);\n };\n\n // Messages from server\n socket.onmessage = function (e) {\n console.info('[WebSocket] msg:', e.data);\n const data = JSON.parse(e.data);\n Array.isArray(data) ? data.forEach(handleEvent) : handleEvent(data);\n };\n });\n}\n\nfunction handleEvent(data) {\n const eventName = data[0];\n const [ref, viewId, payload] = data[1];\n switch (eventName) {\n case 'init': {\n state.views = payload;\n break;\n }\n case 'patch': {\n const rendered = state.views[viewId];\n const html = patch(rendered, payload);\n sendMsgToClient(ref, viewId, 'patch', html);\n break;\n }\n default: {\n sendMsgToClient(ref, viewId, eventName, payload);\n break;\n }\n }\n}\n\nfunction sendMsgToClient(ref, viewId, eventName, payload) {\n self.postMessage({ ref, viewId, eventName, payload });\n}\n\nfunction sendMsgToServer({ subject, attachment }) {\n if (isSocketOpen()) {\n state.socket.send(JSON.stringify([subject, attachment]));\n } else {\n state.eventQueue.push({ subject, attachment });\n console.warn('[WebSocket] not ready to send messages');\n }\n}\n\nfunction isSocketOpen() {\n return state.socket.readyState === WebSocket.OPEN;\n}\n\nfunction genSocketUrl(queryParams) {\n const proto = 'ws';\n const host = location.host;\n const uri = '/websocket';\n const queryString = `?${Object.keys(queryParams)\n .map((key) => `${key}=${encodeURIComponent(queryParams[key])}`)\n .join('&')}`;\n return `${proto}://${host}${uri}${queryString}`;\n}\n"], 5 | "mappings": "MAIO,SAASA,EAAMC,EAAUC,EAAM,CACpC,GAAID,EAAS,CAAC,IAAM,YAAcA,EAAS,SAAW,EAAG,CACvD,IAAME,EAAaF,EAAS,CAAC,EACvBG,EAAc,CAAC,GAAGH,EAAS,CAAC,CAAC,EACnC,OAAOI,EAAcF,EAAYC,EAAaF,CAAI,CACpD,SAAWD,EAAS,CAAC,IAAM,QAAUA,EAAS,SAAW,EAAG,CAC1D,IAAME,EAAaF,EAAS,CAAC,EACvBG,EAAc,CAAC,GAAGH,EAAS,CAAC,CAAC,EACnC,OAAOK,EAAUH,EAAYC,CAAW,CAC1C,KACE,QAAOH,CAEX,CAMA,SAASI,EAAcF,EAAYC,EAAaF,EAAM,CACpD,OAAAE,EAAcG,EAAaH,EAAaF,CAAI,EACrCM,EAAIL,EAAYC,EAAaF,CAAI,CAC1C,CAEA,SAASI,EAAUH,EAAYC,EAAa,CAC1C,OAAOA,EAAY,QAASK,GAAMD,EAAIL,EAAYM,CAAC,CAAC,CACtD,CAEA,SAASF,EAAaH,EAAaF,EAAM,CACvC,GAAI,CAACA,EAAM,OAAOE,EAClB,OAAW,CAACM,EAAOC,CAAK,IAAK,OAAO,QAAQT,CAAI,EAC1C,OAAOS,GAAU,UAAY,CAAC,MAAM,QAAQA,CAAK,EACnDP,EAAYM,CAAK,EAAIV,EAAMI,EAAYM,CAAK,EAAGC,CAAK,EAEpDP,EAAYM,CAAK,EAAIC,EAGzB,OAAOP,CACT,CAEA,SAASI,EAAIL,EAAYC,EAAaF,EAAM,CAC1C,IAAIU,EAAM,GACV,QAASC,EAAI,EAAGA,EAAI,KAAK,IAAIV,EAAW,OAAQC,EAAY,MAAM,EAAGS,IACnED,GAAO,GAAGT,EAAWU,CAAC,GAAK,EAAE,GAAGb,EAAMI,EAAYS,CAAC,GAAK,GAAIX,EAAOA,EAAKW,CAAC,EAAI,IAAI,CAAC,GAEpF,OAAOD,CACT,CC/CA,IAAME,EAAQ,CACZ,YAAa,CAAC,EACd,OAAQ,KACR,MAAO,CAAC,EACR,WAAY,CAAC,CACf,EAGA,KAAK,UAAY,SAAU,EAAG,CAC5B,GAAM,CAAE,KAAMC,CAAI,EAAI,EAItB,GAFA,QAAQ,KAAK,2BAA4BA,CAAG,EAExC,OAAOA,GAAQ,UAAY,CAACA,EAAI,QAAS,CAC3C,QAAQ,MAAM,sCAAuCA,CAAG,EACxD,MACF,CAEA,OAAQA,EAAI,QAAS,CACnB,IAAK,UAAW,CACd,GAAM,CAAE,IAAAC,EAAK,YAAAC,CAAY,EAAIF,EAAI,WACjCG,EAAQF,EAAKC,CAAW,EACxB,KACF,CACA,QACEE,EAAgBJ,CAAG,CACvB,CACF,EAEA,SAASG,EAAQF,EAAKC,EAAa,CACjC,OAAO,IAAI,QAASG,GAAY,CAC9B,IAAMC,EAAMC,EAAaL,CAAW,EAC9BM,EAAS,IAAI,UAAUF,CAAG,EAEhCP,EAAM,YAAcG,EACpBH,EAAM,OAASS,EAEfA,EAAO,OAAS,UAAY,CAC1B,QAAQ,KAAK,yBAA0BT,CAAK,EAE5C,IAAMU,EAAe,CAAC,GAAGV,EAAM,UAAU,EACzCA,EAAM,WAAW,OAAS,EAC1BU,EAAa,QAAQL,CAAe,EAEpCM,EAAgBT,EAAK,OAAW,YAAa,EAAI,EAEjDI,EAAQ,CACV,EAEAG,EAAO,QAAU,SAAUG,EAAG,CAC5B,QAAQ,KAAK,4BAA6BA,CAAC,EAC3CD,EAAgBT,EAAK,OAAW,YAAa,EAAK,CACpD,EAGAO,EAAO,UAAY,SAAUG,EAAG,CAC9B,QAAQ,KAAK,mBAAoBA,EAAE,IAAI,EACvC,IAAMC,EAAO,KAAK,MAAMD,EAAE,IAAI,EAC9B,MAAM,QAAQC,CAAI,EAAIA,EAAK,QAAQC,CAAW,EAAIA,EAAYD,CAAI,CACpE,CACF,CAAC,CACH,CAEA,SAASC,EAAYD,EAAM,CACzB,IAAME,EAAYF,EAAK,CAAC,EAClB,CAACX,EAAKc,EAAQC,CAAO,EAAIJ,EAAK,CAAC,EACrC,OAAQE,EAAW,CACjB,IAAK,OAAQ,CACXf,EAAM,MAAQiB,EACd,KACF,CACA,IAAK,QAAS,CACZ,IAAMC,EAAWlB,EAAM,MAAMgB,CAAM,EAC7BG,EAAOC,EAAMF,EAAUD,CAAO,EACpCN,EAAgBT,EAAKc,EAAQ,QAASG,CAAI,EAC1C,KACF,CACA,QAAS,CACPR,EAAgBT,EAAKc,EAAQD,EAAWE,CAAO,EAC/C,KACF,CACF,CACF,CAEA,SAASN,EAAgBT,EAAKc,EAAQD,EAAWE,EAAS,CACxD,KAAK,YAAY,CAAE,IAAAf,EAAK,OAAAc,EAAQ,UAAAD,EAAW,QAAAE,CAAQ,CAAC,CACtD,CAEA,SAASZ,EAAgB,CAAE,QAAAgB,EAAS,WAAAC,CAAW,EAAG,CAC5CC,EAAa,EACfvB,EAAM,OAAO,KAAK,KAAK,UAAU,CAACqB,EAASC,CAAU,CAAC,CAAC,GAEvDtB,EAAM,WAAW,KAAK,CAAE,QAAAqB,EAAS,WAAAC,CAAW,CAAC,EAC7C,QAAQ,KAAK,wCAAwC,EAEzD,CAEA,SAASC,GAAe,CACtB,OAAOvB,EAAM,OAAO,aAAe,UAAU,IAC/C,CAEA,SAASQ,EAAaL,EAAa,CACjC,IAAMqB,EAAQ,KACRC,EAAO,SAAS,KAChBC,EAAM,aACNC,EAAc,IAAI,OAAO,KAAKxB,CAAW,EAC5C,IAAKyB,GAAQ,GAAGA,CAAG,IAAI,mBAAmBzB,EAAYyB,CAAG,CAAC,CAAC,EAAE,EAC7D,KAAK,GAAG,CAAC,GACZ,MAAO,GAAGJ,CAAK,MAAMC,CAAI,GAAGC,CAAG,GAAGC,CAAW,EAC/C", 6 | "names": ["patch", "rendered", "diff", "staticList", "dynamicList", "patchTemplate", "patchList", "patchDynamic", "zip", "d", "index", "value", "str", "i", "state", "msg", "ref", "queryParams", "connect", "sendMsgToServer", "resolve", "url", "genSocketUrl", "socket", "queuedEvents", "sendMsgToClient", "e", "data", "handleEvent", "eventName", "viewId", "payload", "rendered", "html", "patch", "subject", "attachment", "isSocketOpen", "proto", "host", "uri", "queryString", "key"] 7 | } 8 | -------------------------------------------------------------------------------- /priv/static/assets/js/arizona.min.js: -------------------------------------------------------------------------------- 1 | (()=>{var Y=11;function ie(e,s){var t=s.attributes,n,r,d,o,O;if(!(s.nodeType===Y||e.nodeType===Y)){for(var T=t.length-1;T>=0;T--)n=t[T],r=n.name,d=n.namespaceURI,o=n.value,d?(r=n.localName||r,O=e.getAttributeNS(d,r),O!==o&&(n.prefix==="xmlns"&&(r=n.name),e.setAttributeNS(d,r,o))):(O=e.getAttribute(r),O!==o&&e.setAttribute(r,o));for(var N=e.attributes,u=N.length-1;u>=0;u--)n=N[u],r=n.name,d=n.namespaceURI,d?(r=n.localName||r,s.hasAttributeNS(d,r)||e.removeAttributeNS(d,r)):s.hasAttribute(r)||e.removeAttribute(r)}}var I,se="http://www.w3.org/1999/xhtml",m=typeof document>"u"?void 0:document,fe=!!m&&"content"in m.createElement("template"),ue=!!m&&m.createRange&&"createContextualFragment"in m.createRange();function ce(e){var s=m.createElement("template");return s.innerHTML=e,s.content.childNodes[0]}function le(e){I||(I=m.createRange(),I.selectNode(m.body));var s=I.createContextualFragment(e);return s.childNodes[0]}function de(e){var s=m.createElement("body");return s.innerHTML=e,s.childNodes[0]}function ve(e){return e=e.trim(),fe?ce(e):ue?le(e):de(e)}function F(e,s){var t=e.nodeName,n=s.nodeName,r,d;return t===n?!0:(r=t.charCodeAt(0),d=n.charCodeAt(0),r<=90&&d>=97?t===n.toUpperCase():d<=90&&r>=97?n===t.toUpperCase():!1)}function oe(e,s){return!s||s===se?m.createElement(e):m.createElementNS(s,e)}function he(e,s){for(var t=e.firstChild;t;){var n=t.nextSibling;s.appendChild(t),t=n}return s}function X(e,s,t){e[t]!==s[t]&&(e[t]=s[t],e[t]?e.setAttribute(t,""):e.removeAttribute(t))}var $={OPTION:function(e,s){var t=e.parentNode;if(t){var n=t.nodeName.toUpperCase();n==="OPTGROUP"&&(t=t.parentNode,n=t&&t.nodeName.toUpperCase()),n==="SELECT"&&!t.hasAttribute("multiple")&&(e.hasAttribute("selected")&&!s.selected&&(e.setAttribute("selected","selected"),e.removeAttribute("selected")),t.selectedIndex=-1)}X(e,s,"selected")},INPUT:function(e,s){X(e,s,"checked"),X(e,s,"disabled"),e.value!==s.value&&(e.value=s.value),s.hasAttribute("value")||e.removeAttribute("value")},TEXTAREA:function(e,s){var t=s.value;e.value!==t&&(e.value=t);var n=e.firstChild;if(n){var r=n.nodeValue;if(r==t||!t&&r==e.placeholder)return;n.nodeValue=t}},SELECT:function(e,s){if(!s.hasAttribute("multiple")){for(var t=-1,n=0,r=e.firstChild,d,o;r;)if(o=r.nodeName&&r.nodeName.toUpperCase(),o==="OPTGROUP")d=r,r=d.firstChild;else{if(o==="OPTION"){if(r.hasAttribute("selected")){t=n;break}n++}r=r.nextSibling,!r&&d&&(r=d.nextSibling,d=null)}e.selectedIndex=t}}},B=1,Q=11,Z=3,ee=8;function _(){}function be(e){if(e)return e.getAttribute&&e.getAttribute("id")||e.id}function pe(e){return function(t,n,r){if(r||(r={}),typeof n=="string")if(t.nodeName==="#document"||t.nodeName==="HTML"||t.nodeName==="BODY"){var d=n;n=m.createElement("html"),n.innerHTML=d}else n=ve(n);else n.nodeType===Q&&(n=n.firstElementChild);var o=r.getNodeKey||be,O=r.onBeforeNodeAdded||_,T=r.onNodeAdded||_,N=r.onBeforeElUpdated||_,u=r.onElUpdated||_,l=r.onBeforeNodeDiscarded||_,v=r.onNodeDiscarded||_,b=r.onBeforeElChildrenUpdated||_,p=r.skipFromChildren||_,S=r.addChild||function(a,i){return a.appendChild(i)},y=r.childrenOnly===!0,g=Object.create(null),w=[];function M(a){w.push(a)}function C(a,i){if(a.nodeType===B)for(var h=a.firstChild;h;){var f=void 0;i&&(f=o(h))?M(f):(v(h),h.firstChild&&C(h,i)),h=h.nextSibling}}function D(a,i,h){l(a)!==!1&&(i&&i.removeChild(a),v(a),C(a,h))}function L(a){if(a.nodeType===B||a.nodeType===Q)for(var i=a.firstChild;i;){var h=o(i);h&&(g[h]=i),L(i),i=i.nextSibling}}L(t);function W(a){T(a);for(var i=a.firstChild;i;){var h=i.nextSibling,f=o(i);if(f){var c=g[f];c&&F(i,c)?(i.parentNode.replaceChild(c,i),j(c,i)):W(i)}else W(i);i=h}}function te(a,i,h){for(;i;){var f=i.nextSibling;(h=o(i))?M(h):D(i,a,!0),i=f}}function j(a,i,h){var f=o(i);if(f&&delete g[f],!h){var c=N(a,i);if(c===!1||(c instanceof HTMLElement&&(a=c,L(a)),e(a,i),u(a),b(a,i)===!1))return}a.nodeName!=="TEXTAREA"?re(a,i):$.TEXTAREA(a,i)}function re(a,i){var h=p(a,i),f=i.firstChild,c=a.firstChild,R,U,V,z,x;e:for(;f;){for(z=f.nextSibling,R=o(f);!h&&c;){if(V=c.nextSibling,f.isSameNode&&f.isSameNode(c)){f=z,c=V;continue e}U=o(c);var H=c.nodeType,P=void 0;if(H===f.nodeType&&(H===B?(R?R!==U&&((x=g[R])?V===x?P=!1:(a.insertBefore(x,c),U?M(U):D(c,a,!0),c=x,U=o(c)):P=!1):U&&(P=!1),P=P!==!1&&F(c,f),P&&j(c,f)):(H===Z||H==ee)&&(P=!0,c.nodeValue!==f.nodeValue&&(c.nodeValue=f.nodeValue))),P){f=z,c=V;continue e}U?M(U):D(c,a,!0),c=V}if(R&&(x=g[R])&&F(x,f))h||S(a,x),j(x,f);else{var K=O(f);K!==!1&&(K&&(f=K),f.actualize&&(f=f.actualize(a.ownerDocument||m)),S(a,f),W(f))}f=z,c=V}te(a,c,U);var J=$[a.nodeName];J&&J(a,i)}var A=t,k=A.nodeType,q=n.nodeType;if(!y){if(k===B)q===B?F(t,n)||(v(t),A=he(t,oe(n.nodeName,n.namespaceURI))):A=n;else if(k===Z||k===ee){if(q===k)return A.nodeValue!==n.nodeValue&&(A.nodeValue=n.nodeValue),A;A=n}}if(A===n)v(t);else{if(n.isSameNode&&n.isSameNode(A))return;if(j(A,n,y),w)for(var E=0,ae=w.length;E{function e(u={},l,v){let b=n();typeof l=="function"&&r(b,"connected",l,v);let S={...Object.fromEntries([...new URLSearchParams(window.location.search)]),...u,path:location.pathname};o("connect",{ref:b,queryParams:S})}function s(u,l,v,b,p){let S;typeof b=="function"&&(S=n(),r(S,u,b,p)),o("event",[S,l,u,v])}function t(u,l){let v=!1,b=[];function p(g){return new Promise((w,M)=>{v&&M("alreadyJoined");let C=n();r(C,"join",([D,L])=>{v=D==="ok",v?w(L):M(L)},{once:!0}),o("join",[C,l,u,g])})}function S(g,w){let M=n(),C=r(M,u,g,w);return b.push(C),this}function y(){b.forEach(g=>g()),b.length=0}return Object.freeze({join:p,handle:S,leave:y})}function n(){return Math.random().toString(36).substring(2,9)}function r(u,l,v,b={}){if(typeof v!="function"||typeof b!="object"||Array.isArray(b)){console.error("[Arizona] invalid subscribe data:",{eventName:l,callback:v,opts:b});return}let p=T.get(l);return p||(p=new Map),p.set(u,{ref:u,callback:v,opts:b}),T.set(l,p),N.set(u,l),console.table({action:"subscribed",eventName:l,ref:u,subscribers:T,unsubscribers:N}),function(){d(u)}}function d(u){let l=N.get(u);if(!l)return;let v=T.get(l);v&&(v.delete(u),v.size?T.set(l,v):T.delete(l),N.delete(u),console.table({action:"unsubscribed",eventName:l,ref:u,subscribers:T,unsubscribers:N}))}function o(u,l){O.postMessage({subject:u,attachment:l})}let O=new Worker("assets/js/arizona/worker.js"),T=new Map,N=new Map;return O.addEventListener("message",function(u){console.info("[WebWorker] msg:",u.data);let{ref:l,viewId:v,eventName:b,payload:p}=u.data;switch(b){case"patch":{let y=document.getElementById(v);ne(y,p,{onBeforeElUpdated:(g,w)=>!g.isEqualNode(w)});break}case"leave":{d(p);return}}let S=T.get(b);if(S instanceof Map)if(typeof l=="string"&&l.length){let y=S.get(l);if(!y?.callback)return;y.callback(p)}else S.forEach(function({ref:y,callback:g,opts:w}){g(p),w.once&&d(y)})}),O.addEventListener("error",function(u){console.error("[WebWorker] error:",u)}),Object.freeze({connect:e,send:s,event:t})})();})(); 2 | //# sourceMappingURL=arizona.min.js.map 3 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | {minimum_otp_vsn, "27"}. 2 | 3 | {erl_opts, [ 4 | debug_info, 5 | warnings_as_errors, 6 | warn_missing_spec 7 | ]}. 8 | 9 | {deps, [ 10 | {eqwalizer_support, 11 | {git_subdir, "https://github.com/whatsapp/eqwalizer.git", {branch, "main"}, 12 | "eqwalizer_support"}}, 13 | {cowboy, "2.13.0"} 14 | ]}. 15 | 16 | % See https://github.com/ninenines/cowboy/issues/1670 17 | {overrides, [{override, cowboy, [{deps, [{cowlib, "~> 2.0"}, {ranch, "~> 2.0"}]}]}]}. 18 | 19 | {dialyzer, [ 20 | {plt_apps, all_deps}, 21 | {plt_extra_apps, [ 22 | doctest, 23 | common_test, 24 | eunit, 25 | inets, 26 | syntax_tools 27 | ]}, 28 | {warnings, [ 29 | unknown, 30 | unmatched_returns 31 | ]}, 32 | incremental 33 | ]}. 34 | 35 | {alias, [ 36 | {check, [ 37 | lint, 38 | hank, 39 | xref, 40 | {do, "default as test dialyzer"} 41 | ]}, 42 | {test, [ 43 | eunit, 44 | % Remove options when cover_compiled bug fixed. 45 | {ct, "--cover"}, 46 | cover, 47 | ex_doc 48 | ]}, 49 | {ci, [ 50 | {do, "default as test check"}, 51 | {do, "default as test test"} 52 | ]} 53 | ]}. 54 | 55 | {profiles, [ 56 | {default, [ 57 | {xref_checks, [ 58 | exports_not_used 59 | ]} 60 | ]}, 61 | {test, [ 62 | % Cover is enabled only for ct because the cover_compiled 63 | % bug in the `code:get_doc`. After fixed, we can enable it again. 64 | % See https://github.com/erlang/otp/pull/9433 65 | %{cover_enabled, true}, 66 | {cover_opts, [verbose]}, 67 | {erl_opts, [ 68 | debug_info, 69 | nowarn_missing_spec, 70 | warnings_as_errors 71 | ]}, 72 | {extra_src_dirs, [{"test", [{recursive, true}]}]}, 73 | {deps, [{doctest, "~> 0.12"}]}, 74 | {xref_checks, []} 75 | ]} 76 | ]}. 77 | 78 | {project_plugins, [ 79 | {rebar3_hex, "7.0.8"}, 80 | {erlfmt, "1.6.1"}, 81 | {rebar3_lint, "4.0.0"}, 82 | {rebar3_hank, "1.4.1"}, 83 | {rebar3_ex_doc, "0.2.25"} 84 | ]}. 85 | 86 | {shell, [{apps, [arizona]}]}. 87 | 88 | {erlfmt, [ 89 | write, 90 | {files, [ 91 | "elvis.config", 92 | "rebar.config", 93 | "src/*.app.src", 94 | "src/**/*.erl", 95 | "test/**/*.erl" 96 | ]} 97 | ]}. 98 | 99 | {ex_doc, [ 100 | {extras, [ 101 | "README.md", 102 | "SECURITY.md", 103 | "CODE_OF_CONDUCT.md", 104 | "CONTRIBUTING.md", 105 | "LICENSE.md", 106 | "CHANGELOG.md" 107 | ]}, 108 | {main, "README.md"}, 109 | {api_reference, false}, 110 | {source_url, "https://github.com/arizona-framework/arizona"}, 111 | {prefix_ref_vsn_with_v, false} 112 | ]}. 113 | 114 | {hex, [{doc, #{provider => ex_doc}}]}. 115 | 116 | {eunit_opts, [ 117 | no_tty, 118 | {report, {doctest_eunit_report, [{print_depth, 200}]}} 119 | ]}. 120 | -------------------------------------------------------------------------------- /rebar.lock: -------------------------------------------------------------------------------- 1 | {"1.2.0", 2 | [{<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.13.0">>},0}, 3 | {<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.14.0">>},1}, 4 | {<<"eqwalizer_support">>, 5 | {git_subdir,"https://github.com/whatsapp/eqwalizer.git", 6 | {ref,"36bb1a2809a831372a68556795806bc34c15e3d6"}, 7 | "eqwalizer_support"}, 8 | 0}, 9 | {<<"ranch">>,{pkg,<<"ranch">>,<<"2.2.0">>},1}]}. 10 | [ 11 | {pkg_hash,[ 12 | {<<"cowboy">>, <<"09D770DD5F6A22CC60C071F432CD7CB87776164527F205C5A6B0F24FF6B38990">>}, 13 | {<<"cowlib">>, <<"623791C56C1CC9DF54A71A9C55147A401549917F00A2E48A6AE12B812C586CED">>}, 14 | {<<"ranch">>, <<"25528F82BC8D7C6152C57666CA99EC716510FE0925CB188172F41CE93117B1B0">>}]}, 15 | {pkg_hash_ext,[ 16 | {<<"cowboy">>, <<"E724D3A70995025D654C1992C7B11DBFEA95205C047D86FF9BF1CDA92DDC5614">>}, 17 | {<<"cowlib">>, <<"0AF652D1550C8411C3B58EED7A035A7FB088C0B86AFF6BC504B0BC3B7F791AA2">>}, 18 | {<<"ranch">>, <<"FA0B99A1780C80218A4197A59EA8D3BDAE32FBFF7E88527D7D8A4787EFF4F8E7">>}]} 19 | ]. 20 | -------------------------------------------------------------------------------- /src/arizona.app.src: -------------------------------------------------------------------------------- 1 | {application, arizona, [ 2 | {description, "Arizona is a Web Framework for Erlang"}, 3 | {vsn, semver}, 4 | {mod, {arizona_app, []}}, 5 | {applications, [ 6 | % OTP dependencies 7 | kernel, 8 | stdlib, 9 | syntax_tools, 10 | % External dependencies 11 | cowboy 12 | ]}, 13 | {licenses, ["Apache-2.0"]}, 14 | {links, [{"GitHub", "https://github.com/arizona-framework/arizona"}]} 15 | ]}. 16 | -------------------------------------------------------------------------------- /src/arizona_app.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_app). 2 | -moduledoc false. 3 | -behaviour(application). 4 | 5 | %% -------------------------------------------------------------------- 6 | %% Behaviour (application) exports 7 | %% -------------------------------------------------------------------- 8 | 9 | -export([start/2]). 10 | -export([stop/1]). 11 | 12 | %% -------------------------------------------------------------------- 13 | %% Behaviour (application) callbacks 14 | %% -------------------------------------------------------------------- 15 | 16 | -spec start(StartType, StartArgs) -> StartRet when 17 | StartType :: application:start_type(), 18 | StartArgs :: term(), 19 | StartRet :: {ok, pid()} | {error, term()}. 20 | start(_StartType, _StartArgs) -> 21 | maybe 22 | {ok, _ServerPid} ?= arizona_server:start(arizona_config:endpoint()), 23 | {ok, _SupPid} ?= arizona_sup:start_link() 24 | else 25 | {error, Reason} -> 26 | {error, Reason} 27 | end. 28 | 29 | -spec stop(State) -> ok when 30 | State :: term(). 31 | stop(_State) -> 32 | ok. 33 | -------------------------------------------------------------------------------- /src/arizona_component.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_component). 2 | -moduledoc ~""" 3 | The `arizona_component` module provides functionality for rendering 4 | **stateless components** in the Arizona framework. 5 | 6 | Components are reusable UI elements that can be embedded within views. 7 | Unlike views, components do not maintain their own state; instead, they 8 | rely on the state of the parent view that calls them. This makes components 9 | lightweight and easy to reuse across different parts of an application. 10 | 11 | ## Usage 12 | 13 | Components are typically used to encapsulate reusable UI elements, such as 14 | buttons, forms, or cards. They are rendered within a view's template and 15 | inherit their state from the parent view. 16 | 17 | Here’s how components fit into the Arizona framework: 18 | 19 | - `Stateless`: Components do not maintain their own state. They rely on the 20 | state of the parent view. 21 | - `Reusable`: Components can be used across multiple views or even multiple 22 | times within the same view. 23 | - `Lightweight`: Since they are stateless, components are simple to implement 24 | and efficient to render. 25 | """. 26 | 27 | %% -------------------------------------------------------------------- 28 | %% API function exports 29 | %% -------------------------------------------------------------------- 30 | 31 | -export([render/3]). 32 | 33 | %% -------------------------------------------------------------------- 34 | %% API function definitions 35 | %% -------------------------------------------------------------------- 36 | 37 | -doc ~""" 38 | Renders a stateless component by delegating to the specified function (`Fun`) 39 | in the component module (`Mod`). 40 | 41 | The component's state is derived from the parent view's state (`View`), making 42 | it stateless and reusable. 43 | 44 | ## Parameters 45 | 46 | - `Mod`: The module name of the component. This must be a valid atom. 47 | - `Fun`: The function name (`t:atom/0`) within the component module that handles 48 | rendering. 49 | - `View`: The current view state (`t:arizona:view/0`) of the parent view. This is 50 | passed to the component to access bindings or other data. 51 | 52 | ## Returns 53 | 54 | The rendered component template as `t:arizona:rendered_component_template`. 55 | """. 56 | -spec render(Mod, Fun, View) -> Token when 57 | Mod :: module(), 58 | Fun :: atom(), 59 | View :: arizona:view(), 60 | Token :: arizona:rendered_component_template(). 61 | render(Mod, Fun, View) when is_atom(Mod), is_atom(Fun) -> 62 | erlang:apply(Mod, Fun, [View]). 63 | -------------------------------------------------------------------------------- /src/arizona_config.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_config). 2 | 3 | %% -------------------------------------------------------------------- 4 | %% API function exports 5 | %% -------------------------------------------------------------------- 6 | 7 | -export([static_dir/0]). 8 | -export([endpoint/0]). 9 | 10 | %% -------------------------------------------------------------------- 11 | %% API function definitions 12 | %% -------------------------------------------------------------------- 13 | 14 | -spec static_dir() -> file:filename_all(). 15 | static_dir() -> 16 | get_env(static_dir, "static"). 17 | 18 | -spec endpoint() -> arizona_server:opts(). 19 | endpoint() -> 20 | get_env(endpoint, #{}). 21 | 22 | %% -------------------------------------------------------------------- 23 | %% Private functions 24 | %% -------------------------------------------------------------------- 25 | 26 | get_env(Key, Default) -> 27 | application:get_env(arizona, Key, Default). 28 | -------------------------------------------------------------------------------- /src/arizona_diff.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_diff). 2 | 3 | %% -------------------------------------------------------------------- 4 | %% Support function exports 5 | %% -------------------------------------------------------------------- 6 | 7 | -export([diff/4]). 8 | -export([diff/6]). 9 | 10 | % 11 | 12 | -ignore_xref([diff/6]). 13 | 14 | %% -------------------------------------------------------------------- 15 | %% Types (and their exports) 16 | %% -------------------------------------------------------------------- 17 | 18 | -type diff() :: [{index(), arizona_renderer:rendered_value() | diff()}]. 19 | -export_type([diff/0]). 20 | 21 | -type index() :: non_neg_integer(). 22 | -export_type([index/0]). 23 | 24 | -type var() :: atom(). 25 | -export_type([var/0]). 26 | 27 | -type token_callback() :: fun(() -> arizona_renderer:token()). 28 | -export_type([token_callback/0]). 29 | 30 | -type options() :: #{force_changed => boolean()}. 31 | -export_type([options/0]). 32 | 33 | %% -------------------------------------------------------------------- 34 | %% Support function definitions 35 | %% -------------------------------------------------------------------- 36 | 37 | -spec diff(Payload, Index, View0, Socket0) -> {View1, Socket1} when 38 | Payload :: Token | RenderedValue, 39 | Token :: arizona_renderer:token(), 40 | RenderedValue :: arizona_renderer:rendered_value(), 41 | Index :: index(), 42 | View0 :: arizona_view:view(), 43 | Socket0 :: arizona_socket:socket(), 44 | View1 :: arizona_view:view(), 45 | Socket1 :: arizona_socket:socket(). 46 | diff({view_template, _Static, Dynamic}, _Index, View, Socket) -> 47 | diff_view_template(View, Socket, Dynamic); 48 | diff({component_template, _Static, Dynamic}, _Index, View, Socket) -> 49 | diff_component_template(View, Socket, Dynamic); 50 | diff({nested_template, _Static, Dynamic}, Index, ParentView, Socket) -> 51 | diff_nested_template(ParentView, Socket, Dynamic, Index); 52 | diff({list_template, _Static, DynamicCallback, List}, Index, ParentView, Socket) -> 53 | diff_list_template(ParentView, Socket, DynamicCallback, List, Index); 54 | diff({view, Mod, Bindings}, Index, ParentView, Socket) -> 55 | diff_view(ParentView, Socket, Mod, Bindings, Index); 56 | diff({component, Mod, Fun, Bindings}, Index, ParentView, Socket) -> 57 | diff_component(ParentView, Socket, Mod, Fun, Bindings, Index); 58 | diff({list, _Static, DynamicList}, Index, ParentView, Socket) -> 59 | diff_list(ParentView, Socket, DynamicList, Index); 60 | diff(Diff, Index, View0, Socket) when is_binary(Diff); is_list(Diff) -> 61 | View = arizona_view:put_diff(Index, Diff, View0), 62 | {View, Socket}. 63 | 64 | -spec diff(Index, Vars, TokenCallback, ViewAcc0, Socket0, Opts) -> {ViewAcc1, Socket1} when 65 | Index :: index(), 66 | Vars :: [var()], 67 | TokenCallback :: token_callback(), 68 | ViewAcc0 :: arizona_view:view(), 69 | Socket0 :: arizona_socket:socket(), 70 | Opts :: options(), 71 | ViewAcc1 :: arizona_view:view(), 72 | Socket1 :: arizona_socket:socket(). 73 | diff(Index, Vars, TokenCallback, ViewAcc, Socket, Opts) -> 74 | case maps:get(force_changed, Opts, false) of 75 | true -> 76 | diff_1(Index, TokenCallback, ViewAcc, Socket); 77 | false -> 78 | Bindings = arizona_view:bindings(ViewAcc), 79 | ChangedBindings = arizona_view:changed_bindings(ViewAcc), 80 | case changed(Bindings, ChangedBindings, Vars) of 81 | true -> 82 | diff_1(Index, TokenCallback, ViewAcc, Socket); 83 | false -> 84 | {ViewAcc, Socket} 85 | end 86 | end. 87 | 88 | %% -------------------------------------------------------------------- 89 | %% Private functions 90 | %% -------------------------------------------------------------------- 91 | 92 | diff_1(Index, TokenCallback, ViewAcc0, Socket0) -> 93 | Token = erlang:apply(TokenCallback, []), 94 | {ViewAcc, Socket} = diff(Token, Index, ViewAcc0, Socket0), 95 | {ViewAcc, Socket}. 96 | 97 | changed(Bindings, ChangedBindings, Vars) -> 98 | lists:any( 99 | fun(Var) -> 100 | case ChangedBindings of 101 | #{Var := Value} -> 102 | Value =/= maps:get(Var, Bindings); 103 | #{} -> 104 | false 105 | end 106 | end, 107 | Vars 108 | ). 109 | 110 | diff_view_template(View0, Socket0, Dynamic) -> 111 | {View1, Socket1} = diff_dynamic(Dynamic, View0, Socket0, #{}), 112 | View = arizona_view:merge_changed_bindings(View1), 113 | Socket = arizona_socket:put_view(View, Socket1), 114 | {View, Socket}. 115 | 116 | diff_component_template(View0, Socket0, Dynamic) -> 117 | {View, Socket} = diff_dynamic(Dynamic, View0, Socket0, #{}), 118 | {View, Socket}. 119 | 120 | diff_nested_template(ParentView0, Socket0, Dynamic, Index) -> 121 | Bindings = arizona_view:bindings(ParentView0), 122 | ChangedBindings = arizona_view:changed_bindings(ParentView0), 123 | View0 = arizona_view:new(undefined, Bindings, ChangedBindings, [], [], []), 124 | {View, Socket} = diff_dynamic(Dynamic, View0, Socket0, #{}), 125 | Diff = arizona_view:diff(View), 126 | ParentView = arizona_view:put_diff(Index, Diff, ParentView0), 127 | {ParentView, Socket}. 128 | 129 | diff_list_template(ParentView0, Socket, Callback, List, Index) -> 130 | View = arizona_view:new(arizona_view:bindings(ParentView0)), 131 | Diff = diff_dynamic_list_callback(List, Callback, Index, View, Socket), 132 | ParentView = arizona_view:put_diff(Index, Diff, ParentView0), 133 | {ParentView, Socket}. 134 | 135 | diff_dynamic_list_callback([], _Callback, _Index, _View, _Socket) -> 136 | []; 137 | diff_dynamic_list_callback([Item | T], Callback, Index, View, Socket) -> 138 | Dynamic = erlang:apply(Callback, [Item]), 139 | {DiffView, _Socket} = diff(Dynamic, Index, View, Socket), 140 | [ 141 | arizona_view:diff(DiffView) 142 | | diff_dynamic_list_callback(T, Callback, Index, View, Socket) 143 | ]. 144 | 145 | diff_view(ParentView, Socket, Mod, Bindings, Index) -> 146 | ViewId = maps:get(id, Bindings), 147 | case arizona_socket:get_view(ViewId, Socket) of 148 | {ok, View0} -> 149 | View = arizona_view:set_changed_bindings(Bindings, View0), 150 | diff_view_1(ParentView, Socket, Mod, Bindings, Index, View, ViewId); 151 | error -> 152 | mount_view(ParentView, Socket, Mod, Bindings, Index) 153 | end. 154 | 155 | diff_view_1(ParentView0, Socket0, Mod, NewBindings, Index, View0, ViewId) -> 156 | {view_template, _Static, Dynamic} = arizona_view:render(View0), 157 | OldBindings = arizona_view:bindings(View0), 158 | ChangedBindings = view_changed_bindings(OldBindings, NewBindings), 159 | View1 = arizona_view:set_changed_bindings(ChangedBindings, View0), 160 | {View2, Socket1} = diff_dynamic(Dynamic, View1, Socket0, #{}), 161 | case ChangedBindings of 162 | #{id := _NewViewId} -> 163 | Socket2 = arizona_socket:remove_view(ViewId, Socket1), 164 | mount_view(ParentView0, Socket2, Mod, NewBindings, Index); 165 | #{} -> 166 | View3 = arizona_view:merge_changed_bindings(View2), 167 | Diff = arizona_view:diff(View3), 168 | ParentView = arizona_view:put_diff(Index, Diff, ParentView0), 169 | View = arizona_view:set_diff([], View3), 170 | Socket = arizona_socket:put_view(View, Socket1), 171 | {ParentView, Socket} 172 | end. 173 | 174 | view_changed_bindings(OldBindings, NewBindings) -> 175 | maps:filter( 176 | fun(Key, Value) -> 177 | maps:get(Key, OldBindings) =/= Value 178 | end, 179 | NewBindings 180 | ). 181 | 182 | mount_view(ParentView0, Socket0, Mod, Bindings, Index) -> 183 | case arizona_view:mount(Mod, Bindings, Socket0) of 184 | {ok, View0} -> 185 | Token = arizona_view:render(View0), 186 | Socket1 = arizona_socket:set_render_context(render, Socket0), 187 | {View1, Socket2} = arizona_renderer:render(Token, View0, ParentView0, Socket1), 188 | Rendered = arizona_view:tmp_rendered(View1), 189 | ParentView = arizona_view:put_diff(Index, Rendered, ParentView0), 190 | View2 = arizona_view:set_tmp_rendered([], View1), 191 | View = arizona_view:set_diff([], View2), 192 | Socket3 = arizona_socket:put_view(View, Socket2), 193 | Socket = arizona_socket:set_render_context(diff, Socket3), 194 | {ParentView, Socket}; 195 | ignore -> 196 | {ParentView0, Socket0} 197 | end. 198 | 199 | diff_component(ParentView0, Socket0, Mod, Fun, Bindings, Index) -> 200 | View0 = arizona_view:new(Bindings), 201 | {component_template, _Static, Dynamic} = arizona_component:render(Mod, Fun, View0), 202 | {View, Socket} = diff_dynamic(Dynamic, View0, Socket0, #{force_changed => true}), 203 | Diff = arizona_view:diff(View), 204 | ParentView = arizona_view:put_diff(Index, Diff, ParentView0), 205 | {ParentView, Socket}. 206 | 207 | diff_list(ParentView0, Socket, DynamicList0, Index) -> 208 | View = arizona_view:new(arizona_view:bindings(ParentView0)), 209 | Diff = diff_dynamic_list(DynamicList0, View, Socket), 210 | ParentView = arizona_view:put_diff(Index, Diff, ParentView0), 211 | {ParentView, Socket}. 212 | 213 | diff_dynamic_list([], _View, _Socket) -> 214 | []; 215 | diff_dynamic_list([Dynamic | T], View, Socket) -> 216 | {DiffView, _Socket} = diff_dynamic(Dynamic, View, Socket, #{force_changed => true}), 217 | [arizona_view:diff(DiffView) | diff_dynamic_list(T, View, Socket)]. 218 | 219 | diff_dynamic([], View, Socket, _Opts) -> 220 | {View, Socket}; 221 | diff_dynamic([Callback | T], View0, Socket0, Opts) -> 222 | {View, Socket} = erlang:apply(Callback, [View0, Socket0, Opts]), 223 | diff_dynamic(T, View, Socket, Opts). 224 | -------------------------------------------------------------------------------- /src/arizona_js.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_js). 2 | 3 | %% -------------------------------------------------------------------- 4 | %% API function exports 5 | %% -------------------------------------------------------------------- 6 | 7 | -export([send_event/3]). 8 | 9 | %% -------------------------------------------------------------------- 10 | %% API function definitions 11 | %% -------------------------------------------------------------------- 12 | 13 | -spec send_event(ViewId, EventName, Payload) -> Js when 14 | ViewId :: arizona_view:id(), 15 | EventName :: binary(), 16 | Payload :: dynamic(), 17 | Js :: binary(). 18 | send_event(ViewId, EventName, Payload) when is_binary(ViewId), is_binary(EventName) -> 19 | <<"arizona.send("", EventName/binary, "", "", ViewId/binary, "", ", 20 | (encode(Payload))/binary, ")">>. 21 | 22 | %% -------------------------------------------------------------------- 23 | %% Private functions 24 | %% -------------------------------------------------------------------- 25 | 26 | %% FIXME: HTML encoding. 27 | encode(Bin) when is_binary(Bin) -> 28 | <<""", Bin/binary, """>>; 29 | encode(Payload) -> 30 | iolist_to_binary(json:encode(Payload)). 31 | -------------------------------------------------------------------------------- /src/arizona_layout.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_layout). 2 | -moduledoc ~""" 3 | The `arizona_layout` behaviour defines the interface for creating layouts 4 | in the Arizona framework. 5 | 6 | Layouts are used to wrap views in a consistent structure, such as a common 7 | HTML template with headers, footers, and navigation. Unlike views, layouts 8 | **do not maintain state** and are **rendered only once** when the view is 9 | first mounted. Subsequent updates and re-renders only affect the wrapped view, 10 | not the layout. 11 | 12 | To implement a layout in Arizona, a module must define the following callbacks: 13 | 14 | - `c:mount/2`: Initializes the layout with bindings and a WebSocket connection. 15 | - `c:render/1`: Renders the layout's template **once**, wrapping the view's content. 16 | 17 | Layouts provide a way to create consistent UI structures across multiple views, 18 | such as headers, footers, or navigation bars. 19 | """. 20 | 21 | %% -------------------------------------------------------------------- 22 | %% Callback support function exports 23 | %% -------------------------------------------------------------------- 24 | 25 | -export([mount/3]). 26 | -export([render/1]). 27 | 28 | %% -------------------------------------------------------------------- 29 | %% Callback definitions 30 | %% -------------------------------------------------------------------- 31 | 32 | -doc ~""" 33 | Initializes the layout when it is first rendered. 34 | 35 | This callback sets up the layout's bindings and prepares it to wrap the view's 36 | content. The layout is rendered only once, and its state remains static throughout 37 | the lifecycle of the view. 38 | 39 | ## Parameters 40 | 41 | - `Bindings`: A map (`t:arizona:bindings/0`) containing the initial data for the layout. 42 | - `Socket`: The WebSocket connection (`t:arizona:socket/0`) associated with the layout. 43 | 44 | ## Returns 45 | 46 | The initialized view state (`t:arizona:view/0`) for the layout. 47 | """. 48 | -callback mount(Bindings, Socket) -> View when 49 | Bindings :: arizona:bindings(), 50 | Socket :: arizona:socket(), 51 | View :: arizona:view(). 52 | 53 | -doc ~""" 54 | Renders the layout's template, wrapping the view's content. 55 | 56 | This callback is invoked only once when the layout is first rendered. 57 | Subsequent updates and re-renders only affect the wrapped view, not the layout. 58 | 59 | ## Parameters 60 | 61 | - `View`: The current view state (`t:arizona:view/0`), which includes the layout's 62 | bindings and the view's content. 63 | 64 | ## Returns 65 | 66 | The rendered template as `t:arizona:rendered_layout_template/0`. 67 | """. 68 | -callback render(View) -> Rendered when 69 | View :: arizona:view(), 70 | Rendered :: arizona:rendered_layout_template(). 71 | 72 | %% -------------------------------------------------------------------- 73 | %% Callback support function definitions 74 | %% -------------------------------------------------------------------- 75 | 76 | -doc ~""" 77 | Delegates to the `c:mount/2` callback defined in the layout module (`Mod`). 78 | 79 | This function is used internally by Arizona to initialize the layout. 80 | 81 | ## Parameters 82 | 83 | - `Mod`: The module name of the layout being mounted. This must be a valid atom. 84 | - `Bindings`: A map (`t:arizona:bindings/0`) containing the initial data for the layout. 85 | - `Socket`: The WebSocket connection (`t:arizona:socket/0`) associated with the layout. 86 | 87 | ## Returns 88 | 89 | The initialized view state (`t:arizona:view/0`) for the layout. 90 | """. 91 | -spec mount(Mod, Bindings, Socket) -> View when 92 | Mod :: module(), 93 | Bindings :: arizona:bindings(), 94 | Socket :: arizona:socket(), 95 | View :: arizona:view(). 96 | mount(Mod, Bindings, Socket) when is_atom(Mod), is_map(Bindings) -> 97 | erlang:apply(Mod, mount, [Bindings, Socket]). 98 | 99 | -doc ~""" 100 | Delegates to the `c:render/1` callback defined in the layout module (`Mod`). 101 | 102 | This function is used internally by Arizona to render the layout's template once 103 | when the view is first mounted. 104 | 105 | ## Parameters 106 | 107 | - `View`: The current view state (`t:arizona:view/0`), which includes the layout's 108 | bindings and the view's content. 109 | 110 | ## Returns 111 | 112 | The rendered template as `t:arizona:rendered_layout_template/0`. 113 | """. 114 | -spec render(View) -> Rendered when 115 | View :: arizona:view(), 116 | Rendered :: arizona:rendered_layout_template(). 117 | render(View) -> 118 | Mod = arizona_view:module(View), 119 | erlang:apply(Mod, render, [View]). 120 | -------------------------------------------------------------------------------- /src/arizona_parser.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_parser). 2 | 3 | %% -------------------------------------------------------------------- 4 | %% API function exports 5 | %% -------------------------------------------------------------------- 6 | 7 | -export([parse/2]). 8 | 9 | %% -------------------------------------------------------------------- 10 | %% Types (and their exports) 11 | %% -------------------------------------------------------------------- 12 | 13 | -type options() :: #{ 14 | render_context => from_socket | render | none, 15 | bindings => erl_eval:binding_struct() 16 | }. 17 | -export_type([options/0]). 18 | 19 | %% -------------------------------------------------------------------- 20 | %% Doctests 21 | %% -------------------------------------------------------------------- 22 | 23 | -ifdef(TEST). 24 | -include_lib("eunit/include/eunit.hrl"). 25 | doctest_test() -> doctest:module(?MODULE). 26 | -endif. 27 | 28 | %% -------------------------------------------------------------------- 29 | %% API function definitions 30 | %% -------------------------------------------------------------------- 31 | 32 | -doc ~""" 33 | Parses scanned template tokens. 34 | 35 | ## Result 36 | 37 | It returns a `{Static, Dynamic}` tuple where Static is an AST list of 38 | binaries and the Dynamic is an AST list of Erlang terms. 39 | """. 40 | -spec parse(Tokens, Opts) -> {Static, Dynamic} when 41 | Tokens :: [Token], 42 | Opts :: options(), 43 | Token :: arizona_scanner:token(), 44 | Static :: [tuple()], 45 | Dynamic :: [tuple() | [tuple()]]. 46 | parse(Tokens0, Opts) when is_list(Tokens0), is_map(Opts) -> 47 | Tokens1 = drop_comments(Tokens0), 48 | Tokens = add_empty_text_tokens(Tokens1), 49 | {HtmlTokens, ErlTokens} = tokens_partition(Tokens), 50 | Static = [scan_and_parse_html_token_to_ast(HtmlToken) || HtmlToken <- HtmlTokens], 51 | ErlTokensEnum = lists:enumerate(0, ErlTokens), 52 | RenderContext = maps:get(render_context, Opts, from_socket), 53 | Bindings = maps:get(bindings, Opts, []), 54 | Dynamic = [ 55 | scan_and_parse_erlang_token_to_ast(ErlToken, Index, RenderContext, Bindings) 56 | || {Index, ErlToken} <- ErlTokensEnum 57 | ], 58 | {Static, Dynamic}. 59 | 60 | %% -------------------------------------------------------------------- 61 | %% Private functions 62 | %% -------------------------------------------------------------------- 63 | 64 | % Comments are not rendered, so they're dropped. 65 | drop_comments(Tokens) -> 66 | [Token || {Category, _Location, _Content} = Token <- Tokens, Category =/= comment]. 67 | 68 | % Dummy empty texts are required for the correct zip between Static and Dynamic 69 | % when consecutive Erlang expressions are found. 70 | add_empty_text_tokens([]) -> 71 | []; 72 | add_empty_text_tokens([{erlang, _, _} = ExprA, {erlang, _, _} = ExprB | T]) -> 73 | [ExprA, {html, {0, 0}, ~""} | add_empty_text_tokens([ExprB | T])]; 74 | add_empty_text_tokens([H | T]) -> 75 | [H | add_empty_text_tokens(T)]. 76 | 77 | % Html tokens are static, so the partition result is {Static, Dynamic}. 78 | tokens_partition(Tokens) -> 79 | lists:partition(fun(Token) -> element(1, Token) =:= html end, Tokens). 80 | 81 | scan_and_parse_html_token_to_ast({html, _Loc, Text0}) -> 82 | Text = quote_text(Text0), 83 | scan_and_parse_to_ast(<<"<<", $", Text/binary, $", "/utf8>>">>). 84 | 85 | scan_and_parse_erlang_token_to_ast({erlang, _Loc, Expr0}, Index0, RenderContext, Bindings) -> 86 | Index = integer_to_binary(Index0), 87 | Vars = vars_to_binary(expr_vars(Expr0)), 88 | Form = arizona_transform:transform(merl:quote(Expr0), Bindings), 89 | Expr = norm_expr(RenderContext, form_to_iolist(Form), Index, Vars), 90 | scan_and_parse_to_ast(iolist_to_binary(Expr)). 91 | 92 | form_to_iolist(Forms) when is_list(Forms) -> 93 | erl_pp:exprs(Forms); 94 | form_to_iolist(Form) when is_tuple(Form) -> 95 | erl_pp:expr(Form). 96 | 97 | norm_expr(from_socket, Expr, Index, Vars) -> 98 | [ 99 | ["fun(ViewAcc, Socket, Opts) ->\n"], 100 | [" case arizona_socket:render_context(Socket) of\n"], 101 | [" render ->\n"], 102 | [" arizona_renderer:render(", Expr, ", View, ViewAcc, Socket);\n"], 103 | [" diff ->\n"], 104 | [" Index = ", Index, ",\n"], 105 | [" Vars = ", Vars, ",\n"], 106 | [" TokenCallback = fun() -> ", Expr, " end,\n"], 107 | [" arizona_diff:diff(Index, Vars, TokenCallback, ViewAcc, Socket, Opts)\n"], 108 | [" end\n"], 109 | ["end"] 110 | ]; 111 | norm_expr(render, Expr, _Index, _Vars) -> 112 | [ 113 | ["fun(ViewAcc, Socket, Opts) ->\n"], 114 | [" arizona_renderer:render(", Expr, ", View, ViewAcc, Socket)\n"], 115 | ["end"] 116 | ]; 117 | norm_expr(none, Expr, _Index, _Vars) -> 118 | Expr. 119 | 120 | % The text must be quoted to transform it in an Erlang AST form, for example: 121 | % 122 | % ~""" 123 | % f"o\"o 124 | % """. 125 | % 126 | % To produce a binary it must be <<"f\"o\\\"o">>. 127 | quote_text(<<>>) -> 128 | <<>>; 129 | quote_text(<<$\\, $", Rest/binary>>) -> 130 | <<$\\, $\\, $\\, $", (quote_text(Rest))/binary>>; 131 | quote_text(<<$", Rest/binary>>) -> 132 | <<$\\, $", (quote_text(Rest))/binary>>; 133 | quote_text(<>) -> 134 | <>. 135 | 136 | scan_and_parse_to_ast(Text) -> 137 | Str = binary_to_list(<>), 138 | {ok, Tokens, _EndLoc} = erl_scan:string(Str), 139 | {ok, [Ast]} = erl_parse:parse_exprs(Tokens), 140 | Ast. 141 | 142 | expr_vars(Expr) -> 143 | case 144 | re:run( 145 | Expr, 146 | "arizona:get_binding\\(([a-z][a-zA-Z_@]*|'(.*?)')", 147 | [global, {capture, all_but_first, binary}] 148 | ) 149 | of 150 | {match, Vars0} -> 151 | Vars = lists:flatten([pick_quoted_var(List) || List <- Vars0]), 152 | lists:usort(lists:map(fun binary_to_atom/1, Vars)); 153 | nomatch -> 154 | [] 155 | end. 156 | 157 | pick_quoted_var([<<$', _/binary>> = Var | _T]) -> 158 | Var; 159 | pick_quoted_var([Var]) -> 160 | iolist_to_binary([$', Var, $']); 161 | pick_quoted_var([_Var | T]) -> 162 | pick_quoted_var(T). 163 | 164 | vars_to_binary(Vars0) -> 165 | Vars = lists:map(fun atom_to_binary/1, Vars0), 166 | iolist_to_binary([$[, lists:join(", ", Vars), $]]). 167 | -------------------------------------------------------------------------------- /src/arizona_pubsub.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_pubsub). 2 | 3 | %% -------------------------------------------------------------------- 4 | %% API function exports 5 | %% -------------------------------------------------------------------- 6 | 7 | -export([start_link/0]). 8 | -export([subscribe/2]). 9 | -export([publish/4]). 10 | 11 | % 12 | 13 | -ignore_xref([start_link/0]). 14 | 15 | %% -------------------------------------------------------------------- 16 | %% API function definitions 17 | %% -------------------------------------------------------------------- 18 | 19 | -spec start_link() -> {ok, pid()} | {error, dynamic()}. 20 | start_link() -> 21 | pg:start_link(?MODULE). 22 | 23 | -spec subscribe(EventName, Subscriber) -> ok when 24 | EventName :: arizona:event_name(), 25 | Subscriber :: pid(). 26 | subscribe(EventName, Subscriber) when is_binary(EventName), is_pid(Subscriber) -> 27 | pg:join(?MODULE, EventName, Subscriber). 28 | 29 | -spec publish(ViewId, EventName, Payload, Sender) -> ok when 30 | ViewId :: arizona_view:id(), 31 | EventName :: arizona:event_name(), 32 | Payload :: arizona:event_payload(), 33 | Sender :: pid(). 34 | publish(ViewId, EventName, Payload, Sender) when 35 | is_binary(ViewId), 36 | is_binary(EventName), 37 | is_pid(Sender) 38 | -> 39 | _ = [ 40 | Pid ! {broadcast, Sender, ViewId, EventName, Payload} 41 | || Pid <- members(EventName) 42 | ], 43 | ok. 44 | 45 | %% -------------------------------------------------------------------- 46 | %% Private functions 47 | %% -------------------------------------------------------------------- 48 | 49 | -spec members(EventName) -> [Member] when 50 | EventName :: arizona:event_name(), 51 | Member :: pid(). 52 | members(EventName) -> 53 | pg:get_members(?MODULE, EventName). 54 | -------------------------------------------------------------------------------- /src/arizona_renderer.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_renderer). 2 | 3 | %% -------------------------------------------------------------------- 4 | %% API function exports 5 | %% -------------------------------------------------------------------- 6 | 7 | -export([render_view_template/2]). 8 | -export([render_component_template/2]). 9 | -export([render_nested_template/1]). 10 | -export([render_view/2]). 11 | -export([render_component/3]). 12 | -export([render_if_true/2]). 13 | -export([render_list/2]). 14 | 15 | %% -------------------------------------------------------------------- 16 | %% Support function exports 17 | %% -------------------------------------------------------------------- 18 | 19 | -export([render/4]). 20 | -export([render_nested_template/2]). 21 | -export([render_layout/6]). 22 | 23 | % 24 | 25 | -ignore_xref([render_nested_template/2]). 26 | 27 | %% -------------------------------------------------------------------- 28 | %% Types (and their exports) 29 | %% -------------------------------------------------------------------- 30 | 31 | -type static_list() :: [binary()]. 32 | -export_type([static_list/0]). 33 | 34 | -type dynamic_list() :: [ 35 | fun( 36 | ( 37 | ViewAcc :: arizona_view:view(), 38 | Socket :: arizona_socket:socket(), 39 | DiffOpts :: arizona_diff:options() 40 | ) -> binary() 41 | ) 42 | ]. 43 | -export_type([dynamic_list/0]). 44 | 45 | -type token() :: 46 | {view_template, Static :: static_list(), Dynamic :: dynamic_list()} 47 | | {component_template, Static :: static_list(), Dynamic :: dynamic_list()} 48 | | {nested_template, Static :: static_list(), Dynamic :: dynamic_list()} 49 | | {list_template, Static :: static_list(), Dynamic :: dynamic_list()} 50 | | {view, Mod :: module(), Bindings :: arizona_view:bindings()} 51 | | {component, Mod :: module(), Fun :: atom(), Bindings :: arizona_view:bindings()} 52 | | {list, Static :: static_list(), Dynamic :: dynamic_list()}. 53 | -export_type([token/0]). 54 | 55 | -type rendered() :: 56 | [rendered_value()] 57 | | [template | Static :: static_list() | Dynamic :: dynamic_list()] 58 | | [list_template | Static :: static_list() | DynamicList :: [dynamic_list()]]. 59 | -export_type([rendered/0]). 60 | 61 | -type rendered_value() :: 62 | binary() 63 | | rendered() 64 | % I could not figure out a correct type without the `dynamic/0`. 65 | | dynamic(). 66 | -export_type([rendered_value/0]). 67 | 68 | %% -------------------------------------------------------------------- 69 | %% Doctests 70 | %% -------------------------------------------------------------------- 71 | 72 | -ifdef(TEST). 73 | -include_lib("eunit/include/eunit.hrl"). 74 | doctest_test() -> doctest:module(?MODULE). 75 | -endif. 76 | 77 | %% -------------------------------------------------------------------- 78 | %% API function definitions 79 | %% -------------------------------------------------------------------- 80 | 81 | -spec render_view_template(Payload, Template) -> Token when 82 | Payload :: View | Bindings, 83 | View :: arizona_view:view(), 84 | Bindings :: erl_eval:binding_struct(), 85 | Template :: binary() | {file, file:filename_all()}, 86 | Token :: {view_template, Static, Dynamic}, 87 | Static :: static_list(), 88 | Dynamic :: dynamic_list(). 89 | render_view_template(Bindings, Template) when is_map(Bindings) -> 90 | {Static, Dynamic} = parse_template(Bindings, Template), 91 | {view_template, Static, Dynamic}; 92 | render_view_template(View, Template) -> 93 | Bindings = #{'View' => View}, 94 | render_view_template(Bindings, Template). 95 | 96 | -spec render_component_template(Payload, Template) -> Token when 97 | Payload :: View | Bindings, 98 | View :: arizona_view:view(), 99 | Bindings :: erl_eval:binding_struct(), 100 | Template :: binary() | {file, file:filename_all()}, 101 | Token :: {component_template, Static, Dynamic}, 102 | Static :: static_list(), 103 | Dynamic :: dynamic_list(). 104 | render_component_template(Bindings, Template) when is_map(Bindings) -> 105 | {Static, Dynamic} = parse_template(Bindings, Template), 106 | {component_template, Static, Dynamic}; 107 | render_component_template(View, Template) -> 108 | Bindings = #{'View' => View}, 109 | render_component_template(Bindings, Template). 110 | 111 | -spec render_nested_template(Template) -> Error when 112 | Template :: binary(), 113 | Error :: no_return(). 114 | render_nested_template(Template) -> 115 | missing_parse_transform_error(Template). 116 | 117 | -spec render_view(Mod, Bindings) -> Token when 118 | Mod :: module(), 119 | Bindings :: arizona_view:bindings(), 120 | Token :: {view, Mod, Bindings}. 121 | render_view(Mod, Bindings) when is_atom(Mod), is_map(Bindings), is_map_key(id, Bindings) -> 122 | {view, Mod, Bindings}. 123 | 124 | -spec render_component(Mod, Fun, Bindings) -> Token when 125 | Mod :: module(), 126 | Fun :: atom(), 127 | Bindings :: arizona_view:bindings(), 128 | Token :: {component, Mod, Fun, Bindings}. 129 | render_component(Mod, Fun, Bindings) when is_atom(Mod), is_atom(Fun), is_map(Bindings) -> 130 | {component, Mod, Fun, Bindings}. 131 | 132 | -spec render_if_true(Cond, Callback) -> Rendered when 133 | Cond :: boolean(), 134 | Callback :: fun(() -> Rendered), 135 | Rendered :: rendered_value(). 136 | render_if_true(Cond, Callback) when is_function(Callback, 0) -> 137 | case Cond of 138 | true -> 139 | erlang:apply(Callback, []); 140 | false -> 141 | ~"" 142 | end. 143 | 144 | -spec render_list(Callback, List) -> Token when 145 | Callback :: fun((Item :: dynamic()) -> token() | rendered_value()), 146 | List :: list(), 147 | Token :: {list, Static, DynamicList}, 148 | Static :: static_list(), 149 | DynamicList :: [dynamic_list()]. 150 | render_list(Callback, []) when is_function(Callback, 1) -> 151 | {list, [], []}; 152 | render_list(Callback, List) when is_function(Callback, 1), is_list(List) -> 153 | NestedTemplates = [erlang:apply(Callback, [Item]) || Item <- List], 154 | {nested_template, Static, _Dynamic} = hd(NestedTemplates), 155 | DynamicList = [Dynamic || {nested_template, _Static, Dynamic} <- NestedTemplates], 156 | {list, Static, DynamicList}. 157 | 158 | %% -------------------------------------------------------------------- 159 | %% Support function definitions 160 | %% -------------------------------------------------------------------- 161 | 162 | -spec render(Payload, View, ParentView, ParentSocket) -> {View, Socket} when 163 | Payload :: Token | Rendered, 164 | Token :: token(), 165 | Rendered :: rendered_value(), 166 | ParentView :: arizona_view:view(), 167 | ParentSocket :: arizona_socket:socket(), 168 | View :: ParentView | arizona_view:view(), 169 | Socket :: ParentSocket | arizona_socket:socket(). 170 | render({view_template, Static, Dynamic}, View, _ParentView, Socket) -> 171 | render_view_template(View, Socket, Static, Dynamic); 172 | render({component_template, Static, Dynamic}, View, _ParentView, Socket) -> 173 | render_component_template(View, Socket, Static, Dynamic); 174 | render({nested_template, Static, Dynamic}, _View, ParentView, Socket) -> 175 | render_nested_template(ParentView, Socket, Static, Dynamic); 176 | render({list_template, Static, DynamicCallback, List}, View, ParentView, Socket) -> 177 | render_list_template(View, ParentView, Socket, Static, DynamicCallback, List); 178 | render({view, Mod, Bindings}, _View, ParentView, Socket) -> 179 | render_view(ParentView, Socket, Mod, Bindings); 180 | render({component, Mod, Fun, Bindings}, _View, ParentView, Socket) -> 181 | render_component(ParentView, Socket, Mod, Fun, Bindings); 182 | render({list, Static, DynamicList}, View, ParentView, Socket) -> 183 | render_list(Static, DynamicList, View, ParentView, Socket); 184 | render(List, View, ParentView, Socket) when is_list(List) -> 185 | fold(List, View, ParentView, Socket); 186 | render({inner_content, Callback}, _View, ParentView, Socket) when is_function(Callback, 2) -> 187 | erlang:apply(Callback, [ParentView, Socket]); 188 | render(Bin, _View, View0, Socket) when is_binary(Bin) -> 189 | View = arizona_view:put_tmp_rendered(Bin, View0), 190 | {View, Socket}. 191 | 192 | -spec render_nested_template(Payload, Template) -> Token when 193 | Payload :: ParentView | Bindings, 194 | ParentView :: arizona_view:view(), 195 | Bindings :: erl_eval:binding_struct(), 196 | Template :: binary() | {file, file:filename_all()}, 197 | Token :: {nested_template, Static, Dynamic}, 198 | Static :: static_list(), 199 | Dynamic :: dynamic_list(). 200 | render_nested_template(Bindings, Template) when is_map(Bindings) -> 201 | {Static, Dynamic} = parse_template(Bindings, Template), 202 | {nested_template, Static, Dynamic}; 203 | render_nested_template(ParentView, Template) -> 204 | Bindings = #{'View' => ParentView}, 205 | render_nested_template(Bindings, Template). 206 | 207 | -spec render_layout(LayoutMod, ViewMod, PathParams, QueryString, Bindings, Socket0) -> Layout when 208 | LayoutMod :: module(), 209 | ViewMod :: module(), 210 | PathParams :: arizona:path_params(), 211 | QueryString :: arizona:query_string(), 212 | Bindings :: arizona_view:bindings(), 213 | Socket0 :: arizona_socket:socket(), 214 | Layout :: {LayoutView, Socket1}, 215 | LayoutView :: arizona_view:view(), 216 | Socket1 :: arizona_socket:socket(). 217 | render_layout(LayoutMod, ViewMod, PathParams, QueryString, Bindings0, Socket) when 218 | is_atom(LayoutMod), 219 | is_atom(ViewMod), 220 | is_map(PathParams), 221 | is_binary(QueryString), 222 | is_map(Bindings0) 223 | -> 224 | % The 'inner_content' must be a list for correct rendering. 225 | InnerContent = [render_inner_content(ViewMod, PathParams, QueryString)], 226 | Bindings = Bindings0#{inner_content => InnerContent}, 227 | LayoutView = arizona_layout:mount(LayoutMod, Bindings, Socket), 228 | Token = arizona_layout:render(LayoutView), 229 | render(Token, LayoutView, LayoutView, Socket). 230 | 231 | %% -------------------------------------------------------------------- 232 | %% Private functions 233 | %% -------------------------------------------------------------------- 234 | 235 | missing_parse_transform_error(Template) when is_list(Template) -> 236 | error(missing_parse_transform, [Template], [ 237 | {error_info, #{ 238 | cause => 239 | << 240 | "the attribute '-compile({parse_transform, arizona_transform}).' " 241 | "is missing in the template module" 242 | >> 243 | }} 244 | ]). 245 | 246 | render_view_template(View0, Socket0, Static, Dynamic0) -> 247 | {View1, Socket1} = render_dynamic(Dynamic0, View0, Socket0), 248 | Dynamic = lists:reverse(arizona_view:tmp_rendered(View1)), 249 | Template = [template, Static, Dynamic], 250 | View2 = arizona_view:set_rendered(Template, View1), 251 | View3 = arizona_view:set_tmp_rendered(Template, View2), 252 | View = arizona_view:merge_changed_bindings(View3), 253 | Socket = arizona_socket:put_view(View, Socket1), 254 | {View, Socket}. 255 | 256 | render_component_template(View0, Socket0, Static, Dynamic0) -> 257 | {View1, Socket} = render_dynamic(Dynamic0, View0, Socket0), 258 | Dynamic = lists:reverse(arizona_view:tmp_rendered(View1)), 259 | Template = [template, Static, Dynamic], 260 | View2 = arizona_view:set_rendered(Template, View1), 261 | View = arizona_view:set_tmp_rendered(Template, View2), 262 | {View, Socket}. 263 | 264 | render_nested_template(ParentView0, Socket0, Static, Dynamic0) -> 265 | Bindings = arizona_view:bindings(ParentView0), 266 | View0 = arizona_view:new(Bindings), 267 | {View1, Socket} = render_dynamic(Dynamic0, View0, Socket0), 268 | Dynamic = arizona_view:tmp_rendered(View1), 269 | Template = [template, Static, Dynamic], 270 | ParentView = arizona_view:put_tmp_rendered(Template, ParentView0), 271 | {ParentView, Socket}. 272 | 273 | render_list_template(View0, ParentView0, Socket, Static, Callback, List) -> 274 | View = arizona_view:new(arizona_view:bindings(View0)), 275 | DynamicList = render_dynamic_list_callback(List, Callback, View, View, Socket), 276 | Template = [list_template, Static, DynamicList], 277 | ParentView = arizona_view:put_tmp_rendered(Template, ParentView0), 278 | {ParentView, Socket}. 279 | 280 | render_dynamic_list_callback([], _Callback, _View, _ParentView, _Socket) -> 281 | []; 282 | render_dynamic_list_callback([Item | T], Callback, View, ParentView, Socket) -> 283 | Dynamic = erlang:apply(Callback, [Item]), 284 | {RenderedView, _Socket} = render(Dynamic, View, ParentView, Socket), 285 | [ 286 | arizona_view:tmp_rendered(RenderedView) 287 | | render_dynamic_list_callback(T, Callback, View, ParentView, Socket) 288 | ]. 289 | 290 | render_view(ParentView0, Socket0, Mod, Bindings) -> 291 | case arizona_view:mount(Mod, Bindings, Socket0) of 292 | {ok, View0} -> 293 | Token = arizona_view:render(View0), 294 | {View, Socket1} = render(Token, View0, ParentView0, Socket0), 295 | Rendered = arizona_view:tmp_rendered(View), 296 | ParentView = arizona_view:put_tmp_rendered(Rendered, ParentView0), 297 | Socket = arizona_socket:put_view(View, Socket1), 298 | {ParentView, Socket}; 299 | ignore -> 300 | {ParentView0, Socket0} 301 | end. 302 | 303 | render_component(ParentView0, Socket0, Mod, Fun, Bindings) -> 304 | View0 = arizona_view:new(Bindings), 305 | Token = arizona_component:render(Mod, Fun, View0), 306 | {View, Socket1} = render(Token, View0, ParentView0, Socket0), 307 | Rendered = arizona_view:tmp_rendered(View), 308 | ParentView1 = arizona_view:put_rendered(Rendered, ParentView0), 309 | ParentView = arizona_view:put_tmp_rendered(Rendered, ParentView1), 310 | {ParentView, Socket1}. 311 | 312 | render_list(Static, DynamicList0, View0, ParentView0, Socket) -> 313 | View = arizona_view:new(arizona_view:bindings(View0)), 314 | DynamicList = render_dynamic_list(DynamicList0, View, Socket), 315 | Rendered = [list, Static, DynamicList], 316 | ParentView1 = arizona_view:put_rendered(Rendered, ParentView0), 317 | ParentView = arizona_view:put_tmp_rendered(Rendered, ParentView1), 318 | {ParentView, Socket}. 319 | 320 | render_inner_content(Mod, PathParams, QueryString) -> 321 | Callback = fun(ParentView, Socket) -> 322 | Bindings0 = arizona_view:bindings(ParentView), 323 | Bindings = maps:remove(inner_content, Bindings0), 324 | arizona_view:init_root(Mod, PathParams, QueryString, Bindings, Socket) 325 | end, 326 | {inner_content, Callback}. 327 | 328 | render_dynamic_list([], _View, _Socket) -> 329 | []; 330 | render_dynamic_list([Dynamic | T], View, Socket) -> 331 | {RenderedView, _Socket} = render_dynamic(Dynamic, View, Socket), 332 | [arizona_view:tmp_rendered(RenderedView) | render_dynamic_list(T, View, Socket)]. 333 | 334 | render_dynamic([], View, Socket) -> 335 | {View, Socket}; 336 | render_dynamic([Callback | T], View0, Socket0) -> 337 | {View, Socket} = erlang:apply(Callback, [View0, Socket0, _DiffOpts = #{}]), 338 | render_dynamic(T, View, Socket). 339 | 340 | fold([], View, ParentView0, Socket) -> 341 | Rendered = arizona_view:tmp_rendered(View), 342 | ParentView = arizona_view:put_tmp_rendered(Rendered, ParentView0), 343 | {ParentView, Socket}; 344 | fold([Dynamic | T], View0, ParentView, Socket0) -> 345 | {View, Socket} = render(Dynamic, View0, ParentView, Socket0), 346 | fold(T, View, ParentView, Socket). 347 | 348 | parse_template(Bindings, Template) when is_binary(Template) -> 349 | Tokens = arizona_scanner:scan(#{}, Template), 350 | {StaticAst, DynamicAst} = arizona_parser:parse(Tokens, #{}), 351 | Static = eval_static_ast(StaticAst), 352 | Dynamic = eval_dynamic_ast(Bindings, DynamicAst), 353 | {Static, Dynamic}; 354 | parse_template(Bindings, {file, Filename}) -> 355 | {ok, Template} = file:read_file(Filename), 356 | parse_template(Bindings, Template). 357 | 358 | eval_static_ast(Ast) -> 359 | [eval_expr(Expr, []) || Expr <- Ast]. 360 | 361 | eval_dynamic_ast(Bindings, Ast) -> 362 | [eval_expr(Expr, Bindings) || Expr <- Ast]. 363 | 364 | eval_expr(Expr, Bindings) -> 365 | {value, Value, _NewBindings} = erl_eval:expr(Expr, Bindings), 366 | Value. 367 | -------------------------------------------------------------------------------- /src/arizona_scanner.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_scanner). 2 | 3 | %% -------------------------------------------------------------------- 4 | %% API function exports 5 | %% -------------------------------------------------------------------- 6 | 7 | -export([scan/2]). 8 | 9 | %% -------------------------------------------------------------------- 10 | %% Types (and their exports) 11 | %% -------------------------------------------------------------------- 12 | 13 | -type scan_opts() :: #{ 14 | line => pos_integer(), 15 | column => pos_integer(), 16 | indentation => non_neg_integer() 17 | }. 18 | -export_type([scan_opts/0]). 19 | 20 | -type token() :: { 21 | Category :: html | erlang | comment, 22 | Location :: {Line :: pos_integer(), Column :: pos_integer()}, 23 | Content :: binary() 24 | }. 25 | -export_type([token/0]). 26 | 27 | %% -------------------------------------------------------------------- 28 | %% Private types 29 | %% -------------------------------------------------------------------- 30 | 31 | -record(state, { 32 | line :: pos_integer(), 33 | column :: pos_integer(), 34 | indentation :: non_neg_integer(), 35 | position :: non_neg_integer() 36 | }). 37 | 38 | %% -------------------------------------------------------------------- 39 | %% Doctests 40 | %% -------------------------------------------------------------------- 41 | 42 | -ifdef(TEST). 43 | -include_lib("eunit/include/eunit.hrl"). 44 | doctest_test() -> doctest:module(?MODULE). 45 | -endif. 46 | 47 | %% -------------------------------------------------------------------- 48 | %% API function definitions 49 | %% -------------------------------------------------------------------- 50 | 51 | -doc ~""" 52 | Tokenizes a template. 53 | 54 | ## Examples 55 | 56 | ``` 57 | > arizona_scanner:scan(#{}, ~"foo{bar}{% baz }"). 58 | [{html,{1,1},<<"foo">>}, 59 | {erlang,{1,4},<<"bar">>}, 60 | {comment,{1,9},<<"baz">>}] 61 | ``` 62 | 63 | ## Result 64 | 65 | It returns `[Token]` where a Token is one of: 66 | 67 | - `{html, Location, Content}` 68 | - `{erlang, Location, Content}` 69 | - `{comment, Location, Content}` 70 | """. 71 | -spec scan(Opts, Template) -> [Token] when 72 | Opts :: scan_opts(), 73 | Template :: binary(), 74 | Token :: token(). 75 | scan(Opts, Template) when is_map(Opts), is_binary(Template) -> 76 | State = #state{ 77 | line = maps:get(line, Opts, 1), 78 | column = maps:get(column, Opts, 1 + maps:get(indentation, Opts, 0)), 79 | indentation = maps:get(indentation, Opts, 0), 80 | position = 0 81 | }, 82 | scan(Template, Template, State). 83 | 84 | %% -------------------------------------------------------------------- 85 | %% Private functions 86 | %% -------------------------------------------------------------------- 87 | 88 | scan(Rest, Bin, State) -> 89 | scan(Rest, Bin, 0, State, State). 90 | 91 | scan(<<$\\, ${, Rest/binary>>, Bin0, Len, TextState, State) -> 92 | % Extract the part of the binary before the backslash and opening brace 93 | PrefixBin = binary_part(Bin0, State#state.position, Len), 94 | 95 | % Extract the part of the binary after the backslash and opening brace 96 | SuffixBin = binary:part(Bin0, Len + 2, byte_size(Bin0) - Len - 2), 97 | 98 | % Combine the prefix, opening brace, and suffix into a new binary 99 | % (effectively removing the backslash) 100 | Bin = <>, 101 | 102 | % Continue scanning with the updated binary, length, and state 103 | scan(Rest, Bin, Len + 1, TextState, incr_col(2, State)); 104 | scan(<<${, Rest/binary>>, Bin, Len, TextState, State) -> 105 | ExprTokens = scan_expr(Rest, Bin, 1, incr_pos(Len + 1, State)), 106 | maybe_prepend_text_token(Bin, Len, TextState, ExprTokens); 107 | scan(<<$\r, $\n, Rest/binary>>, Bin, Len, TextState, State) -> 108 | scan(Rest, Bin, Len + 2, TextState, new_line(State)); 109 | scan(<<$\r, Rest/binary>>, Bin, Len, TextState, State) -> 110 | scan(Rest, Bin, Len + 1, TextState, new_line(State)); 111 | scan(<<$\n, Rest/binary>>, Bin, Len, TextState, State) -> 112 | scan(Rest, Bin, Len + 1, TextState, new_line(State)); 113 | scan(<<_, Rest/binary>>, Bin, Len, TextState, State) -> 114 | scan(Rest, Bin, Len + 1, TextState, incr_col(1, State)); 115 | scan(<<>>, Bin, Len, TextState, _State) -> 116 | maybe_prepend_text_token(Bin, Len, TextState, []). 117 | 118 | maybe_prepend_text_token(Bin, Len, State, Tokens) -> 119 | case trim(binary_part(Bin, State#state.position, Len)) of 120 | <<>> -> 121 | Tokens; 122 | Text -> 123 | [{html, location(State), Text} | Tokens] 124 | end. 125 | 126 | % Removes extra leading and trailing whitespaces. 127 | % Keeps one whitespace if needed. 128 | % See https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Whitespace 129 | trim(<<>>) -> 130 | <<>>; 131 | trim(<<$\r, $\n, Rest/binary>>) -> 132 | trim(<<$\s, Rest/binary>>); 133 | trim(<<$\r, Rest/binary>>) -> 134 | trim(<<$\s, Rest/binary>>); 135 | trim(<<$\n, Rest/binary>>) -> 136 | trim(<<$\s, Rest/binary>>); 137 | trim(<<$\s, $\s, Rest/binary>>) -> 138 | trim(Rest); 139 | trim(Rest) -> 140 | trim_trailing(Rest). 141 | 142 | trim_trailing(Rest) -> 143 | trim_trailing_1(binary_reverse(Rest)). 144 | 145 | trim_trailing_1(<<>>) -> 146 | <<>>; 147 | trim_trailing_1(<<$\n, $\r, Rest/binary>>) -> 148 | trim_trailing_1(<<$\s, Rest/binary>>); 149 | trim_trailing_1(<<$\r, Rest/binary>>) -> 150 | trim_trailing_1(<<$\s, Rest/binary>>); 151 | trim_trailing_1(<<$\n, Rest/binary>>) -> 152 | trim_trailing_1(<<$\s, Rest/binary>>); 153 | trim_trailing_1(<<$\s, $\s, Rest/binary>>) -> 154 | trim_trailing_1(Rest); 155 | trim_trailing_1(Rest) -> 156 | binary_reverse(Rest). 157 | 158 | binary_reverse(<<>>) -> 159 | <<>>; 160 | binary_reverse(Bin) -> 161 | binary:encode_unsigned(binary:decode_unsigned(Bin, little)). 162 | 163 | scan_expr(Rest0, Bin, StartMarkerLen, State0) -> 164 | {Len, EndMarkerLen, State1, Rest1} = scan_expr_end(Rest0, 0, 0, State0), 165 | Expr0 = binary_part(Bin, State0#state.position, Len), 166 | {Expr, Category} = expr_category(Expr0, State0), 167 | case maybe_skip_new_line(Rest1) of 168 | {true, NLMarkerLen, Rest} -> 169 | State = new_line(incr_pos(Len + EndMarkerLen + NLMarkerLen, State1)), 170 | [{Category, location(State0), Expr} | scan(Rest, Bin, State)]; 171 | false -> 172 | State = incr_col(StartMarkerLen, incr_pos(Len + EndMarkerLen, State1)), 173 | [{Category, location(State0), Expr} | scan(Rest1, Bin, State)] 174 | end. 175 | 176 | scan_expr_end(<<$}, Rest/binary>>, 0, Len, State) -> 177 | {Len, _MarkerLen = 1, incr_col(1, State), Rest}; 178 | scan_expr_end(<<$}, Rest/binary>>, Depth, Len, State) -> 179 | scan_expr_end(Rest, Depth - 1, Len + 1, incr_col(1, State)); 180 | scan_expr_end(<<${, Rest/binary>>, Depth, Len, State) -> 181 | scan_expr_end(Rest, Depth + 1, Len + 1, incr_col(1, State)); 182 | scan_expr_end(<<$\r, $\n, Rest/binary>>, Depth, Len, State) -> 183 | scan_expr_end(Rest, Depth, Len + 2, new_line(State)); 184 | scan_expr_end(<<$\r, Rest/binary>>, Depth, Len, State) -> 185 | scan_expr_end(Rest, Depth, Len + 1, new_line(State)); 186 | scan_expr_end(<<$\n, Rest/binary>>, Depth, Len, State) -> 187 | scan_expr_end(Rest, Depth, Len + 1, new_line(State)); 188 | scan_expr_end(<<_, Rest/binary>>, Depth, Len, State) -> 189 | scan_expr_end(Rest, Depth, Len + 1, incr_col(1, State)); 190 | scan_expr_end(<<>>, _Depth, Len, State) -> 191 | error({unexpected_expr_end, location(incr_pos(Len, State))}). 192 | 193 | maybe_skip_new_line(<<$\r, $\n, Rest/binary>>) -> 194 | {true, 2, Rest}; 195 | maybe_skip_new_line(<<$\r, Rest/binary>>) -> 196 | {true, 1, Rest}; 197 | maybe_skip_new_line(<<$\n, Rest/binary>>) -> 198 | {true, 1, Rest}; 199 | maybe_skip_new_line(_Rest) -> 200 | false. 201 | 202 | expr_category(Expr, State) -> 203 | try 204 | case merl:quote(Expr) of 205 | Forms when is_list(Forms) -> 206 | case lists:all(fun is_comment/1, Forms) of 207 | true -> 208 | Comments0 = re:split(Expr, <<"\\n">>, [{return, binary}, {newline, lf}]), 209 | Comments1 = [norm_comment(Comment) || Comment <- Comments0], 210 | Comment = iolist_to_binary(lists:join("\n", Comments1)), 211 | {Comment, comment}; 212 | false -> 213 | {Expr, erlang} 214 | end; 215 | Form -> 216 | case is_comment(Form) of 217 | true -> 218 | {norm_comment(Expr), comment}; 219 | false -> 220 | {Expr, erlang} 221 | end 222 | end 223 | catch 224 | _:Exception:Stacktrace -> 225 | error({badexpr, location(State), Expr}, none, [ 226 | {error_info, #{ 227 | cause => {Exception, Stacktrace}, 228 | line => State#state.line 229 | }} 230 | ]) 231 | end. 232 | 233 | is_comment(Form) -> 234 | erl_syntax:type(Form) =:= comment. 235 | 236 | norm_comment(<<$%, Rest/binary>>) -> 237 | norm_comment(Rest); 238 | norm_comment(<<$\s, Rest/binary>>) -> 239 | norm_comment(Rest); 240 | norm_comment(Comment) -> 241 | string:trim(Comment, trailing). 242 | 243 | new_line(#state{line = Ln} = State) -> 244 | State#state{line = Ln + 1, column = 1 + State#state.indentation}. 245 | 246 | incr_col(N, #state{column = Col} = State) -> 247 | State#state{column = Col + N}. 248 | 249 | incr_pos(N, #state{position = Pos} = State) -> 250 | State#state{position = Pos + N}. 251 | 252 | location(#state{line = Ln, column = Col}) -> 253 | location(Ln, Col). 254 | 255 | location(Ln, Col) -> 256 | {Ln, Col}. 257 | -------------------------------------------------------------------------------- /src/arizona_server.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_server). 2 | 3 | %% -------------------------------------------------------------------- 4 | %% API function exports 5 | %% -------------------------------------------------------------------- 6 | 7 | -export([start/1]). 8 | -export([req_route/1]). 9 | 10 | %% -------------------------------------------------------------------- 11 | %% Macros 12 | %% -------------------------------------------------------------------- 13 | 14 | -define(LISTENER, arizona_http_listener). 15 | -define(PERSIST_KEY, arizona_dispatch). 16 | -define(DEFAULT_PORT, 8080). 17 | -elvis([ 18 | {elvis_style, no_macros, #{ 19 | allow => [ 20 | 'LISTENER', 'PERSIST_KEY', 'DEFAULT_PORT' 21 | ] 22 | }} 23 | ]). 24 | 25 | %% -------------------------------------------------------------------- 26 | %% Types (and their exports) 27 | %% -------------------------------------------------------------------- 28 | 29 | -type opts() :: #{ 30 | scheme => http | https, 31 | transport => ranch_tcp:opts(), 32 | host => '_' | iodata(), 33 | % as per cowboy_router:route_match(), 34 | routes => list(), 35 | proto => cowboy:opts() 36 | }. 37 | -export_type([opts/0]). 38 | 39 | %% -------------------------------------------------------------------- 40 | %% API function definitions 41 | %% -------------------------------------------------------------------- 42 | 43 | -spec start(Opts) -> {ok, ServerPid} | {error, Error} when 44 | Opts :: opts(), 45 | ServerPid :: pid(), 46 | Error :: term(). 47 | start(Opts) when is_map(Opts) -> 48 | start_1(norm_opts(Opts)). 49 | 50 | -spec req_route(Req) -> Route when 51 | Req :: cowboy_req:req(), 52 | Route :: 53 | {ok, cowboy_req:req(), cowboy_middleware:env()} 54 | | {suspend, module(), atom(), [term()]} 55 | | {stop, cowboy_req:req()}. 56 | req_route(Req) -> 57 | Qs = cowboy_req:match_qs([path], Req), 58 | Path = maps:get(path, Qs), 59 | cowboy_router:execute( 60 | Req#{path => Path}, 61 | #{dispatch => {persistent_term, ?PERSIST_KEY}} 62 | ). 63 | 64 | %% -------------------------------------------------------------------- 65 | %% Private functions 66 | %% -------------------------------------------------------------------- 67 | 68 | start_1(#{scheme := http, transport := Transport, proto := Proto}) -> 69 | cowboy:start_clear(?LISTENER, Transport, Proto); 70 | start_1(#{scheme := https, transport := Transport, proto := Proto}) -> 71 | cowboy:start_tls(?LISTENER, Transport, Proto). 72 | 73 | norm_opts(Opts) -> 74 | #{ 75 | scheme => maps:get(scheme, Opts, http), 76 | transport => norm_transport_opts(maps:get(transport, Opts, [])), 77 | proto => norm_proto_opts( 78 | maps:get(host, Opts, '_'), 79 | maps:get(routes, Opts, []), 80 | maps:get(proto, Opts, #{}) 81 | ) 82 | }. 83 | 84 | norm_transport_opts(Opts) when is_list(Opts) -> 85 | case proplists:lookup(port, Opts) of 86 | {port, _Port} -> 87 | Opts; 88 | none -> 89 | [{port, ?DEFAULT_PORT} | Opts] 90 | end. 91 | 92 | norm_proto_opts(Host, Routes0, Opts) when 93 | (Host =:= '_' orelse is_list(Host)), is_list(Routes0), is_map(Opts) 94 | -> 95 | Routes = [ 96 | {"/assets/js/arizona/worker.js", cowboy_static, 97 | {priv_file, arizona, "static/assets/js/arizona-worker.min.js"}}, 98 | {"/assets/js/arizona/main.js", cowboy_static, 99 | {priv_file, arizona, "static/assets/js/arizona.min.js"}}, 100 | {"/websocket", arizona_websocket, []} 101 | | Routes0 102 | ], 103 | Dispatch = cowboy_router:compile([{Host, Routes}]), 104 | persistent_term:put(?PERSIST_KEY, Dispatch), 105 | Env = maps:get(env, Opts, #{}), 106 | Opts#{env => Env#{dispatch => {persistent_term, ?PERSIST_KEY}}}. 107 | -------------------------------------------------------------------------------- /src/arizona_socket.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_socket). 2 | 3 | %% -------------------------------------------------------------------- 4 | %% Support function exports 5 | %% -------------------------------------------------------------------- 6 | 7 | -export([new/1]). 8 | -export([new/2]). 9 | -export([new/3]). 10 | -export([render_context/1]). 11 | -export([set_render_context/2]). 12 | -export([connected/1]). 13 | -export([views/1]). 14 | -export([put_view/2]). 15 | -export([get_view/2]). 16 | -export([remove_view/2]). 17 | 18 | % 19 | 20 | -ignore_xref([new/3]). 21 | -ignore_xref([render_context/1]). 22 | 23 | %% -------------------------------------------------------------------- 24 | %% Types (and their exports) 25 | %% -------------------------------------------------------------------- 26 | 27 | -record(socket, { 28 | render_context :: render_context(), 29 | transport_pid :: pid() | undefined, 30 | views :: views() 31 | }). 32 | -opaque socket() :: #socket{}. 33 | -export_type([socket/0]). 34 | 35 | -type render_context() :: render | diff. 36 | -export_type([render_context/0]). 37 | 38 | -type views() :: #{arizona_view:id() => arizona_view:view()}. 39 | -export_type([views/0]). 40 | 41 | %% -------------------------------------------------------------------- 42 | %% Support function definitions 43 | %% -------------------------------------------------------------------- 44 | 45 | -spec new(RenderContext) -> Socket when 46 | RenderContext :: render_context(), 47 | Socket :: socket(). 48 | new(RenderContext) -> 49 | new(RenderContext, undefined, #{}). 50 | 51 | -spec new(RenderContext, TransportPid) -> Socket when 52 | RenderContext :: render_context(), 53 | TransportPid :: pid(), 54 | Socket :: socket(). 55 | new(RenderContext, TransportPid) when is_pid(TransportPid) -> 56 | new(RenderContext, TransportPid, #{}). 57 | 58 | -spec new(RenderContext, TransportPid, Views) -> Socket when 59 | RenderContext :: render_context(), 60 | TransportPid :: pid() | undefined, 61 | Views :: views(), 62 | Socket :: socket(). 63 | new(RenderContext, TransportPid, Views) -> 64 | #socket{ 65 | render_context = RenderContext, 66 | transport_pid = TransportPid, 67 | views = Views 68 | }. 69 | 70 | -spec render_context(Socket) -> RenderContext when 71 | Socket :: socket(), 72 | RenderContext :: render_context(). 73 | render_context(#socket{} = Socket) -> 74 | Socket#socket.render_context. 75 | 76 | -spec set_render_context(RenderContext, Socket0) -> Socket1 when 77 | RenderContext :: render_context(), 78 | Socket0 :: socket(), 79 | Socket1 :: socket(). 80 | set_render_context(render, #socket{} = Socket) -> 81 | Socket#socket{render_context = render}; 82 | set_render_context(diff, #socket{} = Socket) -> 83 | Socket#socket{render_context = diff}. 84 | 85 | -spec connected(Socket) -> boolean() when 86 | Socket :: socket(). 87 | connected(#socket{} = Socket) -> 88 | TransportPid = Socket#socket.transport_pid, 89 | is_pid(TransportPid) andalso is_process_alive(TransportPid). 90 | 91 | -spec views(Socket) -> Views when 92 | Socket :: socket(), 93 | Views :: views(). 94 | views(#socket{} = Socket) -> 95 | Socket#socket.views. 96 | 97 | -spec put_view(View, Socket0) -> Socket1 when 98 | View :: arizona_view:view(), 99 | Socket0 :: socket(), 100 | Socket1 :: socket(). 101 | put_view(View, Socket) -> 102 | ViewId = arizona_view:get_binding(id, View), 103 | put_view(ViewId, View, Socket). 104 | 105 | -spec get_view(ViewId, Socket) -> {ok, View} | error when 106 | ViewId :: arizona_view:id(), 107 | Socket :: socket(), 108 | View :: arizona_view:view(). 109 | get_view(ViewId, #socket{} = Socket) when is_binary(ViewId) -> 110 | case Socket#socket.views of 111 | #{ViewId := View} -> 112 | {ok, View}; 113 | #{} -> 114 | error 115 | end. 116 | 117 | -spec remove_view(ViewId, Socket0) -> Socket1 when 118 | ViewId :: arizona_view:id(), 119 | Socket0 :: socket(), 120 | Socket1 :: socket(). 121 | remove_view(ViewId, #socket{views = Views} = Socket) when is_map_key(ViewId, Views) -> 122 | Socket#socket{views = maps:remove(ViewId, Views)}. 123 | 124 | %% -------------------------------------------------------------------- 125 | %% Private functions 126 | %% -------------------------------------------------------------------- 127 | 128 | put_view(ViewId, View0, #socket{views = Views} = Socket) when is_binary(ViewId) -> 129 | case Socket#socket.render_context of 130 | render -> 131 | View = arizona_view:set_tmp_rendered([], View0), 132 | Socket#socket{views = Views#{ViewId => View}}; 133 | diff -> 134 | View = arizona_view:set_diff([], View0), 135 | Socket#socket{views = Views#{ViewId => View}} 136 | end. 137 | -------------------------------------------------------------------------------- /src/arizona_static.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_static). 2 | 3 | %% -------------------------------------------------------------------- 4 | %% API function exports 5 | %% -------------------------------------------------------------------- 6 | 7 | -export([generate/0]). 8 | 9 | %% -------------------------------------------------------------------- 10 | %% Support function exports 11 | %% -------------------------------------------------------------------- 12 | 13 | -export([generate/2]). 14 | 15 | % 16 | 17 | -ignore_xref([generate/2]). 18 | 19 | %% -------------------------------------------------------------------- 20 | %% API function definitions 21 | %% -------------------------------------------------------------------- 22 | 23 | -spec generate() -> ok. 24 | generate() -> 25 | Routes = maps:get(routes, arizona_config:endpoint()), 26 | StaticDir = arizona_config:static_dir(), 27 | generate(Routes, StaticDir). 28 | 29 | %% -------------------------------------------------------------------- 30 | %% Support function definitions 31 | %% -------------------------------------------------------------------- 32 | 33 | -spec generate(Routes, StaticDir) -> ok when 34 | Routes :: list(), 35 | StaticDir :: file:filename_all(). 36 | generate(Routes, StaticDir) when 37 | is_list(Routes), (is_list(StaticDir) orelse is_binary(StaticDir)) 38 | -> 39 | lists:foreach(fun(Route) -> process_route(Route, StaticDir) end, Routes). 40 | 41 | %% -------------------------------------------------------------------- 42 | %% Private functions 43 | %% -------------------------------------------------------------------- 44 | 45 | process_route({Path, cowboy_static, {priv_file, App, Filename}}, StaticDir) -> 46 | write_priv_file(Path, App, Filename, StaticDir); 47 | process_route({Path, cowboy_static, {priv_dir, App, Dir}}, StaticDir) -> 48 | write_priv_dir_files(Path, App, Dir, StaticDir); 49 | process_route({Path, arizona_view_handler, {Mod, Bindings}}, StaticDir) -> 50 | write_view_as_html(Path, Mod, Bindings, StaticDir); 51 | process_route(Route, StaticDir) when is_tuple(Route) -> 52 | error(invalid_route, [Route, StaticDir], [ 53 | {error_info, #{cause => ~"only static route is allowed"}} 54 | ]). 55 | 56 | write_priv_file(Path, App, Filename, StaticDir) -> 57 | ok = check_path_segments(Path), 58 | AppDir = code:lib_dir(App), 59 | Source = filename:join([AppDir, "priv", Filename]), 60 | Destination = filename:join([StaticDir, norm_path(Path)]), 61 | {ok, Bin} = file:read_file(Source), 62 | ok = filelib:ensure_path(filename:dirname(Destination)), 63 | ok = file:write_file(Destination, Bin). 64 | 65 | write_priv_dir_files(Path0, App, Dir, StaticDir) -> 66 | Path = lists:flatten(string:replace(Path0, "/[...]", "")), 67 | ok = check_path_segments(Path), 68 | AppDir = code:lib_dir(App), 69 | Wildcard = filename:join([AppDir, "priv", Dir, "**", "*"]), 70 | Files = [File || File <- filelib:wildcard(Wildcard), filelib:is_regular(File)], 71 | ok = lists:foreach( 72 | fun(Filename) -> 73 | Source = filename:join([AppDir, "priv", Filename]), 74 | Destination = filename:join([StaticDir, norm_path(Path), filename:basename(Filename)]), 75 | {ok, Bin} = file:read_file(Source), 76 | ok = filelib:ensure_path(filename:dirname(Destination)), 77 | ok = file:write_file(Destination, Bin) 78 | end, 79 | Files 80 | ). 81 | 82 | write_view_as_html(Path, Mod, Bindings, StaticDir) -> 83 | ok = check_path_segments(Path), 84 | Socket0 = arizona_socket:new(render), 85 | {ok, View0} = arizona_view:mount(Mod, Bindings, Socket0), 86 | Token = arizona_view:render(View0), 87 | {View, _Socket} = arizona_renderer:render(Token, View0, View0, Socket0), 88 | Html = arizona_view:rendered_to_iolist(View), 89 | Destination = filename:join([StaticDir, norm_path(Path), "index.html"]), 90 | ok = filelib:ensure_path(filename:dirname(Destination)), 91 | ok = file:write_file(Destination, Html). 92 | 93 | check_path_segments(Path) -> 94 | [[] | Segments] = string:split(Path, "/", all), 95 | case contains_dynamic_segment(Segments) of 96 | true -> 97 | error(badpath, [Path], [ 98 | {error_info, #{cause => ~"match syntax is not allowed"}} 99 | ]); 100 | false -> 101 | ok 102 | end. 103 | 104 | % Searches for dynamic segments, e.g "/[:user_id]/profile". 105 | contains_dynamic_segment([]) -> 106 | false; 107 | contains_dynamic_segment([Segment | T]) -> 108 | case Segment =/= [] andalso hd(Segment) of 109 | $[ -> 110 | true; 111 | _ -> 112 | contains_dynamic_segment(T) 113 | end. 114 | 115 | norm_path("/" ++ Path) -> 116 | Path; 117 | norm_path(Path) -> 118 | Path. 119 | -------------------------------------------------------------------------------- /src/arizona_sup.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_sup). 2 | -moduledoc false. 3 | -behaviour(supervisor). 4 | 5 | %% -------------------------------------------------------------------- 6 | %% API function exports 7 | %% -------------------------------------------------------------------- 8 | 9 | -export([start_link/0]). 10 | 11 | %% -------------------------------------------------------------------- 12 | %% Behaviour (supervisor) exports 13 | %% -------------------------------------------------------------------- 14 | 15 | -export([init/1]). 16 | 17 | %% -------------------------------------------------------------------- 18 | %% API function definitions 19 | %% -------------------------------------------------------------------- 20 | 21 | -spec start_link() -> supervisor:startlink_ret(). 22 | start_link() -> 23 | supervisor:start_link({local, ?MODULE}, ?MODULE, []). 24 | 25 | %% -------------------------------------------------------------------- 26 | %% Behaviour (supervisor) callbacks 27 | %% -------------------------------------------------------------------- 28 | 29 | -spec init(Args) -> {ok, {SupFlags, [ChildSpec]}} when 30 | Args :: term(), 31 | SupFlags :: supervisor:sup_flags(), 32 | ChildSpec :: supervisor:child_spec(). 33 | init(_Args) -> 34 | SupFlags = #{ 35 | strategy => one_for_all, 36 | intensity => 0, 37 | period => 1 38 | }, 39 | ChildSpecs = [ 40 | #{ 41 | id => arizona_pubsub, 42 | start => {arizona_pubsub, start_link, []} 43 | } 44 | ], 45 | {ok, {SupFlags, ChildSpecs}}. 46 | -------------------------------------------------------------------------------- /src/arizona_transform.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_transform). 2 | 3 | %% -------------------------------------------------------------------- 4 | %% Support function exports 5 | %% -------------------------------------------------------------------- 6 | 7 | -export([parse_transform/2]). 8 | -export([transform/2]). 9 | 10 | % 11 | 12 | -ignore_xref([parse_transform/2]). 13 | 14 | %% -------------------------------------------------------------------- 15 | %% Support function definitions 16 | %% -------------------------------------------------------------------- 17 | 18 | -spec parse_transform(Forms0, Opts) -> Forms1 when 19 | Forms0 :: [tuple()], 20 | Opts :: list(), 21 | Forms1 :: [tuple()]. 22 | parse_transform(Forms0, _Opts) -> 23 | Forms = [transform_function(Form) || Form <- Forms0], 24 | % NOTE: Uncomment the function below for debugging. 25 | % debug(Forms0, Forms), 26 | Forms. 27 | 28 | -spec transform(FormOrForms, Bindings) -> Transformed when 29 | FormOrForms :: tuple() | [tuple()], 30 | Bindings :: erl_eval:binding_struct(), 31 | Transformed :: tuple() | [tuple()]. 32 | transform( 33 | {call, _Pos1, {remote, _Pos2, {atom, _Pos3, Mod}, {atom, _Pos4, Fun}}, Body} = Form, 34 | Bindings 35 | ) -> 36 | case transform_fun_body(Mod, Fun, Body, Bindings) of 37 | {true, NewForm} -> 38 | NewForm; 39 | false -> 40 | Form 41 | end; 42 | transform({match, Pos, A, B}, Bindings) -> 43 | {match, Pos, transform(A, Bindings), transform(B, Bindings)}; 44 | transform({clause, Pos, Pattern, Guards, Body}, Bindings) -> 45 | {clause, Pos, Pattern, Guards, transform(Body, Bindings)}; 46 | transform({'case', Pos, Cond, Clauses}, Bindings) -> 47 | {'case', Pos, Cond, transform(Clauses, Bindings)}; 48 | transform({'if', Pos, Clauses}, Bindings) -> 49 | {'if', Pos, transform(Clauses, Bindings)}; 50 | transform({map, Pos, Forms}, Bindings) -> 51 | {map, Pos, transform(Forms, Bindings)}; 52 | transform({tuple, Pos, Forms}, Bindings) -> 53 | {tuple, Pos, transform(Forms, Bindings)}; 54 | transform({cons, Pos, Form, Next}, Bindings) -> 55 | {cons, Pos, transform(Form, Bindings), transform(Next, Bindings)}; 56 | transform({'fun', Pos, {clauses, Clauses}}, Bindings) -> 57 | {'fun', Pos, {clauses, transform(Clauses, Bindings)}}; 58 | transform(Forms, Bindings) when is_list(Forms) -> 59 | [transform(Form, Bindings) || Form <- Forms]; 60 | transform(Form, _Bindings) -> 61 | Form. 62 | 63 | %% -------------------------------------------------------------------- 64 | %% Private functions 65 | %% -------------------------------------------------------------------- 66 | 67 | % NOTE: Use this function to output the transformation to "/tmp/.erl". 68 | % debug(Forms, NewForms) -> 69 | % case 70 | % lists:search( 71 | % fun(Form) -> 72 | % erl_syntax:type(Form) =:= attribute andalso 73 | % erl_syntax:atom_value(erl_syntax:attribute_name(Form)) =:= module 74 | % end, 75 | % Forms 76 | % ) 77 | % of 78 | % {value, ModAttr} -> 79 | % Mod = erl_syntax:atom_value(hd(erl_syntax:attribute_arguments(ModAttr))), 80 | % Str = [erl_prettypr:format(Form, [{pape, 9999999}]) || Form <- NewForms], 81 | % ok = file:write_file("/tmp/" ++ atom_to_list(Mod) ++ ".erl", Str); 82 | % false -> 83 | % ok 84 | % end. 85 | 86 | transform_function({function, Pos1, Name, Arity, [{clause, Pos2, Pattern, Guards, Body0}]}) -> 87 | Body = [transform(Form, []) || Form <- Body0], 88 | {function, Pos1, Name, Arity, [{clause, Pos2, Pattern, Guards, Body}]}; 89 | transform_function(Form) -> 90 | Form. 91 | 92 | transform_fun_body(arizona, render_view_template, Body, Bindings) -> 93 | [_View, TemplateAst] = Body, 94 | ParseOpts = #{}, 95 | {Static, Dynamic} = eval_template(TemplateAst, Bindings, ParseOpts), 96 | Token = token(view_template, [Static, Dynamic]), 97 | {true, Token}; 98 | transform_fun_body(arizona, render_component_template, Body, Bindings) -> 99 | [_View, TemplateAst] = Body, 100 | ParseOpts = #{}, 101 | {Static, Dynamic} = eval_template(TemplateAst, Bindings, ParseOpts), 102 | Token = token(component_template, [Static, Dynamic]), 103 | {true, Token}; 104 | transform_fun_body(arizona, render_nested_template, Body, Bindings) -> 105 | TemplateAst = nested_template_ast(Body), 106 | ParseOpts = #{render_context => render}, 107 | {Static, Dynamic} = eval_template(TemplateAst, Bindings, ParseOpts), 108 | Token = token(nested_template, [Static, Dynamic]), 109 | {true, Token}; 110 | transform_fun_body(arizona, render_list, Body, Bindings) -> 111 | [Callback, List] = Body, 112 | {Static, Dynamic} = callback_to_static_dynamic(Callback, Bindings), 113 | Token = token(list_template, [Static, Dynamic, List]), 114 | {true, Token}; 115 | transform_fun_body(_Mod, _Fun, _Body, _Bindings) -> 116 | false. 117 | 118 | callback_to_static_dynamic(Callback0, Bindings) -> 119 | {'fun', Pos1, 120 | {clauses, [ 121 | {clause, Pos2, Pattern, Guards, [ 122 | {call, Pos3, {remote, _, {atom, _, arizona}, {atom, _, render_nested_template}}, 123 | Body} 124 | ]} 125 | ]}} = Callback0, 126 | TemplateAst = nested_template_ast(Body), 127 | ParseOpts = #{render_context => none}, 128 | {Static, DynamicList0} = eval_template(TemplateAst, Bindings, ParseOpts), 129 | DynamicList = erl_syntax:set_pos(DynamicList0, Pos3), 130 | Callback = 131 | {'fun', Pos1, 132 | {clauses, [ 133 | {clause, Pos2, Pattern, Guards, [DynamicList]} 134 | ]}}, 135 | {Static, Callback}. 136 | 137 | nested_template_ast([TemplateAst]) -> 138 | TemplateAst; 139 | nested_template_ast([_Payload, TemplateAst]) -> 140 | TemplateAst. 141 | 142 | eval_template(TemplateAst, Bindings, ParseOpts) -> 143 | ScanOpts = #{ 144 | % Consider it a triple-quoted string that start one line below. 145 | % See https://www.erlang.org/eeps/eep-0064#triple-quoted-string-start 146 | line => line(TemplateAst) + 1, 147 | % TODO: Indentation option 148 | indentation => 4 149 | }, 150 | {value, Template, _NewBindings} = erl_eval:exprs([TemplateAst], Bindings), 151 | Tokens = arizona_scanner:scan(ScanOpts, Template), 152 | {StaticAst, DynamicAst} = arizona_parser:parse(Tokens, ParseOpts), 153 | Static = erl_syntax:list(StaticAst), 154 | Dynamic = erl_syntax:list(DynamicAst), 155 | {Static, Dynamic}. 156 | 157 | line(Form) -> 158 | case erl_syntax:get_pos(Form) of 159 | Ln when is_integer(Ln) -> 160 | Ln; 161 | {Ln, _Col} when is_integer(Ln) -> 162 | Ln; 163 | Anno -> 164 | erl_anno:line(Anno) 165 | end. 166 | 167 | token(Name, Params) when is_atom(Name), is_list(Params) -> 168 | erl_syntax:revert(erl_syntax:tuple([erl_syntax:atom(Name) | Params])). 169 | -------------------------------------------------------------------------------- /src/arizona_view_handler.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_view_handler). 2 | -behaviour(cowboy_handler). 3 | 4 | %% -------------------------------------------------------------------- 5 | %% Behaviour (cowboy_handler) exports 6 | %% -------------------------------------------------------------------- 7 | 8 | -export([init/2]). 9 | 10 | %% -------------------------------------------------------------------- 11 | %% Types (and their exports) 12 | %% -------------------------------------------------------------------- 13 | 14 | -type state() :: { 15 | Mod :: module(), 16 | Bindings :: arizona_view:bindings(), 17 | Opts :: opts() 18 | }. 19 | -export_type([state/0]). 20 | 21 | -type opts() :: #{ 22 | layout => module() 23 | }. 24 | -export_type([opts/0]). 25 | 26 | %% -------------------------------------------------------------------- 27 | %% Behaviour (cowboy_handler) callbacks 28 | %% -------------------------------------------------------------------- 29 | 30 | -spec init(Req0, State) -> {ok, Req1, State} when 31 | Req0 :: cowboy_req:req(), 32 | State :: state(), 33 | Req1 :: cowboy_req:req(). 34 | init(Req0, {Mod, Bindings, Opts} = State) when is_atom(Mod), is_map(Bindings), is_map(Opts) -> 35 | PathParams = cowboy_req:bindings(Req0), 36 | QueryString = cowboy_req:qs(Req0), 37 | Socket = arizona_socket:new(render), 38 | View = maybe_render_layout(Opts, Mod, PathParams, QueryString, Bindings, Socket), 39 | Html = arizona_view:rendered_to_iolist(View), 40 | Headers = #{~"content-type" => ~"text/html"}, 41 | Req = cowboy_req:reply(200, Headers, Html, Req0), 42 | {ok, Req, State}. 43 | 44 | %% -------------------------------------------------------------------- 45 | %% Private functions 46 | %% -------------------------------------------------------------------- 47 | 48 | maybe_render_layout(Opts, ViewMod, PathParams, QueryString, Bindings, Socket) -> 49 | case Opts of 50 | #{layout := LayoutMod} -> 51 | {LayoutView, _Socket} = arizona_renderer:render_layout( 52 | LayoutMod, ViewMod, PathParams, QueryString, Bindings, Socket 53 | ), 54 | LayoutView; 55 | #{} -> 56 | {View, _Socket} = arizona_view:init_root( 57 | ViewMod, PathParams, QueryString, Bindings, Socket 58 | ), 59 | View 60 | end. 61 | -------------------------------------------------------------------------------- /src/arizona_websocket.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_websocket). 2 | -behaviour(cowboy_websocket). 3 | 4 | %% -------------------------------------------------------------------- 5 | %% Behaviour (cowboy_websocket) exports 6 | %% -------------------------------------------------------------------- 7 | 8 | -export([init/2]). 9 | -export([websocket_init/1]). 10 | -export([websocket_handle/2]). 11 | -export([websocket_info/2]). 12 | -export([terminate/3]). 13 | 14 | %% -------------------------------------------------------------------- 15 | %% Libs 16 | %% -------------------------------------------------------------------- 17 | 18 | -include_lib("kernel/include/logger.hrl"). 19 | 20 | %% -------------------------------------------------------------------- 21 | %% Types (and their exports) 22 | %% -------------------------------------------------------------------- 23 | 24 | -opaque init_state() :: { 25 | PathParams :: path_params(), 26 | QueryString :: query_string(), 27 | HandlerState :: arizona_view_handler:state() 28 | }. 29 | -export_type([init_state/0]). 30 | 31 | -type path_params() :: cowboy_router:bindings(). 32 | -export_type([path_params/0]). 33 | 34 | -type query_string() :: binary(). 35 | -export_type([query_string/0]). 36 | 37 | -type query_params() :: [{binary(), binary() | true}]. 38 | -export_type([query_params/0]). 39 | 40 | %% -------------------------------------------------------------------- 41 | %% Behaviour (cowboy_websocket) callbacks 42 | %% -------------------------------------------------------------------- 43 | 44 | -spec init(Req0, []) -> Result when 45 | Req0 :: cowboy_req:req(), 46 | Result :: {cowboy_websocket, Req1, InitState}, 47 | Req1 :: cowboy_req:req(), 48 | InitState :: init_state(). 49 | init(Req0, []) -> 50 | {ok, Req, Env} = arizona_server:req_route(Req0), 51 | HandlerState = maps:get(handler_opts, Env), 52 | PathParams = cowboy_req:bindings(Req), 53 | QueryString = cowboy_req:qs(Req), 54 | {cowboy_websocket, Req, {PathParams, QueryString, HandlerState}}. 55 | 56 | -spec websocket_init(InitState) -> {Events, Socket} when 57 | InitState :: init_state(), 58 | Events :: cowboy_websocket:commands(), 59 | Socket :: arizona_socket:socket(). 60 | websocket_init({PathParams, QueryString, {Mod, Bindings, _Opts}}) -> 61 | ?LOG_INFO(#{ 62 | text => ~"init", 63 | in => ?MODULE, 64 | view_module => Mod, 65 | bindings => Bindings, 66 | path_params => PathParams, 67 | query_string => QueryString 68 | }), 69 | Socket0 = arizona_socket:new(render, self()), 70 | {_View, Socket1} = arizona_view:init_root(Mod, PathParams, QueryString, Bindings, Socket0), 71 | Socket = arizona_socket:set_render_context(diff, Socket1), 72 | Events = put_init_event(Socket, []), 73 | Cmds = commands(Events), 74 | {Cmds, Socket}. 75 | 76 | -spec websocket_handle(Event, Socket0) -> {Events, Socket1} when 77 | Event :: {text, Msg}, 78 | Msg :: binary(), 79 | Socket0 :: arizona_socket:socket(), 80 | Events :: cowboy_websocket:commands(), 81 | Socket1 :: arizona_socket:socket(). 82 | websocket_handle({text, Msg}, Socket) -> 83 | ?LOG_INFO(#{ 84 | text => ~"message received", 85 | in => ?MODULE, 86 | messge => Msg, 87 | socket => Socket 88 | }), 89 | [Subject, Attachment] = json:decode(Msg), 90 | handle_message(Subject, Attachment, Socket). 91 | 92 | -spec websocket_info(Msg, Socket0) -> {Events, Socket1} when 93 | Msg :: dynamic(), 94 | Events :: cowboy_websocket:commands(), 95 | Socket0 :: arizona_socket:socket(), 96 | Socket1 :: arizona_socket:socket(). 97 | websocket_info({broadcast, From, ViewId, EventName, Payload}, Socket0) -> 98 | ?LOG_INFO(#{ 99 | text => ~"broadcast message received", 100 | in => ?MODULE, 101 | from => From, 102 | view_id => ViewId, 103 | event_name => EventName, 104 | payload => Payload, 105 | socket => Socket0 106 | }), 107 | {ok, View0} = arizona_socket:get_view(ViewId, Socket0), 108 | Ref = undefined, 109 | {Events0, View1} = handle_event(Ref, ViewId, EventName, Payload, From, View0), 110 | {Events, Socket} = handle_diff(Ref, ViewId, Events0, View1, Socket0), 111 | Cmds = commands(Events), 112 | {Cmds, Socket}; 113 | websocket_info(Msg, Socket) -> 114 | ?LOG_INFO(#{ 115 | text => ~"info received", 116 | in => ?MODULE, 117 | messge => Msg, 118 | socket => Socket 119 | }), 120 | {[], Socket}. 121 | 122 | -spec terminate(Reason, Req, Socket) -> ok when 123 | Reason :: term(), 124 | Req :: cowboy_req:req(), 125 | Socket :: arizona_socket:socket(). 126 | terminate(Reason, _Req, Socket) -> 127 | ?LOG_INFO(#{ 128 | text => ~"terminated", 129 | in => ?MODULE, 130 | reason => Reason, 131 | socket => Socket 132 | }), 133 | ok. 134 | 135 | %% -------------------------------------------------------------------- 136 | %% Private functions 137 | %% -------------------------------------------------------------------- 138 | 139 | handle_message(~"event", [Ref, ViewId, EventName, Payload], Socket0) -> 140 | {ok, View0} = arizona_socket:get_view(ViewId, Socket0), 141 | {Events0, View1} = handle_event(Ref, ViewId, EventName, Payload, self(), View0), 142 | {Events, Socket} = handle_diff(Ref, ViewId, Events0, View1, Socket0), 143 | Cmds = commands(Events), 144 | {Cmds, Socket}; 145 | handle_message(~"join", [Ref, ViewId, EventName, Payload], Socket0) -> 146 | {ok, View0} = arizona_socket:get_view(ViewId, Socket0), 147 | {Events0, View} = handle_join(Ref, ViewId, EventName, Payload, View0), 148 | {Events, Socket} = handle_diff(Ref, ViewId, Events0, View, Socket0), 149 | Cmds = commands(Events), 150 | {Cmds, Socket}. 151 | 152 | handle_event(Ref, ViewId, EventName, Payload, From, View) -> 153 | Result = arizona_view:handle_event(EventName, Payload, From, View), 154 | ?LOG_INFO(#{ 155 | text => ~"handle_event result", 156 | in => ?MODULE, 157 | result => Result, 158 | view_id => ViewId, 159 | view => View 160 | }), 161 | norm_handle_event_result(Result, Ref, ViewId, EventName). 162 | 163 | norm_handle_event_result({noreply, View}, _Ref, _ViewId, _EventName) -> 164 | {[], View}; 165 | norm_handle_event_result({reply, Payload, View}, Ref, ViewId, EventName) -> 166 | Event = event_tuple(EventName, Ref, ViewId, Payload), 167 | {[Event], View}. 168 | 169 | handle_diff(Ref, ViewId, Events0, View0, Socket0) -> 170 | Token = arizona_view:render(View0), 171 | {View2, Socket1} = arizona_diff:diff(Token, 0, View0, Socket0), 172 | Diff = arizona_view:diff(View2), 173 | Events = put_diff_event(Diff, Ref, ViewId, Events0), 174 | View = arizona_view:merge_changed_bindings(View2), 175 | Socket = arizona_socket:put_view(View, Socket1), 176 | {Events, Socket}. 177 | 178 | put_init_event(Socket, Events) -> 179 | Views = #{Id => arizona_view:rendered(View) || Id := View <- arizona_socket:views(Socket)}, 180 | Event = event_tuple(~"init", undefined, undefined, Views), 181 | [Event | Events]. 182 | 183 | put_diff_event([], _Ref, _ViewId, Events) -> 184 | Events; 185 | put_diff_event(Diff, Ref, ViewId, Events) -> 186 | ?LOG_INFO(#{ 187 | text => ~"view diff", 188 | in => ?MODULE, 189 | diff => Diff, 190 | ref => Ref, 191 | id => ViewId, 192 | views => Diff 193 | }), 194 | [event_tuple(~"patch", Ref, ViewId, {diff, Diff}) | Events]. 195 | 196 | handle_join(Ref, ViewId, EventName, Payload, View) -> 197 | Mod = arizona_view:module(View), 198 | Result = arizona_view:handle_join(Mod, EventName, Payload, View), 199 | ?LOG_INFO(#{ 200 | text => ~"handle_join result", 201 | in => ?MODULE, 202 | ref => Ref, 203 | view_id => ViewId, 204 | event_name => EventName, 205 | payload => Payload, 206 | result => Result, 207 | view => View 208 | }), 209 | norm_handle_join_result(Result, Ref, ViewId, EventName). 210 | 211 | norm_handle_join_result({ok, Payload, View}, Ref, ViewId, EventName) -> 212 | ok = arizona_pubsub:subscribe(EventName, self()), 213 | ?LOG_INFO(#{ 214 | text => ~"joined", 215 | in => ?MODULE, 216 | ref => Ref, 217 | view_id => ViewId, 218 | event_name => EventName, 219 | payload => Payload, 220 | view => View, 221 | sender => self() 222 | }), 223 | Event = event_tuple(~"join", Ref, ViewId, [~"ok", Payload]), 224 | {[Event], View}; 225 | norm_handle_join_result({error, Reason, View}, Ref, ViewId, _EventName) -> 226 | Event = event_tuple(~"join", Ref, ViewId, [~"error", Reason]), 227 | {[Event], View}. 228 | 229 | event_tuple(EventName, Ref, ViewId, Payload) -> 230 | {EventName, [Ref, ViewId, Payload]}. 231 | 232 | commands([]) -> 233 | []; 234 | commands(Events) -> 235 | [{text, encode(lists:reverse(Events))}]. 236 | 237 | encode(Term) -> 238 | json:encode(Term, fun 239 | ({diff, Diff}, Encode) -> 240 | json:encode(proplists:to_map(Diff), Encode); 241 | ([{_, _} | _] = Proplist, Encode) -> 242 | json:encode(proplist_to_list(Proplist), Encode); 243 | (Other, Encode) -> 244 | json:encode_value(Other, Encode) 245 | end). 246 | 247 | proplist_to_list([]) -> 248 | []; 249 | proplist_to_list([{K, V} | T]) -> 250 | [[K, V] | proplist_to_list(T)]. 251 | -------------------------------------------------------------------------------- /test/arizona_renderer_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_renderer_SUITE). 2 | -behaviour(ct_suite). 3 | -include_lib("stdlib/include/assert.hrl"). 4 | -compile([export_all, nowarn_export_all]). 5 | 6 | %% -------------------------------------------------------------------- 7 | %% Behaviour (ct_suite) callbacks 8 | %% -------------------------------------------------------------------- 9 | 10 | all() -> 11 | [{group, render}]. 12 | 13 | groups() -> 14 | [ 15 | {render, [parallel], [ 16 | render_view_template, 17 | render_component_template, 18 | render_nested_template, 19 | render_view, 20 | render_component 21 | ]} 22 | ]. 23 | 24 | %% -------------------------------------------------------------------- 25 | %% Tests 26 | %% -------------------------------------------------------------------- 27 | 28 | render_view_template(Config) when is_list(Config) -> 29 | View = arizona_view:new(#{id => ~"foo", foo => ~"foo", bar => ~"bar"}), 30 | Got = arizona_renderer:render_view_template(View, ~""" 31 |
32 | {arizona:get_binding(foo, View)} 33 | {arizona:get_binding(bar, View)} 34 |
35 | """), 36 | ?assertMatch( 37 | {view_template, [~"
", ~"", ~"
"], [Callback, _, _]} when 38 | is_function(Callback, 3), 39 | Got 40 | ). 41 | 42 | render_component_template(Config) when is_list(Config) -> 43 | View = arizona_view:new(#{foo => ~"foo", bar => ~"bar"}), 44 | Got = arizona_renderer:render_component_template(View, ~""" 45 |
46 | {arizona:get_binding(foo, View)} 47 | {arizona:get_binding(bar, View)} 48 |
49 | """), 50 | ?assertMatch( 51 | {component_template, [~"
", ~"", ~"
"], [Callback, _]} when 52 | is_function(Callback, 3), 53 | Got 54 | ). 55 | 56 | render_nested_template(Config) when is_list(Config) -> 57 | View = arizona_view:new(#{foo => ~"foo", bar => ~"bar"}), 58 | Got = arizona_renderer:render_nested_template(View, ~""" 59 |
60 | {arizona:get_binding(foo, View)} 61 | {arizona:get_binding(bar, View)} 62 |
63 | """), 64 | ?assertMatch( 65 | {nested_template, [~"
", ~"", ~"
"], [Callback, _]} when is_function(Callback, 3), 66 | Got 67 | ). 68 | 69 | render_view(Config) when is_list(Config) -> 70 | Mod = foo, 71 | Bindings = #{id => ~"foo"}, 72 | Expect = {view, Mod, Bindings}, 73 | Got = arizona_renderer:render_view(Mod, Bindings), 74 | ?assertEqual(Expect, Got). 75 | 76 | render_component(Config) when is_list(Config) -> 77 | Mod = foo, 78 | Fun = bar, 79 | Bindings = #{}, 80 | Expect = {component, Mod, Fun, Bindings}, 81 | Got = arizona_renderer:render_component(Mod, Fun, Bindings), 82 | ?assertEqual(Expect, Got). 83 | -------------------------------------------------------------------------------- /test/arizona_scanner_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_scanner_SUITE). 2 | -behaviour(ct_suite). 3 | -include_lib("stdlib/include/assert.hrl"). 4 | -compile([export_all, nowarn_export_all]). 5 | 6 | %% -------------------------------------------------------------------- 7 | %% Behaviour (ct_suite) callbacks 8 | %% -------------------------------------------------------------------- 9 | 10 | all() -> 11 | [{group, scan}]. 12 | 13 | groups() -> 14 | [ 15 | {scan, [parallel], [ 16 | scan, 17 | scan_html, 18 | scan_erlang, 19 | scan_comment, 20 | scan_start_html_end_html, 21 | scan_start_html_end_erlang, 22 | scan_start_erlang_end_html, 23 | scan_start_erlang_end_erlang, 24 | scan_new_line_cr, 25 | scan_new_line_crlf, 26 | scan_escape, 27 | scan_error_unexpected_expr_end, 28 | scan_error_badexpr 29 | ]} 30 | ]. 31 | 32 | %% -------------------------------------------------------------------- 33 | %% Tests 34 | %% -------------------------------------------------------------------- 35 | 36 | scan(Config) when is_list(Config) -> 37 | Expect = [ 38 | {comment, {60, 5}, ~"start"}, 39 | {html, {61, 5}, ~"Text1 "}, 40 | {erlang, {62, 5}, ~"{{{expr1}}}"}, 41 | {comment, {63, 5}, ~"before text"}, 42 | {html, {63, 20}, ~"Text2\nText3"}, 43 | {comment, {64, 10}, ~"after text"}, 44 | {comment, {65, 5}, ~"before expr"}, 45 | {erlang, {65, 21}, ~"expr2"}, 46 | {erlang, {66, 5}, ~"expr3"}, 47 | {comment, {66, 12}, ~"after expr"}, 48 | {html, {67, 5}, ~"Text4"}, 49 | {comment, {67, 10}, ~"between text"}, 50 | {html, {67, 26}, ~"Text5 "}, 51 | {erlang, {68, 5}, ~"expr4"}, 52 | {comment, {68, 12}, ~"between expr"}, 53 | {erlang, {68, 28}, ~"expr5"}, 54 | {comment, {69, 5}, ~"mutiple\nlines of\ncomment"}, 55 | {erlang, {72, 5}, ~"expr6"}, 56 | {erlang, {72, 12}, ~"Foo = foo, case Foo of foo -> {foo, expr7}; _ -> expr7 end"}, 57 | {comment, {73, 5}, ~"end"} 58 | ], 59 | Got = arizona_scanner:scan(#{line => ?LINE + 1, indentation => 4}, ~""" 60 | {% start } 61 | Text1 62 | {{{{expr1}}}} 63 | {%before text }Text2 64 | Text3{% after text} 65 | {% before expr }{expr2} 66 | {expr3}{%after expr} 67 | Text4{%between text }Text5 68 | {expr4}{% between expr}{expr5} 69 | {% mutiple 70 | % lines of 71 | % comment} 72 | {expr6}{Foo = foo, case Foo of foo -> {foo, expr7}; _ -> expr7 end} 73 | {%end} 74 | """), 75 | ?assertEqual(Expect, Got). 76 | 77 | scan_html(Config) when is_list(Config) -> 78 | Expect = [{html, {1, 1}, ~"Text1"}], 79 | Got = arizona_scanner:scan(#{}, ~""" 80 | Text1 81 | """), 82 | ?assertEqual(Expect, Got). 83 | 84 | scan_erlang(Config) when is_list(Config) -> 85 | Expect = [{erlang, {1, 1}, ~"expr1"}], 86 | Got = arizona_scanner:scan(#{}, ~""" 87 | {expr1} 88 | """), 89 | ?assertEqual(Expect, Got). 90 | 91 | scan_comment(Config) when is_list(Config) -> 92 | Expect = [{comment, {1, 1}, ~"comment"}], 93 | Got = arizona_scanner:scan(#{}, ~""" 94 | {% comment } 95 | """), 96 | ?assertEqual(Expect, Got). 97 | 98 | scan_start_html_end_html(Config) when is_list(Config) -> 99 | Expect = [ 100 | {html, {105, 5}, ~"Text1\nText2"}, 101 | {erlang, {106, 10}, ~"expr1"}, 102 | {html, {106, 17}, ~"Text3\nText4"} 103 | ], 104 | Got = arizona_scanner:scan(#{line => ?LINE + 1, indentation => 4}, ~""" 105 | Text1 106 | Text2{expr1}Text3 107 | Text4 108 | """), 109 | ?assertEqual(Expect, Got). 110 | 111 | scan_start_html_end_erlang(Config) when is_list(Config) -> 112 | Expect = [ 113 | {html, {119, 5}, ~"Text1\nText2"}, 114 | {erlang, {120, 10}, ~"expr1"}, 115 | {html, {120, 17}, ~"Text3\nText4 "}, 116 | {erlang, {122, 5}, ~"expr2"} 117 | ], 118 | Got = arizona_scanner:scan(#{line => ?LINE + 1, indentation => 4}, ~""" 119 | Text1 120 | Text2{expr1}Text3 121 | Text4 122 | {expr2} 123 | """), 124 | ?assertEqual(Expect, Got). 125 | 126 | scan_start_erlang_end_html(Config) when is_list(Config) -> 127 | Expect = [ 128 | {erlang, {134, 5}, ~"expr1"}, 129 | {html, {135, 5}, ~"Text1\nText2"}, 130 | {erlang, {136, 10}, ~"expr2"}, 131 | {html, {136, 17}, ~"Text3\nText4"} 132 | ], 133 | Got = arizona_scanner:scan(#{line => ?LINE + 1, indentation => 4}, ~""" 134 | {expr1} 135 | Text1 136 | Text2{expr2}Text3 137 | Text4 138 | """), 139 | ?assertEqual(Expect, Got). 140 | 141 | scan_start_erlang_end_erlang(Config) when is_list(Config) -> 142 | Expect = [ 143 | {erlang, {150, 5}, ~"expr1"}, 144 | {html, {151, 5}, ~"Text1\nText2"}, 145 | {erlang, {152, 10}, ~"expr2"}, 146 | {html, {152, 17}, ~"Text3\nText4 "}, 147 | {erlang, {154, 5}, ~"expr3"} 148 | ], 149 | Got = arizona_scanner:scan(#{line => ?LINE + 1, indentation => 4}, ~""" 150 | {expr1} 151 | Text1 152 | Text2{expr2}Text3 153 | Text4 154 | {expr3} 155 | """), 156 | ?assertEqual(Expect, Got). 157 | 158 | scan_new_line_cr(Config) when is_list(Config) -> 159 | Expect = [ 160 | {html, {1, 1}, ~"1 "}, 161 | {erlang, {2, 1}, ~"[2,\r3]"}, 162 | {html, {4, 1}, ~"4"} 163 | ], 164 | Got = arizona_scanner:scan(#{}, ~"1\r{[2,\r3]}\r4"), 165 | ?assertEqual(Expect, Got). 166 | 167 | scan_new_line_crlf(Config) when is_list(Config) -> 168 | Expect = [ 169 | {html, {1, 1}, ~"1 "}, 170 | {erlang, {2, 1}, ~"[2,\r\n3]"}, 171 | {html, {4, 1}, ~"4"} 172 | ], 173 | Got = arizona_scanner:scan(#{}, ~"1\r\n{[2,\r\n3]}\r\n4"), 174 | ?assertEqual(Expect, Got). 175 | 176 | scan_escape(Config) when is_list(Config) -> 177 | Expect = [{html, {1, 1}, <<"">>}], 178 | Got = arizona_scanner:scan(#{}, ~[]), 179 | ?assertEqual(Expect, Got). 180 | 181 | scan_error_unexpected_expr_end(Config) when is_list(Config) -> 182 | Error = {unexpected_expr_end, {1, 6}}, 183 | ?assertError(Error, arizona_scanner:scan(#{}, ~"{error")). 184 | 185 | scan_error_badexpr(Config) when is_list(Config) -> 186 | Error = {badexpr, {1, 1}, ~"[error"}, 187 | ?assertError(Error, arizona_scanner:scan(#{}, ~"{[error}")). 188 | -------------------------------------------------------------------------------- /test/arizona_static_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_static_SUITE). 2 | -behaviour(ct_suite). 3 | -include_lib("stdlib/include/assert.hrl"). 4 | -compile([export_all, nowarn_export_all]). 5 | 6 | %% -------------------------------------------------------------------- 7 | %% Behaviour (ct_suite) callbacks 8 | %% -------------------------------------------------------------------- 9 | 10 | all() -> 11 | [ 12 | {group, write}, 13 | {group, error} 14 | ]. 15 | 16 | groups() -> 17 | [ 18 | {write, [parallel], [ 19 | write_priv_file, 20 | write_deep_priv_file, 21 | write_priv_dir, 22 | write_deep_priv_dir, 23 | write_view_as_html, 24 | write_deep_view_as_html 25 | ]}, 26 | {error, [parallel], [ 27 | badpath_error, 28 | invalid_route_error 29 | ]} 30 | ]. 31 | 32 | %% -------------------------------------------------------------------- 33 | %% Tests 34 | %% -------------------------------------------------------------------- 35 | 36 | write_priv_file(Config) when is_list(Config) -> 37 | Route = 38 | {"/arizona.js", cowboy_static, {priv_file, arizona, "static/assets/js/arizona.min.js"}}, 39 | ?assertEqual(ok, generate(Route)), 40 | ?assert(exists("arizona.js")). 41 | 42 | write_deep_priv_file(Config) when is_list(Config) -> 43 | Route = 44 | {"/a/b/c/arizona.js", cowboy_static, 45 | {priv_file, arizona, "static/assets/js/arizona.min.js"}}, 46 | ?assertEqual(ok, generate(Route)), 47 | ?assert(exists("a/b/c/arizona.js")). 48 | 49 | write_priv_dir(Config) when is_list(Config) -> 50 | Route = {"/[...]", cowboy_static, {priv_dir, arizona, "static/assets/js"}}, 51 | ?assertEqual(ok, generate(Route)), 52 | ?assert(exists("arizona.min.js")), 53 | ?assert(exists("arizona-worker.min.js")). 54 | 55 | write_deep_priv_dir(Config) when is_list(Config) -> 56 | Route = {"/a/b/c/[...]", cowboy_static, {priv_dir, arizona, "static/assets/js"}}, 57 | ?assertEqual(ok, generate(Route)), 58 | ?assert(exists("a/b/c/arizona.min.js")), 59 | ?assert(exists("a/b/c/arizona-worker.min.js")). 60 | 61 | write_view_as_html(Config) when is_list(Config) -> 62 | Route = {"/", arizona_view_handler, {arizona_example_counter, #{count => 0}}}, 63 | ?assertEqual(ok, generate(Route)), 64 | ?assert(exists("index.html")). 65 | 66 | write_deep_view_as_html(Config) when is_list(Config) -> 67 | Route = {"/a/b/c/", arizona_view_handler, {arizona_example_counter, #{count => 0}}}, 68 | ?assertEqual(ok, generate(Route)), 69 | ?assert(exists("a/b/c/index.html")). 70 | 71 | badpath_error(Config) when is_list(Config) -> 72 | Route = {"/[:user_id]/profile", cowboy_static, {priv_file, foo, "foo.html"}}, 73 | ?assertError(badpath, generate(Route)). 74 | 75 | invalid_route_error(Config) when is_list(Config) -> 76 | Route = {"/websocket", arizona_websocket, []}, 77 | ?assertError(invalid_route, generate(Route)). 78 | 79 | %% -------------------------------------------------------------------- 80 | %% Support 81 | %% -------------------------------------------------------------------- 82 | 83 | generate(Route) -> 84 | arizona_static:generate([Route], static_dir()). 85 | 86 | exists(Filename) -> 87 | filelib:is_regular(filename:join(static_dir(), Filename)). 88 | 89 | static_dir() -> ~"static". 90 | -------------------------------------------------------------------------------- /test/arizona_transform_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_transform_SUITE). 2 | -behaviour(ct_suite). 3 | -include_lib("stdlib/include/assert.hrl"). 4 | -compile([export_all, nowarn_export_all]). 5 | 6 | % 7 | 8 | -dialyzer({nowarn_function, render_view_template/1}). 9 | -dialyzer({nowarn_function, render_component_template/1}). 10 | -dialyzer({nowarn_function, render_nested_template/1}). 11 | 12 | %% -------------------------------------------------------------------- 13 | %% Behaviour (ct_suite) callbacks 14 | %% -------------------------------------------------------------------- 15 | 16 | all() -> 17 | [{group, render}]. 18 | 19 | groups() -> 20 | [ 21 | {render, [parallel], [ 22 | render_view_template, 23 | render_component_template, 24 | render_nested_template 25 | ]} 26 | ]. 27 | 28 | %% -------------------------------------------------------------------- 29 | %% Tests 30 | %% -------------------------------------------------------------------- 31 | 32 | render_view_template(Config) when is_list(Config) -> 33 | Forms = merl:quote(~"""" 34 | -module(arizona_renderer_view_template). 35 | render(View) -> 36 | arizona:render_view_template(View, ~""" 37 | Hello, {arizona:get_binding(name, View)}! 38 | """). 39 | """"), 40 | Got = arizona_transform:parse_transform(Forms, []), 41 | ?assertMatch( 42 | [ 43 | _, 44 | {function, 2, render, 1, [ 45 | {clause, 2, [{var, 2, 'View'}], [], [ 46 | {tuple, 0, [{atom, 0, view_template}, _, _]} 47 | ]} 48 | ]} 49 | ], 50 | Got 51 | ). 52 | 53 | render_component_template(Config) when is_list(Config) -> 54 | Forms = merl:quote(~"""" 55 | -module(arizona_renderer_component_template). 56 | render(View) -> 57 | arizona:render_component_template(View, ~""" 58 | Hello, {arizona:get_binding(name, View)}! 59 | """). 60 | """"), 61 | Got = arizona_transform:parse_transform(Forms, []), 62 | ?assertMatch( 63 | [ 64 | _, 65 | {function, 2, render, 1, [ 66 | {clause, 2, [{var, 2, 'View'}], [], [ 67 | {tuple, 0, [{atom, 0, component_template}, _, _]} 68 | ]} 69 | ]} 70 | ], 71 | Got 72 | ). 73 | 74 | render_nested_template(Config) when is_list(Config) -> 75 | Forms = merl:quote(~"""" 76 | -module(arizona_renderer_nested_template). 77 | render(View) -> 78 | arizona:render_nested_template(View, ~""" 79 | Hello, {arizona:get_binding(name, View)}! 80 | """). 81 | """"), 82 | Got = arizona_transform:parse_transform(Forms, []), 83 | ?assertMatch( 84 | [ 85 | _, 86 | {function, 2, render, 1, [ 87 | {clause, 2, [{var, 2, 'View'}], [], [ 88 | {tuple, 0, [{atom, 0, nested_template}, _, _]} 89 | ]} 90 | ]} 91 | ], 92 | Got 93 | ). 94 | -------------------------------------------------------------------------------- /test/arizona_view_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_view_SUITE). 2 | -behaviour(ct_suite). 3 | -include_lib("stdlib/include/assert.hrl"). 4 | -compile([export_all, nowarn_export_all]). 5 | 6 | %% -------------------------------------------------------------------- 7 | %% Behaviour (ct_suite) callbacks 8 | %% -------------------------------------------------------------------- 9 | 10 | all() -> 11 | [ 12 | {group, mount}, 13 | {group, render}, 14 | {group, render_to_iolist}, 15 | {group, diff} 16 | ]. 17 | 18 | groups() -> 19 | [ 20 | {mount, [parallel], [ 21 | mount, 22 | mount_ignore 23 | ]}, 24 | {render, [parallel], [ 25 | render, 26 | render_table_component 27 | ]}, 28 | {render_to_iolist, [parallel], [ 29 | rendered_to_iolist, 30 | render_nested_template_to_iolist, 31 | render_table_component_to_iolist 32 | ]}, 33 | {diff, [parallel], [ 34 | diff, 35 | diff_to_iolist 36 | ]} 37 | ]. 38 | 39 | %% -------------------------------------------------------------------- 40 | %% Tests 41 | %% -------------------------------------------------------------------- 42 | 43 | mount(Config) when is_list(Config) -> 44 | Mod = arizona_example_template, 45 | Bindings = #{id => ~"app", count => 0}, 46 | Expect = {ok, arizona_view:new(Mod, Bindings)}, 47 | Socket = arizona_socket:new(render), 48 | Got = arizona_view:mount(Mod, Bindings, Socket), 49 | ?assertEqual(Expect, Got). 50 | 51 | mount_ignore(Config) when is_list(Config) -> 52 | Expect = ignore, 53 | Socket = arizona_socket:new(render), 54 | Got = arizona_view:mount(arizona_example_ignore, #{}, Socket), 55 | ?assertEqual(Expect, Got). 56 | 57 | render(Config) when is_list(Config) -> 58 | Mod = arizona_example_template, 59 | Bindings = #{id => ~"app", count => 0}, 60 | Rendered = [ 61 | template, 62 | [ 63 | ~"\n \n ", 65 | ~"\n" 66 | ], 67 | [ 68 | ~"app", 69 | [ 70 | template, 71 | [~"
", ~"", ~"
"], 72 | [ 73 | ~"counter", 74 | ~"0", 75 | [ 76 | template, 77 | [~""], 78 | [~"Increment"] 79 | ] 80 | ] 81 | ] 82 | ] 83 | ], 84 | Expect = { 85 | arizona_view:new(Mod, Bindings, #{}, Rendered, Rendered, []), 86 | arizona_socket:new(render, undefined, #{ 87 | ~"app" => arizona_view:new(Mod, Bindings, #{}, Rendered, [], []), 88 | ~"counter" => arizona_view:new( 89 | arizona_example_counter, 90 | #{id => ~"counter", count => 0, btn_text => ~"Increment"}, 91 | #{}, 92 | [ 93 | template, 94 | [ 95 | ~"
", 97 | ~"", 98 | ~"
" 99 | ], 100 | [ 101 | ~"counter", 102 | ~"0", 103 | [ 104 | template, 105 | [~""], 106 | [~"Increment"] 107 | ] 108 | ] 109 | ], 110 | [], 111 | [] 112 | ) 113 | }) 114 | }, 115 | ParentView = arizona_view:new(#{}), 116 | Socket = arizona_socket:new(render), 117 | {ok, View} = arizona_view:mount(Mod, Bindings, Socket), 118 | Token = arizona_view:render(View), 119 | Got = arizona_renderer:render(Token, View, ParentView, Socket), 120 | ?assertEqual(Expect, Got). 121 | 122 | rendered_to_iolist(Config) when is_list(Config) -> 123 | Expect = [ 124 | ~"\n \n ", 127 | [ 128 | ~"
", 131 | ~"0", 132 | ~"", 133 | [~""], 134 | ~"
" 135 | ], 136 | ~"\n" 137 | ], 138 | ParentView = arizona_view:new(#{}), 139 | Socket = arizona_socket:new(render), 140 | Mod = arizona_example_template, 141 | Bindings = #{id => ~"app", count => 0}, 142 | {ok, View0} = arizona_view:mount(Mod, Bindings, Socket), 143 | Token = arizona_view:render(View0), 144 | {View, _Socket} = arizona_renderer:render(Token, View0, ParentView, Socket), 145 | Got = arizona_view:rendered_to_iolist(View), 146 | ?assertEqual(Expect, Got). 147 | 148 | render_nested_template_to_iolist(Config) when is_list(Config) -> 149 | Expect = [ 150 | [ 151 | ~"
", 152 | [~" ", ~"Hello, World!", ~""], 153 | ~"
" 154 | ] 155 | ], 156 | ParentView0 = arizona_view:new(#{show_dialog => true, message => ~"Hello, World!"}), 157 | Token = arizona_renderer:render_nested_template(ParentView0, ~"""" 158 |
159 | {arizona_renderer:render_if_true(arizona:get_binding(show_dialog, View), fun() -> 160 | arizona_renderer:render_nested_template(View, ~""" 161 | 162 | {arizona_view:get_binding(message, View)} 163 | 164 | """) 165 | end)} 166 |
167 | """"), 168 | Socket = arizona_socket:new(render), 169 | {ParentView, _Socket} = arizona_renderer:render(Token, ParentView0, ParentView0, Socket), 170 | Got = arizona_view:rendered_to_iolist(ParentView), 171 | ?assertEqual(Expect, Got). 172 | 173 | render_table_component(Config) when is_list(Config) -> 174 | Mod = arizona_example_components, 175 | Fun = table, 176 | Bindings = #{ 177 | columns => [ 178 | #{ 179 | label => ~"Name", 180 | callback => fun(User) -> maps:get(name, User) end 181 | }, 182 | #{ 183 | label => ~"Age", 184 | callback => fun(User) -> maps:get(age, User) end 185 | } 186 | ], 187 | rows => [ 188 | #{name => ~"Jane", age => ~"34"}, 189 | #{name => ~"Bob", age => ~"51"} 190 | ] 191 | }, 192 | Rendered = [ 193 | template, 194 | [~"\n ", ~" ", ~"
"], 195 | [ 196 | [ 197 | list_template, 198 | [~"", ~""], 199 | [[[~"Name"]], [[~"Age"]]] 200 | ], 201 | [ 202 | list_template, 203 | [~" ", ~""], 204 | [ 205 | [ 206 | [ 207 | [ 208 | list_template, 209 | [~" ", ~""], 210 | [[[~"Jane"]], [[~"34"]]] 211 | ] 212 | ] 213 | ], 214 | [ 215 | [ 216 | [ 217 | list_template, 218 | [~" ", ~""], 219 | [[[~"Bob"]], [[~"51"]]] 220 | ] 221 | ] 222 | ] 223 | ] 224 | ] 225 | ] 226 | ], 227 | Socket = arizona_socket:new(render), 228 | Expect = { 229 | arizona_view:new(undefined, Bindings, #{}, Rendered, Rendered, []), 230 | Socket 231 | }, 232 | View = arizona_view:new(Bindings), 233 | Token = arizona_component:render(Mod, Fun, View), 234 | Got = arizona_renderer:render(Token, View, View, Socket), 235 | ?assertEqual(Expect, Got). 236 | 237 | render_table_component_to_iolist(Config) when is_list(Config) -> 238 | Mod = arizona_example_components, 239 | Fun = table, 240 | Bindings = #{ 241 | columns => [ 242 | #{ 243 | label => ~"Name", 244 | callback => fun(User) -> maps:get(name, User) end 245 | }, 246 | #{ 247 | label => ~"Age", 248 | callback => fun(User) -> maps:get(age, User) end 249 | } 250 | ], 251 | rows => [ 252 | #{name => ~"Jane", age => ~"34"}, 253 | #{name => ~"Bob", age => ~"51"} 254 | ] 255 | }, 256 | View0 = arizona_view:new(Bindings), 257 | Socket = arizona_socket:new(render), 258 | Expect = [ 259 | ~"\n ", 260 | [ 261 | [~""], 262 | [~""] 263 | ], 264 | ~" ", 265 | [ 266 | [ 267 | ~" ", 268 | [ 269 | [ 270 | [~""], 271 | [~""] 272 | ] 273 | ], 274 | ~"" 275 | ], 276 | [ 277 | ~" ", 278 | [ 279 | [ 280 | [~""], 281 | [~""] 282 | ] 283 | ], 284 | ~"" 285 | ] 286 | ], 287 | ~"
", [~"Name"], ~"", [~"Age"], ~"
", [~"Jane"], ~" ", [~"34"], ~"
", [~"Bob"], ~" ", [~"51"], ~"
" 288 | ], 289 | Token = arizona_component:render(Mod, Fun, View0), 290 | {View, _Socket} = arizona_renderer:render(Token, View0, View0, Socket), 291 | Got = arizona_view:rendered_to_iolist(View), 292 | ?assertEqual(Expect, Got). 293 | 294 | diff(Config) when is_list(Config) -> 295 | Index = 0, 296 | Vars = [id, count, btn_text], 297 | Mod = arizona_example_template, 298 | CounterMod = arizona_example_counter, 299 | ViewId = ~"app", 300 | CounterViewId = ~"counter", 301 | Bindings = #{id => ViewId, count => 0, btn_text => ~"Increment"}, 302 | ChangedBindings = #{count => 1, btn_text => ~"+1"}, 303 | ExpectBindings = maps:merge(Bindings, ChangedBindings), 304 | Rendered = [ 305 | template, 306 | [ 307 | ~"\n \n ", 309 | ~"\n" 310 | ], 311 | [ 312 | ~"app", 313 | [ 314 | template, 315 | [~"
", ~"", ~"
"], 316 | [ 317 | ~"counter", 318 | ~"0", 319 | [ 320 | template, 321 | [~""], 322 | [~"Increment"] 323 | ] 324 | ] 325 | ] 326 | ] 327 | ], 328 | Diff = [{1, [{2, [{0, ~"+1"}]}, {1, ~"1"}]}], 329 | Expect = { 330 | arizona_view:new(Mod, ExpectBindings, #{}, Rendered, [], Diff), 331 | arizona_socket:new(diff, undefined, #{ 332 | ViewId => arizona_view:new(Mod, ExpectBindings, #{}, Rendered, [], []), 333 | CounterViewId => arizona_view:new( 334 | CounterMod, 335 | ExpectBindings#{id => CounterViewId}, 336 | #{}, 337 | [ 338 | template, 339 | [ 340 | ~"
", 342 | ~"", 343 | ~"
" 344 | ], 345 | [ 346 | ~"counter", 347 | ~"0", 348 | [ 349 | template, 350 | [~""], 351 | [~"Increment"] 352 | ] 353 | ] 354 | ], 355 | [], 356 | [] 357 | ) 358 | }) 359 | }, 360 | RenderSocket = arizona_socket:new(render), 361 | {ok, MountedView} = arizona_view:mount(Mod, Bindings, RenderSocket), 362 | RenderToken = arizona_view:render(MountedView), 363 | ParentView = arizona_view:new(#{}), 364 | {RenderedView, Socket0} = arizona_renderer:render( 365 | RenderToken, MountedView, ParentView, RenderSocket 366 | ), 367 | View0 = arizona_view:set_tmp_rendered([], RenderedView), 368 | View = arizona_view:put_bindings(ChangedBindings, View0), 369 | Token = arizona_view:render(View), 370 | TokenCallback = fun() -> Token end, 371 | Socket = arizona_socket:set_render_context(diff, Socket0), 372 | Got = arizona_diff:diff(Index, Vars, TokenCallback, View, Socket, #{}), 373 | ?assertEqual(Expect, Got). 374 | 375 | diff_to_iolist(Config) when is_list(Config) -> 376 | Index = 0, 377 | Vars = [id, count, btn_text], 378 | Mod = arizona_example_template, 379 | ViewId = ~"app", 380 | Bindings = #{id => ViewId, count => 0, btn_text => ~"Increment"}, 381 | ChangedBindings = #{count => 1, btn_text => ~"+1"}, 382 | Expect = [ 383 | ~"\n \n ", 386 | [ 387 | ~"
", 390 | ~"1", 391 | ~"", 392 | [~""], 393 | ~"
" 394 | ], 395 | ~"\n" 396 | ], 397 | RenderSocket = arizona_socket:new(render), 398 | {ok, MountedView} = arizona_view:mount(Mod, Bindings, RenderSocket), 399 | RenderToken = arizona_view:render(MountedView), 400 | ParentView = arizona_view:new(#{}), 401 | {RenderedView, Socket0} = arizona_renderer:render( 402 | RenderToken, MountedView, ParentView, RenderSocket 403 | ), 404 | View0 = arizona_view:set_tmp_rendered([], RenderedView), 405 | View = arizona_view:put_bindings(ChangedBindings, View0), 406 | Token = arizona_view:render(View), 407 | TokenCallback = fun() -> Token end, 408 | Socket = arizona_socket:set_render_context(diff, Socket0), 409 | {DiffView, _Socket} = arizona_diff:diff(Index, Vars, TokenCallback, View, Socket, #{}), 410 | Got = arizona_view:diff_to_iolist(DiffView), 411 | ?assertEqual(Expect, Got). 412 | -------------------------------------------------------------------------------- /test/arizona_view_handler_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_view_handler_SUITE). 2 | -behaviour(ct_suite). 3 | -behaviour(arizona_view). 4 | -include_lib("stdlib/include/assert.hrl"). 5 | -compile([export_all, nowarn_export_all]). 6 | 7 | %% -------------------------------------------------------------------- 8 | %% Behaviour (ct_suite) callbacks 9 | %% -------------------------------------------------------------------- 10 | 11 | all() -> 12 | [hello_world]. 13 | 14 | init_per_suite(Config) -> 15 | application:set_env([ 16 | {arizona, [ 17 | {endpoint, #{ 18 | routes => [ 19 | {"/hello-world/:id", arizona_view_handler, 20 | {?MODULE, 21 | #{ 22 | data_dir => proplists:get_value(data_dir, Config), 23 | title => ~"Arizona", 24 | name => ~"Joe" 25 | }, 26 | #{layout => arizona_example_layout}}} 27 | ] 28 | }} 29 | ]} 30 | ]), 31 | {ok, _} = application:ensure_all_started(arizona), 32 | Config. 33 | 34 | end_per_suite(Config) -> 35 | Config. 36 | 37 | %% -------------------------------------------------------------------- 38 | %% Behaviour (arizona_live_view) callbacks 39 | %% -------------------------------------------------------------------- 40 | 41 | -spec handle_params(PathParams, QueryString) -> Return when 42 | PathParams :: arizona:path_params(), 43 | QueryString :: arizona:query_string(), 44 | Return :: arizona:handle_params_ret(). 45 | handle_params(PathParams, QueryString) -> 46 | QueryParams = arizona:parse_query_string(QueryString), 47 | {true, #{ 48 | id => arizona:get_path_param(id, PathParams), 49 | name => arizona:get_query_param(name, QueryParams) 50 | }}. 51 | 52 | -spec mount(Bindings, Socket) -> Return when 53 | Bindings :: arizona:bindings(), 54 | Socket :: arizona:socket(), 55 | Return :: arizona:mount_ret(). 56 | mount(Bindings, _Socket) -> 57 | View = arizona_view:new(?MODULE, Bindings), 58 | {ok, View}. 59 | 60 | -spec render(View) -> Token when 61 | View :: arizona:view(), 62 | Token :: arizona:rendered_view_template(). 63 | render(View) -> 64 | arizona:render_view_template(View, ~"""" 65 |
66 | Hello, {arizona:get_binding(name, View)}! 67 |
68 | """"). 69 | 70 | -spec handle_event(EventName, Payload, From, View) -> Return when 71 | EventName :: arizona:event_name(), 72 | Payload :: arizona:event_payload(), 73 | From :: pid(), 74 | View :: arizona:view(), 75 | Return :: arizona:handle_event_ret(). 76 | handle_event(_EventName, _Payload, _From, View) -> 77 | {noreply, View}. 78 | 79 | %% -------------------------------------------------------------------- 80 | %% Tests 81 | %% -------------------------------------------------------------------- 82 | 83 | hello_world(Config) when is_list(Config) -> 84 | ?assertEqual({404, ~""}, request("/404")), 85 | ?assertEqual({200, ~""" 86 | 87 | 88 | 89 | 90 | 91 | 92 | Arizona 93 | 94 | 95 |
96 | Hello, World! 97 |
98 | 99 | """}, request("/hello-world/helloWorld?name=World")). 100 | 101 | %% -------------------------------------------------------------------- 102 | %% Test support 103 | %% -------------------------------------------------------------------- 104 | 105 | request(Uri) -> 106 | Url = io_lib:format("http://localhost:8080~s", [Uri]), 107 | Headers = [], 108 | HttpOptions = [], 109 | Options = [{body_format, binary}, {full_result, false}], 110 | {ok, Response} = httpc:request(get, {Url, Headers}, HttpOptions, Options), 111 | Response. 112 | -------------------------------------------------------------------------------- /test/arizona_view_handler_SUITE_data/layout.herl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {arizona:get_binding(title, View)} 8 | 9 | 10 | 11 | {arizona:get_binding(inner_content, View)} 12 | 13 | -------------------------------------------------------------------------------- /test/support/arizona_example_components.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_example_components). 2 | -compile({parse_transform, arizona_transform}). 3 | -export([button/1]). 4 | -export([list/1]). 5 | -export([table/1]). 6 | 7 | button(View) -> 8 | arizona:render_component_template(View, ~""" 9 | 12 | """). 13 | 14 | list(View) -> 15 | arizona:render_component_template(View, ~"""" 16 |
    17 | {arizona:render_list(fun(Item) -> 18 | arizona:render_nested_template(~""" 19 |
  • {Item}
  • 20 | """) 21 | end, arizona:get_binding(list, View))} 22 |
23 | """"). 24 | 25 | table(View) -> 26 | arizona:render_component_template(View, ~""""" 27 | 28 | 29 | {arizona:render_list(fun(Col) -> 30 | arizona:render_nested_template(~""" 31 | 32 | """) 33 | end, arizona:get_binding(columns, View))} 34 | 35 | {arizona:render_list(fun(Row) -> 36 | arizona:render_nested_template(~"""" 37 | 38 | {arizona:render_list(fun(Col) -> 39 | arizona:render_nested_template(~""" 40 | 43 | """) 44 | end, arizona:get_binding(columns, View))} 45 | 46 | """") 47 | end, arizona:get_binding(rows, View))} 48 |
{maps:get(label, Col)}
41 | {erlang:apply(maps:get(callback, Col), [Row])} 42 |
49 | """""). 50 | -------------------------------------------------------------------------------- /test/support/arizona_example_counter.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_example_counter). 2 | -compile({parse_transform, arizona_transform}). 3 | -behaviour(arizona_view). 4 | 5 | -export([mount/2]). 6 | -export([render/1]). 7 | -export([handle_event/4]). 8 | 9 | mount(Bindings, _Socket) -> 10 | View = arizona:new_view(?MODULE, Bindings#{ 11 | id => maps:get(id, Bindings, ~"counter") 12 | }), 13 | {ok, View}. 14 | 15 | render(View) -> 16 | arizona:render_view_template(View, ~"""" 17 |
18 | {integer_to_binary(arizona:get_binding(count, View))} 19 | {arizona:render_component(arizona_example_components, button, #{ 20 | text => arizona:get_binding(btn_text, View, ~"Increment") 21 | })} 22 |
23 | """"). 24 | 25 | handle_event(_Event, _Payload, _From, View) -> 26 | {noreply, View}. 27 | -------------------------------------------------------------------------------- /test/support/arizona_example_ignore.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_example_ignore). 2 | -behaviour(arizona_view). 3 | 4 | -export([mount/2]). 5 | -export([render/1]). 6 | -export([handle_event/4]). 7 | 8 | mount(_Bindings, _Socket) -> 9 | ignore. 10 | 11 | render(View) -> 12 | arizona:render_view_template(View, ~"""" 13 | ignored 14 | """"). 15 | 16 | handle_event(_Event, _Payload, _From, View) -> 17 | {noreply, View}. 18 | -------------------------------------------------------------------------------- /test/support/arizona_example_layout.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_example_layout). 2 | -behaviour(arizona_layout). 3 | 4 | -export([mount/2]). 5 | -export([render/1]). 6 | 7 | mount(Bindings, _Socket) -> 8 | arizona:new_view(?MODULE, Bindings). 9 | 10 | render(View) -> 11 | arizona:render_layout_template(View, {file, template_file(View)}). 12 | 13 | template_file(View) -> 14 | filename:join(arizona:get_binding(data_dir, View), "layout.herl"). 15 | -------------------------------------------------------------------------------- /test/support/arizona_example_new_id.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_example_new_id). 2 | -behaviour(arizona_view). 3 | 4 | -export([mount/2]). 5 | -export([render/1]). 6 | -export([handle_event/4]). 7 | 8 | mount(#{ignore := true}, _Socket) -> 9 | ignore; 10 | mount(Bindings, _Socket) -> 11 | View = arizona:new_view(?MODULE, Bindings), 12 | {ok, View}. 13 | 14 | render(View) -> 15 | arizona:render_view_template(View, ~"""" 16 |
17 | Hello, {arizona:get_binding(name, View)}! 18 |
19 | """"). 20 | 21 | handle_event(_Event, _Payload, _From, View) -> 22 | {noreply, View}. 23 | -------------------------------------------------------------------------------- /test/support/arizona_example_template.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_example_template). 2 | -compile({parse_transform, arizona_transform}). 3 | -behaviour(arizona_view). 4 | 5 | -export([mount/2]). 6 | -export([render/1]). 7 | -export([handle_event/4]). 8 | 9 | mount(Bindings, _Socket) -> 10 | View = arizona:new_view(?MODULE, Bindings), 11 | {ok, View}. 12 | 13 | render(View) -> 14 | arizona:render_view_template(View, ~"""" 15 | 16 | 17 | 18 | {arizona:render_view(arizona_example_counter, #{ 19 | id => ~"counter", 20 | count => arizona:get_binding(count, View), 21 | btn_text => arizona:get_binding(btn_text, View, ~"Increment") 22 | })} 23 | 24 | 25 | """"). 26 | 27 | handle_event(_Event, _Payload, _From, View) -> 28 | {noreply, View}. 29 | -------------------------------------------------------------------------------- /test/support/arizona_example_template_new_id.erl: -------------------------------------------------------------------------------- 1 | -module(arizona_example_template_new_id). 2 | -behaviour(arizona_view). 3 | 4 | -export([mount/2]). 5 | -export([render/1]). 6 | -export([handle_event/4]). 7 | 8 | mount(#{ignore := true}, _Socket) -> 9 | ignore; 10 | mount(Bindings, _Socket) -> 11 | View = arizona:new_view(?MODULE, Bindings), 12 | {ok, View}. 13 | 14 | render(View) -> 15 | arizona:render_view_template(View, ~""" 16 |
17 | {arizona:render_view(arizona_example_new_id, #{ 18 | id => arizona:get_binding(view_id, View), 19 | name => arizona:get_binding(name, View), 20 | ignore => arizona:get_binding(ignore, View) 21 | })} 22 |
23 | """). 24 | 25 | handle_event(_Event, _Payload, _From, View) -> 26 | {noreply, View}. 27 | --------------------------------------------------------------------------------