├── .formatter.exs ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md └── workflows │ └── elixir.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── INSTALLATION.md ├── LICENSE.md ├── README.md ├── assets ├── copy │ ├── js │ │ └── server.js │ ├── package.json │ ├── postcss.config.js │ ├── tsconfig.json │ ├── vite.config.js │ └── vue │ │ ├── .gitignore │ │ ├── Counter.vue │ │ └── index.js └── js │ └── live_vue │ ├── app.ts │ ├── hooks.ts │ ├── index.ts │ ├── link.ts │ ├── server.ts │ ├── serverElementPolyfill.d.ts │ ├── types.ts │ ├── use.ts │ ├── utils.ts │ └── vitePlugin.ts ├── config └── config.exs ├── example_project ├── .formatter.exs ├── .gitignore ├── README.md ├── assets │ ├── css │ │ └── app.css │ ├── img │ │ ├── card-top.jpeg │ │ ├── phoenix-logo.svg │ │ ├── plus-icon.svg │ │ └── vue-logo.svg │ ├── js │ │ ├── app.js │ │ └── server.js │ ├── package-lock.json │ ├── package.json │ ├── postcss.config.js │ ├── tailwind.config.js │ ├── ts_config_example │ │ ├── tailwind.config.ts │ │ ├── vite.config.mts │ │ └── vue │ │ │ └── index.ts │ ├── tsconfig.json │ ├── vite.config.js │ └── vue │ │ ├── .gitignore │ │ ├── Card.vue │ │ ├── Counter.vue │ │ ├── FormExample.vue │ │ ├── NavigationExample.vue │ │ ├── PrimeVueExample.vue │ │ ├── ShowState.vue │ │ ├── Simple.vue │ │ ├── VuetifyCalendar.vue │ │ └── index.js ├── config │ ├── config.exs │ ├── dev.exs │ ├── prod.exs │ ├── runtime.exs │ └── test.exs ├── lib │ ├── live_vue_examples.ex │ ├── live_vue_examples │ │ └── application.ex │ ├── live_vue_examples_web.ex │ └── live_vue_examples_web │ │ ├── components │ │ ├── core_components.ex │ │ ├── layouts.ex │ │ └── layouts │ │ │ ├── app.html.heex │ │ │ └── root.html.heex │ │ ├── controllers │ │ ├── error_html.ex │ │ ├── error_json.ex │ │ ├── page_controller.ex │ │ ├── page_html.ex │ │ └── page_html │ │ │ └── dead.html.heex │ │ ├── endpoint.ex │ │ ├── live │ │ ├── calendar.ex │ │ ├── counter.ex │ │ ├── form.ex │ │ ├── home.ex │ │ ├── live_prime_vue.ex │ │ ├── navigation │ │ │ ├── page_one.ex │ │ │ └── page_two.ex │ │ ├── sigil.ex │ │ └── slots.ex │ │ ├── router.ex │ │ └── telemetry.ex ├── mix.exs ├── mix.lock ├── priv │ └── static │ │ └── images │ │ ├── phoenix-logo.svg │ │ └── vue-logo.svg └── test │ ├── live_vue_examples_web │ ├── controllers │ │ ├── error_html_test.exs │ │ └── error_json_test.exs │ └── live │ │ └── counter_test.exs │ ├── support │ └── conn_case.ex │ └── test_helper.exs ├── lib ├── live_vue.ex ├── live_vue │ ├── components.ex │ ├── reload.ex │ ├── slots.ex │ ├── ssr.ex │ ├── ssr │ │ ├── node_js.ex │ │ └── vite_js.ex │ └── test.ex └── mix │ └── tasks │ └── setup.ex ├── logo.png ├── mix.exs ├── mix.lock ├── package-lock.json ├── package.json ├── test ├── live_vue_test.exs └── test_helper.exs ├── tsconfig.client.json ├── tsconfig.json └── tsconfig.server.json /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | import_deps: [:phoenix], 4 | line_length: 120, 5 | plugins: [Phoenix.LiveView.HTMLFormatter], 6 | inputs: ["{mix,.formatter}.exs", "{config,lib,test,example_project}/**/*.{ex,exs}"] 7 | ] 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | priv/static/**/* linguist-generated -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help improve LiveVue 4 | title: 'BUG: ' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | ### Description 10 | 14 | 15 | ### Actual Behavior 16 | 17 | 18 | ### Expected Behavior 19 | 20 | 21 | ## Environment 22 | 35 | 36 | ``` 37 | # Paste the output here 38 | Operating system: 39 | Browser (if relevant): 40 | ``` 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: LiveVue Discussions 4 | url: https://github.com/Valian/live_vue/discussions 5 | about: Please ask and answer questions here 6 | - name: LiveVue Documentation 7 | url: https://hexdocs.pm/live_vue 8 | about: Check out the official documentation -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for LiveVue 4 | title: 'FEATURE: ' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | ## Problem Statement 10 | 13 | 14 | ## Proposed Solution 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | name: Build and test ${{ matrix.elixir }} / OTP ${{ matrix.otp }} 12 | runs-on: ubuntu-20.04 13 | 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | MIX_ENV: test 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - elixir: '1.13' 23 | otp: '23.3' 24 | - elixir: '1.15' 25 | otp: '25.3' 26 | - elixir: '1.17' 27 | otp: '27.1' 28 | 29 | steps: 30 | - uses: actions/checkout@v3 31 | - name: Set up Elixir 32 | uses: erlef/setup-beam@v1 33 | with: 34 | elixir-version: ${{ matrix.elixir }} 35 | otp-version: ${{ matrix.otp }} 36 | - name: Restore dependency cache 37 | uses: actions/cache@v3.3.1 38 | id: deps-cache 39 | with: 40 | path: deps 41 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-${{ hashFiles('**/mix.lock') }} 42 | restore-keys: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix- 43 | - name: Restore build cache 44 | uses: actions/cache@v3.3.1 45 | with: 46 | path: _build 47 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-build-${{ hashFiles('**/mix.lock') }} 48 | restore-keys: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-build- 49 | - name: Install dependencies 50 | if: steps.deps-cache.outputs.cache-hit != 'true' 51 | run: mix deps.get 52 | - name: Compile application 53 | run: mix compile 54 | - name: Run tests 55 | run: mix test 56 | - name: Run tests and coverage 57 | if: matrix.elixir == '1.17' && matrix.otp == '27.1' 58 | run: mix coveralls.github -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | live_vue-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # NPM dependencies 29 | node_modules/ 30 | 31 | .DS_Store 32 | *.code-workspace 33 | 34 | RELEASE.md 35 | 36 | priv/static 37 | .elixir_ls/ 38 | .vscode/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | priv/ 2 | example_project/deps/ 3 | deps/ 4 | doc/ 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSameLine": false, 4 | "bracketSpacing": true, 5 | "printWidth": 120, 6 | "semi": false, 7 | "singleQuote": false, 8 | "tabWidth": 2, 9 | "trailingComma": "es5" 10 | } 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | 9 | 10 | ## 0.5.7 - 2024-12-04 11 | 12 | ### Fixes 13 | 14 | - Fix the `useLiveVue` hook typings to show all available functions and properties. 15 | 16 | 17 | ## 0.5.6 - 2024-11-27 18 | 19 | ### Improvements 20 | 21 | - Much better typing for the library [#41](https://github.com/Valian/live_vue/pull/41). Big thanks to [@francois-codes](https://github.com/francois-codes) for the contribution! 22 | - Added `worker` option to `vite.config.js`, and added instruction how to deal with typescript error [#45](https://github.com/Valian/live_vue/pull/45) 23 | 24 | 25 | ## 0.5.5 - 2024-11-14 26 | 27 | ### Fixes 28 | 29 | - Slots are now rendered correctly in SSR [#39](https://github.com/Valian/live_vue/pull/39) 30 | 31 | 32 | ## 0.5.4 - 2024-11-13 33 | 34 | ### Fixed 35 | 36 | - added type: module to package.json in live_vue to fix the older nodejs module resolution issue [#36](https://github.com/Valian/live_vue/issues/36) 37 | 38 | 39 | ## 0.5.3 - 2024-11-12 40 | 41 | ### Fixed 42 | 43 | - Added explicit extensions to all JS imports. It should fix some issues with module resulution. [#36](https://github.com/Valian/live_vue/issues/36) 44 | 45 | 46 | ## 0.5.2 - 2024-10-08 47 | 48 | ### Changed 49 | 50 | - Added hint to pass `--silent` flag to `npm` watcher in `INSTALLATION.md`. It prevents `npm` from printing executed command which is not useful and makes output messy. 51 | 52 | ```elixir 53 | config :my_app, MyAppWeb.Endpoint, 54 | # ... 55 | watchers: [ 56 | npm: ["--silent", "run", "dev", cd: Path.expand("../assets", __DIR__)] 57 | ] 58 | ``` 59 | 60 | 61 | ## 0.5.1 - 2024-10-08 62 | 63 | ### Fixed 64 | 65 | - Fixed a bug where the server was not preloading the correct assets for the Vue components. It happened because CursorAI "skipped" important part of the code when migrating to the TypeScript 😅 66 | 67 | 68 | ## 0.5.0 - 2024-10-08 69 | 70 | ### Changed 71 | 72 | - Migrated the project to TypeScript 💜 [#32](https://github.com/Valian/live_vue/pull/32) 73 | - Added `createLiveVue` entrypoint to make it easier to customize Vue app initialization 74 | 75 | 76 | ### Deprecations 77 | 78 | - `assets/vue/index.js` should export app created by `createLiveVue()`, not just available components. See migration below. 79 | 80 | 81 | ### Migration 82 | 83 | In `assets/js/app.js`, instead of: 84 | 85 | ```js 86 | export default { 87 | ...import.meta.glob("./**/*.vue", { eager: true }), 88 | ...import.meta.glob("../../lib/**/*.vue", { eager: true }), 89 | } 90 | ``` 91 | 92 | use: 93 | ```js 94 | // polyfill recommended by Vite https://vitejs.dev/config/build-options#build-modulepreload 95 | import "vite/modulepreload-polyfill" 96 | import { h } from "vue" 97 | import { createLiveVue, findComponent } from "live_vue" 98 | 99 | export default createLiveVue({ 100 | resolve: name => { 101 | const components = { 102 | ...import.meta.glob("./**/*.vue", { eager: true }), 103 | ...import.meta.glob("../../lib/**/*.vue", { eager: true }), 104 | } 105 | 106 | // finds component by name or path suffix and gives a nice error message. 107 | // `path/to/component/index.vue` can be found as `path/to/component` or simply `component` 108 | // `path/to/Component.vue` can be found as `path/to/Component` or simply `Component` 109 | return findComponent(components, name) 110 | }, 111 | setup: ({ createApp, component, props, slots, plugin, el }) => { 112 | const app = createApp({ render: () => h(component, props, slots) }) 113 | app.use(plugin) 114 | app.mount(el) 115 | return app 116 | }, 117 | }) 118 | ``` 119 | 120 | then, in `assets/js/app.js`, instead of: 121 | 122 | ```js 123 | import components from "./vue" 124 | ``` 125 | 126 | simply do 127 | 128 | ```js 129 | import { getHooks } from "live_vue" 130 | import liveVueApp from "./vue" 131 | // ... 132 | 133 | const hooks = { ...getHooks(liveVueApp) } 134 | ``` 135 | 136 | If you had any custom initialization code, you have to move it to `createLiveVue().setup()` function. 137 | 138 | 139 | ### Fixed 140 | 141 | - Nicely formatted JS error stracktraces during SSR [commit](https://github.com/Valian/live_vue/commit/10f672bce4104a38523905c52c4879083e4bc6db) 142 | - Previously `initializeVueApp` was not working in SSR mode, since it was declared in app.js which couldn't be imported by server bundle. Now it's in a separate file as `createLiveVue().setup()` and can be imported by both client and server bundles. 143 | 144 | 145 | ## 0.4.1 - 2024-08-30 146 | 147 | ### Changed 148 | 149 | - Improved `pathToFullPathAndFilename` to work with `index.vue` files. Now `../ComponentName/index.vue` can be referenced as `ComponentName` [#23](https://github.com/Valian/live_vue/pull/23) 150 | 151 | 152 | ## 0.4.0 - 2024-06-12 153 | 154 | ### New feature 155 | 156 | - Support for custom Vue instance initialization [#13](https://github.com/Valian/live_vue/pull/13) by @morfert 157 | 158 | 159 | ## 0.3.9 - 2024-06-07 160 | 161 | 162 | 163 | 164 | ## 0.3.8 - 2024-06-01 165 | 166 | ### Fixed 167 | 168 | - Invalid live_vue import in copied package.json (`file:../..` -> `file:../deps/live_vue`) 169 | - Changed `useLiveVue` inject key from `Symbol()` to `_live_vue` string, so it's working if Vite does a reload and Symbol is re-evaluated. 170 | 171 | ### Added 172 | 173 | - Added live_vue, phoenix, phoenix_html and phonenix_live_vue to vite `optimizeDeps.include` config options. It should pre-bundle these packages in development, making it consistent with packages imported from node_modules and improve DX. 174 | - Added initial typescript definitions. Apparently it's enough to name them `.d.mts`, so I've created them both for `index.mjs` and `server.mjs` 175 | 176 | 177 | ## 0.3.7 - 2024-05-26 178 | 179 | ### Changed 180 | 181 | - Added a Mix.Task to make JS file setup more straightforward and cross-platform [#11](https://github.com/Valian/live_vue/pull/11). Contribution by @morfert 🔥 182 | - Installation instruction was moved to the separate file 183 | - Package.json was added to files automatically copied from live_vue when using `mix live_vue.setup` 184 | 185 | ### Fixed 186 | 187 | - Removed `build: {modulePreload: { polyfill: false }}` from vite.config.js as it made it impossible to use `vite/modulepreload-polyfill`. To migrate: please remove that line from yours vite.config.js. Fixed [#12](https://github.com/Valian/live_vue/issues/12) 188 | 189 | 190 | ## 0.3.6 - 2024-05-24 191 | 192 | ### Fixed 193 | 194 | - Fixed missing import in loadManifest 195 | - Added `import "vite/modulepreload-polyfill";` to `assets/vue/index.js`. To migrate, add that line to the top. It adds polyfill for module preload, required for some browsers. More here: https://vitejs.dev/config/build-options#build-modulepreload 196 | 197 | 198 | ## 0.3.5 - 2024-05-24 199 | 200 | ### Changed 201 | 202 | - Removed `body-parser` dependency from `live_vue`. Should fix [#9](https://github.com/Valian/live_vue/issues/9) 203 | 204 | 205 | ## 0.3.4 - 2024-05-22 206 | 207 | ### Fixed 208 | 209 | - Props are correctly updated when being arrays of structs 210 | 211 | 212 | ## 0.3.3 - 2024-05-22 213 | 214 | ### Fixed 215 | 216 | - Javascript imports were mixed - vitePlugin.js was using CJS, rest was using ESM. Now it's explicit by adding ".mjs" extension. 217 | - Removed `:attr` declarations for `<.vue>` component to avoid warnings related to unexpected props being passed to `:rest` attribute [#8](https://github.com/Valian/live_vue/pull/8) 218 | 219 | 220 | ## 0.3.2 - 2024-05-19 221 | 222 | ### Fixed 223 | 224 | - Hot reload of CSS when updating Elixir files 225 | 226 | ## 0.3.1 - 2024-05-17 227 | 228 | ### Changed 229 | 230 | - Simplified `assets/vue/index.js` file - mapping filenames to keys is done by the library. Previous version should still work. 231 | 232 | ## 0.3.0 - 2024-05-17 233 | 234 | ### CHANGED 235 | 236 | - removed esbuild from live_vue, `package.json` points directly to `assets/js/live_vue` 237 | - added support to lazy loading components. See more in README. To migrate, ensure all steps from installation are up-to-date. 238 | 239 | ## 0.2.0 - 2024-05-17 240 | 241 | QoL release 242 | 243 | ### Added 244 | 245 | - `@` added to Vite & typescript paths. To migrate, see `assets/copy/tsconfig.json` and `assets/copy/vite.config.js` 246 | - Added Vite types to tsconfig.json to support special imports, eg. svg. To migrate, add `"types": ["vite/client"]`. 247 | - Added possibility to colocate Vue files in `lib` directory. To migrate, copy `assets/copy/vue/index.js` to your project. 248 | 249 | ### Changed 250 | 251 | - Adjusted files hierarchy to match module names 252 | - Publishing with expublish 253 | 254 | ## [0.1.0] - 2024-05-15 255 | 256 | ### Initial release 257 | 258 | - Start of the project 259 | - End-To-End Reactivity with LiveView 260 | - Server-Side Rendered (SSR) Vue 261 | - Tailwind Support 262 | - Dead View Support 263 | - Vite support 264 | -------------------------------------------------------------------------------- /INSTALLATION.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | LiveVue replaces `esbuild` with [Vite](https://vitejs.dev/) for both client side code and SSR to achieve an amazing development experience. Why? 4 | 5 | - Vite provides a best-in-class Hot-Reload functionality and offers [many benefits](https://vitejs.dev/guide/why#why-vite) not present in esbuild 6 | - `esbuild` package doesn't support plugins, so we would need to setup it anyway 7 | 8 | In production, we'll use [elixir-nodejs](https://github.com/revelrylabs/elixir-nodejs) for SSR. If you don't need SSR, you can disable it with one line of code. TypeScript will be supported as well. 9 | 10 | ## Steps 11 | 12 | 0. Please install `node` 😉 13 | 14 | 1. Add `live_vue` to your list of dependencies of your Phoenix app in `mix.exs` and run `mix deps.get` 15 | 16 | ```elixir 17 | defp deps do 18 | [ 19 | {:live_vue, "~> 0.5"} 20 | ] 21 | end 22 | ``` 23 | 24 | 2. Add config entry to your `config/dev.exs` file 25 | 26 | ```elixir 27 | config :live_vue, 28 | vite_host: "http://localhost:5173", 29 | ssr_module: LiveVue.SSR.ViteJS, 30 | # if you want to disable SSR by default, make it false 31 | ssr: true 32 | ``` 33 | 34 | 3. Add config entry to your `config/prod.exs` file 35 | 36 | ```elixir 37 | config :live_vue, 38 | ssr_module: LiveVue.SSR.NodeJS, 39 | ssr: true 40 | ``` 41 | 42 | 4. Add LiveVue to your `html_helpers` in `lib/my_app_web.ex` 43 | 44 | ```elixir 45 | defp html_helpers do 46 | quote do 47 | # ... 48 | # Add support to Vue components 49 | use LiveVue 50 | 51 | # Generate component for each vue file, so you can omit v-component="name". 52 | # You can configure path to your components by using optional :vue_root param 53 | use LiveVue.Components, vue_root: ["./assets/vue", "./lib/my_app_web"] 54 | end 55 | end 56 | ``` 57 | 58 | 5. LiveVue comes with a handy command to setup all the required files. It won't alter any files you already have in your project, you need to adjust them on your own by looking at the provided sources. Additional instructions how to adjust `package.json` can be found at the end of this page. 59 | 60 | It will create: 61 | 62 | - `package.json` 63 | - vite, typescript and postcss configs 64 | - server entrypoint 65 | - vue entrypoint 66 | 67 | ```bash 68 | mix deps.get 69 | mix live_vue.setup 70 | cd assets && npm install 71 | ``` 72 | 73 | Now we just have to adjust `js/app.js` hooks and tailwind config to include `vue` files: 74 | 75 | ```js 76 | // app.js 77 | import topbar from "topbar" // instead of ../vendor/topbar 78 | import {getHooks} from "live_vue" 79 | import liveVueApp from "../vue" 80 | 81 | // remember to import your css here 82 | import "../css/app.css" 83 | 84 | let liveSocket = new LiveSocket("/live", Socket, { 85 | // ... 86 | hooks: getHooks(liveVueApp), 87 | }) 88 | ``` 89 | 90 | ```js 91 | // tailwind.config.js 92 | 93 | module.exports = { 94 | content: [ 95 | // ... 96 | // include Vue files 97 | "./vue/**/*.vue", 98 | "../lib/**/*.vue", 99 | ], 100 | } 101 | ``` 102 | 103 | 6. Let's update root.html.heex to use Vite files in development. There's a handy wrapper for it. 104 | 105 | ```html 106 | 108 | 109 | 110 | 111 | 113 | 114 | ``` 115 | 116 | 7. Update `mix.exs` aliases and get rid of `tailwind` and `esbuild` packages 117 | 118 | ```elixir 119 | defp aliases do 120 | [ 121 | setup: ["deps.get", "assets.setup", "assets.build"], 122 | "assets.setup": ["cmd --cd assets npm install"], 123 | "assets.build": [ 124 | "cmd --cd assets npm run build", 125 | "cmd --cd assets npm run build-server" 126 | ], 127 | "assets.deploy": [ 128 | "cmd --cd assets npm run build", 129 | "cmd --cd assets npm run build-server", 130 | "phx.digest" 131 | ] 132 | ] 133 | end 134 | 135 | defp deps do 136 | [ 137 | # remove these lines, we don't need esbuild or tailwind here anymore 138 | # {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, 139 | # {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, 140 | ] 141 | end 142 | ``` 143 | 144 | 8. Remove esbuild and tailwind config from `config/config.exs` 145 | 146 | 9. Update watchers in `config/dev.exs` to look like this 147 | 148 | ```elixir 149 | config :my_app, MyAppWeb.Endpoint, 150 | # ... 151 | watchers: [ 152 | npm: ["--silent", "run", "dev", cd: Path.expand("../assets", __DIR__)] 153 | ] 154 | 155 | ``` 156 | 157 | 10. To make SSR working with `LiveVue.SSR.NodeJS` (recommended for production), you have to add this entry to your `application.ex` supervision tree: 158 | 159 | ```elixir 160 | children = [ 161 | {NodeJS.Supervisor, [path: LiveVue.SSR.NodeJS.server_path(), pool_size: 4]}, 162 | # ... 163 | ] 164 | ``` 165 | 166 | 11. Confirm everything is working by rendering an example Vue component anywhere in your LiveViews: 167 | 168 | ```elixir 169 | ~H""" 170 | <.vue 171 | count={@count} 172 | v-component="Counter" 173 | v-socket={@socket} 174 | v-on:inc={JS.push("inc")} 175 | /> 176 | """ 177 | ``` 178 | 179 | 12. (Optional) enable [stateful hot reload](https://twitter.com/jskalc/status/1788308446007132509) of phoenix LiveViews - it allows for stateful reload across the whole stack 🤯. Just adjust your `dev.exs` to look like this - add `notify` section and remove `live|components` from patterns. 180 | 181 | ```elixir 182 | # Watch static and templates for browser reloading. 183 | config :my_app, MyAppWeb.Endpoint, 184 | live_reload: [ 185 | notify: [ 186 | live_view: [ 187 | ~r"lib/my_app_web/core_components.ex$", 188 | ~r"lib/my_app_web/(live|components)/.*(ex|heex)$" 189 | ] 190 | ], 191 | patterns: [ 192 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", 193 | ~r"lib/my_app_web/controllers/.*(ex|heex)$" 194 | ] 195 | ] 196 | ``` 197 | 198 | Voila! Easy, isn't it? 😉 199 | 200 | ## Adjust your own package.json 201 | 202 | Install these packages 203 | 204 | ```bash 205 | cd assets 206 | 207 | # vite 208 | npm install -D vite @vitejs/plugin-vue 209 | 210 | # tailwind 211 | npm install -D tailwindcss autoprefixer postcss @tailwindcss/forms 212 | 213 | # typescript 214 | npm install -D typescript vue-tsc 215 | 216 | # runtime dependencies 217 | npm install --save vue topbar ../deps/live_vue ../deps/phoenix ../deps/phoenix_html ../deps/phoenix_live_view 218 | 219 | # remove topbar from vendor, since we'll use it from node_modules 220 | rm vendor/topbar.js 221 | ``` 222 | 223 | and add these scripts used by watcher and `mix assets.build` command 224 | 225 | ```json 226 | { 227 | "private": true, 228 | "type": "module", 229 | "scripts": { 230 | "dev": "vite --host -l warn", 231 | "build": "vue-tsc && vite build", 232 | "build-server": "vue-tsc && vite build --ssr js/server.js --out-dir ../priv/vue --minify esbuild --ssrManifest && echo '{\"type\": \"module\" } ' > ../priv/vue/package.json" 233 | } 234 | } 235 | ``` 236 | 237 | 238 | ## Troubleshooting 239 | 240 | 241 | ### Typescript error 242 | 243 | 244 | There's a [known bug](https://github.com/vuejs/language-tools/issues/5018) where recent version of TS is not compatible with latest version of `vue-tsc`. If you're getting 245 | 246 | ```bash 247 | Search string not found: "/supportedTSExtensions = .*(?=;)/" 248 | (Use `node --trace-uncaught ...` to show where the exception was thrown) 249 | 250 | Node.js v22.2.0 251 | ** (exit) 1 252 | ``` 253 | 254 | then you'd need to downgrade [typescript and vue-tsc](https://github.com/Valian/live_vue/issues/43#issuecomment-2501152160) to versions 5.5.4 and 2.10.0 respectively. 255 | 256 | You can do it by running 257 | 258 | ```bash 259 | npm install typescript@5.5.4 vue-tsc@2.10.0 260 | ``` 261 | 262 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2024 Jakub Skalecki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /assets/copy/js/server.js: -------------------------------------------------------------------------------- 1 | import components from "../vue" 2 | import { getRender, loadManifest } from "live_vue/server" 3 | 4 | // present only in prod build. Returns empty obj if doesn't exist 5 | // used to render preload links 6 | const manifest = loadManifest("../priv/vue/.vite/ssr-manifest.json") 7 | export const render = getRender(components, manifest) 8 | -------------------------------------------------------------------------------- /assets/copy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "dev": "vite --host -l warn", 6 | "build": "vue-tsc && vite build", 7 | "build-server": "vue-tsc && vite build --ssr js/server.js --out-dir ../priv/vue --minify esbuild --ssrManifest && echo '{\"type\": \"module\" } ' > ../priv/vue/package.json" 8 | }, 9 | "devDependencies": { 10 | "@vitejs/plugin-vue": "^5.0.4", 11 | "vite": "^5.2.9", 12 | 13 | "@tailwindcss/forms": "^0.5.7", 14 | "autoprefixer": "^10.4.19", 15 | "postcss": "^8.4.38", 16 | "tailwindcss": "^3.4.3", 17 | 18 | "typescript": "^5.4.5", 19 | "vue-tsc": "^2.0.13" 20 | }, 21 | "dependencies": { 22 | "live_vue": "file:../deps/live_vue", 23 | "phoenix": "file:../deps/phoenix", 24 | "phoenix_html": "file:../deps/phoenix_html", 25 | "phoenix_live_view": "file:../deps/phoenix_live_view", 26 | "topbar": "^2.0.2", 27 | "vue": "^3.4.21" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /assets/copy/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /assets/copy/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Types for Vite https://vitejs.dev/guide/features#client-types */ 10 | "types": ["vite/client"], 11 | 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "preserve", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | 26 | "paths": { 27 | "@/*": ["./*"] 28 | } 29 | }, 30 | "include": ["js/**/*.ts", "js/**/*.js", "js/**/*.tsx", "vue/**/*.vue"], 31 | } -------------------------------------------------------------------------------- /assets/copy/vite.config.js: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import { defineConfig } from "vite" 3 | 4 | import vue from "@vitejs/plugin-vue" 5 | import liveVuePlugin from "live_vue/vitePlugin" 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig(({ command }) => { 9 | const isDev = command !== "build" 10 | 11 | return { 12 | base: isDev ? undefined : "/assets", 13 | publicDir: "static", 14 | plugins: [vue(), liveVuePlugin()], 15 | worker: { 16 | format: "es", 17 | }, 18 | ssr: { 19 | // we need it, because in SSR build we want no external 20 | // and in dev, we want external for fast updates 21 | noExternal: isDev ? undefined : true, 22 | }, 23 | resolve: { 24 | alias: { 25 | vue: path.resolve(__dirname, "node_modules/vue"), 26 | "@": path.resolve(__dirname, "."), 27 | }, 28 | }, 29 | optimizeDeps: { 30 | // these packages are loaded as file:../deps/ imports 31 | // so they're not optimized for development by vite by default 32 | // we want to enable it for better DX 33 | // more https://vitejs.dev/guide/dep-pre-bundling#monorepos-and-linked-dependencies 34 | include: ["live_vue", "phoenix", "phoenix_html", "phoenix_live_view"], 35 | }, 36 | build: { 37 | commonjsOptions: { transformMixedEsModules: true }, 38 | target: "es2020", 39 | outDir: "../priv/static/assets", // emit assets to priv/static/assets 40 | emptyOutDir: true, 41 | sourcemap: isDev, // enable source map in dev build 42 | manifest: false, // do not generate manifest.json 43 | rollupOptions: { 44 | input: { 45 | app: path.resolve(__dirname, "./js/app.js"), 46 | }, 47 | output: { 48 | // remove hashes to match phoenix way of handling assets 49 | entryFileNames: "[name].js", 50 | chunkFileNames: "[name].js", 51 | assetFileNames: "[name][extname]", 52 | }, 53 | }, 54 | }, 55 | } 56 | }) 57 | -------------------------------------------------------------------------------- /assets/copy/vue/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore automatically generated Vue files by the ~V sigil 2 | _build/ -------------------------------------------------------------------------------- /assets/copy/vue/Counter.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /assets/copy/vue/index.js: -------------------------------------------------------------------------------- 1 | // polyfill recommended by Vite https://vitejs.dev/config/build-options#build-modulepreload 2 | import "vite/modulepreload-polyfill" 3 | import { h } from "vue" 4 | import { createLiveVue, findComponent } from "live_vue" 5 | 6 | export default createLiveVue({ 7 | // name will be passed as-is in v-component of the .vue HEEX component 8 | resolve: name => { 9 | // we're importing from ../../lib to allow collocating Vue files with LiveView files 10 | // eager: true disables lazy loading - all these components will be part of the app.js bundle 11 | // more: https://vite.dev/guide/features.html#glob-import 12 | const components = { 13 | ...import.meta.glob("./**/*.vue", { eager: true }), 14 | ...import.meta.glob("../../lib/**/*.vue", { eager: true }), 15 | } 16 | 17 | // finds component by name or path suffix and gives a nice error message. 18 | // `path/to/component/index.vue` can be found as `path/to/component` or simply `component` 19 | // `path/to/Component.vue` can be found as `path/to/Component` or simply `Component` 20 | return findComponent(components, name) 21 | }, 22 | // it's a default implementation of creating and mounting vue app, you can easily extend it to add your own plugins, directives etc. 23 | setup: ({ createApp, component, props, slots, plugin, el }) => { 24 | const app = createApp({ render: () => h(component, props, slots) }) 25 | app.use(plugin) 26 | // add your own plugins here 27 | // app.use(pinia) 28 | app.mount(el) 29 | return app 30 | }, 31 | }) 32 | -------------------------------------------------------------------------------- /assets/js/live_vue/app.ts: -------------------------------------------------------------------------------- 1 | import { type App, type Component, h } from "vue" 2 | import type { 3 | ComponentOrComponentModule, 4 | ComponentOrComponentPromise, 5 | SetupContext, 6 | LiveVueOptions, 7 | ComponentMap, 8 | LiveVueApp, 9 | } from "./types.js" 10 | 11 | /** 12 | * Initializes a Vue app with the given options and mounts it to the specified element. 13 | * It's a default implementation of the `setup` option, which can be overridden. 14 | * If you want to override it, simply provide your own implementation of the `setup` option. 15 | */ 16 | export const defaultSetup = ({ createApp, component, props, slots, plugin, el }: SetupContext) => { 17 | const app = createApp({ render: () => h(component, props, slots) }) 18 | app.use(plugin) 19 | app.mount(el) 20 | return app 21 | } 22 | 23 | export const migrateToLiveVueApp = ( 24 | components: ComponentMap, 25 | options: { initializeApp?: (context: SetupContext) => App } = {} 26 | ): LiveVueApp => { 27 | if ("resolve" in components && "setup" in components) { 28 | return components as LiveVueApp 29 | } else { 30 | console.warn("deprecation warning:\n\nInstead of passing components, use createLiveVue({resolve, setup})") 31 | return createLiveVue({ 32 | resolve: (name: string) => { 33 | for (const [key, value] of Object.entries(components)) { 34 | if (key.endsWith(`${name}.vue`) || key.endsWith(`${name}/index.vue`)) { 35 | return value 36 | } 37 | } 38 | }, 39 | setup: options.initializeApp, 40 | }) 41 | } 42 | } 43 | 44 | const resolveComponent = async (component: ComponentOrComponentModule): Promise => { 45 | if (typeof component === "function") { 46 | // it's an async component, let's try to load it 47 | component = await (component as () => Promise)() 48 | } else if (component instanceof Promise) { 49 | component = await component 50 | } 51 | 52 | if (component && "default" in component) { 53 | // if there's a default export, use it 54 | component = component.default 55 | } 56 | 57 | return component 58 | } 59 | 60 | export const createLiveVue = ({ resolve, setup }: LiveVueOptions) => { 61 | return { 62 | setup: setup || defaultSetup, 63 | resolve: async (path: string): Promise => { 64 | let component = resolve(path) 65 | if (!component) throw new Error(`Component ${path} not found!`) 66 | return await resolveComponent(component) 67 | }, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /assets/js/live_vue/hooks.ts: -------------------------------------------------------------------------------- 1 | import { createApp, createSSRApp, h, reactive, type App } from "vue" 2 | import { migrateToLiveVueApp } from "./app.js" 3 | import { ComponentMap, LiveHookInternal, LiveVueApp, LiveVueOptions } from "./types.js" 4 | import { liveInjectKey } from "./use.js" 5 | import { mapValues } from "./utils.js" 6 | 7 | /** 8 | * Parses the JSON object from the element's attribute and returns them as an object. 9 | */ 10 | const getAttributeJson = (el: HTMLElement, attributeName: string): Record => { 11 | const data = el.getAttribute(attributeName) 12 | return data ? JSON.parse(data) : {} 13 | } 14 | 15 | /** 16 | * Parses the slots from the element's attributes and returns them as a record. 17 | * The slots are parsed from the "data-slots" attribute. 18 | * The slots are converted to a function that returns a div with the innerHTML set to the base64 decoded slot. 19 | */ 20 | const getSlots = (el: HTMLElement): Record any> => { 21 | const dataSlots = getAttributeJson(el, "data-slots") 22 | return mapValues(dataSlots, base64 => () => h("div", { innerHTML: atob(base64).trim() })) 23 | } 24 | 25 | /** 26 | * Parses the event handlers from the element's attributes and returns them as a record. 27 | * The handlers are parsed from the "data-handlers" attribute. 28 | * The handlers are converted to snake case and returned as a record. 29 | * A special case is made for the "JS.push" event, where the event is replaced with $event. 30 | * @param el - The element to parse the handlers from. 31 | * @param liveSocket - The LiveSocket instance. 32 | * @returns The handlers as an object. 33 | */ 34 | const getHandlers = (el: HTMLElement, liveSocket: any): Record void> => { 35 | const handlers = getAttributeJson(el, "data-handlers") 36 | const result: Record void> = {} 37 | for (const handlerName in handlers) { 38 | const ops = handlers[handlerName] 39 | const snakeCaseName = `on${handlerName.charAt(0).toUpperCase() + handlerName.slice(1)}` 40 | result[snakeCaseName] = event => { 41 | // a little bit of magic to replace the event with the value of the input 42 | const parsedOps = JSON.parse(ops) 43 | const replacedOps = parsedOps.map(([op, args, ...other]: [string, any, ...any[]]) => { 44 | if (op === "push" && !args.value) args.value = event 45 | return [op, args, ...other] 46 | }) 47 | liveSocket.execJS(el, JSON.stringify(replacedOps)) 48 | } 49 | } 50 | return result 51 | } 52 | 53 | /** 54 | * Parses the props from the element's attributes and returns them as an object. 55 | * The props are parsed from the "data-props" attribute. 56 | * The props are merged with the event handlers from the "data-handlers" attribute. 57 | * @param el - The element to parse the props from. 58 | * @param liveSocket - The LiveSocket instance. 59 | * @returns The props as an object. 60 | */ 61 | const getProps = (el: HTMLElement, liveSocket: any): Record => ({ 62 | ...getAttributeJson(el, "data-props"), 63 | ...getHandlers(el, liveSocket), 64 | }) 65 | 66 | export const getVueHook = ({ resolve, setup }: LiveVueApp): LiveHookInternal => ({ 67 | async mounted() { 68 | const componentName = this.el.getAttribute("data-name") as string 69 | const component = await resolve(componentName) 70 | 71 | const makeApp = this.el.getAttribute("data-ssr") === "true" ? createSSRApp : createApp 72 | 73 | const props = reactive(getProps(this.el, this.liveSocket)) 74 | const slots = reactive(getSlots(this.el)) 75 | const app = setup({ 76 | createApp: makeApp, 77 | component, 78 | props, 79 | slots, 80 | plugin: { install: (app: App) => app.provide(liveInjectKey, this) }, 81 | el: this.el, 82 | ssr: false, 83 | }) 84 | 85 | if (!app) throw new Error("Setup function did not return a Vue app!") 86 | 87 | this.vue = { props, slots, app } 88 | }, 89 | updated() { 90 | Object.assign(this.vue.props ?? {}, getProps(this.el, this.liveSocket)) 91 | Object.assign(this.vue.slots ?? {}, getSlots(this.el)) 92 | }, 93 | destroyed() { 94 | const instance = this.vue.app 95 | // TODO - is there maybe a better way to cleanup the app? 96 | if (instance) { 97 | window.addEventListener("phx:page-loading-stop", () => instance.unmount(), { once: true }) 98 | } 99 | }, 100 | }) 101 | 102 | /** 103 | * Returns the hooks for the LiveVue app. 104 | * @param components - The components to use in the app. 105 | * @param options - The options for the LiveVue app. 106 | * @returns The hooks for the LiveVue app. 107 | */ 108 | type VueHooks = { VueHook: LiveHookInternal } 109 | type getHooksAppFn = (app: LiveVueApp) => VueHooks 110 | type getHooksComponentsOptions = { initializeApp?: LiveVueOptions["setup"] } 111 | type getHooksComponentsFn = (components: ComponentMap, options?: getHooksComponentsOptions) => VueHooks 112 | 113 | export const getHooks: getHooksComponentsFn | getHooksAppFn = ( 114 | componentsOrApp: ComponentMap | LiveVueApp, 115 | options?: getHooksComponentsOptions 116 | ) => { 117 | const app = migrateToLiveVueApp(componentsOrApp, options ?? {}) 118 | return { VueHook: getVueHook(app) } 119 | } 120 | -------------------------------------------------------------------------------- /assets/js/live_vue/index.ts: -------------------------------------------------------------------------------- 1 | export { createLiveVue } from "./app.js" 2 | export { getHooks } from "./hooks.js" 3 | export type { LiveVueApp, LiveVueOptions, SetupContext, VueComponent } from "./types.js" 4 | export { useLiveVue } from "./use.js" 5 | export { findComponent } from "./utils.js" 6 | export { default as Link } from "./link.js" 7 | -------------------------------------------------------------------------------- /assets/js/live_vue/link.ts: -------------------------------------------------------------------------------- 1 | import { computed, defineComponent, h } from "vue" 2 | 3 | type PhxLinkAttrs = { 4 | href: string 5 | "data-phx-link"?: "redirect" | "patch" 6 | "data-phx-link-state"?: "replace" | "push" 7 | } 8 | 9 | export default defineComponent({ 10 | props: { 11 | /** 12 | * Uses traditional browser navigation to the new location. 13 | * This means the whole page is reloaded on the browser. 14 | */ 15 | href: { 16 | type: String, 17 | default: null, 18 | }, 19 | /** 20 | * Patches the current LiveView. 21 | * The `handle_params` callback of the current LiveView will be invoked and the minimum content 22 | * will be sent over the wire, as any other LiveView diff. 23 | */ 24 | patch: { 25 | type: String, 26 | default: null, 27 | }, 28 | /** 29 | * Navigates to a LiveView. 30 | * When redirecting across LiveViews, the browser page is kept, but a new LiveView process 31 | * is mounted and its contents is loaded on the page. It is only possible to navigate 32 | * between LiveViews declared under the same router 33 | * [`live_session`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Router.html#live_session/3). 34 | * When used outside of a LiveView or across live sessions, it behaves like a regular 35 | * browser redirect. 36 | */ 37 | navigate: { 38 | type: String, 39 | default: null, 40 | }, 41 | /** 42 | * When using `:patch` or `:navigate`, 43 | * should the browser's history be replaced with `pushState`? 44 | */ 45 | replace: { 46 | type: Boolean, 47 | default: false, 48 | }, 49 | }, 50 | setup(props, { attrs, slots }) { 51 | const linkAttrs = computed(() => { 52 | if (!props.patch && !props.navigate) { 53 | return { 54 | href: props.href || "#", 55 | } 56 | } 57 | 58 | return { 59 | href: (props.navigate ? props.navigate : props.patch) || "#", 60 | "data-phx-link": props.navigate ? "redirect" : "patch", 61 | "data-phx-link-state": props.replace ? "replace" : "push", 62 | } 63 | }) 64 | 65 | return () => { 66 | return h( 67 | "a", 68 | { 69 | ...attrs, 70 | ...linkAttrs.value, 71 | }, 72 | slots 73 | ) 74 | } 75 | }, 76 | }) 77 | -------------------------------------------------------------------------------- /assets/js/live_vue/server.ts: -------------------------------------------------------------------------------- 1 | // @ts-config ./tsconfig.server.json 2 | 3 | import fs from "fs" 4 | import { basename, resolve } from "path" 5 | import { ViewHookInternal } from "phoenix_live_view" 6 | import { App, Component, createSSRApp, h } from "vue" 7 | import { renderToString, type SSRContext } from "vue/server-renderer" 8 | import { migrateToLiveVueApp } from "./app.js" 9 | import { LiveVueOptions, VueArgs } from "./types.js" 10 | import { mapValues } from "./utils.js" 11 | 12 | type Components = Record 13 | type Manifest = Record 14 | 15 | const mockLive: Partial> & { 16 | el: {} 17 | liveSocket: {} 18 | removeHandleEvent: () => void 19 | upload: () => void 20 | uploadTo: () => void 21 | vue: Omit & { app: object } 22 | } = { 23 | el: {}, 24 | liveSocket: {}, 25 | pushEvent: () => 0, 26 | pushEventTo: () => 0, 27 | handleEvent: () => () => {}, 28 | removeHandleEvent: () => {}, 29 | upload: () => {}, 30 | uploadTo: () => {}, 31 | vue: { 32 | props: {}, 33 | slots: {}, 34 | app: {}, 35 | }, 36 | } 37 | export const getRender = (componentsOrApp: Components | LiveVueOptions, manifest: Manifest = {}) => { 38 | const { resolve, setup } = migrateToLiveVueApp(componentsOrApp) 39 | 40 | return async (name: string, props: Record, slots: Record) => { 41 | const component = await resolve(name) 42 | const slotComponents = mapValues(slots, html => () => h("div", { innerHTML: html })) 43 | const app = setup({ 44 | createApp: createSSRApp, 45 | component, 46 | props, 47 | slots: slotComponents, 48 | plugin: { 49 | install: (app: App) => { 50 | // we don't want to mount the app in SSR 51 | app.mount = (...args: unknown[]): any => undefined 52 | // we don't have hook instance in SSR, so we need to mock it 53 | app.provide("_live_vue", Object.assign({}, mockLive)) 54 | }, 55 | }, 56 | // @ts-ignore - this is just an IDE issue. the compiler is correctly processing this with the server tsconfig 57 | el: {}, 58 | ssr: true, 59 | }) 60 | 61 | if (!app) throw new Error("Setup function did not return a Vue app!") 62 | 63 | const ctx: SSRContext = {} 64 | const html = await renderToString(app, ctx) 65 | 66 | // the SSR manifest generated by Vite contains module -> chunk/asset mapping 67 | // which we can then use to determine what files need to be preloaded for this 68 | // request. 69 | const preloadLinks = renderPreloadLinks(ctx.modules, manifest) 70 | // easy to split structure 71 | return preloadLinks + "" + html 72 | } 73 | } 74 | /** 75 | * Loads the manifest file from the given path and returns a record of the assets. 76 | * Manifest file is a JSON file generated by Vite for the client build. 77 | * We need to load it to know which files to preload for the given page. 78 | * @param path - The path to the manifest file. 79 | * @returns A record of the assets. 80 | */ 81 | export const loadManifest = (path: string): Record => { 82 | try { 83 | // it's generated only in prod build 84 | const content = fs.readFileSync(resolve(path), "utf-8") 85 | return JSON.parse(content) 86 | } catch (e) { 87 | // manifest is not available in dev, so let's just ignore it 88 | return {} 89 | } 90 | } 91 | 92 | function renderPreloadLinks(modules: SSRContext["modules"], manifest: Manifest) { 93 | let links = "" 94 | const seen = new Set() 95 | modules.forEach((id: string) => { 96 | const files = manifest[id] 97 | if (files) { 98 | files.forEach(file => { 99 | if (!seen.has(file)) { 100 | seen.add(file) 101 | const filename = basename(file) 102 | if (manifest[filename]) { 103 | for (const depFile of manifest[filename]) { 104 | links += renderPreloadLink(depFile) 105 | seen.add(depFile) 106 | } 107 | } 108 | links += renderPreloadLink(file) 109 | } 110 | }) 111 | } 112 | }) 113 | return links 114 | } 115 | 116 | function renderPreloadLink(file: string) { 117 | if (file.endsWith(".js")) { 118 | return `` 119 | } else if (file.endsWith(".css")) { 120 | return `` 121 | } else if (file.endsWith(".woff")) { 122 | return ` ` 123 | } else if (file.endsWith(".woff2")) { 124 | return ` ` 125 | } else if (file.endsWith(".gif")) { 126 | return ` ` 127 | } else if (file.endsWith(".jpg") || file.endsWith(".jpeg")) { 128 | return ` ` 129 | } else if (file.endsWith(".png")) { 130 | return ` ` 131 | } else { 132 | // TODO 133 | return "" 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /assets/js/live_vue/serverElementPolyfill.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Element extends Record {} 3 | } 4 | 5 | export {} 6 | -------------------------------------------------------------------------------- /assets/js/live_vue/types.ts: -------------------------------------------------------------------------------- 1 | import type { LiveSocket, ViewHook, ViewHookInternal } from "phoenix_live_view" 2 | import type { App, Component, createApp, createSSRApp, h, Plugin } from "vue" 3 | 4 | export type ComponentOrComponentModule = Component | { default: Component } 5 | export type ComponentOrComponentPromise = ComponentOrComponentModule | Promise 6 | export type ComponentMap = Record 7 | 8 | export type VueComponent = ComponentOrComponentPromise 9 | 10 | type VueComponentInternal = Parameters[0] 11 | type VuePropsInternal = Parameters[1] 12 | type VueSlotsInternal = Parameters[2] 13 | 14 | export type VueArgs = { 15 | props: VuePropsInternal 16 | slots: VueSlotsInternal 17 | app: App 18 | } 19 | 20 | // all the functions and additional properties that are available on the LiveHook 21 | export type LiveHook = ViewHookInternal & { vue: VueArgs; liveSocket: LiveSocket } 22 | 23 | // internal type for the hooks.ts file 24 | export type LiveHookInternal = ViewHook<{ vue: VueArgs; liveSocket: LiveSocket }> 25 | 26 | export interface SetupContext { 27 | createApp: typeof createSSRApp | typeof createApp 28 | component: VueComponentInternal 29 | props: Record 30 | slots: Record unknown> 31 | plugin: Plugin<[]> 32 | el: Element 33 | ssr: boolean 34 | } 35 | 36 | export type LiveVueOptions = { 37 | resolve: (path: string) => ComponentOrComponentPromise | undefined | null 38 | setup?: (context: SetupContext) => App 39 | } 40 | 41 | export type LiveVueApp = { 42 | setup: (context: SetupContext) => App 43 | resolve: (path: string) => ComponentOrComponentPromise 44 | } 45 | 46 | export interface LiveVue { 47 | VueHook: ViewHook 48 | } 49 | -------------------------------------------------------------------------------- /assets/js/live_vue/use.ts: -------------------------------------------------------------------------------- 1 | import { inject } from "vue" 2 | import type { LiveHook } from "./types.js" 3 | 4 | export const liveInjectKey = "_live_vue" 5 | 6 | /** 7 | * Returns the LiveVue instance. 8 | * Can be used to access the LiveVue instance from within a LiveVue component. 9 | * It allows to e.g. push events to the LiveView. 10 | */ 11 | export const useLiveVue = (): LiveHook => { 12 | const live = inject(liveInjectKey) 13 | if (!live) throw new Error("LiveVue not provided. Are you using this inside a LiveVue component?") 14 | return live 15 | } 16 | -------------------------------------------------------------------------------- /assets/js/live_vue/utils.ts: -------------------------------------------------------------------------------- 1 | import { ComponentMap, ComponentOrComponentPromise } from "./types.js" 2 | 3 | /** 4 | * Maps the values of an object using a callback function and returns a new object with the mapped values. 5 | * @returns A new object with the mapped values. 6 | */ 7 | export const mapValues = ( 8 | object: Record, 9 | cb: (value: T, key: string, object: Record) => U 10 | ): Record => 11 | Object.entries(object).reduce((acc, [key, value]) => { 12 | acc[key] = cb(value, key, object) 13 | return acc 14 | }, {} as Record) 15 | 16 | /** 17 | * Flattens the keys of an object using a callback function and returns a new object with the flattened keys. 18 | * @returns A new object with the flattened keys. 19 | */ 20 | export const flatMapKeys = ( 21 | object: Record, 22 | cb: (key: string, value: any, object: Record) => string[] 23 | ): Record => 24 | Object.entries(object).reduce((acc, [key, value]) => { 25 | const newKeys = cb(key, value, object) 26 | for (const newKey of newKeys) acc[newKey] = value 27 | return acc 28 | }, {} as Record) 29 | 30 | /** 31 | * Finds a component by name or path suffix. 32 | * @returns The component if found, otherwise throws an error with a list of available components. 33 | */ 34 | export const findComponent = (components: ComponentMap, name: string): ComponentOrComponentPromise => { 35 | // we're looking for a component by name or path suffix. 36 | for (const [key, value] of Object.entries(components)) { 37 | if (key.endsWith(`${name}.vue`) || key.endsWith(`${name}/index.vue`)) { 38 | return value 39 | } 40 | } 41 | 42 | // a helpful message for the user 43 | const availableComponents = Object.keys(components) 44 | .map(key => key.replace("../../lib/", "").replace("/index.vue", "").replace(".vue", "").replace("./", "")) 45 | .filter(key => !key.startsWith("_build")) 46 | .join("\n") 47 | 48 | throw new Error(`Component '${name}' not found! Available components:\n\n${availableComponents}\n\n`) 49 | } 50 | -------------------------------------------------------------------------------- /assets/js/live_vue/vitePlugin.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { IncomingMessage, ServerResponse } from "http" 4 | import { Connect, ModuleNode, Plugin } from "vite" 5 | 6 | interface PluginOptions { 7 | path?: string 8 | entrypoint?: string 9 | } 10 | 11 | interface ExtendedIncomingMessage extends Connect.IncomingMessage { 12 | body?: Record // or more specific type if known 13 | } 14 | 15 | function hotUpdateType(path: string): "css-update" | "js-update" | null { 16 | if (path.endsWith("css")) return "css-update" 17 | if (path.endsWith("js")) return "js-update" 18 | return null 19 | } 20 | 21 | const jsonResponse = (res: ServerResponse, statusCode: number, data: unknown) => { 22 | res.statusCode = statusCode 23 | res.setHeader("Content-Type", "application/json") 24 | res.end(JSON.stringify(data)) 25 | } 26 | 27 | // Custom JSON parsing middleware 28 | const jsonMiddleware = ( 29 | req: ExtendedIncomingMessage, 30 | res: ServerResponse, 31 | next: () => Promise 32 | ) => { 33 | let data = "" 34 | 35 | // Listen for data event to collect the chunks of data 36 | req.on("data", chunk => { 37 | data += chunk 38 | }) 39 | 40 | // Listen for end event to finish data collection 41 | req.on("end", () => { 42 | try { 43 | // Parse the collected data as JSON 44 | req.body = JSON.parse(data) 45 | next() // Proceed to the next middleware 46 | } catch (error) { 47 | // Handle JSON parse error 48 | jsonResponse(res, 400, { error: "Invalid JSON" }) 49 | } 50 | }) 51 | 52 | // Handle error event 53 | req.on("error", (err: Error) => { 54 | console.error(err) 55 | jsonResponse(res, 500, { error: "Internal Server Error" }) 56 | }) 57 | } 58 | 59 | function liveVuePlugin(opts: PluginOptions = {}): Plugin { 60 | return { 61 | name: "live-vue", 62 | handleHotUpdate({ file, modules, server, timestamp }) { 63 | if (file.match(/\.(heex|ex)$/)) { 64 | // if it's and .ex or .heex file, invalidate all related files so they'll be updated correctly 65 | const invalidatedModules = new Set() 66 | for (const mod of modules) { 67 | server.moduleGraph.invalidateModule(mod, invalidatedModules, timestamp, true) 68 | } 69 | 70 | const updates = Array.from(invalidatedModules).flatMap(m => { 71 | const { file } = m 72 | 73 | if (!file) return [] 74 | 75 | const updateType = hotUpdateType(file) 76 | 77 | if (!updateType) return [] 78 | 79 | return { 80 | type: updateType, 81 | path: m.url, 82 | acceptedPath: m.url, 83 | timestamp: timestamp, 84 | } 85 | }) 86 | 87 | // ask client to hot-reload updated modules 88 | server.ws.send({ 89 | type: "update", 90 | updates, 91 | }) 92 | 93 | // we handle the hot update ourselves 94 | return [] 95 | } 96 | }, 97 | configureServer(server) { 98 | // Terminate the watcher when Phoenix quits 99 | // configureServer is only called in dev, so it's safe to use here 100 | process.stdin.on("close", () => process.exit(0)) 101 | process.stdin.resume() 102 | 103 | // setup SSR endpoint /ssr_render 104 | const path = opts.path || "/ssr_render" 105 | const entrypoint = opts.entrypoint || "./js/server.js" 106 | server.middlewares.use(function liveVueMiddleware(req: ExtendedIncomingMessage, res, next) { 107 | if (req.method == "POST" && req.url?.split("?", 1)[0] === path) { 108 | jsonMiddleware(req, res, async () => { 109 | try { 110 | const render = (await server.ssrLoadModule(entrypoint)).render 111 | const html = await render(req.body?.name, req.body?.props, req.body?.slots) 112 | res.end(html) 113 | } catch (e) { 114 | e instanceof Error && server.ssrFixStacktrace(e) 115 | jsonResponse(res, 500, { error: e }) 116 | } 117 | }) 118 | } else { 119 | next() 120 | } 121 | }) 122 | }, 123 | } 124 | } 125 | 126 | export default liveVuePlugin 127 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :live_vue, 4 | # for dev LiveVue.SSR.ViteJS 5 | # for prod LiveVue.SSR.NodeJS 6 | ssr_module: nil, 7 | 8 | # if we should by default use ssr or not. 9 | # can be overridden by v-ssr={true|false} attribute 10 | ssr: nil, 11 | 12 | # in dev most likely http://localhost:5173 13 | vite_host: nil, 14 | 15 | # it's relative to LiveVue.SSR.NodeJS.server_path, so "priv" directory 16 | # that file is created by Vite "build-server" command 17 | ssr_filepath: "./vue/server.js" 18 | -------------------------------------------------------------------------------- /example_project/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | plugins: [Phoenix.LiveView.HTMLFormatter], 4 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /example_project/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | live_vue_examples-*.tar 27 | 28 | # Ignore assets that are produced by build tools. 29 | /priv/static/assets/ 30 | 31 | # Ignore digested assets cache. 32 | /priv/static/cache_manifest.json 33 | 34 | # In case you use Node.js/npm, you want to ignore these. 35 | npm-debug.log 36 | /assets/node_modules/ 37 | 38 | 39 | # Ignore automatically generated Vue files by the ~V sigil 40 | /assets/vue/_build/ 41 | 42 | # Ignore ssr build for vue. 43 | /priv/vue/ -------------------------------------------------------------------------------- /example_project/README.md: -------------------------------------------------------------------------------- 1 | # LiveVueExamples 2 | 3 | To start your Phoenix server: 4 | 5 | * Run `mix setup` to install and setup dependencies 6 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` 7 | 8 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 9 | 10 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 11 | 12 | ## Learn more 13 | 14 | * Official website: https://www.phoenixframework.org/ 15 | * Guides: https://hexdocs.pm/phoenix/overview.html 16 | * Docs: https://hexdocs.pm/phoenix 17 | * Forum: https://elixirforum.com/c/phoenix-forum 18 | * Source: https://github.com/phoenixframework/phoenix 19 | -------------------------------------------------------------------------------- /example_project/assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | /* This file is for your main application CSS */ 6 | -------------------------------------------------------------------------------- /example_project/assets/img/card-top.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Valian/live_vue/88b6f570cf9c0cc8723726db2a9d575ef60f97a9/example_project/assets/img/card-top.jpeg -------------------------------------------------------------------------------- /example_project/assets/img/phoenix-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /example_project/assets/img/plus-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /example_project/assets/img/vue-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /example_project/assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 19 | import "phoenix_html" 20 | // Establish Phoenix Socket and LiveView configuration. 21 | import { Socket } from "phoenix" 22 | import { LiveSocket } from "phoenix_live_view" 23 | import topbar from "topbar" 24 | 25 | // live_vue related imports 26 | import { getHooks } from "live_vue" 27 | import "../css/app.css" 28 | import liveVueApp from "../vue" 29 | 30 | const hooks = getHooks(liveVueApp) 31 | 32 | let csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") 33 | let liveSocket = new LiveSocket("/live", Socket, { 34 | params: { _csrf_token: csrfToken }, 35 | hooks, 36 | }) 37 | 38 | // Show progress bar on live navigation and form submits 39 | topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }) 40 | window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) 41 | window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) 42 | 43 | // connect if there are any LiveViews on the page 44 | liveSocket.connect() 45 | 46 | // expose liveSocket on window for web console debug logs and latency simulation: 47 | // >> liveSocket.enableDebug() 48 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 49 | // >> liveSocket.disableLatencySim() 50 | window.liveSocket = liveSocket 51 | 52 | // declare global { 53 | // interface Window { 54 | // liveSocket: LiveSocket 55 | // } 56 | // } -------------------------------------------------------------------------------- /example_project/assets/js/server.js: -------------------------------------------------------------------------------- 1 | import { getRender, loadManifest } from "live_vue/server" 2 | import app from "../vue" 3 | 4 | // present only in prod build. Returns empty obj if doesn't exist 5 | // used to render preload links 6 | const manifest = loadManifest("../priv/vue/.vite/ssr-manifest.json") 7 | export const render = getRender(app, manifest) 8 | -------------------------------------------------------------------------------- /example_project/assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "dev": "vite --host -l warn", 6 | "build": "vue-tsc && vite build", 7 | "build-server": "vue-tsc && vite build --ssr js/server.js --out-dir ../priv/vue --minify esbuild --ssrManifest && echo '{\"type\": \"module\" } ' > ../priv/vue/package.json" 8 | }, 9 | "devDependencies": { 10 | "@tailwindcss/forms": "^0.5.7", 11 | "@types/phoenix_live_view": "^0.20.0", 12 | "@vitejs/plugin-vue": "^5.0.4", 13 | "autoprefixer": "^10.4.19", 14 | "postcss": "^8.4.38", 15 | "tailwindcss": "^3.4.3", 16 | "typescript": "^5.4.5", 17 | "vite": "^5.2.9", 18 | "vue-tsc": "^2.0.13" 19 | }, 20 | "dependencies": { 21 | "@primevue/themes": "^4.0.3", 22 | "live_vue": "file:../..", 23 | "phoenix": "file:../deps/phoenix", 24 | "phoenix_html": "file:../deps/phoenix_html", 25 | "phoenix_live_view": "file:../deps/phoenix_live_view", 26 | "pinia": "^2.1.7", 27 | "primevue": "^4.0.3", 28 | "topbar": "^2.0.2", 29 | "vite-plugin-vuetify": "^2.0.4", 30 | "vue": "^3.4.21", 31 | "vuetify": "^3.7.1" 32 | }, 33 | "overrides": { 34 | "cross-spawn": "^7.0.5", 35 | "nanoid": "^3.3.8", 36 | "rollup": "^4.22.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /example_project/assets/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /example_project/assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin") 5 | const fs = require("fs") 6 | const path = require("path") 7 | const defaultTheme = require('tailwindcss/defaultTheme') 8 | 9 | module.exports = { 10 | darkMode: 'selector', 11 | content: [ 12 | "./js/**/*.js", 13 | "../lib/live_vue_examples_web.ex", 14 | "../lib/live_vue_examples_web/**/*.*ex", 15 | "./vue/**/*.vue", 16 | "../lib/**/*.vue" 17 | ], 18 | theme: { 19 | extend: { 20 | colors: { 21 | brand: "#FD4F00", 22 | orange: { 23 | phoenix: '#FD4F00' 24 | } 25 | }, 26 | fontFamily: { 27 | 'sans': ['"Inter var"', "Inter", ...defaultTheme.fontFamily.sans], 28 | }, 29 | }, 30 | }, 31 | plugins: [ 32 | require("@tailwindcss/forms"), 33 | // Allows prefixing tailwind classes with LiveView classes to add rules 34 | // only when LiveView classes are applied, for example: 35 | // 36 | //
37 | // 38 | plugin(({ addVariant }) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), 39 | plugin(({ addVariant }) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), 40 | plugin(({ addVariant }) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), 41 | plugin(({ addVariant }) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), 42 | 43 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle 44 | // See your `CoreComponents.icon/1` for more information. 45 | // 46 | plugin(function ({ matchComponents, theme }) { 47 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") 48 | let values = {} 49 | let icons = [ 50 | ["", "/24/outline"], 51 | ["-solid", "/24/solid"], 52 | ["-mini", "/20/solid"], 53 | ["-micro", "/16/solid"] 54 | ] 55 | icons.forEach(([suffix, dir]) => { 56 | fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { 57 | let name = path.basename(file, ".svg") + suffix 58 | values[name] = { name, fullPath: path.join(iconsDir, dir, file) } 59 | }) 60 | }) 61 | matchComponents({ 62 | "hero": ({ name, fullPath }) => { 63 | let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") 64 | let size = theme("spacing.6") 65 | if (name.endsWith("-mini")) { 66 | size = theme("spacing.5") 67 | } else if (name.endsWith("-micro")) { 68 | size = theme("spacing.4") 69 | } 70 | return { 71 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, 72 | "-webkit-mask": `var(--hero-${name})`, 73 | "mask": `var(--hero-${name})`, 74 | "mask-repeat": "no-repeat", 75 | "background-color": "currentColor", 76 | "vertical-align": "middle", 77 | "display": "inline-block", 78 | "width": size, 79 | "height": size 80 | } 81 | } 82 | }, { values }) 83 | }) 84 | ] 85 | } -------------------------------------------------------------------------------- /example_project/assets/ts_config_example/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | /// 4 | 5 | /** 6 | * This file is given as a reference should you want to use typescript 7 | * with your tailwindcss configuration. If you want to use this, you can 8 | * replace assets/tailwind.config.js with this file. 9 | * 10 | * Depending on your tsconfig, and wether you use type:module in your 11 | * assets/package.json you may have a resolution error using a .ts file 12 | * but you can try to name it tailwind.config.mts instead. 13 | */ 14 | 15 | import tailwindcssForms from "@tailwindcss/forms" 16 | import fs from "node:fs" 17 | import path from "node:path" 18 | import defaultTheme from "tailwindcss/defaultTheme" 19 | import plugin from "tailwindcss/plugin" 20 | import { CSSRuleObject, DarkModeConfig } from "tailwindcss/types/config" 21 | 22 | export default { 23 | darkMode: 'selector' as DarkModeConfig, 24 | content: [ 25 | "./js/**/*.js", 26 | "../lib/live_vue_examples_web.ex", 27 | "../lib/live_vue_examples_web/**/*.*ex", 28 | "./vue/**/*.vue", 29 | "../lib/**/*.vue" 30 | ], 31 | theme: { 32 | extend: { 33 | colors: { 34 | brand: "#FD4F00", 35 | orange: { 36 | phoenix: '#FD4F00' 37 | } 38 | }, 39 | fontFamily: { 40 | 'sans': ['"Inter var"', "Inter", ...defaultTheme.fontFamily.sans], 41 | }, 42 | }, 43 | }, 44 | plugins: [ 45 | tailwindcssForms(), 46 | // Allows prefixing tailwind classes with LiveView classes to add rules 47 | // only when LiveView classes are applied, for example: 48 | // 49 | //
50 | // 51 | plugin(({ addVariant }) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), 52 | plugin(({ addVariant }) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), 53 | plugin(({ addVariant }) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), 54 | plugin(({ addVariant }) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), 55 | 56 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle 57 | // See your `CoreComponents.icon/1` for more information. 58 | // 59 | plugin(function ({ matchComponents, theme }) { 60 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") 61 | let values: Record = {} 62 | let icons = [ 63 | ["", "/24/outline"], 64 | ["-solid", "/24/solid"], 65 | ["-mini", "/20/solid"], 66 | ["-micro", "/16/solid"] 67 | ] 68 | icons.forEach(([suffix, dir]) => { 69 | fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { 70 | let name = path.basename(file, ".svg") + suffix 71 | values[name] = { name, fullPath: path.join(iconsDir, dir, file) } 72 | }) 73 | }) 74 | matchComponents({ 75 | "hero": (options) => { 76 | if (typeof options === "string") return null 77 | const { name, fullPath } = options 78 | let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") 79 | let size = theme("spacing.6") 80 | if (name.endsWith("-mini")) { 81 | size = theme("spacing.5") 82 | } else if (name.endsWith("-micro")) { 83 | size = theme("spacing.4") 84 | } 85 | const css:CSSRuleObject = Object.assign({ 86 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, 87 | "-webkit-mask": `var(--hero-${name})`, 88 | "mask": `var(--hero-${name})`, 89 | "mask-repeat": "no-repeat", 90 | "background-color": "currentColor", 91 | "vertical-align": "middle", 92 | "display": "inline-block", 93 | }, size ? { width: size, height: size } : {}) 94 | 95 | return css 96 | } 97 | }, { values }) 98 | }) 99 | ] 100 | } 101 | -------------------------------------------------------------------------------- /example_project/assets/ts_config_example/vite.config.mts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is provided as an example of how to configure Vite for a 3 | * Phoenix LiveView project, using typescript. If you want to use this, you can 4 | * replace assets/vite.config.js with this file. 5 | * 6 | * Some Vite plugins don't have a very good typescript support so it's hard 7 | * to get rid of the the cast to PluginOption[] in the plugins array. 8 | */ 9 | 10 | import path from "path" 11 | import { defineConfig, PluginOption } from "vite" 12 | 13 | import vue from "@vitejs/plugin-vue" 14 | import autoprefixer from "autoprefixer" 15 | import liveVuePlugin from "live_vue/vitePlugin" 16 | import tailwindcss from "tailwindcss" 17 | import { fileURLToPath } from "url" 18 | import vuetify from "vite-plugin-vuetify" 19 | import tailwindConfig from "./tailwind.config.ts" 20 | 21 | const __filename = fileURLToPath(import.meta.url) 22 | const __dirname = path.dirname(__filename) 23 | 24 | // https://vitejs.dev/config/ 25 | export default defineConfig(({ command }) => { 26 | const isDev = command !== "build" 27 | 28 | return { 29 | base: isDev ? undefined : "/assets", 30 | publicDir: "static", 31 | // when using typescript in your vite config, you must delete the 32 | // postcss.config.js file, and rely on the vite css option instead 33 | // to load the autoprefixer and tailwindcss plugins 34 | css: { 35 | postcss: { plugins: [tailwindcss(tailwindConfig), autoprefixer()] }, 36 | }, 37 | plugins: [ 38 | vue(), 39 | // if you change ./js/server.js, to typescript, you need 40 | // to set livePlugin({ entrypoint: "./js/server.ts" }) 41 | liveVuePlugin(), 42 | vuetify({ autoImport: { labs: true } }), 43 | ] as PluginOption[], 44 | ssr: { 45 | // we need it, because in SSR build we want no external 46 | // and in dev, we want external for fast updates 47 | noExternal: ["vuetify"], 48 | }, 49 | resolve: { 50 | alias: { 51 | vue: path.resolve(__dirname, "node_modules/vue"), 52 | "@": path.resolve(__dirname, "."), 53 | }, 54 | }, 55 | optimizeDeps: { 56 | // these packages are loaded as file:../deps/ imports 57 | // so they're not optimized for development by vite by default 58 | // we want to enable it for better DX 59 | // more https://vitejs.dev/guide/dep-pre-bundling#monorepos-and-linked-dependencies 60 | include: ["phoenix", "phoenix_html", "phoenix_live_view"], 61 | }, 62 | build: { 63 | commonjsOptions: { transformMixedEsModules: true }, 64 | target: "es2020", 65 | outDir: "../priv/static/assets", // emit assets to priv/static/assets 66 | emptyOutDir: true, 67 | sourcemap: isDev, // enable source map in dev build 68 | manifest: false, // do not generate manifest.json 69 | rollupOptions: { 70 | input: { 71 | app: path.resolve(__dirname, "./js/app.js"), 72 | }, 73 | output: { 74 | // remove hashes to match phoenix way of handling assets 75 | entryFileNames: "[name].js", 76 | chunkFileNames: "[name].js", 77 | assetFileNames: "[name][extname]", 78 | }, 79 | }, 80 | }, 81 | } 82 | }) 83 | -------------------------------------------------------------------------------- /example_project/assets/ts_config_example/vue/index.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * This is an example if you want to use typescript for your LiveVue setup. 5 | * It is an drop-in replacement for the assets/js/vue/index.js file, so you 6 | * can replace it with this file. 7 | */ 8 | 9 | // polyfill recommended by Vite https://vitejs.dev/config/build-options#build-modulepreload 10 | import { createLiveVue, findComponent, type SetupContext, type VueComponent } from "live_vue" 11 | import "vite/modulepreload-polyfill" 12 | import { h } from "vue" 13 | 14 | // Example integration with Vuetify 15 | // not importing styles because it conflicts with tailwind, if you want vuetify don't use tailwind 16 | // Calendar example works fine without importing styles 17 | // https://github.com/tailwindlabs/tailwindcss/issues/465 18 | // import "vuetify/styles" 19 | import { createVuetify } from "vuetify" 20 | import * as vuetifyDirectives from "vuetify/directives" 21 | import { VCalendar } from "vuetify/labs/VCalendar" 22 | 23 | const vuetify = createVuetify({ 24 | // @ts-expect-error this one is a problem with Vuetify typings 25 | vuetifyComponents: { VCalendar }, 26 | vuetifyDirectives, 27 | }) 28 | 29 | // Example integration with PrimeVue 30 | import Aura from "@primevue/themes/aura" 31 | import PrimeVue from "primevue/config" 32 | 33 | export default createLiveVue({ 34 | resolve: (name: string) => { 35 | // we get back a map of components with their relative paths as keys. 36 | // we're importing from ../../lib to allow collocating Vue files with LiveView files 37 | // eager: true disables lazy loading - all these components will be part of the app.js bundle 38 | const components = { 39 | ...import.meta.glob("./**/*.vue", { eager: true }), 40 | ...import.meta.glob("../../lib/**/*.vue"), 41 | } 42 | // finds component by name or path suffix and gives a nice error message. 43 | // `path/to/component/index.vue` can be found as `path/to/component` or simply `component` 44 | // `path/to/Component.vue` can be found as `path/to/Component` or simply `Component` 45 | return findComponent(components, name) 46 | }, 47 | setup: ({ createApp, component, props, slots, plugin, el }: SetupContext) => { 48 | // it's a default implementation, you can easily extend it to add your own plugins, directives etc. 49 | // @ts-expect-error this can fail because we import vue from two different places, but the types are ok 50 | const app = createApp({ render: () => h(component, props, slots) }) 51 | app.use(plugin) 52 | // @ts-expect-error - This is a problem with PrimeVue typings 53 | app.use(PrimeVue, { theme: { preset: Aura } }) 54 | // @ts-expect-error - This is a problem with vuetify typings 55 | app.use(vuetify) 56 | app.mount(el) 57 | return app 58 | }, 59 | }) 60 | -------------------------------------------------------------------------------- /example_project/assets/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Types for Vite https://vitejs.dev/guide/features#client-types */ 10 | "types": ["vite/client"], 11 | 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "preserve", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | 26 | "paths": { 27 | "@/*": ["./*"] 28 | } 29 | }, 30 | "include": ["js/**/*.ts", "js/**/*.js", "js/**/*.tsx", "vue/**/*.vue", "*.config.ts"], 31 | } -------------------------------------------------------------------------------- /example_project/assets/vite.config.js: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import { defineConfig } from "vite" 3 | 4 | import vue from "@vitejs/plugin-vue" 5 | import liveVuePlugin from "live_vue/vitePlugin" 6 | import vuetify from "vite-plugin-vuetify" 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig(({ command }) => { 10 | const isDev = command !== "build" 11 | 12 | return { 13 | base: isDev ? undefined : "/assets", 14 | publicDir: "static", 15 | plugins: [vue(), liveVuePlugin(), vuetify({ autoImport: { labs: true } })], 16 | worker: { 17 | format: "es", 18 | }, 19 | ssr: { 20 | // we need it, because in SSR build we want no external 21 | // and in dev, we want external for fast updates 22 | noExternal: ["vuetify"], 23 | }, 24 | resolve: { 25 | alias: { 26 | vue: path.resolve(__dirname, "node_modules/vue"), 27 | "@": path.resolve(__dirname, "."), 28 | }, 29 | }, 30 | optimizeDeps: { 31 | // these packages are loaded as file:../deps/ imports 32 | // so they're not optimized for development by vite by default 33 | // we want to enable it for better DX 34 | // more https://vitejs.dev/guide/dep-pre-bundling#monorepos-and-linked-dependencies 35 | include: ["phoenix", "phoenix_html", "phoenix_live_view"], 36 | }, 37 | build: { 38 | commonjsOptions: { transformMixedEsModules: true }, 39 | target: "es2020", 40 | outDir: "../priv/static/assets", // emit assets to priv/static/assets 41 | emptyOutDir: true, 42 | sourcemap: isDev, // enable source map in dev build 43 | manifest: false, // do not generate manifest.json 44 | rollupOptions: { 45 | input: { 46 | app: path.resolve(__dirname, "./js/app.js"), 47 | }, 48 | output: { 49 | // remove hashes to match phoenix way of handling assets 50 | entryFileNames: "[name].js", 51 | chunkFileNames: "[name].js", 52 | assetFileNames: "[name][extname]", 53 | }, 54 | }, 55 | }, 56 | } 57 | }) 58 | -------------------------------------------------------------------------------- /example_project/assets/vue/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore automatically generated Vue files by the ~V sigil 2 | _build/ -------------------------------------------------------------------------------- /example_project/assets/vue/Card.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | -------------------------------------------------------------------------------- /example_project/assets/vue/Counter.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example_project/assets/vue/FormExample.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /example_project/assets/vue/NavigationExample.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 72 | -------------------------------------------------------------------------------- /example_project/assets/vue/PrimeVueExample.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 28 | -------------------------------------------------------------------------------- /example_project/assets/vue/ShowState.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 29 | 30 | -------------------------------------------------------------------------------- /example_project/assets/vue/Simple.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /example_project/assets/vue/VuetifyCalendar.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 55 | -------------------------------------------------------------------------------- /example_project/assets/vue/index.js: -------------------------------------------------------------------------------- 1 | // polyfill recommended by Vite https://vitejs.dev/config/build-options#build-modulepreload 2 | import { createLiveVue, findComponent } from "live_vue" 3 | import "vite/modulepreload-polyfill" 4 | import { h } from "vue" 5 | 6 | // Example integration with Vuetify 7 | // not importing styles because it conflicts with tailwind, if you want vuetify don't use tailwind 8 | // Calendar example works fine without importing styles 9 | // https://github.com/tailwindlabs/tailwindcss/issues/465 10 | // import "vuetify/styles" 11 | import { createVuetify } from "vuetify" 12 | import * as vuetifyDirectives from "vuetify/directives" 13 | import { VCalendar } from "vuetify/labs/VCalendar" 14 | 15 | const vuetify = createVuetify({ 16 | vuetifyComponents: { VCalendar }, 17 | vuetifyDirectives, 18 | }) 19 | 20 | // Example integration with PrimeVue 21 | import Aura from "@primevue/themes/aura" 22 | import PrimeVue from "primevue/config" 23 | 24 | export default createLiveVue({ 25 | resolve: name => { 26 | // we get back a map of components with their relative paths as keys. 27 | // we're importing from ../../lib to allow collocating Vue files with LiveView files 28 | // eager: true disables lazy loading - all these components will be part of the app.js bundle 29 | const components = { 30 | ...import.meta.glob("./**/*.vue", { eager: true }), 31 | ...import.meta.glob("../../lib/**/*.vue"), 32 | } 33 | // finds component by name or path suffix and gives a nice error message. 34 | // `path/to/component/index.vue` can be found as `path/to/component` or simply `component` 35 | // `path/to/Component.vue` can be found as `path/to/Component` or simply `Component` 36 | return findComponent(components, name) 37 | }, 38 | setup: ({ createApp, component, props, slots, plugin, el }) => { 39 | // it's a default implementation, you can easily extend it to add your own plugins, directives etc. 40 | const app = createApp({ render: () => h(component, props, slots) }) 41 | app.use(plugin) 42 | app.use(PrimeVue, { theme: { preset: Aura } }) 43 | app.use(vuetify) 44 | app.mount(el) 45 | return app 46 | }, 47 | }) -------------------------------------------------------------------------------- /example_project/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :live_vue_examples, 11 | generators: [timestamp_type: :utc_datetime] 12 | 13 | # Configures the endpoint 14 | config :live_vue_examples, LiveVueExamplesWeb.Endpoint, 15 | url: [host: "localhost"], 16 | adapter: Bandit.PhoenixAdapter, 17 | render_errors: [ 18 | formats: [html: LiveVueExamplesWeb.ErrorHTML, json: LiveVueExamplesWeb.ErrorJSON], 19 | layout: false 20 | ], 21 | pubsub_server: LiveVueExamples.PubSub, 22 | live_view: [signing_salt: "+aOlrIce"] 23 | 24 | # Configures Elixir's Logger 25 | config :logger, :console, 26 | format: "$time $metadata[$level] $message\n", 27 | metadata: [:request_id] 28 | 29 | # Use Jason for JSON parsing in Phoenix 30 | config :phoenix, :json_library, Jason 31 | 32 | # Import environment specific config. This must remain at the bottom 33 | # of this file so it overrides the configuration defined above. 34 | import_config "#{config_env()}.exs" 35 | -------------------------------------------------------------------------------- /example_project/config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we can use it 8 | # to bundle .js and .css sources. 9 | config :live_vue_examples, LiveVueExamplesWeb.Endpoint, 10 | # Binding to loopback ipv4 address prevents access from other machines. 11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 12 | http: [ip: {127, 0, 0, 1}, port: 4000], 13 | check_origin: false, 14 | code_reloader: true, 15 | debug_errors: true, 16 | secret_key_base: "O6lODkS+7Il9/crSN+hOVVmqbf1pmm854unf8EY/hyWfx2dmUU7+DnISMEOcJqwa", 17 | watchers: [ 18 | npm: ["run", "dev", cd: Path.expand("../assets", __DIR__)] 19 | ] 20 | 21 | # ## SSL Support 22 | # 23 | # In order to use HTTPS in development, a self-signed 24 | # certificate can be generated by running the following 25 | # Mix task: 26 | # 27 | # mix phx.gen.cert 28 | # 29 | # Run `mix help phx.gen.cert` for more information. 30 | # 31 | # The `http:` config above can be replaced with: 32 | # 33 | # https: [ 34 | # port: 4001, 35 | # cipher_suite: :strong, 36 | # keyfile: "priv/cert/selfsigned_key.pem", 37 | # certfile: "priv/cert/selfsigned.pem" 38 | # ], 39 | # 40 | # If desired, both `http:` and `https:` keys can be 41 | # configured to run both http and https servers on 42 | # different ports. 43 | 44 | # Watch static and templates for browser reloading. 45 | config :live_vue_examples, LiveVueExamplesWeb.Endpoint, 46 | reloadable_apps: [:live_vue, :live_vue_examples_web, :live_vue_examples], 47 | live_reload: [ 48 | notify: [ 49 | live_view: [ 50 | ~r"lib/live_vue_examples_web/core_components.ex$", 51 | ~r"lib/live_vue_examples_web/(live|components)/.*(ex|heex)$" 52 | ] 53 | ], 54 | patterns: [ 55 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", 56 | ~r"lib/live_vue_examples_web/controllers/.*(ex|heex)$" 57 | ] 58 | ] 59 | 60 | # Enable dev routes for dashboard and mailbox 61 | config :live_vue_examples, dev_routes: true 62 | 63 | # Do not include metadata nor timestamps in development logs 64 | config :logger, :console, format: "[$level] $message\n" 65 | 66 | # Set a higher stacktrace during development. Avoid configuring such 67 | # in production as building large stacktraces may be expensive. 68 | config :phoenix, :stacktrace_depth, 20 69 | 70 | # Initialize plugs at runtime for faster development compilation 71 | config :phoenix, :plug_init_mode, :runtime 72 | 73 | config :phoenix_live_view, 74 | # Include HEEx debug annotations as HTML comments in rendered markup 75 | debug_heex_annotations: false, 76 | # Enable helpful, but potentially expensive runtime checks 77 | enable_expensive_runtime_checks: true 78 | 79 | config :live_vue, 80 | vite_host: "http://localhost:5173", 81 | ssr_module: LiveVue.SSR.ViteJS, 82 | ssr_filepath: "./vue/server.ts" 83 | -------------------------------------------------------------------------------- /example_project/config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Note we also include the path to a cache manifest 4 | # containing the digested version of static files. This 5 | # manifest is generated by the `mix assets.deploy` task, 6 | # which you should run after static files are built and 7 | # before starting your production server. 8 | config :live_vue_examples, LiveVueExamplesWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 9 | 10 | # Do not print debug messages in production 11 | config :logger, level: :info 12 | 13 | # Runtime production configuration, including reading 14 | # of environment variables, is done on config/runtime.exs. 15 | 16 | config :live_vue, 17 | ssr_module: LiveVue.SSR.NodeJS 18 | -------------------------------------------------------------------------------- /example_project/config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/live_vue_examples start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :live_vue_examples, LiveVueExamplesWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | # The secret key base is used to sign/encrypt cookies and other secrets. 25 | # A default value is used in config/dev.exs and config/test.exs but you 26 | # want to use a different value for prod and you most likely don't want 27 | # to check this value into version control, so we use an environment 28 | # variable instead. 29 | secret_key_base = 30 | System.get_env("SECRET_KEY_BASE") || 31 | raise """ 32 | environment variable SECRET_KEY_BASE is missing. 33 | You can generate one by calling: mix phx.gen.secret 34 | """ 35 | 36 | host = System.get_env("PHX_HOST") || "example.com" 37 | port = String.to_integer(System.get_env("PORT") || "4000") 38 | 39 | config :live_vue_examples, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") 40 | 41 | config :live_vue_examples, LiveVueExamplesWeb.Endpoint, 42 | url: [host: host, port: port, scheme: "http"], 43 | http: [ 44 | # Enable IPv6 and bind on all interfaces. 45 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 46 | # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 47 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 48 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 49 | port: port 50 | ], 51 | secret_key_base: secret_key_base 52 | 53 | # ## SSL Support 54 | # 55 | # To get SSL working, you will need to add the `https` key 56 | # to your endpoint configuration: 57 | # 58 | # config :live_vue_examples, LiveVueExamplesWeb.Endpoint, 59 | # https: [ 60 | # ..., 61 | # port: 443, 62 | # cipher_suite: :strong, 63 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 64 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 65 | # ] 66 | # 67 | # The `cipher_suite` is set to `:strong` to support only the 68 | # latest and more secure SSL ciphers. This means old browsers 69 | # and clients may not be supported. You can set it to 70 | # `:compatible` for wider support. 71 | # 72 | # `:keyfile` and `:certfile` expect an absolute path to the key 73 | # and cert in disk or a relative path inside priv, for example 74 | # "priv/ssl/server.key". For all supported SSL configuration 75 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 76 | # 77 | # We also recommend setting `force_ssl` in your config/prod.exs, 78 | # ensuring no data is ever sent via http, always redirecting to https: 79 | # 80 | # config :live_vue_examples, LiveVueExamplesWeb.Endpoint, 81 | # force_ssl: [hsts: true] 82 | # 83 | # Check `Plug.SSL` for all available options in `force_ssl`. 84 | end 85 | -------------------------------------------------------------------------------- /example_project/config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :live_vue_examples, LiveVueExamplesWeb.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4002], 7 | secret_key_base: "nxOTJYiY9aKYYLbStmQE7s36hsq0w46YtAnlfB/QNKv0BAeg/L6JG//Y/rizwRcB", 8 | server: false 9 | 10 | # Print only warnings and errors during test 11 | config :logger, level: :warning 12 | 13 | # Initialize plugs at runtime for faster test compilation 14 | config :phoenix, :plug_init_mode, :runtime 15 | 16 | config :phoenix_live_view, 17 | # Enable helpful, but potentially expensive runtime checks 18 | enable_expensive_runtime_checks: true 19 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamples do 2 | @moduledoc """ 3 | LiveVueExamples keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples/application.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamples.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | {NodeJS.Supervisor, [path: LiveVue.SSR.NodeJS.server_path(), pool_size: 4]}, 12 | LiveVueExamplesWeb.Telemetry, 13 | {DNSCluster, query: Application.get_env(:live_vue_examples, :dns_cluster_query) || :ignore}, 14 | {Phoenix.PubSub, name: LiveVueExamples.PubSub}, 15 | # Start a worker by calling: LiveVueExamples.Worker.start_link(arg) 16 | # {LiveVueExamples.Worker, arg}, 17 | # Start to serve requests, typically the last entry 18 | LiveVueExamplesWeb.Endpoint 19 | ] 20 | 21 | # See https://hexdocs.pm/elixir/Supervisor.html 22 | # for other strategies and supported options 23 | opts = [strategy: :one_for_one, name: LiveVueExamples.Supervisor] 24 | Supervisor.start_link(children, opts) 25 | end 26 | 27 | # Tell Phoenix to update the endpoint configuration 28 | # whenever the application is updated. 29 | @impl true 30 | def config_change(changed, _new, removed) do 31 | LiveVueExamplesWeb.Endpoint.config_change(changed, removed) 32 | :ok 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use LiveVueExamplesWeb, :controller 9 | use LiveVueExamplesWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | formats: [:html, :json], 43 | layouts: [html: LiveVueExamplesWeb.Layouts] 44 | 45 | import Plug.Conn 46 | 47 | unquote(verified_routes()) 48 | end 49 | end 50 | 51 | def live_view do 52 | quote do 53 | use Phoenix.LiveView, 54 | layout: {LiveVueExamplesWeb.Layouts, :app} 55 | 56 | unquote(html_helpers()) 57 | end 58 | end 59 | 60 | def live_component do 61 | quote do 62 | use Phoenix.LiveComponent 63 | 64 | unquote(html_helpers()) 65 | end 66 | end 67 | 68 | def html do 69 | quote do 70 | use Phoenix.Component 71 | 72 | # Import convenience functions from controllers 73 | import Phoenix.Controller, 74 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 75 | 76 | # Include general helpers for rendering HTML 77 | unquote(html_helpers()) 78 | end 79 | end 80 | 81 | defp html_helpers do 82 | quote do 83 | # HTML escaping functionality 84 | import Phoenix.HTML 85 | # Core UI components and translation 86 | import LiveVueExamplesWeb.CoreComponents 87 | 88 | # Add support to Vue components 89 | use LiveVue 90 | 91 | use LiveVue.Components, vue_root: ["./assets/vue", "./lib/live_vue_examples_web"] 92 | 93 | # Shortcut for generating JS commands 94 | alias Phoenix.LiveView.JS 95 | 96 | # Routes generation with the ~p sigil 97 | unquote(verified_routes()) 98 | end 99 | end 100 | 101 | def verified_routes do 102 | quote do 103 | use Phoenix.VerifiedRoutes, 104 | endpoint: LiveVueExamplesWeb.Endpoint, 105 | router: LiveVueExamplesWeb.Router, 106 | statics: LiveVueExamplesWeb.static_paths() 107 | end 108 | end 109 | 110 | @doc """ 111 | When used, dispatch to the appropriate controller/live_view/etc. 112 | """ 113 | defmacro __using__(which) when is_atom(which) do 114 | apply(__MODULE__, which, []) 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.Layouts do 2 | @moduledoc """ 3 | This module holds different layouts used by your application. 4 | 5 | See the `layouts` directory for all templates available. 6 | The "root" layout is a skeleton rendered as part of the 7 | application router. The "app" layout is set as the default 8 | layout on both `use LiveVueExamplesWeb, :controller` and 9 | `use LiveVueExamplesWeb, :live_view`. 10 | """ 11 | use LiveVueExamplesWeb, :html 12 | 13 | embed_templates "layouts/*" 14 | 15 | @env Mix.env() # remember value at compile time 16 | def dev_env?, do: @env == :dev 17 | end 18 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 25 |
26 | <%= @inner_content %> 27 |
28 | 29 |
30 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <.live_title suffix=" · Phoenix Framework"> 8 | <%= assigns[:page_title] || "LiveVueExamples" %> 9 | 10 | 11 | 12 | 14 | 15 | 16 | 17 |
18 |
19 |
20 | <.link navigate={~p"/"}> 21 | LiveVue 22 | 23 |
24 | 33 |
34 |
35 |
36 | <%= @inner_content %> 37 |
38 | 39 |
40 | Made with ❤️ by @jskalc 41 |
42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.ErrorHTML do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on HTML requests. 4 | 5 | See config/config.exs. 6 | """ 7 | use LiveVueExamplesWeb, :html 8 | 9 | # If you want to customize your error pages, 10 | # uncomment the embed_templates/1 call below 11 | # and add pages to the error directory: 12 | # 13 | # * lib/live_vue_examples_web/controllers/error_html/404.html.heex 14 | # * lib/live_vue_examples_web/controllers/error_html/500.html.heex 15 | # 16 | # embed_templates "error_html/*" 17 | 18 | # The default is to render a plain text page based on 19 | # the template name. For example, "404.html" becomes 20 | # "Not Found". 21 | def render(template, _assigns) do 22 | Phoenix.Controller.status_message_from_template(template) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.ErrorJSON do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on JSON requests. 4 | 5 | See config/config.exs. 6 | """ 7 | 8 | # If you want to customize a particular status code, 9 | # you may add your own clauses, such as: 10 | # 11 | # def render("500.json", _assigns) do 12 | # %{errors: %{detail: "Internal Server Error"}} 13 | # end 14 | 15 | # By default, Phoenix returns the status message from 16 | # the template name. For example, "404.json" becomes 17 | # "Not Found". 18 | def render(template, _assigns) do 19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.PageController do 2 | use LiveVueExamplesWeb, :controller 3 | 4 | def home(conn, _params), do: render(conn, :home) 5 | def dead(conn, _params), do: render(conn, :dead) 6 | end 7 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/controllers/page_html.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.PageHTML do 2 | @moduledoc """ 3 | This module contains pages rendered by PageController. 4 | 5 | See the `page_html` directory for all templates available. 6 | """ 7 | use LiveVueExamplesWeb, :html 8 | 9 | embed_templates "page_html/*" 10 | end 11 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/controllers/page_html/dead.html.heex: -------------------------------------------------------------------------------- 1 | <.header>Dead view 2 |
3 |
4 |

With SSR

5 | <.vue v-component="Simple" v-ssr={true} /> 6 |
7 | 8 |
9 |

Without SSR

10 | <.vue v-component="Simple" v-ssr={false} /> 11 |
12 |
13 | 14 |

SSR is not necessary on live navigation within the same live session!

15 | 16 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :live_vue_examples 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_live_vue_examples_key", 10 | signing_salt: "mWezvMmB", 11 | same_site: "Lax" 12 | ] 13 | 14 | socket "/live", Phoenix.LiveView.Socket, 15 | websocket: [connect_info: [session: @session_options]], 16 | longpoll: [connect_info: [session: @session_options]] 17 | 18 | # Serve at "/" the static files from "priv/static" directory. 19 | # 20 | # You should set gzip to true if you are running phx.digest 21 | # when deploying your static files in production. 22 | plug Plug.Static, 23 | at: "/", 24 | from: :live_vue_examples, 25 | gzip: false, 26 | only: LiveVueExamplesWeb.static_paths() 27 | 28 | if Mix.env() == :dev do 29 | plug Plug.Static, 30 | at: "/", 31 | from: "assets", 32 | gzip: false 33 | end 34 | 35 | # Code reloading can be explicitly enabled under the 36 | # :code_reloader configuration of your endpoint. 37 | if code_reloading? do 38 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 39 | plug Phoenix.LiveReloader 40 | plug Phoenix.CodeReloader 41 | end 42 | 43 | plug Phoenix.LiveDashboard.RequestLogger, 44 | param_key: "request_logger", 45 | cookie_key: "request_logger" 46 | 47 | plug Plug.RequestId 48 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 49 | 50 | plug Plug.Parsers, 51 | parsers: [:urlencoded, :multipart, :json], 52 | pass: ["*/*"], 53 | json_decoder: Phoenix.json_library() 54 | 55 | plug Plug.MethodOverride 56 | plug Plug.Head 57 | plug Plug.Session, @session_options 58 | plug LiveVueExamplesWeb.Router 59 | end 60 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/live/calendar.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.CalendarLive do 2 | use LiveVueExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 | <.header>LiveVue Vuetify Calendar 7 | 8 |
9 | <.vue id="calendar" v-component="VuetifyCalendar" v-socket={@socket} v-ssr={false} /> 10 |
11 | """ 12 | end 13 | 14 | def mount(_params, _session, socket) do 15 | {:ok, socket} 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/live/counter.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.LiveCounter do 2 | use LiveVueExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 | <.header>LiveVue hybrid counter 7 | <.vue id="counter" count={@count} v-component="Counter" v-socket={@socket} v-ssr={true} v-on:inc={JS.push("inc")} /> 8 | """ 9 | end 10 | 11 | def mount(_params, _session, socket) do 12 | {:ok, assign(socket, count: 10)} 13 | end 14 | 15 | def handle_event("inc", %{"value" => diff}, socket) do 16 | socket = update(socket, :count, &(&1 + diff)) 17 | 18 | {:noreply, socket} 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/live/form.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.LiveForm do 2 | use LiveVueExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 | <.header>Form example - TODO 7 | <.FormExample data={@data} v-on:save={JS.push("save")} v-socket={@socket} /> 8 | """ 9 | end 10 | 11 | def mount(_params, _session, socket) do 12 | {:ok, assign(socket, data: %{})} 13 | end 14 | 15 | def handle_event("save", %{} = params, socket) do 16 | {:noreply, assign(socket, :data, params)} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/live/home.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.LiveHome do 2 | use LiveVueExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 |
7 |
8 |
9 |

LiveVue

10 |

11 | End-to-end reactivity with LiveView and Vue 12 |

13 | 24 |
25 |
26 |
27 |
28 | 29 |
30 |
31 |
32 | 33 |
34 |
35 |
36 |
37 |
38 |
39 |

End-To-End Reactivity

40 |

Seamless integration with LiveView for real-time updates

41 |
42 |
43 |
🔋
44 |

Server-Side Rendered

45 |

Vue components rendered on the server for optimal performance

46 |
47 |
48 |
🐌
49 |

Lazy-loading

50 |

Load Vue components on-demand for faster initial page loads

51 |
52 |
53 |
🪄
54 |

~V Sigil

55 |

Alternative LiveView DSL for inline Vue components

56 |
57 |
58 |
🦄
59 |

Tailwind Support

60 |

Seamless integration with Tailwind CSS for styling

61 |
62 |
63 |
💀
64 |

Dead View Support

65 |

Use Vue components in both live and dead views

66 |
67 |
68 |
🦥
69 |

Slot Interoperability

70 |

Pass content from Phoenix to Vue components using slots

71 |
72 |
73 |
🚀
74 |

Amazing DX

75 |

Excellent developer experience with Vite integration

76 |
77 |
78 |
79 | """ 80 | end 81 | 82 | def mount(_params, _session, socket) do 83 | socket = assign(socket, :container_class, "max-w-6xl") 84 | {:ok, socket, layout: false} 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/live/live_prime_vue.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.LivePrimeVue do 2 | use LiveVueExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 | <.header>PrimeVue timeline example 7 | <.vue v-component="PrimeVueExample" v-socket={@socket} v-ssr={false} /> 8 | """ 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/live/navigation/page_one.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.Navigation.PageOne do 2 | use LiveVueExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 | <.header>Navigation example: Page one 7 | <.vue v-component="NavigationExample" params={@params} v-socket={@socket} /> 8 | """ 9 | end 10 | 11 | def handle_params(params, _uri, socket) do 12 | {:noreply, assign(socket, :params, params)} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/live/navigation/page_two.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.Navigation.PageTwo do 2 | use LiveVueExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 | <.header>Navigation example: Page Two 7 | <.vue v-component="NavigationExample" params={@params} v-socket={@socket} /> 8 | """ 9 | end 10 | 11 | def handle_params(params, _uri, socket) do 12 | {:noreply, assign(socket, :params, params)} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/live/sigil.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.LiveSigil do 2 | use LiveVueExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~V""" 6 | 11 | 12 | 26 | """ 27 | end 28 | 29 | def mount(_params, _session, socket) do 30 | {:ok, assign(socket, count: 0)} 31 | end 32 | 33 | def handle_event("inc", %{"diff" => diff}, socket) do 34 | {:noreply, update(socket, :count, &(&1 + String.to_integer(diff)))} 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/live/slots.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.LiveSlots do 2 | use LiveVueExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 | <.header>Slots example 7 | <.Card title="The coldest sunset" tags={@tags} v-socket={@socket}> 8 |

This is card content passed from phoenix!

9 |

Even icons are working! <.icon name="hero-information-circle-mini" />

10 |

There are <%= length(@tags) %> tags

11 | <:footer>And this is a footer from phoenix 12 | 13 | """ 14 | end 15 | 16 | def mount(_params, _session, socket) do 17 | {:ok, assign(socket, tags: ["sun", "sunset", "winter"])} 18 | end 19 | 20 | def handle_event("add-tag", _, socket) do 21 | {:noreply, update(socket, :tags, &(&1 ++ [Enum.random(["nice", "wow", "so cool"])]))} 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.Router do 2 | use LiveVueExamplesWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, html: {LiveVueExamplesWeb.Layouts, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | end 16 | 17 | scope "/", LiveVueExamplesWeb do 18 | pipe_through :browser 19 | 20 | live "/", LiveHome 21 | get "/dead", PageController, :dead 22 | live "/counter", LiveCounter 23 | live "/slots", LiveSlots 24 | live "/form", LiveForm 25 | live "/sigil", LiveSigil 26 | live "/prime_vue", LivePrimeVue 27 | live "/calendar", CalendarLive 28 | 29 | live "/navigation/page_one", Navigation.PageOne 30 | live "/navigation/page_two", Navigation.PageTwo 31 | end 32 | 33 | # Other scopes may use custom stacks. 34 | # scope "/api", LiveVueExamplesWeb do 35 | # pipe_through :api 36 | # end 37 | 38 | # Enable LiveDashboard in development 39 | if Application.compile_env(:live_vue_examples, :dev_routes) do 40 | # If you want to use the LiveDashboard in production, you should put 41 | # it behind authentication and allow only admins to access it. 42 | # If your application does not have an admins-only section yet, 43 | # you can use Plug.BasicAuth to set up some basic authentication 44 | # as long as you are also using SSL (which you should anyway). 45 | import Phoenix.LiveDashboard.Router 46 | 47 | scope "/dev" do 48 | pipe_through :browser 49 | 50 | live_dashboard "/dashboard", metrics: LiveVueExamplesWeb.Telemetry 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /example_project/lib/live_vue_examples_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.start.system_time", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.endpoint.stop.duration", 29 | unit: {:native, :millisecond} 30 | ), 31 | summary("phoenix.router_dispatch.start.system_time", 32 | tags: [:route], 33 | unit: {:native, :millisecond} 34 | ), 35 | summary("phoenix.router_dispatch.exception.duration", 36 | tags: [:route], 37 | unit: {:native, :millisecond} 38 | ), 39 | summary("phoenix.router_dispatch.stop.duration", 40 | tags: [:route], 41 | unit: {:native, :millisecond} 42 | ), 43 | summary("phoenix.socket_connected.duration", 44 | unit: {:native, :millisecond} 45 | ), 46 | summary("phoenix.channel_joined.duration", 47 | unit: {:native, :millisecond} 48 | ), 49 | summary("phoenix.channel_handled_in.duration", 50 | tags: [:event], 51 | unit: {:native, :millisecond} 52 | ), 53 | 54 | # VM Metrics 55 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 56 | summary("vm.total_run_queue_lengths.total"), 57 | summary("vm.total_run_queue_lengths.cpu"), 58 | summary("vm.total_run_queue_lengths.io") 59 | ] 60 | end 61 | 62 | defp periodic_measurements do 63 | [ 64 | # A module, function and arguments to be invoked periodically. 65 | # This function must call :telemetry.execute/3 and a metric must be added above. 66 | # {LiveVueExamplesWeb, :count_users, []} 67 | ] 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /example_project/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamples.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :live_vue_examples, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [ 21 | mod: {LiveVueExamples.Application, []}, 22 | extra_applications: [:logger, :runtime_tools] 23 | ] 24 | end 25 | 26 | # Specifies which paths to compile per environment. 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Specifies your project dependencies. 31 | # 32 | # Type `mix help deps` for examples and options. 33 | defp deps do 34 | [ 35 | {:phoenix, "~> 1.7.12"}, 36 | {:phoenix_html, "~> 4.0"}, 37 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 38 | {:phoenix_live_view, "~> 0.20.2"}, 39 | {:floki, ">= 0.30.0", only: :test}, 40 | {:phoenix_live_dashboard, "~> 0.8.3"}, 41 | {:heroicons, 42 | github: "tailwindlabs/heroicons", tag: "v2.1.1", sparse: "optimized", app: false, compile: false, depth: 1}, 43 | {:telemetry_metrics, "~> 1.0"}, 44 | {:telemetry_poller, "~> 1.0"}, 45 | {:jason, "~> 1.2"}, 46 | {:dns_cluster, "~> 0.1.1"}, 47 | {:bandit, "~> 1.2"}, 48 | {:live_vue, path: ".."}, 49 | 50 | # overriden with a forked version with package.json 51 | # remove when https://github.com/revelrylabs/elixir-nodejs/pull/89 is released 52 | # you shouldn't have to do this in your project 53 | # here it's needed because live_vue has type: module in package.json and it's a parent dir of this project 54 | {:nodejs, github: "Valian/elixir-nodejs", branch: "master", override: true} 55 | ] 56 | end 57 | 58 | # Aliases are shortcuts or tasks specific to the current project. 59 | # For example, to install project dependencies and perform other setup tasks, run: 60 | # 61 | # $ mix setup 62 | # 63 | # See the documentation for `Mix` for more info on aliases. 64 | defp aliases do 65 | [ 66 | setup: ["deps.get", "assets.setup", "assets.build"], 67 | "assets.setup": ["cmd --cd assets npm install"], 68 | "assets.build": [ 69 | "cmd --cd assets npm run build", 70 | "cmd --cd assets npm run build-server" 71 | ], 72 | "assets.deploy": [ 73 | "cmd --cd assets npm run build", 74 | "cmd --cd assets npm run build-server", 75 | "phx.digest" 76 | ] 77 | ] 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /example_project/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "1.5.0", "3bc864a0da7f013ad3713a7f550c6a6ec0e19b8d8715ec678256a0dc197d5539", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "92d18d9a7228a597e0d4661ef69a874ea82d63ff49c7d801a5c68cb18ebbbd72"}, 3 | "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, 4 | "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, 5 | "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, 6 | "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, 7 | "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, 8 | "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, 9 | "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, 10 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 11 | "json_diff_ex": {:hex, :json_diff_ex, "0.6.7", "679eb6df8fb59b061434798ecf641f510e8dc7ae9d6ca22e593457bfe3d51a5e", [:mix], [], "hexpm", "7da6360cfb8aea96513d67c1a6401e1bb1cf2c988e2e81cfb67224fd05187043"}, 12 | "jsonpatch": {:hex, :jsonpatch, "2.2.0", "e97972ca85735db63f89678da4103e590011a29e34e0926d1695d4138671efad", [:make, :mix], [], "hexpm", "b508930a8d0e6d3afbad0e1d3e006b1d55b4e90a5f82c669342989d8c5bb2a71"}, 13 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 14 | "nodejs": {:git, "https://github.com/Valian/elixir-nodejs.git", "5651fff2ce31e69769f31d7690356fc98d1b8bd7", [branch: "master"]}, 15 | "phoenix": {:hex, :phoenix, "1.7.12", "1cc589e0eab99f593a8aa38ec45f15d25297dd6187ee801c8de8947090b5a9d3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d646192fbade9f485b01bc9920c139bfdd19d0f8df3d73fd8eaf2dfbe0d2837c"}, 16 | "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, 17 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"}, 18 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, 19 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.14", "70fa101aa0539e81bed4238777498f6215e9dda3461bdaa067cad6908110c364", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "82f6d006c5264f979ed5eb75593d808bbe39020f20df2e78426f4f2d570e2402"}, 20 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 21 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 22 | "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, 23 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 24 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, 25 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 26 | "tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"}, 27 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 28 | "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, 29 | "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, 30 | "thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"}, 31 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 32 | "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"}, 33 | } 34 | -------------------------------------------------------------------------------- /example_project/priv/static/images/phoenix-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /example_project/priv/static/images/vue-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /example_project/test/live_vue_examples_web/controllers/error_html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.ErrorHTMLTest do 2 | use LiveVueExamplesWeb.ConnCase, async: true 3 | 4 | # Bring render_to_string/4 for testing custom views 5 | import Phoenix.Template 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(LiveVueExamplesWeb.ErrorHTML, "404", "html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(LiveVueExamplesWeb.ErrorHTML, "500", "html", []) == 13 | "Internal Server Error" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /example_project/test/live_vue_examples_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.ErrorJSONTest do 2 | use LiveVueExamplesWeb.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert LiveVueExamplesWeb.ErrorJSON.render("404.json", %{}) == %{ 6 | errors: %{detail: "Not Found"} 7 | } 8 | end 9 | 10 | test "renders 500" do 11 | assert LiveVueExamplesWeb.ErrorJSON.render("500.json", %{}) == 12 | %{errors: %{detail: "Internal Server Error"}} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /example_project/test/live_vue_examples_web/live/counter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.CounterTest do 2 | use LiveVueExamplesWeb.ConnCase, async: true 3 | 4 | import Phoenix.LiveViewTest 5 | 6 | test "displays the count", %{conn: conn} do 7 | {:ok, view, html} = live(conn, "/counter") 8 | 9 | vue = LiveVue.Test.get_vue(html) 10 | assert vue.component == "Counter" 11 | assert vue.props["count"] == 10 12 | assert vue.ssr == false 13 | assert vue.handlers["inc"] == Phoenix.LiveView.JS.push("inc") 14 | 15 | render_hook(view, "inc", %{"value" => 2}) 16 | 17 | vue = LiveVue.Test.get_vue(view) 18 | assert vue.props["count"] == 12 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /example_project/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVueExamplesWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use LiveVueExamplesWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # The default endpoint for testing 23 | @endpoint LiveVueExamplesWeb.Endpoint 24 | 25 | use LiveVueExamplesWeb, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import LiveVueExamplesWeb.ConnCase 31 | end 32 | end 33 | 34 | setup _tags do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /example_project/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /lib/live_vue.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue do 2 | @moduledoc """ 3 | See README.md for installation instructions and usage. 4 | """ 5 | 6 | use Phoenix.Component 7 | import Phoenix.HTML 8 | 9 | alias Phoenix.LiveView 10 | alias LiveVue.Slots 11 | alias LiveVue.SSR 12 | 13 | require Logger 14 | 15 | @ssr_default Application.compile_env(:live_vue, :ssr, true) 16 | 17 | defmacro __using__(_opts) do 18 | quote do 19 | import LiveVue 20 | end 21 | end 22 | 23 | # TODO - commented out because it's impossible to make :rest accept all attrs without a warning 24 | # attr( 25 | # :"v-component", 26 | # :string, 27 | # required: true, 28 | # doc: "Name of the Vue component", 29 | # examples: ["YourComponent", "directory/Example"] 30 | # ) 31 | 32 | # attr( 33 | # :id, 34 | # :string, 35 | # default: nil, 36 | # doc: 37 | # "Explicit id of a wrapper component. If not provided, a random one will be generated. Useful to keep ID consistent in development.", 38 | # examples: ["vue-1"] 39 | # ) 40 | 41 | # attr( 42 | # :class, 43 | # :string, 44 | # default: nil, 45 | # doc: "Class to apply to the Vue component", 46 | # examples: ["my-class", "my-class another-class"] 47 | # ) 48 | 49 | # attr( 50 | # :"v-ssr", 51 | # :boolean, 52 | # default: Application.compile_env(:live_vue, :ssr, true), 53 | # doc: "Whether to render the component on the server", 54 | # examples: [true, false] 55 | # ) 56 | 57 | # attr( 58 | # :"v-socket", 59 | # :map, 60 | # default: nil, 61 | # doc: "LiveView socket, should be provided when rendering inside LiveView" 62 | # ) 63 | 64 | # attr :rest, :global 65 | 66 | def vue(assigns) do 67 | init = assigns.__changed__ == nil 68 | dead = assigns[:"v-socket"] == nil or not LiveView.connected?(assigns[:"v-socket"]) 69 | render_ssr? = init and dead and Map.get(assigns, :"v-ssr", @ssr_default) 70 | 71 | # we manually compute __changed__ for the computed props and slots so it's not sent without reason 72 | {props, props_changed?} = extract(assigns, :props) 73 | {slots, slots_changed?} = extract(assigns, :slots) 74 | {handlers, handlers_changed?} = extract(assigns, :handlers) 75 | 76 | assigns = 77 | assigns 78 | |> Map.put_new(:class, nil) 79 | |> Map.put(:__component_name, Map.get(assigns, :"v-component")) 80 | |> Map.put(:props, props) 81 | |> Map.put(:handlers, handlers) 82 | |> Map.put(:slots, if(slots_changed?, do: Slots.rendered_slot_map(slots), else: %{})) 83 | 84 | assigns = 85 | Map.put(assigns, :ssr_render, if(render_ssr?, do: ssr_render(assigns), else: nil)) 86 | 87 | computed_changed = 88 | %{ 89 | props: props_changed?, 90 | slots: slots_changed?, 91 | handlers: handlers_changed?, 92 | ssr_render: render_ssr? 93 | } 94 | 95 | assigns = 96 | update_in(assigns.__changed__, fn 97 | nil -> nil 98 | changed -> for {k, true} <- computed_changed, into: changed, do: {k, true} 99 | end) 100 | 101 | # optimizing diffs by using string interpolation 102 | # https://elixirforum.com/t/heex-attribute-value-in-quotes-send-less-data-than-values-in-braces/63274 103 | ~H""" 104 | <%= raw(@ssr_render[:preloadLinks]) %> 105 |
to_string()} 110 | data-handlers={"#{for({k, v} <- @handlers, into: %{}, do: {k, json(v.ops)}) |> json()}"} 111 | data-slots={"#{@slots |> Slots.base_encode_64() |> json}"} 112 | phx-update="ignore" 113 | phx-hook="VueHook" 114 | phx-no-format 115 | class={@class} 116 | ><%= raw(@ssr_render[:html]) %>
117 | """ 118 | end 119 | 120 | defp extract(assigns, type) do 121 | Enum.reduce(assigns, {%{}, false}, fn {key, value}, {acc, changed} -> 122 | case normalize_key(key, value) do 123 | ^type -> {Map.put(acc, key, value), changed || key_changed(assigns, key)} 124 | {^type, k} -> {Map.put(acc, k, value), changed || key_changed(assigns, key)} 125 | _ -> {acc, changed} 126 | end 127 | end) 128 | end 129 | 130 | defp normalize_key(key, _val) when key in ~w"id class v-ssr v-component v-socket __changed__ __given__"a, do: :special 131 | defp normalize_key(_key, [%{__slot__: _}]), do: :slots 132 | defp normalize_key(key, val) when is_atom(key), do: key |> to_string() |> normalize_key(val) 133 | defp normalize_key("v-on:" <> key, _val), do: {:handlers, key} 134 | defp normalize_key(_key, _val), do: :props 135 | 136 | defp key_changed(%{__changed__: nil}, _key), do: true 137 | defp key_changed(%{__changed__: changed}, key), do: changed[key] != nil 138 | 139 | defp ssr_render(assigns) do 140 | try do 141 | name = assigns[:"v-component"] 142 | 143 | case SSR.render(name, assigns.props, assigns.slots) do 144 | {:error, message} -> 145 | Logger.error("Vue SSR error: #{message}") 146 | nil 147 | 148 | %{preloadLinks: links, html: html} -> 149 | %{preloadLinks: links, html: html} 150 | end 151 | rescue 152 | SSR.NotConfigured -> 153 | nil 154 | end 155 | end 156 | 157 | defp json(data), do: Jason.encode!(data, escape: :html_safe) 158 | 159 | defp id(name) do 160 | # a small trick to avoid collisions of IDs but keep them consistent across dead and live render 161 | # id(name) is called only once during the whole LiveView lifecycle because it's not using any assigns 162 | number = Process.get(:live_vue_counter, 1) 163 | Process.put(:live_vue_counter, number + 1) 164 | "#{name}-#{number}" 165 | end 166 | 167 | @doc false 168 | def get_socket(assigns) do 169 | case get_in(assigns, [:vue_opts, :socket]) || assigns[:socket] do 170 | %LiveView.Socket{} = socket -> socket 171 | _ -> nil 172 | end 173 | end 174 | 175 | @doc false 176 | defmacro sigil_V({:<<>>, _meta, [string]}, []) do 177 | path = "./assets/vue/_build/#{__CALLER__.module}.vue" 178 | 179 | with :ok <- File.mkdir_p(Path.dirname(path)) do 180 | File.write!(path, string) 181 | end 182 | 183 | quote do 184 | ~H""" 185 | 192 | """ 193 | end 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /lib/live_vue/components.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.Components do 2 | @moduledoc """ 3 | Macros to improve the developer experience of crossing the Liveview/Vue boundary. 4 | """ 5 | 6 | @doc """ 7 | Generates functions local to your current module that can be used to render Vue components. 8 | TODO: This could perhaps be optimized to only read the files once per compilation. 9 | 10 | ## Examples 11 | 12 | ```elixir 13 | use LiveVue.Components, vue_root: ["./assets/vue", "./lib/my_app_web"] 14 | ``` 15 | """ 16 | defmacro __using__(opts) do 17 | opts 18 | |> Keyword.get(:vue_root, ["./assets/vue"]) 19 | |> List.wrap() 20 | |> Enum.flat_map(fn vue_root -> 21 | if String.contains?(vue_root, "*"), 22 | do: 23 | raise(""" 24 | Glob pattern is not supported in :vue_root, please specify a list of directories. 25 | 26 | Example: 27 | 28 | use LiveVue.Components, vue_root: ["./assets/vue", "./lib/my_app_web"] 29 | """) 30 | 31 | vue_root 32 | |> Path.join("**/*.vue") 33 | |> Path.wildcard() 34 | |> Enum.map(&Path.basename(&1, ".vue")) 35 | end) 36 | |> Enum.uniq() 37 | |> Enum.map(&name_to_function/1) 38 | end 39 | 40 | defp name_to_function(name) do 41 | quote do 42 | def unquote(:"#{name}")(assigns) do 43 | assigns 44 | |> Map.put(:"v-component", unquote(name)) 45 | |> LiveVue.vue() 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/live_vue/reload.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.Reload do 2 | @moduledoc """ 3 | Utilities for easier integration with Vite in development 4 | """ 5 | 6 | use Phoenix.Component 7 | 8 | attr :assets, :list, required: true 9 | slot :inner_block, required: true, doc: "what should be rendered when Vite path is not defined" 10 | 11 | @doc """ 12 | Renders the vite assets in development, and in production falls back to normal compiled assets 13 | """ 14 | def vite_assets(assigns) do 15 | assigns = 16 | assigns 17 | |> assign(:stylesheets, for(path <- assigns.assets, String.ends_with?(path, ".css"), do: path)) 18 | |> assign(:javascripts, for(path <- assigns.assets, String.ends_with?(path, ".js"), do: path)) 19 | 20 | # TODO - maybe make it configurable in other way than by presence of vite_host config? 21 | ~H""" 22 | <%= if Application.get_env(:live_vue, :vite_host) do %> 23 | 25 | 26 | 28 | <% else %> 29 | <%= render_slot(@inner_block) %> 30 | <% end %> 31 | """ 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/live_vue/slots.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.Slots do 2 | @moduledoc false 3 | 4 | import Phoenix.Component 5 | 6 | @doc false 7 | def rendered_slot_map(assigns) do 8 | for( 9 | {key, [%{__slot__: _}] = slot} <- assigns, 10 | into: %{}, 11 | do: 12 | case(key) do 13 | # we raise here because :inner_block is always there and we want to avoid 14 | # it overriding the default slot content 15 | :default -> raise "Instead of using <:default> use <:inner_block> slot" 16 | :inner_block -> {:default, render(%{slot: slot})} 17 | slot_name -> {slot_name, render(%{slot: slot})} 18 | end 19 | ) 20 | end 21 | 22 | @doc false 23 | def base_encode_64(assigns) do 24 | for {key, value} <- assigns, into: %{}, do: {key, Base.encode64(value)} 25 | end 26 | 27 | @doc false 28 | defp render(assigns) do 29 | ~H""" 30 | <%= if assigns[:slot] do %> 31 | <%= render_slot(@slot) %> 32 | <% end %> 33 | """ 34 | |> Phoenix.HTML.Safe.to_iodata() 35 | |> List.to_string() 36 | |> String.trim() 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/live_vue/ssr.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.SSR.NotConfigured do 2 | @moduledoc false 3 | 4 | defexception [:message] 5 | end 6 | 7 | defmodule LiveVue.SSR do 8 | require Logger 9 | 10 | @moduledoc """ 11 | A behaviour for rendering Vue components server-side. 12 | 13 | To define a custom renderer, change the application config in `config.exs`: 14 | 15 | config :live_vue, ssr_module: MyCustomSSRModule 16 | 17 | Exposes a telemetry span for each render under key `[:live_vue, :ssr]` 18 | """ 19 | 20 | @type component_name :: String.t() 21 | @type props :: %{optional(String.t() | atom) => any} 22 | @type slots :: %{optional(String.t() | atom) => any} 23 | 24 | @typedoc """ 25 | A render response which should have shape 26 | 27 | %{ 28 | html: string, 29 | preloadLinks: string 30 | } 31 | """ 32 | @type render_response :: %{optional(String.t() | atom) => any} 33 | 34 | @callback render(component_name, props, slots) :: render_response | no_return 35 | 36 | @spec render(component_name, props, slots) :: render_response | no_return 37 | def render(name, props, slots) do 38 | case Application.get_env(:live_vue, :ssr_module, nil) do 39 | nil -> 40 | %{preloadLinks: "", html: ""} 41 | 42 | mod -> 43 | meta = %{component: name, props: props, slots: slots} 44 | 45 | body = 46 | :telemetry.span([:live_vue, :ssr], meta, fn -> 47 | {mod.render(name, props, slots), meta} 48 | end) 49 | 50 | with body when is_binary(body) <- body do 51 | case String.split(body, "", parts: 2) do 52 | [links, html] -> %{preloadLinks: links, html: html} 53 | [body] -> %{preloadLinks: "", html: body} 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/live_vue/ssr/node_js.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.SSR.NodeJS do 2 | @moduledoc """ 3 | Implements SSR by using `NodeJS` package. 4 | 5 | Under the hood, it invokes "render" function exposed by `server.js` file. 6 | You can see how `server.js` is created by looking at `assets.deploy` command 7 | and `package.json` build-server script. 8 | """ 9 | 10 | @behaviour LiveVue.SSR 11 | 12 | def render(name, props, slots) do 13 | filename = Application.get_env(:live_vue, :ssr_filepath, "./vue/server.js") 14 | 15 | try do 16 | NodeJS.call!({filename, "render"}, [name, props, slots], 17 | binary: true, 18 | esm: true 19 | ) 20 | catch 21 | :exit, {:noproc, _} -> 22 | message = """ 23 | NodeJS is not configured. Please add the following to your application.ex: 24 | {NodeJS.Supervisor, [path: LiveVue.SSR.NodeJS.server_path(), pool_size: 4]}, 25 | """ 26 | 27 | raise %LiveVue.SSR.NotConfigured{message: message} 28 | end 29 | end 30 | 31 | def server_path() do 32 | {:ok, path} = :application.get_application() 33 | Application.app_dir(path, "/priv") 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/live_vue/ssr/vite_js.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.SSR.ViteJS do 2 | @moduledoc """ 3 | Implements SSR by making a POST request to `http://{:vite_host}/ssr_render`. 4 | 5 | `ssr_render` is implemented as a Vite plugin. You have to add it to the `vite.config.js` plugins section. 6 | 7 | ```javascript 8 | import liveVuePlugin from "live_vue/vitePlugin" 9 | 10 | { 11 | publicDir: "static", 12 | plugins: [vue(), liveVuePlugin()], 13 | // ... 14 | } 15 | ``` 16 | """ 17 | @behaviour LiveVue.SSR 18 | 19 | def render(name, props, slots) do 20 | data = Jason.encode!(%{name: name, props: props, slots: slots}) 21 | url = vite_path("/ssr_render") 22 | params = {String.to_charlist(url), [], ~c"application/json", data} 23 | 24 | case :httpc.request(:post, params, [], []) do 25 | {:ok, {{_, 200, _}, _headers, body}} -> 26 | :erlang.list_to_binary(body) 27 | 28 | {:ok, {{_, 500, _}, _headers, body}} -> 29 | case Jason.decode(body) do 30 | {:ok, %{"error" => %{"message" => msg, "loc" => loc, "frame" => frame}}} -> 31 | {:error, "#{msg}\n#{loc["file"]}:#{loc["line"]}:#{loc["column"]}\n#{frame}"} 32 | 33 | {:ok, %{"error" => %{"stack" => stack}}} -> 34 | {:error, stack} 35 | 36 | _ -> 37 | {:error, "Unexpected Vite SSR response: 500 #{body}"} 38 | end 39 | 40 | {:ok, {{_, status, code}, _headers, _body}} -> 41 | {:error, "Unexpected Vite SSR response: #{status} #{code}"} 42 | 43 | {:error, {:failed_connect, [{:to_address, {url, port}}, {_, _, code}]}} -> 44 | {:error, "Unable to connect to Vite #{url}:#{port}: #{code}"} 45 | end 46 | end 47 | 48 | @doc """ 49 | A handy utility returning path relative to Vite JS host. 50 | """ 51 | def vite_path(path) do 52 | case Application.get_env(:live_vue, :vite_host) do 53 | nil -> 54 | message = """ 55 | Vite.js host is not configured. Please add the following to config/dev.ex 56 | 57 | config :live_vue, vite_host: "http://localhost:5173" 58 | 59 | and ensure vite.js is running 60 | """ 61 | 62 | raise %LiveVue.SSR.NotConfigured{message: message} 63 | 64 | host -> 65 | # we get rid of assets prefix since for vite /assets is root 66 | Path.join(host, path) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/live_vue/test.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.Test do 2 | @moduledoc """ 3 | Helpers for testing LiveVue components and views. 4 | 5 | ## Overview 6 | 7 | LiveVue testing differs from traditional Phoenix LiveView testing in how components 8 | are rendered and inspected: 9 | 10 | * In Phoenix LiveView testing, you use `Phoenix.LiveViewTest.render_component/2` 11 | to get the final rendered HTML 12 | * In LiveVue testing, `render_component/2` returns an unrendered LiveVue root 13 | element containing the Vue component's configuration 14 | 15 | This module provides helpers to extract and inspect Vue component data from the 16 | LiveVue root element, including: 17 | 18 | * Component name and ID 19 | * Props passed to the component 20 | * Event handlers and their operations 21 | * Server-side rendering (SSR) status 22 | * Slot content 23 | * CSS classes 24 | 25 | ## Examples 26 | 27 | # Render a LiveVue component and inspect its properties 28 | {:ok, view, _html} = live(conn, "/") 29 | vue = LiveVue.Test.get_vue(view) 30 | 31 | # Basic component info 32 | assert vue.component == "MyComponent" 33 | assert vue.props["title"] == "Hello" 34 | 35 | # Event handlers 36 | assert vue.handlers["click"] == JS.push("click") 37 | 38 | # SSR status and styling 39 | assert vue.ssr == true 40 | assert vue.class == "my-custom-class" 41 | """ 42 | 43 | @compile {:no_warn_undefined, Floki} 44 | 45 | @doc """ 46 | Extracts Vue component information from a LiveView or HTML string. 47 | 48 | When multiple Vue components are present, you can specify which one to extract using 49 | either the `:name` or `:id` option. 50 | 51 | Returns a map containing the component's configuration: 52 | * `:component` - The Vue component name (from `v-component` attribute) 53 | * `:id` - The unique component identifier (auto-generated or explicitly set) 54 | * `:props` - The decoded props passed to the component 55 | * `:handlers` - Map of event handlers (`v-on:*`) and their operations 56 | * `:slots` - Base64 encoded slot content 57 | * `:ssr` - Boolean indicating if server-side rendering was performed 58 | * `:class` - CSS classes applied to the component root element 59 | 60 | ## Options 61 | * `:name` - Find component by name (from `v-component` attribute) 62 | * `:id` - Find component by ID 63 | 64 | ## Examples 65 | 66 | # From a LiveView, get first Vue component 67 | {:ok, view, _html} = live(conn, "/") 68 | vue = LiveVue.Test.get_vue(view) 69 | 70 | # Get specific component by name 71 | vue = LiveVue.Test.get_vue(view, name: "MyComponent") 72 | 73 | # Get specific component by ID 74 | vue = LiveVue.Test.get_vue(view, id: "my-component-1") 75 | """ 76 | def get_vue(view, opts \\ []) 77 | 78 | def get_vue(view, opts) when is_struct(view, Phoenix.LiveViewTest.View) do 79 | view |> Phoenix.LiveViewTest.render() |> get_vue(opts) 80 | end 81 | 82 | def get_vue(html, opts) when is_binary(html) do 83 | if Code.ensure_loaded?(Floki) do 84 | vue = 85 | html 86 | |> Floki.parse_document!() 87 | |> Floki.find("[phx-hook='VueHook']") 88 | |> find_component!(opts) 89 | 90 | %{ 91 | props: Jason.decode!(attr(vue, "data-props")), 92 | component: attr(vue, "data-name"), 93 | id: attr(vue, "id"), 94 | handlers: extract_handlers(attr(vue, "data-handlers")), 95 | slots: extract_base64_slots(attr(vue, "data-slots")), 96 | ssr: attr(vue, "data-ssr") |> String.to_existing_atom(), 97 | class: attr(vue, "class") 98 | } 99 | else 100 | raise "Floki is not installed. Add {:floki, \">= 0.30.0\", only: :test} to your dependencies to use LiveVue.Test" 101 | end 102 | end 103 | 104 | defp extract_handlers(handlers) do 105 | handlers 106 | |> Jason.decode!() 107 | |> Enum.map(fn {k, v} -> {k, extract_js_ops(v)} end) 108 | |> Enum.into(%{}) 109 | end 110 | 111 | defp extract_base64_slots(slots) do 112 | slots 113 | |> Jason.decode!() 114 | |> Enum.map(fn {key, value} -> {key, Base.decode64!(value)} end) 115 | |> Enum.into(%{}) 116 | end 117 | 118 | defp extract_js_ops(ops) do 119 | ops 120 | |> Jason.decode!() 121 | |> Enum.map(fn 122 | [op, map] when is_map(map) -> [op, for({k, v} <- map, do: {String.to_existing_atom(k), v}, into: %{})] 123 | op -> op 124 | end) 125 | |> then(&%Phoenix.LiveView.JS{ops: &1}) 126 | end 127 | 128 | defp find_component!(components, opts) do 129 | available = 130 | components 131 | |> Enum.map(&"#{attr(&1, "data-name")}##{attr(&1, "id")}") 132 | |> Enum.join(", ") 133 | 134 | components = 135 | Enum.reduce(opts, components, fn 136 | {:id, id}, result -> 137 | with [] <- Enum.filter(result, &(attr(&1, "id") == id)) do 138 | raise "No Vue component found with id=\"#{id}\". Available components: #{available}" 139 | end 140 | 141 | {:name, name}, result -> 142 | with [] <- Enum.filter(result, &(attr(&1, "data-name") == name)) do 143 | raise "No Vue component found with name=\"#{name}\". Available components: #{available}" 144 | end 145 | 146 | {key, _}, _result -> 147 | raise ArgumentError, "invalid keyword option for get_vue/2: #{key}" 148 | end) 149 | 150 | case components do 151 | [vue | _] -> 152 | vue 153 | 154 | [] -> 155 | raise "No Vue components found in the rendered HTML" 156 | end 157 | end 158 | 159 | defp attr(element, name) do 160 | case Floki.attribute(element, name) do 161 | [value] -> value 162 | [] -> nil 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /lib/mix/tasks/setup.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.LiveVue.Setup do 2 | @moduledoc """ 3 | Copies files from assets/copy of the live_vue dependency to phoenix project assets folder 4 | """ 5 | @shortdoc "copy setup files to assets" 6 | 7 | use Mix.Task 8 | 9 | @impl Mix.Task 10 | # Adapted from live_svelte mix task at https://github.com/woutdp/live_svelte/blob/master/lib/mix/tasks/configure_esbuild.ex 11 | def run(_args) do 12 | Mix.Project.deps_paths(depth: 1) 13 | |> Map.fetch!(:live_vue) 14 | |> Path.join("assets/copy/**/{*.*}") 15 | |> Path.wildcard(match_dot: true) 16 | |> Enum.each(fn full_path -> 17 | [_beginning, relative_path] = String.split(full_path, "copy", parts: 2) 18 | new_path = "assets" <> relative_path 19 | 20 | case File.exists?(new_path) do 21 | true -> 22 | log_info(~s/Did not copy `#{full_path}` to `#{new_path}` since file already exists/) 23 | 24 | false -> 25 | Mix.Generator.copy_file(full_path, new_path) 26 | end 27 | end) 28 | end 29 | 30 | # Copied from live_svelte logger file at https://github.com/woutdp/live_svelte/blob/master/lib/logger.ex 31 | defp log_info(status), do: Mix.shell().info([status, :reset]) 32 | end 33 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Valian/live_vue/88b6f570cf9c0cc8723726db2a9d575ef60f97a9/logo.png -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.5.7" 5 | @repo_url "https://github.com/Valian/live_vue" 6 | 7 | def project do 8 | [ 9 | app: :live_vue, 10 | version: @version, 11 | elixir: "~> 1.13", 12 | start_permanent: Mix.env() == :prod, 13 | aliases: aliases(), 14 | deps: deps(), 15 | preferred_cli_env: [ 16 | "test.watch": :test, 17 | coveralls: :test, 18 | "coveralls.detail": :test, 19 | "coveralls.post": :test, 20 | "coveralls.html": :test 21 | ], 22 | 23 | # Hex 24 | description: "E2E reactivity for Vue and LiveView", 25 | package: package(), 26 | 27 | # Docs 28 | name: "LiveVue", 29 | docs: [ 30 | name: "LiveVue", 31 | source_ref: "v#{@version}", 32 | source_url: @repo_url, 33 | homepage_url: @repo_url, 34 | main: "readme", 35 | extras: [ 36 | "README.md": [title: "LiveVue"], 37 | "INSTALLATION.md": [title: "Installation"], 38 | "CHANGELOG.md": [title: "Changelog"] 39 | ], 40 | links: %{ 41 | "GitHub" => @repo_url 42 | } 43 | ], 44 | test_coverage: [tool: ExCoveralls] 45 | ] 46 | end 47 | 48 | defp package() do 49 | [ 50 | maintainers: ["Jakub Skalecki"], 51 | licenses: ["MIT"], 52 | links: %{ 53 | Changelog: @repo_url <> "/blob/master/CHANGELOG.md", 54 | GitHub: @repo_url 55 | }, 56 | files: ~w(priv/static assets/copy lib mix.exs package.json .formatter.exs LICENSE.md README.md CHANGELOG.md) 57 | ] 58 | end 59 | 60 | # Run "mix help compile.app" to learn about applications. 61 | def application do 62 | conditionals = 63 | case Application.get_env(:live_vue, :ssr_module) do 64 | # Needed to use :httpc.request 65 | LiveVue.SSR.ViteJS -> [:inets] 66 | _ -> [] 67 | end 68 | 69 | [ 70 | extra_applications: [:logger] ++ conditionals 71 | ] 72 | end 73 | 74 | # Run "mix help deps" to learn about dependencies. 75 | defp deps do 76 | [ 77 | {:jason, "~> 1.2"}, 78 | {:nodejs, "~> 3.1"}, 79 | {:phoenix, ">= 1.7.0"}, 80 | {:phoenix_live_view, ">= 0.18.0"}, 81 | {:floki, ">= 0.30.0", optional: true}, 82 | {:telemetry, "~> 0.4 or ~> 1.0"}, 83 | {:ex_doc, "~> 0.19", only: :dev, runtime: false}, 84 | {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false}, 85 | {:expublish, "~> 2.5", only: [:dev], runtime: false}, 86 | {:excoveralls, "~> 0.18", only: :test} 87 | ] 88 | end 89 | 90 | defp aliases do 91 | [ 92 | setup: ["deps.get", "cmd npm install"], 93 | "assets.build": ["cmd npm run build"], 94 | "assets.watch": ["cmd npm run dev"], 95 | "release.patch": ["assets.build", "expublish.patch --branch=main --disable-publish"], 96 | "release.minor": ["assets.build", "expublish.minor --branch=main --disable-publish"], 97 | "release.major": ["assets.build", "expublish.major --branch=main --disable-publish"] 98 | ] 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "1.0.6", "ffc42f110ebfdafab0ea159cd43d31365fa0af0ce4a02ecebf1707ae619ee727", [:mix], [], "hexpm", "374c6e7ca752296be3d6780a6d5b922854ffcc74123da90f2f328996b962d33a"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 4 | "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, 5 | "ex_doc": {:hex, :ex_doc, "0.32.1", "21e40f939515373bcdc9cffe65f3b3543f05015ac6c3d01d991874129d173420", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "5142c9db521f106d61ff33250f779807ed2a88620e472ac95dc7d59c380113da"}, 6 | "excoveralls": {:hex, :excoveralls, "0.18.3", "bca47a24d69a3179951f51f1db6d3ed63bca9017f476fe520eb78602d45f7756", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "746f404fcd09d5029f1b211739afb8fb8575d775b21f6a3908e7ce3e640724c6"}, 7 | "expublish": {:hex, :expublish, "2.7.5", "684e20f6538019b2f563f957181ec94176a0fe8de6cba552c6d8471158354701", [:mix], [], "hexpm", "25ca84eb8fe7e17e6baa250f0cca27cabdd6b7d49584ffd13bb541471857df79"}, 8 | "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, 9 | "floki": {:hex, :floki, "0.36.3", "1102f93b16a55bc5383b85ae3ec470f82dee056eaeff9195e8afdf0ef2a43c30", [:mix], [], "hexpm", "fe0158bff509e407735f6d40b3ee0d7deb47f3f3ee7c6c182ad28599f9f6b27a"}, 10 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 11 | "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, 12 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"}, 14 | "makeup_html": {:hex, :makeup_html, "0.1.1", "c3d4abd39d5f7e925faca72ada6e9cc5c6f5fa7cd5bc0158315832656cf14d7f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "44f2a61bc5243645dd7fafeaa6cc28793cd22f3c76b861e066168f9a5b2c26a4"}, 15 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 16 | "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, 17 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 18 | "nodejs": {:hex, :nodejs, "3.1.0", "904c07b81a7b6077af35784df32ab36c62bd2b96edb91bfd04c157c21956cfa5", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1", [hex: :poolboy, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.7", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "a6b4480f3f266abb5927be8afacfc7809feefd7d1337fa3ce957d0b98eeeae52"}, 19 | "phoenix": {:hex, :phoenix, "1.7.12", "1cc589e0eab99f593a8aa38ec45f15d25297dd6187ee801c8de8947090b5a9d3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d646192fbade9f485b01bc9920c139bfdd19d0f8df3d73fd8eaf2dfbe0d2837c"}, 20 | "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, 21 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.14", "70fa101aa0539e81bed4238777498f6215e9dda3461bdaa067cad6908110c364", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "82f6d006c5264f979ed5eb75593d808bbe39020f20df2e78426f4f2d570e2402"}, 22 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 23 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 24 | "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, 25 | "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, 26 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, 27 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 28 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 29 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 30 | "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"}, 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "live_vue", 3 | "version": "0.1.0", 4 | "description": "E2E reactivity for Vue and LiveView", 5 | "license": "MIT", 6 | "author": "Jakub Skałecki ", 7 | "type": "module", 8 | "scripts": { 9 | "format": "npx prettier --write assets", 10 | "build": "rm -rf priv/static && tsc --project tsconfig.client.json && tsc --project tsconfig.server.json", 11 | "dev": "concurrently \"tsc --watch --project tsconfig.client.json\" \"tsc --watch --project tsconfig.server.json\"" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "^22.9.1", 15 | "@types/phoenix_live_view": "^0.20.0", 16 | "concurrently": "^9.0.1", 17 | "prettier": "2.8.7", 18 | "typescript": "^5.6.2", 19 | "vite": "^5.4.8", 20 | "vue": "^3.5.10" 21 | }, 22 | "main": "priv/static/index.js", 23 | "types": "priv/static/types.d.ts", 24 | "exports": { 25 | "./vitePlugin": { 26 | "import": "./priv/static/vitePlugin.js", 27 | "types": "./priv/static/vitePlugin.d.ts" 28 | }, 29 | "./server": { 30 | "import": "./priv/static/server.js", 31 | "types": "./priv/static/server.d.ts" 32 | }, 33 | ".": { 34 | "import": "./priv/static/index.js", 35 | "types": "./priv/static/index.d.ts" 36 | } 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "git://github.com/Valian/live_vue.git" 41 | }, 42 | "files": [ 43 | "README.md", 44 | "LICENSE.md", 45 | "package.json", 46 | "priv/static/*", 47 | "priv/static/*.d.ts" 48 | ], 49 | "overrides": { 50 | "nanoid": "^3.3.8", 51 | "rollup": "^4.22.4" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/live_vue_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveVueTest do 2 | use ExUnit.Case 3 | 4 | import LiveVue 5 | import Phoenix.Component 6 | import Phoenix.LiveViewTest 7 | 8 | alias Phoenix.LiveView.JS 9 | alias LiveVue.Test 10 | 11 | doctest LiveVue 12 | 13 | describe "basic component rendering" do 14 | def simple_component(assigns) do 15 | ~H""" 16 | <.vue name="John" surname="Doe" v-component="MyComponent" /> 17 | """ 18 | end 19 | 20 | test "renders component with correct props" do 21 | html = render_component(&simple_component/1) 22 | vue = Test.get_vue(html) 23 | 24 | assert vue.component == "MyComponent" 25 | assert vue.props == %{"name" => "John", "surname" => "Doe"} 26 | end 27 | 28 | test "generates consistent ID" do 29 | html = render_component(&simple_component/1) 30 | vue = Test.get_vue(html) 31 | 32 | assert vue.id =~ ~r/MyComponent-\d+/ 33 | end 34 | end 35 | 36 | describe "multiple components" do 37 | def multi_component(assigns) do 38 | ~H""" 39 |
40 | <.vue id="profile-1" name="John" v-component="UserProfile" /> 41 | <.vue id="card-1" name="Jane" v-component="UserCard" /> 42 |
43 | """ 44 | end 45 | 46 | test "finds first component by default" do 47 | html = render_component(&multi_component/1) 48 | vue = Test.get_vue(html) 49 | 50 | assert vue.component == "UserProfile" 51 | assert vue.props == %{"name" => "John"} 52 | end 53 | 54 | test "finds specific component by name" do 55 | html = render_component(&multi_component/1) 56 | vue = Test.get_vue(html, name: "UserCard") 57 | 58 | assert vue.component == "UserCard" 59 | assert vue.props == %{"name" => "Jane"} 60 | end 61 | 62 | test "finds specific component by id" do 63 | html = render_component(&multi_component/1) 64 | vue = Test.get_vue(html, id: "card-1") 65 | 66 | assert vue.component == "UserCard" 67 | assert vue.id == "card-1" 68 | end 69 | 70 | test "raises error when component with name not found" do 71 | html = render_component(&multi_component/1) 72 | 73 | assert_raise RuntimeError, 74 | ~r/No Vue component found with name="Unknown".*Available components: UserProfile#profile-1, UserCard#card-1/, 75 | fn -> 76 | Test.get_vue(html, name: "Unknown") 77 | end 78 | end 79 | 80 | test "raises error when component with id not found" do 81 | html = render_component(&multi_component/1) 82 | 83 | assert_raise RuntimeError, 84 | ~r/No Vue component found with id="unknown-id".*Available components: UserProfile#profile-1, UserCard#card-1/, 85 | fn -> 86 | Test.get_vue(html, id: "unknown-id") 87 | end 88 | end 89 | end 90 | 91 | describe "event handlers" do 92 | def component_with_events(assigns) do 93 | ~H""" 94 | <.vue 95 | name="John" 96 | v-component="MyComponent" 97 | v-on:click={JS.push("click", value: %{"abc" => "def"})} 98 | v-on:submit={JS.push("submit")} 99 | /> 100 | """ 101 | end 102 | 103 | test "renders event handlers correctly" do 104 | html = render_component(&component_with_events/1) 105 | vue = Test.get_vue(html) 106 | 107 | assert vue.handlers == %{ 108 | "click" => JS.push("click", value: %{"abc" => "def"}), 109 | "submit" => JS.push("submit") 110 | } 111 | end 112 | end 113 | 114 | describe "styling" do 115 | def styled_component(assigns) do 116 | ~H""" 117 | <.vue name="John" v-component="MyComponent" class="bg-blue-500 rounded" /> 118 | """ 119 | end 120 | 121 | test "applies CSS classes" do 122 | html = render_component(&styled_component/1) 123 | vue = Test.get_vue(html) 124 | 125 | assert vue.class == "bg-blue-500 rounded" 126 | end 127 | end 128 | 129 | describe "SSR behavior" do 130 | def ssr_component(assigns) do 131 | ~H""" 132 | <.vue name="John" v-component="MyComponent" v-ssr={false} /> 133 | """ 134 | end 135 | 136 | test "respects SSR flag" do 137 | html = render_component(&ssr_component/1) 138 | vue = Test.get_vue(html) 139 | 140 | assert vue.ssr == false 141 | end 142 | end 143 | 144 | describe "slots" do 145 | def component_with_slots(assigns) do 146 | ~H""" 147 | <.vue v-component="WithSlots"> 148 | Default content 149 | <:header>Header content 150 | <:footer> 151 |
Footer content
152 | 153 | 154 | 155 | """ 156 | end 157 | 158 | def component_with_default_slot(assigns) do 159 | ~H""" 160 | <.vue v-component="WithSlots"> 161 | <:default>Simple content 162 | 163 | """ 164 | end 165 | 166 | def component_with_inner_block(assigns) do 167 | ~H""" 168 | <.vue v-component="WithSlots"> 169 | Simple content 170 | 171 | """ 172 | end 173 | 174 | test "warns about usage of <:default> slot" do 175 | assert_raise RuntimeError, 176 | "Instead of using <:default> use <:inner_block> slot", 177 | fn -> render_component(&component_with_default_slot/1) end 178 | end 179 | 180 | test "renders multiple slots" do 181 | html = render_component(&component_with_slots/1) 182 | vue = Test.get_vue(html) 183 | 184 | assert vue.slots == %{ 185 | "default" => "Default content", 186 | "header" => "Header content", 187 | "footer" => "
Footer content
\n " 188 | } 189 | end 190 | 191 | test "renders default slot with inner_block" do 192 | html = render_component(&component_with_inner_block/1) 193 | vue = Test.get_vue(html) 194 | 195 | assert vue.slots == %{"default" => "Simple content"} 196 | end 197 | 198 | test "encodes slots as base64" do 199 | html = render_component(&component_with_slots/1) 200 | 201 | # Get raw data-slots attribute to verify base64 encoding 202 | doc = Floki.parse_fragment!(html) 203 | slots_attr = Floki.attribute(doc, "data-slots") |> hd() 204 | 205 | # JSON encoded map 206 | assert slots_attr =~ ~r/^\{.*\}$/ 207 | 208 | slots = 209 | slots_attr 210 | |> Jason.decode!() 211 | |> Enum.map(fn {key, value} -> {key, Base.decode64!(value)} end) 212 | |> Enum.into(%{}) 213 | 214 | assert slots == %{ 215 | "default" => "Default content", 216 | "header" => "Header content", 217 | "footer" => "
Footer content
\n " 218 | } 219 | end 220 | 221 | test "handles empty slots" do 222 | html = 223 | render_component(fn assigns -> 224 | ~H""" 225 | <.vue v-component="WithSlots" /> 226 | """ 227 | end) 228 | 229 | vue = Test.get_vue(html) 230 | 231 | assert vue.slots == %{} 232 | end 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /tsconfig.client.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["ES2020", "DOM"], 5 | "types": [] 6 | }, 7 | "include": ["assets/js/live_vue/index.ts"] 8 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ES2020", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "declarationDir": "./priv/static", 11 | "declaration": true, 12 | "emitDeclarationOnly": false, 13 | "outDir": "./priv/static", 14 | "allowJs": true 15 | }, 16 | "exclude": ["node_modules"], 17 | } -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["ES2020"], 5 | "types": ["node", "./assets/js/live_vue/serverElementPolyfill.d.ts"], 6 | "allowJs": true, 7 | }, 8 | "include": ["assets/js/live_vue/server.ts", "assets/js/live_vue/vitePlugin.ts"] 9 | } --------------------------------------------------------------------------------