diff --git a/apps/frontend/gleam.toml b/apps/frontend/gleam.toml index 3b8c3ca..784dab5 100644 --- a/apps/frontend/gleam.toml +++ b/apps/frontend/gleam.toml @@ -18,7 +18,7 @@ modem = ">= 1.1.0 and < 2.0.0" plinth = ">= 0.2.0 and < 1.0.0" sketch = ">= 2.2.2 and < 3.0.0" tardis = ">= 0.1.0 and < 1.0.0" -lustre = ">= 4.3.0 and < 5.0.0" +lustre = { path = "../../packages/lustre" } [dev-dependencies] gleeunit = "~> 1.0" diff --git a/apps/frontend/manifest.toml b/apps/frontend/manifest.toml index 326780e..801de2c 100644 --- a/apps/frontend/manifest.toml +++ b/apps/frontend/manifest.toml @@ -2,20 +2,21 @@ # You typically do not need to edit this file packages = [ - { name = "birl", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "976CFF85D34D50F7775896615A71745FBE0C325E50399787088F941B539A0497" }, + { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "conversation", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "conversation", source = "hex", outer_checksum = "908B46F60444442785A495197D482558AD8B849C3714A38FAA1940358CC8CCCD" }, { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, { name = "gleam_fetch", version = "0.4.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "7446410A44A1D1328F5BC1FF4FC9CBD1570479EA69349237B3F82E34521CCC10" }, { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, - { name = "gleam_javascript", version = "0.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "14D5B7E1A70681E0776BF0A0357F575B822167960C844D3D3FA114D3A75F05A8" }, + { name = "gleam_javascript", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "483631D3001FCE8EB12ADEAD5E1B808440038E96F93DA7A32D326C82F480C0B2" }, { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, - { name = "gleam_stdlib", version = "0.37.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "5398BD6C2ABA17338F676F42F404B9B7BABE1C8DC7380031ACB05BBE1BCF3742" }, - { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, { name = "grille_pain", version = "1.0.1", build_tools = ["gleam"], requirements = ["birl", "gleam_stdlib", "lustre", "plinth", "sketch", "tardis"], otp_app = "grille_pain", source = "hex", outer_checksum = "F0CA9AA0BD4D03B8E190AB4CBB9429DE9389BC2152CF566C3410261F5729827C" }, - { name = "lustre", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "43642C0602D3E2D6FEC3E24173D68A1F8E646969B53A2B0A5EB61238DDA739C4" }, + { name = "lustre", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], source = "local", path = "../../packages/lustre" }, { name = "lustre_http", version = "0.5.2", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_http", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_http", source = "hex", outer_checksum = "FB0478CBFA6B16DBE8ECA326DAE2EC15645E04900595EF2C4F039ABFA0512ABA" }, { name = "modem", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "modem", source = "hex", outer_checksum = "4C6E448089B09A57C179455D44526A717E4E217D4000B91201617FD2D9F18E68" }, - { name = "plinth", version = "0.2.0", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "83211E672D83F3CE14681D0ECD3AD883EE7588E423E7C9DDDB460014AD60AC24" }, + { name = "plinth", version = "0.4.7", build_tools = ["gleam"], requirements = ["conversation", "gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "BBDD8F05368A1C9AB09A8BF50BBD98E5C23A6AA754DFF2E9B36C87988AC82D47" }, { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, { name = "sketch", version = "2.2.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "lustre", "plinth"], otp_app = "sketch", source = "hex", outer_checksum = "AE2FE447AB1C993CE4884D9EFE915D1971AEDED335904B77BE875B5BE3D7378B" }, { name = "tardis", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre", "plinth", "sketch"], otp_app = "tardis", source = "hex", outer_checksum = "C8E7BAB95C59EF50332905A06B82BB35526B7BE2F191580F3CD8790903AA49F7" }, @@ -28,7 +29,7 @@ gleam_json = { version = ">= 1.0.1 and < 2.0.0" } gleam_stdlib = { version = "~> 0.34 or ~> 1.0" } gleeunit = { version = "~> 1.0" } grille_pain = { version = ">= 1.0.0 and < 2.0.0" } -lustre = { version = ">= 4.3.0 and < 5.0.0"} +lustre = { path = "../../packages/lustre" } lustre_http = { version = "~> 0.5" } modem = { version = ">= 1.1.0 and < 2.0.0" } plinth = { version = ">= 0.2.0 and < 1.0.0" } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index a8e7b4e..a55cbfa 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -2,12 +2,14 @@ "name": "frontend", "version": "1.0.0", "private": true, + "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview" }, "dependencies": { + "@chouqueth/gleam": "^1.2.1", "@gleam-lang/highlight.js-gleam": "^1.5.0", "@sentry/browser": "^8.0.0", "dompurify": "^3.1.4", @@ -20,7 +22,6 @@ "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/dompurify": "^3.0.5", "dotenv": "^16.4.5", - "gleam-lang": "^1.1.0", "prettier": "^3.2.5", "ts-gleam": "^1.0.1", "typescript": "^5.4.2", diff --git a/apps/frontend/src/config.ffi.mjs b/apps/frontend/src/config.ffi.mjs index d9d416a..a1c86ba 100644 --- a/apps/frontend/src/config.ffi.mjs +++ b/apps/frontend/src/config.ffi.mjs @@ -3,8 +3,9 @@ export function is_dev() { } export function scrollTo(id) { - const elem = document.getElementById(id) - if (!elem) return + const cache = document.getElementsByTagName('cache-signatures') + if (!cache?.[0]) return + const elem = cache[0].shadowRoot.getElementById(id) const elemRect = elem.getBoundingClientRect() const navbarRect = document.getElementsByClassName('navbar')?.[0]?.getBoundingClientRect() const bodyRect = document.body.getBoundingClientRect() @@ -22,3 +23,11 @@ export function captureMessage(content) { export function updateTitle(title) { document.title = title } + +export function coerce(a) { + return a +} + +export function coerce_event(a) { + return a.detail +} diff --git a/apps/frontend/src/frontend.gleam b/apps/frontend/src/frontend.gleam index 7bfcf9a..e7d0169 100644 --- a/apps/frontend/src/frontend.gleam +++ b/apps/frontend/src/frontend.gleam @@ -8,7 +8,6 @@ import frontend/view/body/cache import gleam/bool import gleam/dict import gleam/dynamic -import gleam/io import gleam/list import gleam/option.{None} import gleam/result diff --git a/apps/frontend/src/frontend.ts b/apps/frontend/src/frontend.ts index 03705f2..862b3a1 100644 --- a/apps/frontend/src/frontend.ts +++ b/apps/frontend/src/frontend.ts @@ -5,6 +5,13 @@ import plaintext from 'highlight.js/lib/languages/plaintext' // @ts-ignore import { main } from './frontend.gleam' +// @ts-ignore +Element.prototype._attachShadow = Element.prototype.attachShadow +Element.prototype.attachShadow = function () { + // @ts-ignore + return this._attachShadow({ mode: 'open' }) +} + hljs.registerLanguage('gleam', gleamHljs) hljs.registerLanguage('plaintext', plaintext) document.addEventListener('DOMContentLoaded', main) diff --git a/apps/frontend/src/frontend/view/body/body.gleam b/apps/frontend/src/frontend/view/body/body.gleam index f34d44f..a90ab18 100644 --- a/apps/frontend/src/frontend/view/body/body.gleam +++ b/apps/frontend/src/frontend/view/body/body.gleam @@ -8,6 +8,7 @@ import frontend/view/body/styles as s import frontend/view/search_input/search_input import gleam/dict import gleam/int +import gleam/io import gleam/list import gleam/option.{None, Some} import gleam/result @@ -129,6 +130,13 @@ pub fn view_trending(model: Model) { } } +fn on_coerce(value: a) { + Ok(coerce_event(value)) +} + +@external(javascript, "../../../config.ffi.mjs", "coerce_event") +fn coerce_event(value: a) -> b + pub fn body(model: Model) { s.main([], [ case model.route { @@ -162,7 +170,7 @@ pub fn body(model: Model) { |> result.map(fn(content) { el.element( "cache-signatures", - [a.property("content", content)], + [a.property("content", content), e.on("child", on_coerce)], [], ) }) diff --git a/apps/frontend/src/frontend/view/body/cache.gleam b/apps/frontend/src/frontend/view/body/cache.gleam index dad3149..678f0bc 100644 --- a/apps/frontend/src/frontend/view/body/cache.gleam +++ b/apps/frontend/src/frontend/view/body/cache.gleam @@ -144,21 +144,24 @@ pub fn cache_search_results( ]) } -pub type MsgComponent { - UpdateContent(el.Element(MsgComponent)) +pub type MsgComponent(msg) { + UpdateContent(el.Element(msg)) + Received(msg) } +@external(javascript, "../../../config.ffi.mjs", "coerce") +fn coerce(value: a) -> b + pub fn component() { lustre.component( fn(_flags) { #(el.none(), eff.none()) }, - fn(_model, msg) { + fn(model: el.Element(msg), msg: MsgComponent(msg)) { case msg { UpdateContent(c) -> #(c, eff.none()) + Received(msg) -> #(model, e.emit("child", coerce(msg))) } }, - fn(model) { model }, - dict.from_list([ - #("content", fn(dyn) { Ok(UpdateContent(dynamic.unsafe_coerce(dyn))) }), - ]), + fn(model) { el.map(model, Received) |> io.debug }, + dict.from_list([#("content", fn(dyn) { Ok(UpdateContent(coerce(dyn))) })]), ) } diff --git a/apps/frontend/vite.config.js b/apps/frontend/vite.config.js index db170c9..fedf98b 100644 --- a/apps/frontend/vite.config.js +++ b/apps/frontend/vite.config.js @@ -1,11 +1,13 @@ import { sentryVitePlugin } from '@sentry/vite-plugin' import 'dotenv/config' +import { defineConfig } from 'vite' import gleam from 'vite-gleam' -export default { +export default defineConfig(({ mode }) => ({ plugins: [ gleam(), sentryVitePlugin({ + disable: mode === 'development', org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT, authToken: process.env.SENTRY_AUTH_TOKEN, @@ -19,4 +21,4 @@ export default { interop: 'auto', }, }, -} +})) diff --git a/apps/frontend/yarn.lock b/apps/frontend/yarn.lock index 9b04620..8ee25cc 100644 --- a/apps/frontend/yarn.lock +++ b/apps/frontend/yarn.lock @@ -353,6 +353,18 @@ __metadata: languageName: node linkType: hard +"@chouqueth/gleam@npm:^1.2.1": + version: 1.2.1 + resolution: "@chouqueth/gleam@npm:1.2.1" + dependencies: + cachedir: "npm:^2.4.0" + tar: "npm:^7.1.0" + bin: + gleam: bin/cli.mjs + checksum: 10c0/f9b7daaa24c9672ef5e6976f76a687cddb2df2a701670e7cbdf36231eb919d75a51e38ad8b69305e5559c2d2d589718b936bddf289b7a591934aa7d7f9be8a30 + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.19.12": version: 0.19.12 resolution: "@esbuild/aix-ppc64@npm:0.19.12" @@ -1690,6 +1702,7 @@ __metadata: version: 0.0.0-use.local resolution: "frontend@workspace:." dependencies: + "@chouqueth/gleam": "npm:^1.2.1" "@gleam-lang/highlight.js-gleam": "npm:^1.5.0" "@sentry/browser": "npm:^8.0.0" "@sentry/vite-plugin": "npm:^2.16.1" @@ -1697,7 +1710,6 @@ __metadata: "@types/dompurify": "npm:^3.0.5" dompurify: "npm:^3.1.4" dotenv: "npm:^16.4.5" - gleam-lang: "npm:^1.1.0" highlight.js: "npm:^11.9.0" marked: "npm:^12.0.2" marked-highlight: "npm:^2.1.1" @@ -1760,18 +1772,6 @@ __metadata: languageName: node linkType: hard -"gleam-lang@npm:^1.1.0": - version: 1.1.0 - resolution: "gleam-lang@npm:1.1.0" - dependencies: - cachedir: "npm:^2.4.0" - tar: "npm:^7.1.0" - bin: - gleam: bin/cli.mjs - checksum: 10c0/851d41ce6e2e4969e611cb65ee33abc80d5efd579fa170e896534f8f91b5b79d996f41c6a96ae4ec7160d53dc8d94530ec8ff954187b0a30179705271851bef9 - languageName: node - linkType: hard - "glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -1781,7 +1781,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10": +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": version: 10.4.2 resolution: "glob@npm:10.4.2" dependencies: @@ -1797,21 +1797,6 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.3.7": - version: 10.3.15 - resolution: "glob@npm:10.3.15" - dependencies: - foreground-child: "npm:^3.1.0" - jackspeak: "npm:^2.3.6" - minimatch: "npm:^9.0.1" - minipass: "npm:^7.0.4" - path-scurry: "npm:^1.11.0" - bin: - glob: dist/esm/bin.mjs - checksum: 10c0/cda748ddc181b31b3df9548c0991800406d5cc3b3f8110e37a8751ec1e39f37cdae7d7782d5422d7df92775121cdf00599992dff22f7ff1260344843af227c2b - languageName: node - linkType: hard - "glob@npm:^9.3.2": version: 9.3.5 resolution: "glob@npm:9.3.5" @@ -1982,19 +1967,6 @@ __metadata: languageName: node linkType: hard -"jackspeak@npm:^2.3.6": - version: 2.3.6 - resolution: "jackspeak@npm:2.3.6" - dependencies: - "@isaacs/cliui": "npm:^8.0.2" - "@pkgjs/parseargs": "npm:^0.11.0" - dependenciesMeta: - "@pkgjs/parseargs": - optional: true - checksum: 10c0/f01d8f972d894cd7638bc338e9ef5ddb86f7b208ce177a36d718eac96ec86638a6efa17d0221b10073e64b45edc2ce15340db9380b1f5d5c5d000cbc517dc111 - languageName: node - linkType: hard - "jackspeak@npm:^3.1.2": version: 3.4.0 resolution: "jackspeak@npm:3.4.0" @@ -2135,7 +2107,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^9.0.1, minimatch@npm:^9.0.4": +"minimatch@npm:^9.0.4": version: 9.0.4 resolution: "minimatch@npm:9.0.4" dependencies: @@ -2218,14 +2190,14 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.4, minipass@npm:^7.1.0": +"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0": version: 7.1.1 resolution: "minipass@npm:7.1.1" checksum: 10c0/fdccc2f99c31083f45f881fd1e6971d798e333e078ab3c8988fb818c470fbd5e935388ad9adb286397eba50baebf46ef8ff487c8d3f455a69c6f3efc327bdff9 languageName: node linkType: hard -"minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.1.2": +"minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": version: 7.1.2 resolution: "minipass@npm:7.1.2" checksum: 10c0/b0fd20bb9fb56e5fa9a8bfac539e8915ae07430a619e4b86ff71f5fc757ef3924b23b2c4230393af1eda647ed3d75739e4e0acb250a6b1eb277cf7f8fe449557 @@ -2400,7 +2372,7 @@ __metadata: languageName: node linkType: hard -"path-scurry@npm:^1.11.0, path-scurry@npm:^1.11.1, path-scurry@npm:^1.6.1": +"path-scurry@npm:^1.11.1, path-scurry@npm:^1.6.1": version: 1.11.1 resolution: "path-scurry@npm:1.11.1" dependencies: @@ -2754,16 +2726,16 @@ __metadata: linkType: hard "tar@npm:^7.1.0": - version: 7.1.0 - resolution: "tar@npm:7.1.0" + version: 7.4.0 + resolution: "tar@npm:7.4.0" dependencies: "@isaacs/fs-minipass": "npm:^4.0.0" chownr: "npm:^3.0.0" - minipass: "npm:^7.1.0" + minipass: "npm:^7.1.2" minizlib: "npm:^3.0.1" mkdirp: "npm:^3.0.1" yallist: "npm:^5.0.0" - checksum: 10c0/08d85076820a2885855e581623c9c41e17a9ca47ca203e073e4612ccbcecc9e963134a48ff4a392a12d5d2184ebe5f7ed1e4a68d964193cbb52514aa858a0d2a + checksum: 10c0/f4bab85fd101585f2cececc41eb8706191052ab65cc33f1a798198e0c7905f41b06ae3d6731fb2b6084847c53bc1e2265b77e05a105c0c44afaf6cb7a08ddf14 languageName: node linkType: hard diff --git a/packages/lustre/.github/FUNDING.yml b/packages/lustre/.github/FUNDING.yml new file mode 100644 index 0000000..b873a5a --- /dev/null +++ b/packages/lustre/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: [hayleigh-dot-dev] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/packages/lustre/.github/workflows/release.yml b/packages/lustre/.github/workflows/release.yml new file mode 100644 index 0000000..d6612fa --- /dev/null +++ b/packages/lustre/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: release + +on: + push: + tags: ["v*"] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3.1.0 + - uses: erlef/setup-beam@v1.16.0 + with: + otp-version: "26.0.2" + rebar3-version: "3" + gleam-version: "1.2.1" + + - run: cargo install tomlq + - run: | + if [ "v$(tomlq version -f gleam.toml)" == "${{ github.ref_name }}" ]; then + exit 0 + fi + echo "tag does not match version in gleam.toml, refusing to publish" + exit 1 + - run: gleam format --check src test + - run: gleam test + - run: gleam publish -y + env: + HEXPM_USER: ${{ secrets.HEX_USERNAME }} + HEXPM_PASS: ${{ secrets.HEX_PASSWORD }} + + - uses: softprops/action-gh-release@v1 diff --git a/packages/lustre/.gitignore b/packages/lustre/.gitignore new file mode 100644 index 0000000..992d05f --- /dev/null +++ b/packages/lustre/.gitignore @@ -0,0 +1,8 @@ +*.beam +*.ez +build +erl_crash.dump + +.vscode + +node_modules \ No newline at end of file diff --git a/packages/lustre/LICENSE b/packages/lustre/LICENSE new file mode 100644 index 0000000..b8320fd --- /dev/null +++ b/packages/lustre/LICENSE @@ -0,0 +1,18 @@ +Copyright 2022 Hayleigh Thompson + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/packages/lustre/README.md b/packages/lustre/README.md new file mode 100644 index 0000000..29eabfc --- /dev/null +++ b/packages/lustre/README.md @@ -0,0 +1,199 @@ +

Lustre

+ +
+ ✨ Make your frontend shine ✨ +
+ +
+ A framework for building Web apps in Gleam! +
+ +
+ +
+ + Available on Hex + + + Documentation + +
+ +
+

+ + + Quickstart + + | + + Reference + + | + + Discord + +

+
+ +
+ Built with ❤︎ by + Hayleigh Thompson and + + contributors + +
+ +--- + +## Table of contents + +- [Features](#features) +- [Example](#example) +- [Philosophy](#philosophy) +- [Installation](#installation) +- [Where next](#where-next) +- [Support](#support) + +## Features + +- A **declarative**, functional API for constructing HTML. No templates, no macros, + just Gleam. + +- An Erlang and Elm-inspired architecture for **managing state**. + +- **Managed side effects** for predictable, testable code. + +- Universal components. **Write once, run anywhere**. Elm meets Phoenix LiveView. + +- A **batteries-included CLI** that makes scaffolding and building apps a breeze. + +- **Server-side rendering** for static HTML templating. + +## Example + +```gleam +import gleam/int +import lustre +import lustre/element.{text} +import lustre/element/html.{div, button, p} +import lustre/event.{on_click} + +pub fn main() { + let app = lustre.simple(init, update, view) + let assert Ok(_) = lustre.start(app, "#app", Nil) + + Nil +} + +fn init(_flags) { + 0 +} + +type Msg { + Incr + Decr +} + +fn update(model, msg) { + case msg { + Incr -> model + 1 + Decr -> model - 1 + } +} + +fn view(model) { + let count = int.to_string(model) + + div([], [ + button([on_click(Incr)], [text(" + ")]), + p([], [text(count)]), + button([on_click(Decr)], [text(" - ")]) + ]) +} +``` + +## Philosophy + +Lustre is an _opinionated_ framework for building small-to-medium-sized Web +applications. Modern frontend development is hard and complex. Some of that +complexity is necessary, but a lot of it is accidental or comes from having far +too many options. Lustre has the same design philosophy as Gleam: where possible, +there should be only one way to do things. + +That means shipping with a single state management system out of the box, modelled +after Elm and Erlang/OTP. Open any Lustre application and you should feel +right at home. + +It also means we encourage simple approaches to constructing views over complex +ones. Lustre _does_ have a way to create encapsulated stateful components (something +we sorely missed in Elm) but it shouldn't be the default. Prefer simple functions +to stateful components. + +Where components _are_ necessary, lean into the fact that Lustre components can +run _anywhere_. Lustre gives you the tools to write components that can run inside +an existing Lustre application, export them as a standalone Web Component, or run +them on the server with a minimal runtime for patching the DOM. Lustre calls these +**universal components** and they're written with Gleam's multiple targets in mind. + +## Installation + +Lustre is published on [Hex](https://hex.pm/packages/lustre)! You can add it to +your Gleam projects from the command line: + +```sh +gleam add lustre +``` + +Lustre also has a companion package containing development tooling that you might +like to install: + +> **Note**: the lustre_dev_tools development server watches your filesystem for +> changes to your gleam code and can automatically reload the browser. For linux +> users this requires [inotify-tools]() be installed + +```sh +gleam add --dev lustre_dev_tools +``` + +If you're using a different build tool, like Rebar3 or Mix, you can add Lustre +to your `rebar.config` or `mix.exs` file respectively. + +```erlang +{deps, [ + {lustre, "4.0.0"} +]} +``` + +```elixir +defp deps do + [ + {:lustre, "~> 4.0"} + ] +end +``` + +## Where next + +To get up to speed with Lustre, check out the [quickstart guide](https://hexdocs.pm/lustre/guide/01-quickstart.html). +If you prefer to see some code, the [examples](https://github.com/lustre-labs/lustre/tree/main/examples) +directory contains a handful of small applications that demonstrate different +aspects of the framework. + +You can also read through the documentation and API reference on +[HexDocs](https://hexdocs.pm/lustre). + +## Support + +Lustre is mostly built by just me, [Hayleigh](https://github.com/hayleigh-dot-dev), +around two jobs. If you'd like to support my work, you can [sponsor me on GitHub](https://github.com/sponsors/hayleigh-dot-dev). + +Contributions are also very welcome! If you've spotted a bug, or would like to +suggest a feature, please open an issue or a pull request. diff --git a/packages/lustre/birdie_snapshots/can_compute_a_diff_from_one_render_to_the_next.accepted b/packages/lustre/birdie_snapshots/can_compute_a_diff_from_one_render_to_the_next.accepted new file mode 100644 index 0000000..4d6e333 --- /dev/null +++ b/packages/lustre/birdie_snapshots/can_compute_a_diff_from_one_render_to_the_next.accepted @@ -0,0 +1,5 @@ +--- +version: 1.1.0 +title: Can compute a diff from one render to the next +--- +[[["0-0-0",{"content":"3"}]],[],[]] \ No newline at end of file diff --git a/packages/lustre/birdie_snapshots/can_compute_a_diff_from_one_render_to_the_next_with_fragments.accepted b/packages/lustre/birdie_snapshots/can_compute_a_diff_from_one_render_to_the_next_with_fragments.accepted new file mode 100644 index 0000000..10e6efc --- /dev/null +++ b/packages/lustre/birdie_snapshots/can_compute_a_diff_from_one_render_to_the_next_with_fragments.accepted @@ -0,0 +1,5 @@ +--- +version: 1.1.0 +title: Can compute a diff from one render to the next with fragments +--- +[[["0-2-0",{"content":"3"}]],[],[]] \ No newline at end of file diff --git a/packages/lustre/birdie_snapshots/can_render_an_application's_initial_state.accepted b/packages/lustre/birdie_snapshots/can_render_an_application's_initial_state.accepted new file mode 100644 index 0000000..4545fcb --- /dev/null +++ b/packages/lustre/birdie_snapshots/can_render_an_application's_initial_state.accepted @@ -0,0 +1,5 @@ +--- +version: 1.0.1 +title: Can render an application's initial state. +--- +

0

\ No newline at end of file diff --git a/packages/lustre/birdie_snapshots/can_render_an_application's_initial_state_when_using_fragments.accepted b/packages/lustre/birdie_snapshots/can_render_an_application's_initial_state_when_using_fragments.accepted new file mode 100644 index 0000000..d89dc7b --- /dev/null +++ b/packages/lustre/birdie_snapshots/can_render_an_application's_initial_state_when_using_fragments.accepted @@ -0,0 +1,5 @@ +--- +version: 1.1.0 +title: Can render an application's initial state when using fragments +--- +

start fragment

middle fragment

0

order check, last element

\ No newline at end of file diff --git a/packages/lustre/birdie_snapshots/can_render_an_application's_state_after_some_updates.accepted b/packages/lustre/birdie_snapshots/can_render_an_application's_state_after_some_updates.accepted new file mode 100644 index 0000000..0922d44 --- /dev/null +++ b/packages/lustre/birdie_snapshots/can_render_an_application's_state_after_some_updates.accepted @@ -0,0 +1,5 @@ +--- +version: 1.0.1 +title: Can render an application's state after some updates. +--- +

3

\ No newline at end of file diff --git a/packages/lustre/birdie_snapshots/can_render_static_html.accepted b/packages/lustre/birdie_snapshots/can_render_static_html.accepted new file mode 100644 index 0000000..907c6a5 --- /dev/null +++ b/packages/lustre/birdie_snapshots/can_render_static_html.accepted @@ -0,0 +1,5 @@ +--- +version: 1.0.4 +title: Can render static HTML +--- +Hello, World!

Hello, World!

\ No newline at end of file diff --git a/packages/lustre/birdie_snapshots/can_render_static_html_with_unescaped_html.new b/packages/lustre/birdie_snapshots/can_render_static_html_with_unescaped_html.new new file mode 100644 index 0000000..9ffc59d --- /dev/null +++ b/packages/lustre/birdie_snapshots/can_render_static_html_with_unescaped_html.new @@ -0,0 +1,6 @@ +--- +version: 1.1.2 +title: Can render static HTML with unescaped HTML +--- + +
hello!
\ No newline at end of file diff --git a/packages/lustre/birdie_snapshots/can_safely_escape_dangerous_symbols_in_attributes.accepted b/packages/lustre/birdie_snapshots/can_safely_escape_dangerous_symbols_in_attributes.accepted new file mode 100644 index 0000000..cbb7f9d --- /dev/null +++ b/packages/lustre/birdie_snapshots/can_safely_escape_dangerous_symbols_in_attributes.accepted @@ -0,0 +1,5 @@ +--- +version: 1.1.2 +title: Can safely escape dangerous symbols in attributes +--- +
\ No newline at end of file diff --git a/packages/lustre/examples/01-hello-world/README.md b/packages/lustre/examples/01-hello-world/README.md new file mode 100644 index 0000000..71df563 --- /dev/null +++ b/packages/lustre/examples/01-hello-world/README.md @@ -0,0 +1,67 @@ +![](./header.png) + +# 01 Hello World + +This hello world example is a tiny example of what you need to put together to +get a Lustre application running. In later examples we'll touch on server-side +rendering and Lustre Universal Components but for these first examples we'll +be looking at rendering on the client _only_. + +## Configuring the Gleam project + +It's important to remember to add `target = "javascript"` to your `gleam.toml`! +If you forget to do this you might end up confused when it looks like your project +is successfully building but you have no JavaScript output! + +## Creating a `lustre.element` application + +The simplest kind of Lustre application is the `element`. This sets up a static +application that does not have its own update loop and cannot dynamically render +any content. Instead, we provide a static Lustre `Element` to render once. + +### HTML attributes and inline styles + +In Lustre, HTML attributes are modelled as a `List` of attributes. This is a bit +different from many other frameworks that use an object or record for attributes. +Lustre takes the list-of-attributes approach for a couple of reasons: + +- Gleam doesn't have a way to construct an anonymous record: we'd have to have + an infinite number of types to cover every possible varation! + +- Working with lists makes it convenient to merge different sets of attributes + together (like an element that defines some local attributes and merges them + with any passed in as an argument). + +In a similar fashion, inline styles are lists of property/value tuples. In this +example we're setting inline styles for the `width` and `height` properties. + +### Why `element.text`? + +In frameworks like React, it's enough to just return a `String` if you want to +render some text. Gleam's type system works a little differently though, a string +literal isn't compatible with Lustre's `Element` type on its own, so we need to +wrap any text to render in `element.text`. + +You won't see us do it in any of the examples we share, but it's common for folks +to import `text` and any html elements they're using unqualified to cut down on +some of the noise: + +```gleam +import lustre/element.{text} +import lustre/element/html.{div, p} +... +``` + +## Seeing the result + +Lustre has a companion package containing development tooling called +[lustre_dev_tools](https://hexdocs.pm/lustre_dev_tools/). It's already included +in this and all the other example. You can run `gleam run -m lustre/dev start` +in any of these examples to start a development server and head over to +`localhost:1234` to see what it produces. + +## Getting help + +If you're having trouble with Lustre or not sure what the right way to do +something is, the best place to get help is the [Gleam Discord server](https://discord.gg/Fm8Pwmy). +You could also open an issue on the [Lustre GitHub repository](https://github.com/lustre-labs/lustre/issues). diff --git a/packages/lustre/examples/01-hello-world/gleam.toml b/packages/lustre/examples/01-hello-world/gleam.toml new file mode 100644 index 0000000..6b9e4db --- /dev/null +++ b/packages/lustre/examples/01-hello-world/gleam.toml @@ -0,0 +1,13 @@ +name = "app" +version = "1.0.0" +target = "javascript" + +[dependencies] +gleam_json = "1.0.1" +gleam_stdlib = "~> 0.36" +lustre = "~> 4.0" +lustre_ui = "~> 0.4" + +[dev-dependencies] +gleeunit = "~> 1.0" +lustre_dev_tools = "~> 1.0" diff --git a/packages/lustre/examples/01-hello-world/header.png b/packages/lustre/examples/01-hello-world/header.png new file mode 100644 index 0000000..dd76b40 Binary files /dev/null and b/packages/lustre/examples/01-hello-world/header.png differ diff --git a/packages/lustre/examples/01-hello-world/index.html b/packages/lustre/examples/01-hello-world/index.html new file mode 100644 index 0000000..36ddf10 --- /dev/null +++ b/packages/lustre/examples/01-hello-world/index.html @@ -0,0 +1,19 @@ + + + + + + + 🚧 app + + + + + + +
+ + diff --git a/packages/lustre/examples/01-hello-world/manifest.toml b/packages/lustre/examples/01-hello-world/manifest.toml new file mode 100644 index 0000000..ee55acd --- /dev/null +++ b/packages/lustre/examples/01-hello-world/manifest.toml @@ -0,0 +1,49 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "fs", version = "8.6.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "61EA2BDAEDAE4E2024D0D25C63E44DCCF65622D4402DB4A2DF12868D1546503F" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, + { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, + { name = "gleam_httpc", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "CF76C71002DEECF6DC5D9CA83D962728FAE166B57926BE442D827004D3C7DF1B" }, + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_package_interface", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "52A721BCA972C8099BB881195D821AAA64B9F2655BECC102165D5A1097731F01" }, + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "glearray", version = "0.2.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "9C207E05F38D724F464FA921378DB3ABC2B0A2F5821116D8BC8B2CACC68930D5" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "glint", version = "0.18.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "5FB54D7732B4105E4AF4D89A7EE6D5E8CF33DA13A3575D0C6ECE470B97958454" }, + { name = "glisten", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "CF3A9383E9BA4A8CBAF2F7B799716290D02F2AC34E7A77556B49376B662B9314" }, + { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "A996064F04EF6E67F0668FD0ACFB309830B05D0EE3A0C11BBBD2D4464334F792" }, + { name = "lustre", version = "4.2.6", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "9ABD71D63F4B8F362CB824DED2C4CA64895DEFACD8F22B0FF055BF15241B1AE2" }, + { name = "lustre_dev_tools", version = "1.3.3", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_stdlib", "glint", "glisten", "mist", "simplifile", "spinner", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "67B4E62DAD9B8323487AAA697A6F3FA72348B6DEA6674D65D4F7A1407CF377ED" }, + { name = "lustre_ui", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_ui", source = "hex", outer_checksum = "FA1F9E89D89CDD5DF376ED86ABA8A38441CB2E664CD4D402F22A49DA4D7BB56D" }, + { name = "marceau", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "5188D643C181EE350D8A20A3BDBD63AF7B6C505DE333CFBE05EF642ADD88A59B" }, + { name = "mist", version = "1.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "109B4D64E68C104CC23BB3CC5441ECD479DD7444889DA01113B75C6AF0F0E17B" }, + { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, + { name = "repeatedly", version = "2.1.1", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "38808C3EC382B0CD981336D5879C24ECB37FCB9C1D1BD128F7A80B0F74404D79" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, + { name = "spinner", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "200BA3D4A04D468898E63C0D316E23F526E02514BC46454091975CB5BAE41E8F" }, + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, + { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, + { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, + { name = "wisp", version = "0.14.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "9F5453AF1F9275E6F8707BC815D6A6A9DF41551921B16FBDBA52883773BAE684" }, +] + +[requirements] +gleam_json = { version = "1.0.1" } +gleam_stdlib = { version = "~> 0.36" } +gleeunit = { version = "~> 1.0" } +lustre = { version = "~> 4.0" } +lustre_dev_tools = { version = "~> 1.0" } +lustre_ui = { version = "~> 0.4" } diff --git a/packages/lustre/examples/01-hello-world/src/app.gleam b/packages/lustre/examples/01-hello-world/src/app.gleam new file mode 100644 index 0000000..57b9492 --- /dev/null +++ b/packages/lustre/examples/01-hello-world/src/app.gleam @@ -0,0 +1,20 @@ +import lustre +import lustre/attribute +import lustre/element +import lustre/element/html +import lustre/ui + +pub fn main() { + let styles = [#("width", "100vw"), #("height", "100vh"), #("padding", "1rem")] + let app = + lustre.element(ui.centre( + [attribute.style(styles)], + html.div([], [ + html.h1([], [element.text("Hello, world.")]), + html.h2([], [element.text("Welcome to Lustre.")]), + ]), + )) + let assert Ok(_) = lustre.start(app, "#app", Nil) + + Nil +} diff --git a/packages/lustre/examples/02-interactivity/README.md b/packages/lustre/examples/02-interactivity/README.md new file mode 100644 index 0000000..37ebde7 --- /dev/null +++ b/packages/lustre/examples/02-interactivity/README.md @@ -0,0 +1,149 @@ +![](./header.png) + +# 02 Interactivity + +In this example we show the basic structure of all Lustre applications with a +classic counter example. + +## The Model-View-Update architecture + +All Lustre applications are built around the Model-View-Update (MVU) architecture. +This is a pattern that's been popularised by the Elm programming language and +has since been adopted by many other frameworks and languages. + +MVU applications are built around three main concepts: + +- A `Model` and a function to initialise it. +- A `Msg` type and a function to update the model based on messages. +- A `View` function to render the model as a Lustre `Element`. + +These three pieces come together to form a self-contained update loop. You produce +an initial model, render it as HTML, and convert any user interactions into +messages to handle in the update function. + +```text + +--------+ + | | + | update | + | | + +--------+ + ^ | + | | + Msg | | Model + | | + | v ++------+ +------------------------+ +| | Model | | +| init |------------------------>| Lustre Runtime | +| | | | ++------+ +------------------------+ + ^ | + | | + Msg | | Model + | | + | v + +--------+ + | | + | view | + | | + +--------+ +``` + +### Model + +The model represents the entire state of your application. For most Lustre +applications this will be a record, but for this example we're aliasing `Int` to +our `Model` type to keep things simple. + +We also need to write an `init` function that returns the initial state of our +application. It takes one argument, known as "flags" which is provided when the +application is first started. + +```gleam +fn init(initial_count: Int) -> Model { + case initial_count < 0 { + True -> 0 + False -> initial_count + } +} +``` + +Our `init` function takes a starting count, but ensures it cannot be below `0`. + +### Update + +In many other frameworks, it's common to update state directly in an event handler. +MVU applications take a different approach: instead of state updates being scattered +around your codebase, they are handled in a single `update` function. + +To achieve this, we define a `Msg` type that represents all the different kinds of +messages our application can receive. If you're familiar with Erlang this approach +to state management will be familiar to you. If you're coming from a JavaScript +background, this approach is most-similar to state management solutions like Redux +or Vuex. + +```gleam +pub opaque type Msg { + Incr + Decr +} +``` + +This approach means it is easy to quickly get an idea of all the ways your app +can change state, and makes it easy to add new state changes over time. By pattern +matching on an incoming message in our `update` function, we can lean on Gleam's +_exhaustiveness checking_ to ensure we handle all possible messages. + +### View + +Because state management is handled in our `update` function, our `view` becomes +a simple function that takes a model and returns some HTML in the form of a +Lustre `Element`. + +```gleam +fn view(model: Model) -> Element(Msg) { + ... +} +``` + +In Lustre we call _all_ functions that return an `Element` "view functions": there's +nothing special about the `view` that takes your model. + +Folks coming from frameworks like React might notice the absence of components +with local encapsulated state. Lustre _does_ have components like this, but unlike +other frameworks these are a fairly advanced use of the library and are typically +used for larger pieces of UI like an entire form or a table. We'll cover how +components fit into Lustre in later examples, but for now resist the urge to think +in terms of "components" and "state" and try to think of your UI as a composition +of _view functions_. + +## Creating a dynamic Lustre application + +In the previous example we used the `lustre.element` function to construct a +static Lustre app. To introduce the basic MVU loop, we can use `lustre.simple` +instead. From now on we'll see that all the different ways to construct a Lustre +application all take the same three `init`, `update`, and `view` functions. + +Starting a Lustre application with `lustre.start` requires three things: + +- A configured `Application` (that's what we used `lustre.element` for). + +- A [CSS selector](https://developer.mozilla.org/en-US/docs/Web/API/Document_object_model/Locating_DOM_elements_using_selectors) + to locate the DOM node to mount the application on to. As in other frameworks, + it's common to use an element with the id "app": for that you'd write the + selector as `#app`. + +- Some initial data to pass to the application's `init` function. Because applications + constructed with `lustre.element` are not dynamic there's nothing meaningful + to pass in here, so we just use `Nil`. + +Starting an application could fail for a number of reasons, so this function +returns a `Result`. The `Ok` value is a function you can use to send messages to +your running application from the outside world: we'll see more of that in later +examples! + +## Getting help + +If you're having trouble with Lustre or not sure what the right way to do +something is, the best place to get help is the [Gleam Discord server](https://discord.gg/Fm8Pwmy). +You could also open an issue on the [Lustre GitHub repository](https://github.com/lustre-labs/lustre/issues). diff --git a/packages/lustre/examples/02-interactivity/gleam.toml b/packages/lustre/examples/02-interactivity/gleam.toml new file mode 100644 index 0000000..6b9e4db --- /dev/null +++ b/packages/lustre/examples/02-interactivity/gleam.toml @@ -0,0 +1,13 @@ +name = "app" +version = "1.0.0" +target = "javascript" + +[dependencies] +gleam_json = "1.0.1" +gleam_stdlib = "~> 0.36" +lustre = "~> 4.0" +lustre_ui = "~> 0.4" + +[dev-dependencies] +gleeunit = "~> 1.0" +lustre_dev_tools = "~> 1.0" diff --git a/packages/lustre/examples/02-interactivity/header.png b/packages/lustre/examples/02-interactivity/header.png new file mode 100644 index 0000000..6cca8a2 Binary files /dev/null and b/packages/lustre/examples/02-interactivity/header.png differ diff --git a/packages/lustre/examples/02-interactivity/index.html b/packages/lustre/examples/02-interactivity/index.html new file mode 100644 index 0000000..36ddf10 --- /dev/null +++ b/packages/lustre/examples/02-interactivity/index.html @@ -0,0 +1,19 @@ + + + + + + + 🚧 app + + + + + + +
+ + diff --git a/packages/lustre/examples/02-interactivity/manifest.toml b/packages/lustre/examples/02-interactivity/manifest.toml new file mode 100644 index 0000000..ee55acd --- /dev/null +++ b/packages/lustre/examples/02-interactivity/manifest.toml @@ -0,0 +1,49 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "fs", version = "8.6.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "61EA2BDAEDAE4E2024D0D25C63E44DCCF65622D4402DB4A2DF12868D1546503F" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, + { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, + { name = "gleam_httpc", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "CF76C71002DEECF6DC5D9CA83D962728FAE166B57926BE442D827004D3C7DF1B" }, + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_package_interface", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "52A721BCA972C8099BB881195D821AAA64B9F2655BECC102165D5A1097731F01" }, + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "glearray", version = "0.2.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "9C207E05F38D724F464FA921378DB3ABC2B0A2F5821116D8BC8B2CACC68930D5" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "glint", version = "0.18.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "5FB54D7732B4105E4AF4D89A7EE6D5E8CF33DA13A3575D0C6ECE470B97958454" }, + { name = "glisten", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "CF3A9383E9BA4A8CBAF2F7B799716290D02F2AC34E7A77556B49376B662B9314" }, + { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "A996064F04EF6E67F0668FD0ACFB309830B05D0EE3A0C11BBBD2D4464334F792" }, + { name = "lustre", version = "4.2.6", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "9ABD71D63F4B8F362CB824DED2C4CA64895DEFACD8F22B0FF055BF15241B1AE2" }, + { name = "lustre_dev_tools", version = "1.3.3", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_stdlib", "glint", "glisten", "mist", "simplifile", "spinner", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "67B4E62DAD9B8323487AAA697A6F3FA72348B6DEA6674D65D4F7A1407CF377ED" }, + { name = "lustre_ui", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_ui", source = "hex", outer_checksum = "FA1F9E89D89CDD5DF376ED86ABA8A38441CB2E664CD4D402F22A49DA4D7BB56D" }, + { name = "marceau", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "5188D643C181EE350D8A20A3BDBD63AF7B6C505DE333CFBE05EF642ADD88A59B" }, + { name = "mist", version = "1.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "109B4D64E68C104CC23BB3CC5441ECD479DD7444889DA01113B75C6AF0F0E17B" }, + { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, + { name = "repeatedly", version = "2.1.1", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "38808C3EC382B0CD981336D5879C24ECB37FCB9C1D1BD128F7A80B0F74404D79" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, + { name = "spinner", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "200BA3D4A04D468898E63C0D316E23F526E02514BC46454091975CB5BAE41E8F" }, + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, + { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, + { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, + { name = "wisp", version = "0.14.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "9F5453AF1F9275E6F8707BC815D6A6A9DF41551921B16FBDBA52883773BAE684" }, +] + +[requirements] +gleam_json = { version = "1.0.1" } +gleam_stdlib = { version = "~> 0.36" } +gleeunit = { version = "~> 1.0" } +lustre = { version = "~> 4.0" } +lustre_dev_tools = { version = "~> 1.0" } +lustre_ui = { version = "~> 0.4" } diff --git a/packages/lustre/examples/02-interactivity/src/app.gleam b/packages/lustre/examples/02-interactivity/src/app.gleam new file mode 100644 index 0000000..6e69048 --- /dev/null +++ b/packages/lustre/examples/02-interactivity/src/app.gleam @@ -0,0 +1,60 @@ +import gleam/int +import lustre +import lustre/attribute +import lustre/element.{type Element} +import lustre/element/html +import lustre/event +import lustre/ui + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + let app = lustre.simple(init, update, view) + let assert Ok(_) = lustre.start(app, "#app", 0) + + Nil +} + +// MODEL ----------------------------------------------------------------------- + +type Model = + Int + +fn init(initial_count: Int) -> Model { + case initial_count < 0 { + True -> 0 + False -> initial_count + } +} + +// UPDATE ---------------------------------------------------------------------- + +pub opaque type Msg { + Incr + Decr +} + +fn update(model: Model, msg: Msg) -> Model { + case msg { + Incr -> model + 1 + Decr -> model - 1 + } +} + +// VIEW ------------------------------------------------------------------------ + +fn view(model: Model) -> Element(Msg) { + let styles = [#("width", "100vw"), #("height", "100vh"), #("padding", "1rem")] + let count = int.to_string(model) + + ui.centre( + [attribute.style(styles)], + ui.stack([], [ + ui.button([event.on_click(Incr)], [element.text("+")]), + html.p([attribute.style([#("text-align", "center")])], [ + element.text(count), + ]), + ui.button([event.on_click(Decr)], [element.text("-")]), + ]), + ) +} diff --git a/packages/lustre/examples/03-controlled-inputs/README.md b/packages/lustre/examples/03-controlled-inputs/README.md new file mode 100644 index 0000000..95ca620 --- /dev/null +++ b/packages/lustre/examples/03-controlled-inputs/README.md @@ -0,0 +1,70 @@ +![](./header.png) + +# 03 Controlled Inputs + +The most common way to handle inputs and other state-holding elements is in a +_controlled_ way. This means your app's model is the source of truth for that +element's state, and you update that state based on user input or other events. + +This example shows what that means in practice. For any controlled input we need +two things: + +- A field in our model (or a function to derive a value from the model) to use + as the input's `value` attribute. + +- A message variant to handle input events and update the model. + +```gleam +ui.input([ + // Input's value is fixed to the model's `value` field + attribute.value(model.value), + // Whenever the input changes, we send a `UserUpdatedMessage` message with the + // new value + event.on_input(UserUpdatedMessage) +]) +``` + +## Why is this beneficial? + +Central to Lustre's architecture is the idea that your model is the single source +of truth for your application's UI. This opens up the door to things like serialising +program state to load in the future, time-travel debugging, and rehydrating your +app's state from a server. + +It also gives you tighter control of when and how to update your UI in response +to user input. In this example, we only update the model when the new input +value is less than 10 characters long. + +```gleam +case msg { + UserUpdatedMessage(value) -> { + let length = string.length(value) + + case length <= model.max { + True -> Model(..model, value: value, length: length) + False -> model + } + } + + ... +``` + +## A note on message naming + +In our [state management guide](https://hexdocs.pm/lustre/guide/02-state-management.html) +we touch on the idea of "messages not actions." We think the best way to name your +messages is following a "Subject Verb Object" pattern: `UserUpdatedMessage` not +`SetMessage` and so on. + +This approach to message naming can feel a cumbersome at first, especially for +small examples like this. One of Lustre's super powers is that as your app grows +in size, your `Msg` type becomes a very helpful overview of all the different +events your app can handle. When they take the form of `Subject Verb Object` it +gives you an immediate sense of the different things that speak to your app: how +much is coming from your backend, how much is user input, and so on. + +## Getting help + +If you're having trouble with Lustre or not sure what the right way to do +something is, the best place to get help is the [Gleam Discord server](https://discord.gg/Fm8Pwmy). +You could also open an issue on the [Lustre GitHub repository](https://github.com/lustre-labs/lustre/issues). diff --git a/packages/lustre/examples/03-controlled-inputs/gleam.toml b/packages/lustre/examples/03-controlled-inputs/gleam.toml new file mode 100644 index 0000000..6b9e4db --- /dev/null +++ b/packages/lustre/examples/03-controlled-inputs/gleam.toml @@ -0,0 +1,13 @@ +name = "app" +version = "1.0.0" +target = "javascript" + +[dependencies] +gleam_json = "1.0.1" +gleam_stdlib = "~> 0.36" +lustre = "~> 4.0" +lustre_ui = "~> 0.4" + +[dev-dependencies] +gleeunit = "~> 1.0" +lustre_dev_tools = "~> 1.0" diff --git a/packages/lustre/examples/03-controlled-inputs/header.png b/packages/lustre/examples/03-controlled-inputs/header.png new file mode 100644 index 0000000..336fb18 Binary files /dev/null and b/packages/lustre/examples/03-controlled-inputs/header.png differ diff --git a/packages/lustre/examples/03-controlled-inputs/index.html b/packages/lustre/examples/03-controlled-inputs/index.html new file mode 100644 index 0000000..36ddf10 --- /dev/null +++ b/packages/lustre/examples/03-controlled-inputs/index.html @@ -0,0 +1,19 @@ + + + + + + + 🚧 app + + + + + + +
+ + diff --git a/packages/lustre/examples/03-controlled-inputs/manifest.toml b/packages/lustre/examples/03-controlled-inputs/manifest.toml new file mode 100644 index 0000000..ee55acd --- /dev/null +++ b/packages/lustre/examples/03-controlled-inputs/manifest.toml @@ -0,0 +1,49 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "fs", version = "8.6.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "61EA2BDAEDAE4E2024D0D25C63E44DCCF65622D4402DB4A2DF12868D1546503F" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, + { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, + { name = "gleam_httpc", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "CF76C71002DEECF6DC5D9CA83D962728FAE166B57926BE442D827004D3C7DF1B" }, + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_package_interface", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "52A721BCA972C8099BB881195D821AAA64B9F2655BECC102165D5A1097731F01" }, + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "glearray", version = "0.2.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "9C207E05F38D724F464FA921378DB3ABC2B0A2F5821116D8BC8B2CACC68930D5" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "glint", version = "0.18.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "5FB54D7732B4105E4AF4D89A7EE6D5E8CF33DA13A3575D0C6ECE470B97958454" }, + { name = "glisten", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "CF3A9383E9BA4A8CBAF2F7B799716290D02F2AC34E7A77556B49376B662B9314" }, + { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "A996064F04EF6E67F0668FD0ACFB309830B05D0EE3A0C11BBBD2D4464334F792" }, + { name = "lustre", version = "4.2.6", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "9ABD71D63F4B8F362CB824DED2C4CA64895DEFACD8F22B0FF055BF15241B1AE2" }, + { name = "lustre_dev_tools", version = "1.3.3", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_stdlib", "glint", "glisten", "mist", "simplifile", "spinner", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "67B4E62DAD9B8323487AAA697A6F3FA72348B6DEA6674D65D4F7A1407CF377ED" }, + { name = "lustre_ui", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_ui", source = "hex", outer_checksum = "FA1F9E89D89CDD5DF376ED86ABA8A38441CB2E664CD4D402F22A49DA4D7BB56D" }, + { name = "marceau", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "5188D643C181EE350D8A20A3BDBD63AF7B6C505DE333CFBE05EF642ADD88A59B" }, + { name = "mist", version = "1.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "109B4D64E68C104CC23BB3CC5441ECD479DD7444889DA01113B75C6AF0F0E17B" }, + { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, + { name = "repeatedly", version = "2.1.1", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "38808C3EC382B0CD981336D5879C24ECB37FCB9C1D1BD128F7A80B0F74404D79" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, + { name = "spinner", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "200BA3D4A04D468898E63C0D316E23F526E02514BC46454091975CB5BAE41E8F" }, + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, + { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, + { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, + { name = "wisp", version = "0.14.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "9F5453AF1F9275E6F8707BC815D6A6A9DF41551921B16FBDBA52883773BAE684" }, +] + +[requirements] +gleam_json = { version = "1.0.1" } +gleam_stdlib = { version = "~> 0.36" } +gleeunit = { version = "~> 1.0" } +lustre = { version = "~> 4.0" } +lustre_dev_tools = { version = "~> 1.0" } +lustre_ui = { version = "~> 0.4" } diff --git a/packages/lustre/examples/03-controlled-inputs/src/app.gleam b/packages/lustre/examples/03-controlled-inputs/src/app.gleam new file mode 100644 index 0000000..d44e1ae --- /dev/null +++ b/packages/lustre/examples/03-controlled-inputs/src/app.gleam @@ -0,0 +1,71 @@ +import gleam/int +import gleam/string +import lustre +import lustre/attribute +import lustre/element.{type Element} +import lustre/event +import lustre/ui +import lustre/ui/layout/aside + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + let app = lustre.simple(init, update, view) + let assert Ok(_) = lustre.start(app, "#app", Nil) +} + +// MODEL ----------------------------------------------------------------------- + +type Model { + Model(value: String, length: Int, max: Int) +} + +fn init(_flags) -> Model { + Model(value: "", length: 0, max: 10) +} + +// UPDATE ---------------------------------------------------------------------- + +pub opaque type Msg { + UserUpdatedMessage(value: String) + UserResetMessage +} + +fn update(model: Model, msg: Msg) -> Model { + case msg { + UserUpdatedMessage(value) -> { + let length = string.length(value) + + case length <= model.max { + True -> Model(..model, value: value, length: length) + False -> model + } + } + UserResetMessage -> Model(..model, value: "", length: 0) + } +} + +// VIEW ------------------------------------------------------------------------ + +fn view(model: Model) -> Element(Msg) { + let styles = [#("width", "100vw"), #("height", "100vh"), #("padding", "1rem")] + let length = int.to_string(model.length) + let max = int.to_string(model.max) + + ui.centre( + [attribute.style(styles)], + ui.aside( + [aside.content_first(), aside.align_centre()], + ui.field( + [], + [element.text("Write a message:")], + ui.input([ + attribute.value(model.value), + event.on_input(UserUpdatedMessage), + ]), + [element.text(length <> "/" <> max)], + ), + ui.button([event.on_click(UserResetMessage)], [element.text("Reset")]), + ), + ) +} diff --git a/packages/lustre/examples/04-custom-event-handlers/README.md b/packages/lustre/examples/04-custom-event-handlers/README.md new file mode 100644 index 0000000..434725a --- /dev/null +++ b/packages/lustre/examples/04-custom-event-handlers/README.md @@ -0,0 +1,76 @@ +![](./header.png) + +# 04 Custom Event Handlers + +While Lustre's built-in event handlers can cover most of your basic needs, in practice you will often need to provide more advanced functionality. For this, we can reach for the `event.on("eventname", handler)` function to generate attributes that can provide custom event handling. + +But first, let's take a look under the hood to see what event handlers actually _do_. + +## Decoding Dynamic Data + +Lustre is a type-safe framework, but the DOM allows HTML elements to generate events containing values of any arbitrary type and structure. In Gleam, such data is referred to as _dynamic_, and is handled by the `gleam/dynamic` library. `gleam/dynamic` is used for decoding everything from unpredictable JSON input to Lustre's DOM events. + +If you peek at [the gleam\dynamic documentation](https://hexdocs.pm/gleam_stdlib/0.17.1/gleam/dynamic/#module-types), you'll quickly see it exports four types: + +```gleam + pub external type Dynamic + // data for which we don't know the type + + pub type DecodeError { ... } + // the error returned when unexpected data is encountered + + pub type DecodeErrors = List(DecodeError) + + pub type Decoder(t) = fn(Dynamic) -> Result(t, DecodeErrors) + // any function that accepts dynamic data and returns a Result(t, DecodeErrors) +``` + +In Lustre, all DOM event values are converted to `Dynamic` values before being passed to their respective handlers. Event handlers accept those `Dynamic` values and return a `Result` of either `Ok(Msg)`, or `DecodeErrors` - the `DecodeError` list. + +Therefore, Lustre event handlers are simply an implementation of the `Decoder` function type. + +## Writing A Custom Input Handler + +In javascript, input event handlers often look something like this: + +```js +function onInput(event) { + const input = event.target.value; + // do your stuff! +} +``` + +This is very convenient! But it's not type-safe. From the function's perspective, there is no guarantee that _`event`_ is an object with a property named _`target`_ which itself has a property named _`value`_. In a more complex app, we might even pass it a numeric or boolean value on accident. The failure to handle such error conditions leads to many `Uncaught TypeError` crashes. + +Here's how we can extract the event's dynamic value in a type-safe way in Lustre: + +```gleam + let on_input = fn(event: dynamic.Dynamic) -> Result(Msg, dynamic.DecodeErrors) { + use target <- result.try(dynamic.field("target", dynamic.dynamic)(event)) + use value <- result.try(dynamic.field("value", dynamic.string)(target)) + // do your stuff! + Ok(GotInput(value)) + } +``` + +First we extract the `target` field from our `event`, which is expected to be of the type `dynamic.dynamic`. Because the target is itself dynamic, we can again use the dynamic library to extract its `value` field, which is expected to be of type `dynamic.string`. If either of those expectations are not met, the function will return an error, and nothing more will happen. + +This is such a common use case that Lustre's `event` module has a helper function for it. Here is a far less verbose version that provides the exact same type-safe guarantees: + +```gleam + let on_input = fn(event) { + use value <- result.try(event.value(event)) + // do your stuff! + Ok(GotInput(value)) + } +``` + +## Make it Loud + +In this [example code](./src/app.gleam#L63), we define a custom input handler called `make_it_loud`, which calls `string.uppercase` to make sure all our input is LOUD. Then in our [view function](./src/app.gleam#L79), instead of calling `event.on_input(GotInput)` like we did in the last example, we can just call `event.on("input", make_it_loud)`. + +## Getting help + +If you're having trouble with Lustre or not sure what the right way to do +something is, the best place to get help is the [Gleam Discord server](https://discord.gg/Fm8Pwmy). +You could also open an issue on the [Lustre GitHub repository](https://github.com/lustre-labs/lustre/issues). diff --git a/packages/lustre/examples/04-custom-event-handlers/gleam.toml b/packages/lustre/examples/04-custom-event-handlers/gleam.toml new file mode 100644 index 0000000..6b9e4db --- /dev/null +++ b/packages/lustre/examples/04-custom-event-handlers/gleam.toml @@ -0,0 +1,13 @@ +name = "app" +version = "1.0.0" +target = "javascript" + +[dependencies] +gleam_json = "1.0.1" +gleam_stdlib = "~> 0.36" +lustre = "~> 4.0" +lustre_ui = "~> 0.4" + +[dev-dependencies] +gleeunit = "~> 1.0" +lustre_dev_tools = "~> 1.0" diff --git a/packages/lustre/examples/04-custom-event-handlers/header.png b/packages/lustre/examples/04-custom-event-handlers/header.png new file mode 100644 index 0000000..e75e2b5 Binary files /dev/null and b/packages/lustre/examples/04-custom-event-handlers/header.png differ diff --git a/packages/lustre/examples/04-custom-event-handlers/index.html b/packages/lustre/examples/04-custom-event-handlers/index.html new file mode 100644 index 0000000..36ddf10 --- /dev/null +++ b/packages/lustre/examples/04-custom-event-handlers/index.html @@ -0,0 +1,19 @@ + + + + + + + 🚧 app + + + + + + +
+ + diff --git a/packages/lustre/examples/04-custom-event-handlers/manifest.toml b/packages/lustre/examples/04-custom-event-handlers/manifest.toml new file mode 100644 index 0000000..ee55acd --- /dev/null +++ b/packages/lustre/examples/04-custom-event-handlers/manifest.toml @@ -0,0 +1,49 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "fs", version = "8.6.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "61EA2BDAEDAE4E2024D0D25C63E44DCCF65622D4402DB4A2DF12868D1546503F" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, + { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, + { name = "gleam_httpc", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "CF76C71002DEECF6DC5D9CA83D962728FAE166B57926BE442D827004D3C7DF1B" }, + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_package_interface", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "52A721BCA972C8099BB881195D821AAA64B9F2655BECC102165D5A1097731F01" }, + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "glearray", version = "0.2.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "9C207E05F38D724F464FA921378DB3ABC2B0A2F5821116D8BC8B2CACC68930D5" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "glint", version = "0.18.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "5FB54D7732B4105E4AF4D89A7EE6D5E8CF33DA13A3575D0C6ECE470B97958454" }, + { name = "glisten", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "CF3A9383E9BA4A8CBAF2F7B799716290D02F2AC34E7A77556B49376B662B9314" }, + { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "A996064F04EF6E67F0668FD0ACFB309830B05D0EE3A0C11BBBD2D4464334F792" }, + { name = "lustre", version = "4.2.6", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "9ABD71D63F4B8F362CB824DED2C4CA64895DEFACD8F22B0FF055BF15241B1AE2" }, + { name = "lustre_dev_tools", version = "1.3.3", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_stdlib", "glint", "glisten", "mist", "simplifile", "spinner", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "67B4E62DAD9B8323487AAA697A6F3FA72348B6DEA6674D65D4F7A1407CF377ED" }, + { name = "lustre_ui", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_ui", source = "hex", outer_checksum = "FA1F9E89D89CDD5DF376ED86ABA8A38441CB2E664CD4D402F22A49DA4D7BB56D" }, + { name = "marceau", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "5188D643C181EE350D8A20A3BDBD63AF7B6C505DE333CFBE05EF642ADD88A59B" }, + { name = "mist", version = "1.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "109B4D64E68C104CC23BB3CC5441ECD479DD7444889DA01113B75C6AF0F0E17B" }, + { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, + { name = "repeatedly", version = "2.1.1", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "38808C3EC382B0CD981336D5879C24ECB37FCB9C1D1BD128F7A80B0F74404D79" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, + { name = "spinner", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "200BA3D4A04D468898E63C0D316E23F526E02514BC46454091975CB5BAE41E8F" }, + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, + { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, + { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, + { name = "wisp", version = "0.14.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "9F5453AF1F9275E6F8707BC815D6A6A9DF41551921B16FBDBA52883773BAE684" }, +] + +[requirements] +gleam_json = { version = "1.0.1" } +gleam_stdlib = { version = "~> 0.36" } +gleeunit = { version = "~> 1.0" } +lustre = { version = "~> 4.0" } +lustre_dev_tools = { version = "~> 1.0" } +lustre_ui = { version = "~> 0.4" } diff --git a/packages/lustre/examples/04-custom-event-handlers/src/app.gleam b/packages/lustre/examples/04-custom-event-handlers/src/app.gleam new file mode 100644 index 0000000..725b04b --- /dev/null +++ b/packages/lustre/examples/04-custom-event-handlers/src/app.gleam @@ -0,0 +1,77 @@ +import gleam/dynamic +import gleam/int +import gleam/result +import gleam/string +import lustre +import lustre/attribute +import lustre/element.{type Element} +import lustre/event +import lustre/ui +import lustre/ui/layout/aside + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + let app = lustre.simple(init, update, view) + let assert Ok(_) = lustre.start(app, "#app", Nil) +} + +// MODEL ----------------------------------------------------------------------- + +type Model { + Model(value: String, length: Int, max: Int) +} + +fn init(_flags) -> Model { + Model(value: "", length: 0, max: 10) +} + +// UPDATE ---------------------------------------------------------------------- + +pub opaque type Msg { + UserUpdatedMessage(value: String) + UserResetMessage +} + +fn update(model: Model, msg: Msg) -> Model { + case msg { + UserUpdatedMessage(value) -> { + let length = string.length(value) + case length <= model.max { + True -> Model(..model, value: value, length: length) + False -> model + } + } + UserResetMessage -> Model(..model, value: "", length: 0) + } +} + +// VIEW ------------------------------------------------------------------------ + +fn view(model: Model) -> Element(Msg) { + let styles = [#("width", "100vw"), #("height", "100vh"), #("padding", "1rem")] + let length = int.to_string(model.length) + let max = int.to_string(model.max) + let make_it_loud = fn(event) -> Result(Msg, List(dynamic.DecodeError)) { + use target <- result.try(dynamic.field("target", dynamic.dynamic)(event)) + use value <- result.try(dynamic.field("value", dynamic.string)(target)) + + let loud = string.uppercase(value) + + Ok(UserUpdatedMessage(loud)) + } + + ui.centre( + [attribute.style(styles)], + ui.aside( + [aside.content_first(), aside.align_centre()], + ui.field( + [], + [element.text("Write a LOUD message:")], + ui.input([attribute.value(model.value), event.on("input", make_it_loud)]), + [element.text(length <> "/" <> max)], + ), + ui.button([event.on_click(UserResetMessage)], [element.text("Reset")]), + ), + ) +} diff --git a/packages/lustre/examples/05-http-requests/README.md b/packages/lustre/examples/05-http-requests/README.md new file mode 100644 index 0000000..5fb95eb --- /dev/null +++ b/packages/lustre/examples/05-http-requests/README.md @@ -0,0 +1,137 @@ +![](./header.png) + +# 05 HTTP Requests + +In the previous examples, we've seen Lustre applications constructed with the +[`lustre.simple`](https://hexdocs.pm/lustre/lustre.html#simple) constructor. +These kinds of applications are great for introducing the Model-View-Update (MVU) +pattern, but for most real-world applications we'll need a way to talk to the +outside world. + +Lustre's runtime includes _managed effects_, which allow us to perform side effects +like HTTP requests and communicate the results back to our application's `update` +function. To learn more about Lustre's effect system and why it's useful, check +out the [side effects guide](https://hexdocs.pm/lustre/guide/03-side-effects.html), +or the docs for the [`lustre/effect` module](https://hexdocs.pm/lustre/lustre/effect.html). + +This example is a practical look at what effects mean in Lustre, and we'll look +at how to send HTTP requests in a Lustre application: a pretty important thing to +know! + +## Moving on from `lustre.simple` + +From this example onwards, we will use a new application constructor: +[`lustre.application`](https://hexdocs.pm/lustre/lustre.html#application). Full Lustre +applications have the ability to communicate to the runtime. Let's compare the type +of both the `simple` and `application` functions: + +```gleam +pub fn simple( + init: fn(flags) -> model, + update: fn(model, msg) -> model, + view: fn(model) -> Element(msg), +) -> App(flags, model, msg) + +pub fn application( + init: fn(flags) -> #(model, Effect(msg)), + update: fn(model, msg) -> #(model, Effect(msg)), + view: fn(model) -> Element(msg), +) -> App(flags, model, msg) +``` + +All that's changed is the return type of our `init` and `update` functions. Instead +of returning just a new model, they now return a tuple containing both a model and +any side effects we want the runtime to perform. + +You'll notice that running a Lustre app with side effects _changes the signature_ +of our [`init`](src/app.gleam#L43) and [`update`](src/app.gleam#L54) functions. +Instead of returning just a model, we return a tuple containing both a model an +an `Effect(Msg)` value. The effect value specifies any further updates we might +want the Lustre runtime to execute before the next invocation of the `view` +function. + +> **Note**: notice how the type of `view` remains the same. In Lustre, your `view` +> is always a [_pure function_](https://github.com/lustre-labs/lustre/blob/main/pages/hints/pure-functions.md) +> that takes a model and returns the UI to be rendered: we never perform side effects +> in the `view` function itself. + +## HTTP requests as side effects + +The community library [`lustre_http`](https://hexdocs.pm/lustre_http/) gives us +a way to model HTTP requests as Lustre `Effect`s. Crucially, when we call +`lustre_http.get` we are _not_ performing the request! We're constructing a +description of the side effect that we can hand off to the Lustre runtime to +perform. + +```gleam +fn get_quote() -> Effect(Msg) { + let url = "https://api.quotable.io/random" + let decoder = + dynamic.decode2( + Quote, + dynamic.field("author", dynamic.string), + dynamic.field("content", dynamic.string), + ) + + lustre_http.get(url, lustre_http.expect_json(decoder, ApiUpdatedQuote)) +} +``` + +To construct HTTP requests, we need a few different things: + +- The `url` to send the request to. + +- A description of what we _expect_ the result to be. There are a few options: + `expect_anything`, `expect_text`, `expect_json`. In this example we say we're + expecting a JSON response and provide a decoder. + +- Along with what we expect the response to be, we also need to provide a way + to turn that response into a `Msg` value that our `update` function can handle. + +The same applies for post requests too, but there you also need to provide the +JSON body of the request. + +## Tying it together + +We now have a function that can create an `Effect` for us, but we need to hand it +to the runtime to be executed. The only way we can do that is by returning it from +our `update` (or `init`) function! We attach an event listener on a button, and +when the user clicks that button we'll return the `Effect` we want to perform as +the second element of a tuple: + +```gleam +fn view(model: Model) -> Element(Msg) { + ui.centre([], + ui.button([event.on_click(UserClickedRefresh)], [ + element.text("New quote"), + ]), + ) +} + +fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { + case msg { + UserClickedRefresh -> #(model, get_quote()) + ... + } +} +``` + +Of course, we need to handle responses from the quote API in our `update` function +too. When there are no side effects we want the runtime to perform for us, we need +to call `effect.none()`: + +```gleam +fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { + case msg { + ... + ApiUpdatedQuote(Ok(quote)) -> #(Model(quote: Some(quote)), effect.none()) + ApiUpdatedQuote(Error(_)) -> #(model, effect.none()) + } +} +``` + +## Getting help + +If you're having trouble with Lustre or not sure what the right way to do +something is, the best place to get help is the [Gleam Discord server](https://discord.gg/Fm8Pwmy). +You could also open an issue on the [Lustre GitHub repository](https://github.com/lustre-labs/lustre/issues). diff --git a/packages/lustre/examples/05-http-requests/gleam.toml b/packages/lustre/examples/05-http-requests/gleam.toml new file mode 100644 index 0000000..518d327 --- /dev/null +++ b/packages/lustre/examples/05-http-requests/gleam.toml @@ -0,0 +1,14 @@ +name = "app" +version = "1.0.0" +target = "javascript" + +[dependencies] +gleam_json = "1.0.1" +gleam_stdlib = "~> 0.36" +lustre = "~> 4.0" +lustre_ui = "~> 0.4" +lustre_http = "~> 0.5.2" + +[dev-dependencies] +gleeunit = "~> 1.0" +lustre_dev_tools = "~> 1.0" diff --git a/packages/lustre/examples/05-http-requests/header.png b/packages/lustre/examples/05-http-requests/header.png new file mode 100644 index 0000000..f08fb17 Binary files /dev/null and b/packages/lustre/examples/05-http-requests/header.png differ diff --git a/packages/lustre/examples/05-http-requests/index.html b/packages/lustre/examples/05-http-requests/index.html new file mode 100644 index 0000000..36ddf10 --- /dev/null +++ b/packages/lustre/examples/05-http-requests/index.html @@ -0,0 +1,19 @@ + + + + + + + 🚧 app + + + + + + +
+ + diff --git a/packages/lustre/examples/05-http-requests/manifest.toml b/packages/lustre/examples/05-http-requests/manifest.toml new file mode 100644 index 0000000..68abb49 --- /dev/null +++ b/packages/lustre/examples/05-http-requests/manifest.toml @@ -0,0 +1,53 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "fs", version = "8.6.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "61EA2BDAEDAE4E2024D0D25C63E44DCCF65622D4402DB4A2DF12868D1546503F" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, + { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_fetch", version = "0.4.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "7446410A44A1D1328F5BC1FF4FC9CBD1570479EA69349237B3F82E34521CCC10" }, + { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, + { name = "gleam_httpc", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "CF76C71002DEECF6DC5D9CA83D962728FAE166B57926BE442D827004D3C7DF1B" }, + { name = "gleam_javascript", version = "0.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "14D5B7E1A70681E0776BF0A0357F575B822167960C844D3D3FA114D3A75F05A8" }, + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_package_interface", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "52A721BCA972C8099BB881195D821AAA64B9F2655BECC102165D5A1097731F01" }, + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "glearray", version = "0.2.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "9C207E05F38D724F464FA921378DB3ABC2B0A2F5821116D8BC8B2CACC68930D5" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "glint", version = "0.18.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "5FB54D7732B4105E4AF4D89A7EE6D5E8CF33DA13A3575D0C6ECE470B97958454" }, + { name = "glisten", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "CF3A9383E9BA4A8CBAF2F7B799716290D02F2AC34E7A77556B49376B662B9314" }, + { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "A996064F04EF6E67F0668FD0ACFB309830B05D0EE3A0C11BBBD2D4464334F792" }, + { name = "lustre", version = "4.2.6", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "9ABD71D63F4B8F362CB824DED2C4CA64895DEFACD8F22B0FF055BF15241B1AE2" }, + { name = "lustre_dev_tools", version = "1.3.3", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_stdlib", "glint", "glisten", "mist", "simplifile", "spinner", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "67B4E62DAD9B8323487AAA697A6F3FA72348B6DEA6674D65D4F7A1407CF377ED" }, + { name = "lustre_http", version = "0.5.2", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_http", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_http", source = "hex", outer_checksum = "FB0478CBFA6B16DBE8ECA326DAE2EC15645E04900595EF2C4F039ABFA0512ABA" }, + { name = "lustre_ui", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_ui", source = "hex", outer_checksum = "FA1F9E89D89CDD5DF376ED86ABA8A38441CB2E664CD4D402F22A49DA4D7BB56D" }, + { name = "marceau", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "5188D643C181EE350D8A20A3BDBD63AF7B6C505DE333CFBE05EF642ADD88A59B" }, + { name = "mist", version = "1.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "109B4D64E68C104CC23BB3CC5441ECD479DD7444889DA01113B75C6AF0F0E17B" }, + { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, + { name = "repeatedly", version = "2.1.1", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "38808C3EC382B0CD981336D5879C24ECB37FCB9C1D1BD128F7A80B0F74404D79" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, + { name = "spinner", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "200BA3D4A04D468898E63C0D316E23F526E02514BC46454091975CB5BAE41E8F" }, + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, + { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, + { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, + { name = "wisp", version = "0.14.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "9F5453AF1F9275E6F8707BC815D6A6A9DF41551921B16FBDBA52883773BAE684" }, +] + +[requirements] +gleam_json = { version = "1.0.1" } +gleam_stdlib = { version = "~> 0.36" } +gleeunit = { version = "~> 1.0" } +lustre = { version = "~> 4.0" } +lustre_dev_tools = { version = "~> 1.0" } +lustre_http = { version = "~> 0.5.2" } +lustre_ui = { version = "~> 0.4" } diff --git a/packages/lustre/examples/05-http-requests/src/app.gleam b/packages/lustre/examples/05-http-requests/src/app.gleam new file mode 100644 index 0000000..acd5fcc --- /dev/null +++ b/packages/lustre/examples/05-http-requests/src/app.gleam @@ -0,0 +1,93 @@ +import gleam/dynamic +import gleam/option.{type Option, None, Some} +import lustre +import lustre/attribute +import lustre/effect.{type Effect} +import lustre/element.{type Element} +import lustre/element/html +import lustre/event + +// Lustre_http is a community package that provides a simple API for making +// HTTP requests from your update function. You can find the docs for the package +// here: https://hexdocs.pm/lustre_http/index.html +import lustre/ui +import lustre/ui/layout/aside +import lustre_http.{type HttpError} + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + let app = lustre.application(init, update, view) + let assert Ok(_) = lustre.start(app, "#app", Nil) +} + +// MODEL ----------------------------------------------------------------------- + +type Model { + Model(quote: Option(Quote)) +} + +type Quote { + Quote(author: String, content: String) +} + +fn init(_flags) -> #(Model, Effect(Msg)) { + #(Model(quote: None), effect.none()) +} + +// UPDATE ---------------------------------------------------------------------- + +pub opaque type Msg { + UserClickedRefresh + ApiUpdatedQuote(Result(Quote, HttpError)) +} + +fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { + case msg { + UserClickedRefresh -> #(model, get_quote()) + ApiUpdatedQuote(Ok(quote)) -> #(Model(quote: Some(quote)), effect.none()) + ApiUpdatedQuote(Error(_)) -> #(model, effect.none()) + } +} + +fn get_quote() -> Effect(Msg) { + let url = "https://api.quotable.io/random" + let decoder = + dynamic.decode2( + Quote, + dynamic.field("author", dynamic.string), + dynamic.field("content", dynamic.string), + ) + + lustre_http.get(url, lustre_http.expect_json(decoder, ApiUpdatedQuote)) +} + +// VIEW ------------------------------------------------------------------------ + +fn view(model: Model) -> Element(Msg) { + let styles = [#("width", "100vw"), #("height", "100vh"), #("padding", "1rem")] + + ui.centre( + [attribute.style(styles)], + ui.aside( + [aside.min_width(70), attribute.style([#("width", "60ch")])], + view_quote(model.quote), + ui.button([event.on_click(UserClickedRefresh)], [ + element.text("New quote"), + ]), + ), + ) +} + +fn view_quote(quote: Option(Quote)) -> Element(msg) { + case quote { + Some(quote) -> + ui.stack([], [ + element.text(quote.author <> " once said..."), + html.p([attribute.style([#("font-style", "italic")])], [ + element.text(quote.content), + ]), + ]) + None -> html.p([], [element.text("Click the button to get a quote!")]) + } +} diff --git a/packages/lustre/examples/06-custom-effects/README.md b/packages/lustre/examples/06-custom-effects/README.md new file mode 100644 index 0000000..0af61bd --- /dev/null +++ b/packages/lustre/examples/06-custom-effects/README.md @@ -0,0 +1,88 @@ +![](./header.png) + +# 06 Custom Effects + +In the last example, we showed how to use effects provided by `lustre_http`. In +this example we'll see how to create effects of our own using Lustre's +[`effect.from`](https://hexdocs.pm/lustre/lustre/effect.html#from) +function. + +Since we use effects to communicate with _external_ systems (like the browser or +the Erlang VM) you'll find creating custom effects usually involves Gleam's +[external functions](https://tour.gleam.run/everything/#advanced-features-externals). +So be sure to read up on that! + +> Gleam externals are part of its "foreign function interface", which is why +> you'll typically see files with `ffi` in the name - like +> [`app.ffi.mjs`](./src/app.ffi.mjs). + +## Accessing Browser Storage + +In this example, the external system we want to interact with is the browser's +[local storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). +This way, we can write a message into the text input and it will still be there +when we refresh the page. Handy! + +The `view`, `update` and `init` functions should look pretty familiar by now, so +let's focus on the functions that generate our custom effects. + +```rust +fn read_localstorage(key: String) -> Effect(Msg) { + effect.from(fn(dispatch) { + do_read_localstorage(key) + |> CacheUpdatedMessage + |> dispatch + }) +} +``` + +We use `effect.from` by passing it a custom function that describes the effect +we want the Lustre runtime to perform. Our custom function will receive a +`dispatch` function as its only parameter - we can use that to send new messages +back to our `update` function if we want to. + +In this case, we read from local storage, pipe its value into the +`CacheUpdatedMessage` constructor, and pipe that to the `dispatch` function so +our `update` messsage can handle it. + +```rust +fn write_localstorage(key: String, value: String) -> Effect(msg) { + effect.from(fn(_) { + do_write_localstorage(key, value) + }) +} +``` + +Here, our effect is simpler. We tell the gleam compiler we don't need to use the +`dispatch` function by replacing it with a [discard +pattern](https://tour.gleam.run/everything/#basics-discard-patterns). Then we +write to local storage, and no more work needs to be done. + +You may be wondering, why bother using an effect if we aren't also going to update +our program with the result? Why not just fire off `do_write_localstorage` directly +from the `update` function or a custom event handler? + +Using effects has many benefits! It lets us implement our `update` and `view` +functions as [pure functions](https://github.com/lustre-labs/lustre/blob/main/pages/hints/pure-functions.md). +This makes them easier to test, allows for time-travel debugging, and even opens +the door to easily porting them to [server components](https://hexdocs.pm/lustre/lustre/server_component.html). + +## Another note on message naming + +In our [controlled inputs +example](https://github.com/lustre-labs/lustre/tree/main/examples/03-controlled-inputs) +we touched on the idea of naming messages in a "Subject Verb Object" pattern. +This example neatly shows the benefits of taking such an approach once different +"things" start talking to your application. + +It would be easy to have a single `SetMessage` variant that both the user input +and local storage lookup use to update the model, but doing so might encourage +us to conceal the fact that the local storage lookup can fail and makes it +harder to see what things our app deals with. + +## Getting help + +If you're having trouble with Lustre or not sure what the right way to do +something is, the best place to get help is the [Gleam Discord +server](https://discord.gg/Fm8Pwmy). You could also open an issue on the [Lustre +GitHub repository](https://github.com/lustre-labs/lustre/issues). diff --git a/packages/lustre/examples/06-custom-effects/gleam.toml b/packages/lustre/examples/06-custom-effects/gleam.toml new file mode 100644 index 0000000..1066817 --- /dev/null +++ b/packages/lustre/examples/06-custom-effects/gleam.toml @@ -0,0 +1,14 @@ +name = "app" +version = "1.0.0" +target = "javascript" + +[dependencies] +gleam_json = "1.0.1" +gleam_stdlib = "~> 0.36" +lustre = "~> 4.0" +lustre_ui = "~> 0.4" +lustre_http = "~> 0.5" + +[dev-dependencies] +gleeunit = "~> 1.0" +lustre_dev_tools = "~> 1.0" diff --git a/packages/lustre/examples/06-custom-effects/header.png b/packages/lustre/examples/06-custom-effects/header.png new file mode 100644 index 0000000..1dbbdb0 Binary files /dev/null and b/packages/lustre/examples/06-custom-effects/header.png differ diff --git a/packages/lustre/examples/06-custom-effects/index.html b/packages/lustre/examples/06-custom-effects/index.html new file mode 100644 index 0000000..36ddf10 --- /dev/null +++ b/packages/lustre/examples/06-custom-effects/index.html @@ -0,0 +1,19 @@ + + + + + + + 🚧 app + + + + + + +
+ + diff --git a/packages/lustre/examples/06-custom-effects/manifest.toml b/packages/lustre/examples/06-custom-effects/manifest.toml new file mode 100644 index 0000000..69c2af4 --- /dev/null +++ b/packages/lustre/examples/06-custom-effects/manifest.toml @@ -0,0 +1,53 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "fs", version = "8.6.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "61EA2BDAEDAE4E2024D0D25C63E44DCCF65622D4402DB4A2DF12868D1546503F" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, + { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_fetch", version = "0.4.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "7446410A44A1D1328F5BC1FF4FC9CBD1570479EA69349237B3F82E34521CCC10" }, + { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, + { name = "gleam_httpc", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "CF76C71002DEECF6DC5D9CA83D962728FAE166B57926BE442D827004D3C7DF1B" }, + { name = "gleam_javascript", version = "0.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "14D5B7E1A70681E0776BF0A0357F575B822167960C844D3D3FA114D3A75F05A8" }, + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_package_interface", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "52A721BCA972C8099BB881195D821AAA64B9F2655BECC102165D5A1097731F01" }, + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "glearray", version = "0.2.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "9C207E05F38D724F464FA921378DB3ABC2B0A2F5821116D8BC8B2CACC68930D5" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "glint", version = "0.18.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "5FB54D7732B4105E4AF4D89A7EE6D5E8CF33DA13A3575D0C6ECE470B97958454" }, + { name = "glisten", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "CF3A9383E9BA4A8CBAF2F7B799716290D02F2AC34E7A77556B49376B662B9314" }, + { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "A996064F04EF6E67F0668FD0ACFB309830B05D0EE3A0C11BBBD2D4464334F792" }, + { name = "lustre", version = "4.2.6", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "9ABD71D63F4B8F362CB824DED2C4CA64895DEFACD8F22B0FF055BF15241B1AE2" }, + { name = "lustre_dev_tools", version = "1.3.3", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_stdlib", "glint", "glisten", "mist", "simplifile", "spinner", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "67B4E62DAD9B8323487AAA697A6F3FA72348B6DEA6674D65D4F7A1407CF377ED" }, + { name = "lustre_http", version = "0.5.2", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_http", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_http", source = "hex", outer_checksum = "FB0478CBFA6B16DBE8ECA326DAE2EC15645E04900595EF2C4F039ABFA0512ABA" }, + { name = "lustre_ui", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_ui", source = "hex", outer_checksum = "FA1F9E89D89CDD5DF376ED86ABA8A38441CB2E664CD4D402F22A49DA4D7BB56D" }, + { name = "marceau", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "5188D643C181EE350D8A20A3BDBD63AF7B6C505DE333CFBE05EF642ADD88A59B" }, + { name = "mist", version = "1.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "109B4D64E68C104CC23BB3CC5441ECD479DD7444889DA01113B75C6AF0F0E17B" }, + { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, + { name = "repeatedly", version = "2.1.1", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "38808C3EC382B0CD981336D5879C24ECB37FCB9C1D1BD128F7A80B0F74404D79" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, + { name = "spinner", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "200BA3D4A04D468898E63C0D316E23F526E02514BC46454091975CB5BAE41E8F" }, + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, + { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, + { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, + { name = "wisp", version = "0.14.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "9F5453AF1F9275E6F8707BC815D6A6A9DF41551921B16FBDBA52883773BAE684" }, +] + +[requirements] +gleam_json = { version = "1.0.1" } +gleam_stdlib = { version = "~> 0.36" } +gleeunit = { version = "~> 1.0" } +lustre = { version = "~> 4.0" } +lustre_dev_tools = { version = "~> 1.0" } +lustre_http = { version = "~> 0.5" } +lustre_ui = { version = "~> 0.4" } diff --git a/packages/lustre/examples/06-custom-effects/src/app.ffi.mjs b/packages/lustre/examples/06-custom-effects/src/app.ffi.mjs new file mode 100644 index 0000000..c401b99 --- /dev/null +++ b/packages/lustre/examples/06-custom-effects/src/app.ffi.mjs @@ -0,0 +1,11 @@ +import { Ok, Error } from "./gleam.mjs"; + +export function read_localstorage(key) { + const value = window.localStorage.getItem(key); + + return value ? new Ok(value) : new Error(undefined); +} + +export function write_localstorage(key, value) { + window.localStorage.setItem(key, value); +} diff --git a/packages/lustre/examples/06-custom-effects/src/app.gleam b/packages/lustre/examples/06-custom-effects/src/app.gleam new file mode 100644 index 0000000..a6c668e --- /dev/null +++ b/packages/lustre/examples/06-custom-effects/src/app.gleam @@ -0,0 +1,84 @@ +import gleam/option.{type Option, None, Some} +import lustre +import lustre/attribute +import lustre/effect.{type Effect} +import lustre/element.{type Element} +import lustre/event +import lustre/ui + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + let app = lustre.application(init, update, view) + let assert Ok(_) = lustre.start(app, "#app", Nil) +} + +// MODEL ----------------------------------------------------------------------- + +type Model { + Model(message: Option(String)) +} + +fn init(_flags) -> #(Model, Effect(Msg)) { + #(Model(message: None), read_localstorage("message")) +} + +// UPDATE ---------------------------------------------------------------------- + +pub opaque type Msg { + UserUpdatedMessage(String) + CacheUpdatedMessage(Result(String, Nil)) +} + +fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { + case msg { + UserUpdatedMessage(input) -> #( + Model(message: Some(input)), + write_localstorage("message", input), + ) + CacheUpdatedMessage(Ok(message)) -> #( + Model(message: Some(message)), + effect.none(), + ) + CacheUpdatedMessage(Error(_)) -> #(model, effect.none()) + } +} + +fn read_localstorage(key: String) -> Effect(Msg) { + effect.from(fn(dispatch) { + do_read_localstorage(key) + |> CacheUpdatedMessage + |> dispatch + }) +} + +@external(javascript, "./app.ffi.mjs", "read_localstorage") +fn do_read_localstorage(_key: String) -> Result(String, Nil) { + Error(Nil) +} + +fn write_localstorage(key: String, value: String) -> Effect(msg) { + effect.from(fn(_) { do_write_localstorage(key, value) }) +} + +@external(javascript, "./app.ffi.mjs", "write_localstorage") +fn do_write_localstorage(_key: String, _value: String) -> Nil { + Nil +} + +// VIEW ------------------------------------------------------------------------ + +fn view(model: Model) -> Element(Msg) { + let styles = [#("width", "100vw"), #("height", "100vh")] + let message = option.unwrap(model.message, "") + + ui.centre( + [attribute.style(styles)], + ui.field( + [], + [], + ui.input([attribute.value(message), event.on_input(UserUpdatedMessage)]), + [element.text("Type a message and refresh the page")], + ), + ) +} diff --git a/packages/lustre/examples/07-routing/README.md b/packages/lustre/examples/07-routing/README.md new file mode 100644 index 0000000..217b674 --- /dev/null +++ b/packages/lustre/examples/07-routing/README.md @@ -0,0 +1,65 @@ +![](./header.png) + +# 07 Routing + +In this example, we demonstrate basic routing using the community library [modem](https://hexdocs.pm/modem/). Modem's quickstart docs should be all you should need to get up to speed, so that's the best place to start. + +Of course, it's not much fun routing without something to route _to_. This example lets users create new pages on the fly - a guest book for your next house party! Hospitality is very important, and guests will be sure to feel welcome when they see their name in the navigation with a special greeting page just for them. + +## Using Modem + +Modem uses [custom side effects](../06-custom-effects/) and external Javascript to translate browser click and navigation events into `update` messages for the Lustre runtime. All we need to use it is a [route change handler function](./src/app.gleam#L59) that we can pass to `modem.init` in our app's `init` function. + +> _Note:_ See [`modem.advanced`](https://hexdocs.pm/modem/modem.html#advanced) to configure more options. + +Inside our `on_route_change` function, we match URI path segment patterns to our routes: + +```gleam + fn on_route_change(uri: Uri) -> Msg { + case uri.path_segments(uri.path) { + ["welcome", guest] -> OnRouteChange(WelcomeGuest(guest)) + _ -> OnRouteChange(Home) + } + } +``` + +In our `update` function, we assign the matched route to `model.current_route`: + +```gleam +fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { + case msg { + OnRouteChange(route) -> #( + Model(..model, current_route: route), + effect.none(), + ) + ... + } +``` + +And in our `view` function, we use `model.current_route` to determine what to display to the user: + +```gleam +fn view(model: Model) -> Element(Msg) { + let page = case model.current_route { + Home -> render_home(model) + WelcomeGuest(name) -> render_welcome(model, name) + } + ... +} +``` + +## Views: They're just functions! + +Lustre doesn't provide a traditional HTML or JSX-style templating engine, and this is by design. + +Since the `view` portion of this example is a bit more involved than our previous ones have been, it should start to give you more of a feel for how views in Lustre are _just functions_. The layout is a function. Each page view is a function. The nav is a function, and each individual nav _item_ is a function too. + +This means we can build up our entire UI using Gleam's functional syntax, benefiting from features like exhaustive pattern matching based on our routes. + +Since our views are [pure functions](https://github.com/lustre-labs/lustre/blob/main/pages/hints/pure-functions.md), we know they'll always render reliably. + +## Getting help + +If you're having trouble with Lustre or are not sure what the right way to do +something is, the best place to get help is the [Gleam Discord server](https://discord.gg/Fm8Pwmy). +You could also open an issue on the [Lustre GitHub repository](https://github.com/lustre-labs/lustre/issues). diff --git a/packages/lustre/examples/07-routing/gleam.toml b/packages/lustre/examples/07-routing/gleam.toml new file mode 100644 index 0000000..58f37ee --- /dev/null +++ b/packages/lustre/examples/07-routing/gleam.toml @@ -0,0 +1,15 @@ +name = "app" +version = "1.0.0" +target = "javascript" + +[dependencies] +gleam_json = "1.0.1" +gleam_stdlib = "~> 0.36" +lustre = "~> 4.0" +lustre_ui = "~> 0.4" +lustre_http = "~> 0.5.2" +modem = "~> 1.0" + +[dev-dependencies] +gleeunit = "~> 1.0" +lustre_dev_tools = "~> 1.1" diff --git a/packages/lustre/examples/07-routing/header.png b/packages/lustre/examples/07-routing/header.png new file mode 100644 index 0000000..f903c89 Binary files /dev/null and b/packages/lustre/examples/07-routing/header.png differ diff --git a/packages/lustre/examples/07-routing/index.html b/packages/lustre/examples/07-routing/index.html new file mode 100644 index 0000000..36ddf10 --- /dev/null +++ b/packages/lustre/examples/07-routing/index.html @@ -0,0 +1,19 @@ + + + + + + + 🚧 app + + + + + + +
+ + diff --git a/packages/lustre/examples/07-routing/manifest.toml b/packages/lustre/examples/07-routing/manifest.toml new file mode 100644 index 0000000..a02e147 --- /dev/null +++ b/packages/lustre/examples/07-routing/manifest.toml @@ -0,0 +1,55 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "fs", version = "8.6.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "61EA2BDAEDAE4E2024D0D25C63E44DCCF65622D4402DB4A2DF12868D1546503F" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, + { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_fetch", version = "0.4.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "7446410A44A1D1328F5BC1FF4FC9CBD1570479EA69349237B3F82E34521CCC10" }, + { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, + { name = "gleam_httpc", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "CF76C71002DEECF6DC5D9CA83D962728FAE166B57926BE442D827004D3C7DF1B" }, + { name = "gleam_javascript", version = "0.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "14D5B7E1A70681E0776BF0A0357F575B822167960C844D3D3FA114D3A75F05A8" }, + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_package_interface", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "52A721BCA972C8099BB881195D821AAA64B9F2655BECC102165D5A1097731F01" }, + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "glearray", version = "0.2.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "9C207E05F38D724F464FA921378DB3ABC2B0A2F5821116D8BC8B2CACC68930D5" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "glint", version = "0.18.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "5FB54D7732B4105E4AF4D89A7EE6D5E8CF33DA13A3575D0C6ECE470B97958454" }, + { name = "glisten", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "CF3A9383E9BA4A8CBAF2F7B799716290D02F2AC34E7A77556B49376B662B9314" }, + { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "A996064F04EF6E67F0668FD0ACFB309830B05D0EE3A0C11BBBD2D4464334F792" }, + { name = "lustre", version = "4.2.6", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "9ABD71D63F4B8F362CB824DED2C4CA64895DEFACD8F22B0FF055BF15241B1AE2" }, + { name = "lustre_dev_tools", version = "1.3.3", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_stdlib", "glint", "glisten", "mist", "simplifile", "spinner", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "67B4E62DAD9B8323487AAA697A6F3FA72348B6DEA6674D65D4F7A1407CF377ED" }, + { name = "lustre_http", version = "0.5.2", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_http", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_http", source = "hex", outer_checksum = "FB0478CBFA6B16DBE8ECA326DAE2EC15645E04900595EF2C4F039ABFA0512ABA" }, + { name = "lustre_ui", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_ui", source = "hex", outer_checksum = "FA1F9E89D89CDD5DF376ED86ABA8A38441CB2E664CD4D402F22A49DA4D7BB56D" }, + { name = "marceau", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "5188D643C181EE350D8A20A3BDBD63AF7B6C505DE333CFBE05EF642ADD88A59B" }, + { name = "mist", version = "1.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "109B4D64E68C104CC23BB3CC5441ECD479DD7444889DA01113B75C6AF0F0E17B" }, + { name = "modem", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "modem", source = "hex", outer_checksum = "4C6E448089B09A57C179455D44526A717E4E217D4000B91201617FD2D9F18E68" }, + { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, + { name = "repeatedly", version = "2.1.1", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "38808C3EC382B0CD981336D5879C24ECB37FCB9C1D1BD128F7A80B0F74404D79" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, + { name = "spinner", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "200BA3D4A04D468898E63C0D316E23F526E02514BC46454091975CB5BAE41E8F" }, + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, + { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, + { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, + { name = "wisp", version = "0.14.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "9F5453AF1F9275E6F8707BC815D6A6A9DF41551921B16FBDBA52883773BAE684" }, +] + +[requirements] +gleam_json = { version = "1.0.1" } +gleam_stdlib = { version = "~> 0.36" } +gleeunit = { version = "~> 1.0" } +lustre = { version = "~> 4.0" } +lustre_dev_tools = { version = "~> 1.1" } +lustre_http = { version = "~> 0.5.2" } +lustre_ui = { version = "~> 0.4" } +modem = { version = "~> 1.0" } diff --git a/packages/lustre/examples/07-routing/src/app.gleam b/packages/lustre/examples/07-routing/src/app.gleam new file mode 100644 index 0000000..5b65d51 --- /dev/null +++ b/packages/lustre/examples/07-routing/src/app.gleam @@ -0,0 +1,169 @@ +import gleam/dynamic +import gleam/list +import gleam/result +import gleam/string +import gleam/uri.{type Uri} +import lustre +import lustre/attribute +import lustre/effect.{type Effect} +import lustre/element.{type Element} +import lustre/element/html +import lustre/event +import lustre/ui +import lustre/ui/layout/cluster +import lustre/ui/util/cn +import modem + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + let app = lustre.application(init, update, view) + let assert Ok(_) = lustre.start(app, "#app", Nil) +} + +// MODEL ----------------------------------------------------------------------- + +type Model { + Model(current_route: Route, guests: List(Guest), new_guest_name: String) +} + +type Route { + Home + WelcomeGuest(value: String) +} + +type Guest { + Guest(slug: String, name: String) +} + +fn init(_flags) -> #(Model, Effect(Msg)) { + #( + Model( + current_route: Home, + guests: [ + Guest(slug: "chihiro", name: "Chihiro"), + Guest(slug: "totoro", name: "Totoro"), + ], + new_guest_name: "", + ), + modem.init(on_route_change), + ) +} + +fn on_route_change(uri: Uri) -> Msg { + case uri.path_segments(uri.path) { + ["welcome", guest] -> OnRouteChange(WelcomeGuest(guest)) + _ -> OnRouteChange(Home) + } +} + +// UPDATE ---------------------------------------------------------------------- + +pub opaque type Msg { + OnRouteChange(Route) + UserUpdatedNewGuestName(String) + UserAddedNewGuest(Guest) +} + +fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { + case msg { + OnRouteChange(route) -> #( + Model(..model, current_route: route), + effect.none(), + ) + UserUpdatedNewGuestName(name) -> #( + Model(..model, new_guest_name: name), + effect.none(), + ) + UserAddedNewGuest(guest) -> #( + Model( + ..model, + guests: list.append(model.guests, [guest]), + new_guest_name: "", + ), + effect.none(), + ) + } +} + +// VIEW ------------------------------------------------------------------------ + +fn view(model: Model) -> Element(Msg) { + let styles = [#("margin", "15vh")] + + let page = case model.current_route { + Home -> view_home(model) + WelcomeGuest(name) -> view_welcome(model, name) + } + + ui.stack([attribute.style(styles)], [view_nav(model), page]) +} + +fn view_home(model: Model) { + let new_guest_input = fn(event) { + use key_code <- result.try(dynamic.field("key", dynamic.string)(event)) + case key_code { + "Enter" -> { + let guest_slug = + model.new_guest_name + |> string.replace(" ", "-") + |> string.lowercase + Ok( + UserAddedNewGuest(Guest(name: model.new_guest_name, slug: guest_slug)), + ) + } + _ -> { + use value <- result.try(event.value(event)) + Ok(UserUpdatedNewGuestName(value)) + } + } + } + + view_body([ + view_title("Welcome to the Party 🏡"), + html.p([], [element.text("Please sign the guest book:")]), + ui.input([ + event.on("keyup", new_guest_input), + attribute.value(model.new_guest_name), + ]), + ]) +} + +fn view_welcome(model: Model, slug) -> Element(a) { + let guest = + model.guests + |> list.find(fn(guest: Guest) { guest.slug == slug }) + + let title = case guest { + Ok(guest) -> view_title("Hello, " <> guest.name <> "! 🎉") + _ -> view_title("Sorry ... didn't quite catch that.") + } + + view_body([title]) +} + +fn view_nav(model: Model) -> Element(a) { + let item_styles = [#("text-decoration", "underline")] + + let view_nav_item = fn(path, text) { + html.a([attribute.href("/" <> path), attribute.style(item_styles)], [ + element.text(text), + ]) + } + + let guest_nav_items = + model.guests + |> list.map(fn(guest: Guest) { + view_nav_item("welcome/" <> guest.slug, guest.name) + }) + + cluster.of(html.nav, [], [view_nav_item("", "Home"), ..guest_nav_items]) +} + +fn view_body(children) { + ui.centre([cn.mt_xl()], ui.stack([], children)) +} + +fn view_title(text) { + html.h1([cn.text_xl()], [element.text(text)]) +} diff --git a/packages/lustre/examples/99-full-stack-applications/README.md b/packages/lustre/examples/99-full-stack-applications/README.md new file mode 100644 index 0000000..a45e7a9 --- /dev/null +++ b/packages/lustre/examples/99-full-stack-applications/README.md @@ -0,0 +1,25 @@ +# app + +[![Package Version](https://img.shields.io/hexpm/v/app)](https://hex.pm/packages/app) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/app/) + +```sh +gleam add app +``` +```gleam +import app + +pub fn main() { + // TODO: An example of the project in use +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +gleam shell # Run an Erlang shell +``` diff --git a/packages/lustre/examples/99-full-stack-applications/client/gleam.toml b/packages/lustre/examples/99-full-stack-applications/client/gleam.toml new file mode 100644 index 0000000..9025873 --- /dev/null +++ b/packages/lustre/examples/99-full-stack-applications/client/gleam.toml @@ -0,0 +1,24 @@ +name = "app" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "username", repo = "project" } +# links = [{ title = "Website", href = "https://gleam.run" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = ">= 0.34.0 and < 2.0.0" +lustre = ">= 4.2.4 and < 5.0.0" +lustre_http = ">= 0.5.2 and < 1.0.0" +gleam_json = ">= 1.0.1 and < 2.0.0" +decipher = ">= 1.2.0 and < 2.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" +lustre_dev_tools = ">= 1.3.2 and < 2.0.0" diff --git a/packages/lustre/examples/99-full-stack-applications/client/index.html b/packages/lustre/examples/99-full-stack-applications/client/index.html new file mode 100644 index 0000000..da137b1 --- /dev/null +++ b/packages/lustre/examples/99-full-stack-applications/client/index.html @@ -0,0 +1,15 @@ + + + + + + + 🚧 app + + + + + +
+ + diff --git a/packages/lustre/examples/99-full-stack-applications/client/manifest.toml b/packages/lustre/examples/99-full-stack-applications/client/manifest.toml new file mode 100644 index 0000000..4d627ad --- /dev/null +++ b/packages/lustre/examples/99-full-stack-applications/client/manifest.toml @@ -0,0 +1,54 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birl", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "B1FA529E7BE3FF12CADF32814AB8EC7294E74CEDEE8CC734505707B929A98985" }, + { name = "decipher", version = "1.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_json", "gleam_stdlib", "stoiridh_version"], otp_app = "decipher", source = "hex", outer_checksum = "9F1B5C6FF0D798046E4E0EF87D09DD729324CB72BD7F0D4152B797324D51223E" }, + { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "fs", version = "8.6.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "61EA2BDAEDAE4E2024D0D25C63E44DCCF65622D4402DB4A2DF12868D1546503F" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, + { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_fetch", version = "0.4.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "7446410A44A1D1328F5BC1FF4FC9CBD1570479EA69349237B3F82E34521CCC10" }, + { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, + { name = "gleam_httpc", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "CF76C71002DEECF6DC5D9CA83D962728FAE166B57926BE442D827004D3C7DF1B" }, + { name = "gleam_javascript", version = "0.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "14D5B7E1A70681E0776BF0A0357F575B822167960C844D3D3FA114D3A75F05A8" }, + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_package_interface", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "52A721BCA972C8099BB881195D821AAA64B9F2655BECC102165D5A1097731F01" }, + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "glearray", version = "0.2.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "9C207E05F38D724F464FA921378DB3ABC2B0A2F5821116D8BC8B2CACC68930D5" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "glint", version = "0.18.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "5FB54D7732B4105E4AF4D89A7EE6D5E8CF33DA13A3575D0C6ECE470B97958454" }, + { name = "glisten", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "CF3A9383E9BA4A8CBAF2F7B799716290D02F2AC34E7A77556B49376B662B9314" }, + { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "A996064F04EF6E67F0668FD0ACFB309830B05D0EE3A0C11BBBD2D4464334F792" }, + { name = "lustre", version = "4.2.4", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "09B94E1380CBC400DCD594B36A845E5CB2E143DF89E95460B2CA59E44499CAC9" }, + { name = "lustre_dev_tools", version = "1.3.2", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_stdlib", "glint", "glisten", "mist", "simplifile", "spinner", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "CC8F46BCE51C1349862C5F6BA0075B0C68096B866ED1C520B60358FAAB398B60" }, + { name = "lustre_http", version = "0.5.2", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_http", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_http", source = "hex", outer_checksum = "FB0478CBFA6B16DBE8ECA326DAE2EC15645E04900595EF2C4F039ABFA0512ABA" }, + { name = "marceau", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "1AAD727A30BE0F95562C3403BB9B27C823797AD90037714255EEBF617B1CDA81" }, + { name = "mist", version = "1.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "109B4D64E68C104CC23BB3CC5441ECD479DD7444889DA01113B75C6AF0F0E17B" }, + { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, + { name = "repeatedly", version = "2.1.1", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "38808C3EC382B0CD981336D5879C24ECB37FCB9C1D1BD128F7A80B0F74404D79" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, + { name = "spinner", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "200BA3D4A04D468898E63C0D316E23F526E02514BC46454091975CB5BAE41E8F" }, + { name = "stoiridh_version", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "stoiridh_version", source = "hex", outer_checksum = "298ABEA44DF37764A34C2E9190A84BF2770BC59DD9397C6DC7708040E5A0142B" }, + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, + { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, + { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, + { name = "wisp", version = "0.14.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "9F5453AF1F9275E6F8707BC815D6A6A9DF41551921B16FBDBA52883773BAE684" }, +] + +[requirements] +decipher = { version = ">= 1.2.0 and < 2.0.0"} +gleam_json = { version = ">= 1.0.1 and < 2.0.0" } +gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +lustre = { version = ">= 4.2.4 and < 5.0.0" } +lustre_dev_tools = { version = ">= 1.3.2 and < 2.0.0" } +lustre_http = { version = ">= 0.5.2 and < 1.0.0" } diff --git a/packages/lustre/examples/99-full-stack-applications/client/src/app.gleam b/packages/lustre/examples/99-full-stack-applications/client/src/app.gleam new file mode 100644 index 0000000..aae4530 --- /dev/null +++ b/packages/lustre/examples/99-full-stack-applications/client/src/app.gleam @@ -0,0 +1,127 @@ +// IMPORTS --------------------------------------------------------------------- + +import decipher +import gleam/dynamic.{dynamic} +import gleam/int +import gleam/list +import gleam/result +import lustre +import lustre/attribute +import lustre/effect.{type Effect} +import lustre/element.{type Element} +import lustre/element/html +import lustre/event + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + let app = lustre.application(init, update, view) + let assert Ok(_) = lustre.start(app, "#app", Nil) + + Nil +} + +// MODEL ----------------------------------------------------------------------- + +type Model = + List(#(String, Int)) + +fn init(_) -> #(Model, Effect(Msg)) { + let model = [] + let effect = effect.none() + + #(model, effect) +} + +// UPDATE ---------------------------------------------------------------------- + +type Msg { + ServerSavedList(Result(Nil, String)) + UserAddedProduct(name: String) + UserSavedList + UserUpdatedQuantity(name: String, amount: Int) +} + +fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { + case msg { + ServerSavedList(_) -> #(model, effect.none()) + UserAddedProduct(name) -> #([#(name, 1), ..model], effect.none()) + UserSavedList -> #(model, effect.none()) + UserUpdatedQuantity(name, quantity) -> { + let model = + list.map(model, fn(item) { + case item.0 == name { + True -> #(name, quantity) + False -> item + } + }) + + #(model, effect.none()) + } + } +} + +// VIEW ------------------------------------------------------------------------ + +fn view(model: Model) -> Element(Msg) { + let styles = [ + #("max-width", "30ch"), + #("margin", "0 auto"), + #("display", "flex"), + #("flex-direction", "column"), + #("gap", "1em"), + ] + + html.div([attribute.style(styles)], [ + view_grocery_list(model), + view_new_item(), + html.div([], [html.button([], [html.text("Sync")])]), + ]) +} + +fn view_new_item() -> Element(Msg) { + let handle_click = fn(event) { + let path = ["target", "previousElementSibling", "value"] + + event + |> decipher.at(path, dynamic.string) + |> result.map(UserAddedProduct) + } + + html.div([], [ + html.input([]), + html.button([event.on("click", handle_click)], [html.text("Add")]), + ]) +} + +fn view_grocery_list(model: Model) -> Element(Msg) { + let styles = [#("display", "flex"), #("flex-direction", "column-reverse")] + + element.keyed(html.div([attribute.style(styles)], _), { + use #(name, quantity) <- list.map(model) + let item = view_grocery_item(name, quantity) + + #(name, item) + }) +} + +fn view_grocery_item(name: String, quantity: Int) -> Element(Msg) { + let handle_input = fn(e) { + event.value(e) + |> result.nil_error + |> result.then(int.parse) + |> result.map(UserUpdatedQuantity(name, _)) + |> result.replace_error([]) + } + + html.div([attribute.style([#("display", "flex"), #("gap", "1em")])], [ + html.span([attribute.style([#("flex", "1")])], [html.text(name)]), + html.input([ + attribute.style([#("width", "4em")]), + attribute.type_("number"), + attribute.value(int.to_string(quantity)), + attribute.min("0"), + event.on("input", handle_input), + ]), + ]) +} diff --git a/packages/lustre/examples/99-full-stack-applications/server/gleam.toml b/packages/lustre/examples/99-full-stack-applications/server/gleam.toml new file mode 100644 index 0000000..4ea8472 --- /dev/null +++ b/packages/lustre/examples/99-full-stack-applications/server/gleam.toml @@ -0,0 +1,19 @@ +name = "app" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "username", repo = "project" } +# links = [{ title = "Website", href = "https://gleam.run" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = ">= 0.34.0 and < 2.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/packages/lustre/examples/99-full-stack-applications/server/manifest.toml b/packages/lustre/examples/99-full-stack-applications/server/manifest.toml new file mode 100644 index 0000000..2a7bc47 --- /dev/null +++ b/packages/lustre/examples/99-full-stack-applications/server/manifest.toml @@ -0,0 +1,11 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, +] + +[requirements] +gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/packages/lustre/examples/99-full-stack-applications/server/src/app.gleam b/packages/lustre/examples/99-full-stack-applications/server/src/app.gleam new file mode 100644 index 0000000..452ccb7 --- /dev/null +++ b/packages/lustre/examples/99-full-stack-applications/server/src/app.gleam @@ -0,0 +1,5 @@ +import gleam/io + +pub fn main() { + io.println("Hello from app!") +} diff --git a/packages/lustre/examples/99-full-stack-applications/shared/gleam.toml b/packages/lustre/examples/99-full-stack-applications/shared/gleam.toml new file mode 100644 index 0000000..8478ab1 --- /dev/null +++ b/packages/lustre/examples/99-full-stack-applications/shared/gleam.toml @@ -0,0 +1,19 @@ +name = "shared" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "username", repo = "project" } +# links = [{ title = "Website", href = "https://gleam.run" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = ">= 0.34.0 and < 2.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/packages/lustre/examples/99-full-stack-applications/shared/manifest.toml b/packages/lustre/examples/99-full-stack-applications/shared/manifest.toml new file mode 100644 index 0000000..2a7bc47 --- /dev/null +++ b/packages/lustre/examples/99-full-stack-applications/shared/manifest.toml @@ -0,0 +1,11 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, +] + +[requirements] +gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/packages/lustre/examples/99-full-stack-applications/shared/src/shared.gleam b/packages/lustre/examples/99-full-stack-applications/shared/src/shared.gleam new file mode 100644 index 0000000..4b3811b --- /dev/null +++ b/packages/lustre/examples/99-full-stack-applications/shared/src/shared.gleam @@ -0,0 +1,5 @@ +import gleam/io + +pub fn main() { + io.println("Hello from shared!") +} diff --git a/packages/lustre/examples/99-server-components/README.md b/packages/lustre/examples/99-server-components/README.md new file mode 100644 index 0000000..e69de29 diff --git a/packages/lustre/examples/99-server-components/gleam.toml b/packages/lustre/examples/99-server-components/gleam.toml new file mode 100644 index 0000000..1d640d2 --- /dev/null +++ b/packages/lustre/examples/99-server-components/gleam.toml @@ -0,0 +1,27 @@ +name = "app" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "username", repo = "project" } +# links = [{ title = "Website", href = "https://gleam.run" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = "~> 0.36" +lustre = { path = "../../" } +mist = "~> 1.0" +gleam_erlang = "~> 0.24" +gleam_otp = "~> 0.10" +gleam_http = "~> 3.6" +lustre_ui = "~> 0.4" +gleam_json = "~> 1.0" +simplifile = "~> 1.5" + +[dev-dependencies] +gleeunit = "~> 1.0" diff --git a/packages/lustre/examples/99-server-components/manifest.toml b/packages/lustre/examples/99-server-components/manifest.toml new file mode 100644 index 0000000..aea8226 --- /dev/null +++ b/packages/lustre/examples/99-server-components/manifest.toml @@ -0,0 +1,37 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, + { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "glisten", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "CF3A9383E9BA4A8CBAF2F7B799716290D02F2AC34E7A77556B49376B662B9314" }, + { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "A996064F04EF6E67F0668FD0ACFB309830B05D0EE3A0C11BBBD2D4464334F792" }, + { name = "lustre", version = "4.2.6", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], source = "local", path = "../.." }, + { name = "lustre_ui", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_ui", source = "hex", outer_checksum = "FA1F9E89D89CDD5DF376ED86ABA8A38441CB2E664CD4D402F22A49DA4D7BB56D" }, + { name = "mist", version = "1.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "109B4D64E68C104CC23BB3CC5441ECD479DD7444889DA01113B75C6AF0F0E17B" }, + { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, +] + +[requirements] +gleam_erlang = { version = "~> 0.24" } +gleam_http = { version = "~> 3.6" } +gleam_json = { version = "~> 1.0" } +gleam_otp = { version = "~> 0.10" } +gleam_stdlib = { version = "~> 0.36" } +gleeunit = { version = "~> 1.0" } +lustre = { path = "../../" } +lustre_ui = { version = "~> 0.4" } +mist = { version = "~> 1.0" } +simplifile = { version = "~> 1.5" } diff --git a/packages/lustre/examples/99-server-components/src/app.gleam b/packages/lustre/examples/99-server-components/src/app.gleam new file mode 100644 index 0000000..bc3227f --- /dev/null +++ b/packages/lustre/examples/99-server-components/src/app.gleam @@ -0,0 +1,168 @@ +import counter +import gleam/bytes_builder +import gleam/erlang +import gleam/erlang/process.{type Selector, type Subject} +import gleam/http/request.{type Request} +import gleam/http/response.{type Response} +import gleam/json +import gleam/option.{type Option, None, Some} +import gleam/otp/actor +import gleam/result +import lustre +import lustre/attribute +import lustre/element.{element} +import lustre/element/html.{html} +import lustre/server_component +import mist.{ + type Connection, type ResponseData, type WebsocketConnection, + type WebsocketMessage, +} + +pub fn main() { + let assert Ok(_) = + fn(req: Request(Connection)) -> Response(ResponseData) { + case request.path_segments(req) { + // Set up the websocket connection to the client. This is how we send + // DOM updates to the browser and receive events from the client. + ["counter"] -> + mist.websocket( + request: req, + on_init: socket_init, + on_close: socket_close, + handler: socket_update, + ) + + // We need to serve the server component runtime. There's also a minified + // version of this script for production. + ["lustre-server-component.mjs"] -> { + let assert Ok(priv) = erlang.priv_directory("lustre") + let path = priv <> "/static/lustre-server-component.mjs" + + mist.send_file(path, offset: 0, limit: None) + |> result.map(fn(script) { + response.new(200) + |> response.prepend_header("content-type", "application/javascript") + |> response.set_body(script) + }) + |> result.lazy_unwrap(fn() { + response.new(404) + |> response.set_body(mist.Bytes(bytes_builder.new())) + }) + } + + // For all other requests we'll just serve some HTML that renders the + // server component. + _ -> + response.new(200) + |> response.prepend_header("content-type", "text/html") + |> response.set_body( + html([], [ + html.head([], [ + html.link([ + attribute.rel("stylesheet"), + attribute.href( + "https://cdn.jsdelivr.net/gh/lustre-labs/ui/priv/styles.css", + ), + ]), + html.script( + [ + attribute.type_("module"), + attribute.src("/lustre-server-component.mjs"), + ], + "", + ), + ]), + html.body([], [ + element( + "lustre-server-component", + [server_component.route("/counter")], + [html.p([], [html.text("This is a slot")])], + ), + ]), + ]) + |> element.to_document_string_builder + |> bytes_builder.from_string_builder + |> mist.Bytes, + ) + } + } + |> mist.new + |> mist.port(3000) + |> mist.start_http + + process.sleep_forever() +} + +// + +type Counter = + Subject(lustre.Action(counter.Msg, lustre.ServerComponent)) + +fn socket_init( + conn: WebsocketConnection, +) -> #(Counter, Option(Selector(lustre.Patch(counter.Msg)))) { + let self = process.new_subject() + let app = counter.app() + let assert Ok(counter) = lustre.start_actor(app, 0) + + process.send( + counter, + server_component.subscribe( + // server components can have many connected clients, so we need a way to + // identify this client. + "ws", + // this callback is called whenever the server component has a new patch + // to send to the client. here we json encode that patch and send it to + // via the websocket connection. + // + // a more involved version would have us sending the patch to this socket's + // subject, and then it could be handled (perhaps with some other work) in + // the `mist.Custom` branch of `socket_update` below. + process.send(self, _), + ), + ) + + #( + // we store the server component's `Subject` as this socket's state so we + // can shut it down when the socket is closed. + counter, + Some(process.selecting(process.new_selector(), self, fn(a) { a })), + ) +} + +fn socket_update( + counter: Counter, + conn: WebsocketConnection, + msg: WebsocketMessage(lustre.Patch(counter.Msg)), +) { + case msg { + mist.Text(json) -> { + // we attempt to decode the incoming text as an action to send to our + // server component runtime. + let action = json.decode(json, server_component.decode_action) + + case action { + Ok(action) -> process.send(counter, action) + Error(_) -> Nil + } + + actor.continue(counter) + } + + mist.Binary(_) -> actor.continue(counter) + mist.Custom(patch) -> { + let assert Ok(_) = + patch + |> server_component.encode_patch + |> json.to_string + |> mist.send_text_frame(conn, _) + + actor.continue(counter) + } + mist.Closed | mist.Shutdown -> actor.Stop(process.Normal) + } +} + +fn socket_close(counter: Counter) { + process.send(counter, lustre.shutdown()) +} diff --git a/packages/lustre/examples/99-server-components/src/counter.gleam b/packages/lustre/examples/99-server-components/src/counter.gleam new file mode 100644 index 0000000..0c9f115 --- /dev/null +++ b/packages/lustre/examples/99-server-components/src/counter.gleam @@ -0,0 +1,58 @@ +import gleam/int +import lustre +import lustre/attribute +import lustre/element.{type Element} +import lustre/element/html +import lustre/event +import lustre/ui + +// MAIN ------------------------------------------------------------------------ + +pub fn app() { + lustre.simple(init, update, view) +} + +// MODEL ----------------------------------------------------------------------- + +type Model = + Int + +fn init(initial_count: Int) -> Model { + case initial_count < 0 { + True -> 0 + False -> initial_count + } +} + +// UPDATE ---------------------------------------------------------------------- + +pub opaque type Msg { + Incr + Decr +} + +fn update(model: Model, msg: Msg) -> Model { + case msg { + Incr -> model + 1 + Decr -> model - 1 + } +} + +// VIEW ------------------------------------------------------------------------ + +fn view(model: Model) -> Element(Msg) { + let styles = [#("width", "100vw"), #("height", "100vh"), #("padding", "1rem")] + let count = int.to_string(model) + + ui.centre( + [attribute.style(styles)], + ui.stack([], [ + ui.button([event.on_click(Incr)], [element.text("+")]), + html.slot([]), + html.p([attribute.style([#("text-align", "center")])], [ + element.text(count), + ]), + ui.button([event.on_click(Decr)], [element.text("-")]), + ]), + ) +} diff --git a/packages/lustre/examples/README.md b/packages/lustre/examples/README.md new file mode 100644 index 0000000..c3a6009 --- /dev/null +++ b/packages/lustre/examples/README.md @@ -0,0 +1,47 @@ +# Lustre examples + +Each of the examples in this directory is a self-contained, complete Gleam app +that demonstrates a particular feature or concept of the library. For newcomers, +we recommend looking through them in order, as each example tends to build on +the previous ones. Feel free to jump in to any example that interests you, though! + +> **Note**: these examples all use [`lustre/ui`](https://github.com/lustre-labs/ui) +> to show off something a little more visually interesting than unstyled HTML. None +> of the ideas in these examples are specific to `lustre/ui`, though, and you should +> know that you can follow along with any of these examples using only the standard +> `lustre/element/html` module. + +## Examples + +- [`01-hello-world`](./01-hello-world) is a simple example to just get something + on the screen. + +- [`02-interactivity`](./02-interactivity) introduces the core Model-View-Update + loop that underpins every Lustre application. + +- [`03-controlled-inputs`](./03-controlled-inputs) demonstrates the most common + way to handle `` elements in Lustre. + +- [`04-custom-event-handlers`](./04-custom-event-handlers) shows you how to + write your own event handlers and decoders, instead of relying on the ones + provided by `lustre/event`. + +- [`05-http-requests`](./05-http-requests) demonstrates how side effects are + handled in Lustre, using the third-party [`lustre_http`](https://hexdocs.pm/lustre_http/) + package. + +- [`06-custom-effects`](./06-custom-effects) builds on the previous example and + shows you how to write your own side effects for Lustre to perform. + +- [`07-routing`](./07-routing) shows you how to use [`modem`](https://hexdocs.pm/modem/) + to set up routing and navigating between pages in a Lustre app. + +## Getting help + +If you're having trouble with Lustre or not sure what the right way to do +something is, the best place to get help is the [Gleam Discord server](https://discord.gg/Fm8Pwmy). +You could also open an issue on the [Lustre GitHub repository](https://github.com/lustre-labs/lustre/issues). + +While our docs are still a work in progress, the official [Elm guide](https://guide.elm-lang.org) +is also a great resource for learning about the Model-View-Update architecture +and the kinds of patterns that Lustre is built around. diff --git a/packages/lustre/gleam.toml b/packages/lustre/gleam.toml new file mode 100644 index 0000000..8996318 --- /dev/null +++ b/packages/lustre/gleam.toml @@ -0,0 +1,49 @@ +name = "lustre" +version = "4.3.0" +gleam = ">= 1.0.0" + +description = "An Elm-inspired framework for building HTML templates, single page applications, and server-rendered components in Gleam!" +repository = { type = "github", user = "lustre-labs", repo = "lustre" } +licences = ["MIT"] + +links = [ + { title = "Sponsor", href = "https://github.com/sponsors/hayleigh-dot-dev" }, +] + +internal_modules = ["lustre/internals", "lustre/internals/*"] + +[documentation] +pages = [ + { title = "Examples directory", path = "reference/examples.html", source = "./pages/reference/examples.md" }, + { title = " ", path = "#", source = "" }, + { title = "Quickstart guide", path = "guide/01-quickstart.html", source = "./pages/guide/01-quickstart.md" }, + { title = "Managing state", path = "guide/02-state-management.html", source = "./pages/guide/02-state-management.md" }, + { title = "Side effects", path = "guide/03-side-effects.html", source = "./pages/guide/03-side-effects.md" }, + { title = "Server-side rendering", path = "guide/04-server-side-rendering.html", source = "./pages/guide/04-server-side-rendering.md" }, + # { title = "Full-stack apps", path = "guide/05-full-stack-apps.html", source = "./pages/guide/05-full-stack-apps.md" }, + # { title = "Components", path = "#", source = "" }, + # { title = "Server components", path = "#", source = "" }, + # { title = " ", path = "#", source = "" }, + # { title = "SPA deployments", path = "#", source = "" }, + # { title = "Full-stack deployments", path = "#", source = "" }, + # { title = " ", path = "#", source = "" }, + # { title = "Using with Wisp", path = "#", source = "" }, + # { title = "Using with Glen", path = "#", source = "" }, + # { title = "Using with Mist", path = "#", source = "" }, + # { title = " ", path = "#", source = "" }, + { title = "For Elm developers", path = "cheatsheets/elm", source = "./pages/reference/for-elm-devs.md" }, + { title = "For React developers", path = "cheatsheets/react", source = "./pages/reference/for-react-devs.md" }, + { title = "For LiveView developers", path = "cheatsheets/liveview", source = "./pages/reference/for-liveview-devs.md" }, +] + +[dependencies] +gleam_erlang = "~> 0.24" +gleam_json = "~> 1.0 or ~> 2.0" +gleam_otp = "~> 0.9" +gleam_stdlib = "~> 0.36" + +[dev-dependencies] +birdie = "~> 1.0" +gleeunit = "~> 1.0" +shellout = "~> 1.6" +simplifile = "~> 1.4" diff --git a/packages/lustre/manifest.toml b/packages/lustre/manifest.toml new file mode 100644 index 0000000..0dd508b --- /dev/null +++ b/packages/lustre/manifest.toml @@ -0,0 +1,33 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birdie", version = "1.1.5", build_tools = ["gleam"], requirements = ["argv", "filepath", "glance", "gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "justin", "rank", "simplifile", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "E1B6CB7B9EDE8F4C67F7E68C9FB45FBAA54881545F85D315D2B179560CC63F60" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "glance", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "8F3314D27773B7C3B9FB58D8C02C634290422CE531988C0394FA0DF8676B964D" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_stdlib", version = "0.37.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "5398BD6C2ABA17338F676F42F404B9B7BABE1C8DC7380031ACB05BBE1BCF3742" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "glexer", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glexer", source = "hex", outer_checksum = "BD477AD657C2B637FEF75F2405FAEFFA533F277A74EF1A5E17B55B1178C228FB" }, + { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, + { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, + { name = "shellout", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "E2FCD18957F0E9F67E1F497FC9FF57393392F8A9BAEAEA4779541DE7A68DD7E0" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "thoas", version = "1.2.0", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "540C8CB7D9257F2AD0A14145DC23560F91ACDCA995F0CCBA779EB33AF5D859D1" }, + { name = "trie_again", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "5B19176F52B1BD98831B57FDC97BD1F88C8A403D6D8C63471407E78598E27184" }, +] + +[requirements] +birdie = { version = "~> 1.0" } +gleam_erlang = { version = "~> 0.24" } +gleam_json = { version = "~> 1.0" } +gleam_otp = { version = "~> 0.9" } +gleam_stdlib = { version = "~> 0.36" } +gleeunit = { version = "~> 1.0" } +shellout = { version = "~> 1.6" } +simplifile = { version = "~> 1.4" } diff --git a/packages/lustre/package-lock.json b/packages/lustre/package-lock.json new file mode 100644 index 0000000..c11246c --- /dev/null +++ b/packages/lustre/package-lock.json @@ -0,0 +1,3170 @@ +{ + "name": "lustre-client-test", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lustre-client-test", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "esbuild": "^0.20.2", + "linkedom": "^0.16.11", + "npm-run-all": "^4.1.5", + "vitest": "^1.5.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.16.4.tgz", + "integrity": "sha512-GkhjAaQ8oUTOKE4g4gsZ0u8K/IHU1+2WQSgS1TwTcYvL+sjbaQjNHFXbOJ6kgqGHIO1DfUhI/Sphi9GkRT9K+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.16.4.tgz", + "integrity": "sha512-Bvm6D+NPbGMQOcxvS1zUl8H7DWlywSXsphAeOnVeiZLQ+0J6Is8T7SrjGTH29KtYkiY9vld8ZnpV3G2EPbom+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.16.4.tgz", + "integrity": "sha512-i5d64MlnYBO9EkCOGe5vPR/EeDwjnKOGGdd7zKFhU5y8haKhQZTN2DgVtpODDMxUr4t2K90wTUJg7ilgND6bXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.16.4.tgz", + "integrity": "sha512-WZupV1+CdUYehaZqjaFTClJI72fjJEgTXdf4NbW69I9XyvdmztUExBtcI2yIIU6hJtYvtwS6pkTkHJz+k08mAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.16.4.tgz", + "integrity": "sha512-ADm/xt86JUnmAfA9mBqFcRp//RVRt1ohGOYF6yL+IFCYqOBNwy5lbEK05xTsEoJq+/tJzg8ICUtS82WinJRuIw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.16.4.tgz", + "integrity": "sha512-tJfJaXPiFAG+Jn3cutp7mCs1ePltuAgRqdDZrzb1aeE3TktWWJ+g7xK9SNlaSUFw6IU4QgOxAY4rA+wZUT5Wfg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.16.4.tgz", + "integrity": "sha512-7dy1BzQkgYlUTapDTvK997cgi0Orh5Iu7JlZVBy1MBURk7/HSbHkzRnXZa19ozy+wwD8/SlpJnOOckuNZtJR9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.16.4.tgz", + "integrity": "sha512-zsFwdUw5XLD1gQe0aoU2HVceI6NEW7q7m05wA46eUAyrkeNYExObfRFQcvA6zw8lfRc5BHtan3tBpo+kqEOxmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.16.4.tgz", + "integrity": "sha512-p8C3NnxXooRdNrdv6dBmRTddEapfESEUflpICDNKXpHvTjRRq1J82CbU5G3XfebIZyI3B0s074JHMWD36qOW6w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.16.4.tgz", + "integrity": "sha512-Lh/8ckoar4s4Id2foY7jNgitTOUQczwMWNYi+Mjt0eQ9LKhr6sK477REqQkmy8YHY3Ca3A2JJVdXnfb3Rrwkng==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.16.4.tgz", + "integrity": "sha512-1xwwn9ZCQYuqGmulGsTZoKrrn0z2fAur2ujE60QgyDpHmBbXbxLaQiEvzJWDrscRq43c8DnuHx3QorhMTZgisQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.16.4.tgz", + "integrity": "sha512-LuOGGKAJ7dfRtxVnO1i3qWc6N9sh0Em/8aZ3CezixSTM+E9Oq3OvTsvC4sm6wWjzpsIlOCnZjdluINKESflJLA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.16.4.tgz", + "integrity": "sha512-ch86i7KkJKkLybDP2AtySFTRi5fM3KXp0PnHocHuJMdZwu7BuyIKi35BE9guMlmTpwwBTB3ljHj9IQXnTCD0vA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.16.4.tgz", + "integrity": "sha512-Ma4PwyLfOWZWayfEsNQzTDBVW8PZ6TUUN1uFTBQbF2Chv/+sjenE86lpiEwj2FiviSmSZ4Ap4MaAfl1ciF4aSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.16.4.tgz", + "integrity": "sha512-9m/ZDrQsdo/c06uOlP3W9G2ENRVzgzbSXmXHT4hwVaDQhYcRpi9bgBT0FTG9OhESxwK0WjQxYOSfv40cU+T69w==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.16.4.tgz", + "integrity": "sha512-YunpoOAyGLDseanENHmbFvQSfVL5BxW3k7hhy0eN4rb3gS/ct75dVD0EXOWIqFT/nE8XYW6LP6vz6ctKRi0k9A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/@vitest/expect": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.5.2.tgz", + "integrity": "sha512-rf7MTD1WCoDlN3FfYJ9Llfp0PbdtOMZ3FIF0AVkDnKbp3oiMW1c8AmvRZBcqbAhDUAvF52e9zx4WQM1r3oraVA==", + "dev": true, + "dependencies": { + "@vitest/spy": "1.5.2", + "@vitest/utils": "1.5.2", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.5.2.tgz", + "integrity": "sha512-7IJ7sJhMZrqx7HIEpv3WrMYcq8ZNz9L6alo81Y6f8hV5mIE6yVZsFoivLZmr0D777klm1ReqonE9LyChdcmw6g==", + "dev": true, + "dependencies": { + "@vitest/utils": "1.5.2", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.5.2.tgz", + "integrity": "sha512-CTEp/lTYos8fuCc9+Z55Ga5NVPKUgExritjF5VY7heRFUfheoAqBneUlvXSUJHUZPjnPmyZA96yLRJDP1QATFQ==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.5.2.tgz", + "integrity": "sha512-xCcPvI8JpCtgikT9nLpHPL1/81AYqZy1GCy4+MCHBE7xi8jgsYkULpW5hrx5PGLgOQjUpb6fd15lqcriJ40tfQ==", + "dev": true, + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.5.2.tgz", + "integrity": "sha512-sWOmyofuXLJ85VvXNsroZur7mOJGiQeM0JN3/0D1uU8U9bGFM69X1iqHaRXl6R8BwaLY6yPCogP257zxTzkUdA==", + "dev": true, + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", + "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.0.8" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/confbox": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz", + "integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true + }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dev": true, + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/execa/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/execa/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/execa/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/execa/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "dev": true + }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "dev": true, + "dependencies": { + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/js-tokens": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.0.tgz", + "integrity": "sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==", + "dev": true + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "node_modules/linkedom": { + "version": "0.16.11", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.16.11.tgz", + "integrity": "sha512-WgaTVbj7itjyXTsCvgerpneERXShcnNJF5VIV+/4SLtyRLN+HppPre/WDHRofAr2IpEuujSNgJbCBd5lMl6lRw==", + "dev": true, + "dependencies": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^9.1.0", + "uhyphen": "^0.2.0" + } + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/local-pkg": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", + "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==", + "dev": true, + "dependencies": { + "mlly": "^1.4.2", + "pkg-types": "^1.0.3" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.10", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", + "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mlly": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.6.1.tgz", + "integrity": "sha512-vLgaHvaeunuOXHSmEbZ9izxPx3USsk8KCQ8iC+aTlp5sKRSoZvwhHh5L9VbKSaVC6sJDqbyohIS76E2VmHIPAA==", + "dev": true, + "dependencies": { + "acorn": "^8.11.3", + "pathe": "^1.1.2", + "pkg-types": "^1.0.3", + "ufo": "^1.3.2" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.1.0.tgz", + "integrity": "sha512-/RpmvKdxKf8uILTtoOhAgf30wYbP2Qw+L9p3Rvshx1JZVX+XQNZQFjlbmGHEGIm4CkVPlSn+NXmIM8+9oWQaSA==", + "dev": true, + "dependencies": { + "confbox": "^0.1.7", + "mlly": "^1.6.1", + "pathe": "^1.1.2" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/react-is": { + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.0.tgz", + "integrity": "sha512-wRiUsea88TjKDc4FBEn+sLvIDesp6brMbGWnJGjew2waAc9evdhja/2LvePc898HJbHw0L+MTWy7NhpnELAvLQ==", + "dev": true + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.16.4.tgz", + "integrity": "sha512-kuaTJSUbz+Wsb2ATGvEknkI12XV40vIiHmLuFlejoo7HtDok/O5eDDD0UpCVY5bBX5U5RYo8wWP83H7ZsqVEnA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.16.4", + "@rollup/rollup-android-arm64": "4.16.4", + "@rollup/rollup-darwin-arm64": "4.16.4", + "@rollup/rollup-darwin-x64": "4.16.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.16.4", + "@rollup/rollup-linux-arm-musleabihf": "4.16.4", + "@rollup/rollup-linux-arm64-gnu": "4.16.4", + "@rollup/rollup-linux-arm64-musl": "4.16.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.16.4", + "@rollup/rollup-linux-riscv64-gnu": "4.16.4", + "@rollup/rollup-linux-s390x-gnu": "4.16.4", + "@rollup/rollup-linux-x64-gnu": "4.16.4", + "@rollup/rollup-linux-x64-musl": "4.16.4", + "@rollup/rollup-win32-arm64-msvc": "4.16.4", + "@rollup/rollup-win32-ia32-msvc": "4.16.4", + "@rollup/rollup-win32-x64-msvc": "4.16.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", + "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", + "dev": true + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "dev": true + }, + "node_modules/string.prototype.padend": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.0.tgz", + "integrity": "sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tinybench": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", + "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", + "dev": true + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ufo": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz", + "integrity": "sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==", + "dev": true + }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "dev": true + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vite": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.10.tgz", + "integrity": "sha512-PAzgUZbP7msvQvqdSD+ErD5qGnSFiGOoWmV5yAKUEI0kdhjbH6nMWVyZQC/hSc4aXwc0oJ9aEdIiF9Oje0JFCw==", + "dev": true, + "dependencies": { + "esbuild": "^0.20.1", + "postcss": "^8.4.38", + "rollup": "^4.13.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.5.2.tgz", + "integrity": "sha512-Y8p91kz9zU+bWtF7HGt6DVw2JbhyuB2RlZix3FPYAYmUyZ3n7iTp8eSyLyY6sxtPegvxQtmlTMhfPhUfCUF93A==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.5.2.tgz", + "integrity": "sha512-l9gwIkq16ug3xY7BxHwcBQovLZG75zZL0PlsiYQbf76Rz6QGs54416UWMtC0jXeihvHvcHrf2ROEjkQRVpoZYw==", + "dev": true, + "dependencies": { + "@vitest/expect": "1.5.2", + "@vitest/runner": "1.5.2", + "@vitest/snapshot": "1.5.2", + "@vitest/spy": "1.5.2", + "@vitest/utils": "1.5.2", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.5.2", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.5.2", + "@vitest/ui": "1.5.2", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", + "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/packages/lustre/package.json b/packages/lustre/package.json new file mode 100644 index 0000000..3cd6217 --- /dev/null +++ b/packages/lustre/package.json @@ -0,0 +1,32 @@ +{ + "name": "lustre-client-test", + "version": "0.1.0", + "description": "testing for client scripts, and using vitest experimental bench", + "scripts": { + "bench": "run-s build:bench bench:vitest", + "bench:vitest": "vitest bench --config ./vitest.config.js", + + "build": "run-p build:test:**", + "build:bench": "run-s build:test:vdom", + "build:test:02": "cd examples/02-interactivity && gleam build", + "build:test:vdom": "cd test-apps/vdom-test-templates && gleam build", + + "run:vitest": "vitest --config ./vitest.config.js", + + "test": "run-s build \"run:vitest -- --run\"", + "test:02": "run-s build:test:02 \"run:vitest -- --run 02-interactivity.test\"", + "test:vdom": "run-s build:test:vdom \"run:vitest -- --run vdom.ffi \"", + + "watch:test": "run-p \"watch:init:**\"", + "watch:init:build": "run-p build:test:**", + "watch:init:vitest": "run-s run:vitest" + }, + "author": "Jacob Scearcy", + "license": "MIT", + "devDependencies": { + "esbuild": "^0.20.2", + "linkedom": "^0.16.11", + "npm-run-all": "^4.1.5", + "vitest": "^1.5.0" + } +} diff --git a/packages/lustre/pages/guide/01-quickstart.md b/packages/lustre/pages/guide/01-quickstart.md new file mode 100644 index 0000000..6713967 --- /dev/null +++ b/packages/lustre/pages/guide/01-quickstart.md @@ -0,0 +1,422 @@ +# 01 Quickstart guide + +Welcome to the Lustre quickstart guide! This document should get you up to speed +with the core ideas that underpin every Lustre application as well as how to get +something on the screen. + +## What is a SPA? + +Lustre can be used to create HTML in many different contexts, but it is primarily +designed to be used to build Single-Page Applications – or SPAs. SPAs are a type +of Web application that render content primarily in the browser (rather than on +the server) and, crucially, do not require a full page load when navigating +between pages or loading new content. + +To help build these kinds of applications, Lustre comes with an opinionated +runtime. Some of Lustre's core features include: + +- **Declarative rendering**: User interfaces are constructed using a declarative + API that describes HTML as a function of your application's state. This is in + contrast to more traditional imperative approaches to direct DOM mutation like + jQuery. + +- **State management**: If UIs are a function of state, then orchestrating state + changes is crucial! Lustre provides a simple message-based state management + system modelled after OTP [gen_servers](https://www.erlang.org/doc/design_principles/gen_server_concepts), + Gleam's [actors](https://hexdocs.pm/gleam_otp/gleam/otp/actor.html), and the + [Elm Architecture](https://guide.elm-lang.org/architecture/). + +- **Managed side effects**: Managing asynchronous operations like HTTP requests + and timers can be tricky when JavaScript is single-threaded. Lustre provides a + runtime to manage these side effects and let them communicate with your application + using the same messages as your update loop. + +## Your first Lustre program + +To get started, let's create a new Gleam application and add Lustre as a dependency. + +```sh +gleam new app && cd app && gleam add lustre +``` + +By default, Gleam builds projects for the Erlang target unless told otherwise. We +can change this by adding a `target` field to the `gleam.toml` file generated in +the root of the project. + +```diff + name = "app" ++ target = "javascript" + version = "1.0.0" + + ... +``` + +The simplest type of Lustre application is constructed with the `element` function. +This produces an application that renders a static piece of content without the +typical update loop. + +We can start by importing `lustre` and `lustre/element` and just rendering some +text: + +```gleam +import lustre +import lustre/element + +pub fn main() { + let app = lustre.element(element.text("Hello, world!")) + let assert Ok(_) = lustre.start(app, "#app", Nil) + + Nil +} +``` + +Lustre has some official development tooling published in the +[`lustre_dev_tools`](https://hexdocs.pm/lustre_dev_tools/) package. Most projects +will probably want to add those too! + +> **Note**: the lustre_dev_tools development server watches your filesystem for +> changes to your gleam code and can automatically reload the browser. For linux +> users this requires [inotify-tools](https://github.com/inotify-tools/inotify-tools) +> be installed. If you do not or cannot install this, the development server will +> still run but it will not watch your files for changes. + +> **Note**: currently one of lustre_dev_tools' dependencies is not compatible with +> the most recent version of `gleam_json`, making it impossible to install. To fix +> this, add `gleam_json = "1.0.1"` as a dependency in your `gleam.toml` file. + +```sh +gleam add --dev lustre_dev_tools +``` + +It's important to make sure the development tooling is added as a `--dev` +dependency. This ensures they're never included in production builds of your app. + +To start a development server, we can run: + +```sh +gleam run -m lustre/dev start +``` + +The first time you run this command might take a little while, but subsequent runs +should be much faster! + +> **Note**: Lustre uses esbuild under the hood, and attempts to download the [right +> binary for your platform](https://esbuild.github.io/getting-started/#download-a-build). +> If you're not connected to the internet, on an unsupported platform, or don't +> want Lustre to download the binary you can grab or build it yourself and place it +> in `build/.lustre/bin/esbuild`. + +Once the server is up and running you should be able to visit http://localhost:1234 +and be greeted with your "Hello, world!" message. + +We mentioned Lustre has a declarative API for constructing HTML. Let's see what +that looks like by building something slightly more complex. + +```gleam +import lustre +import lustre/attribute +import lustre/element +import lustre/element/html + +pub fn main() { + let app = + lustre.element( + html.div([], [ + html.h1([], [element.text("Hello, world!")]), + html.figure([], [ + html.img([attribute.src("https://cataas.com/cat")]), + html.figcaption([], [element.text("A cat!")]) + ]) + ]) + ) + let assert Ok(_) = lustre.start(app, "#app", Nil) + + Nil +} +``` + +Here we _describe_ the structure of the HTML we want to render, and leave the +busywork to Lustre's runtime: that's what makes it declarative! + +"**Where are the templates?**" we hear you cry. Lustre doesn't have a separate +templating syntax like JSX or HEEx for a few reasons (lack of metaprogramming +built into Gleam, for one). Some folks might find this a bit odd at first, but +we encourage you to give it a try. Realising that your UI is _just functions_ +can be a bit of a lightbulb moment as you build more complex applications. + +## Adding interactivity + +Rendering static HTML is great, but we said at the beginning Lustre was designed +primarily for building SPAs – and SPAs are interactive! To do that we'll need +to move on from `lustre.element` to the first of Lustre's application constructors +that includes an update loop: `lustre.simple`. + +```gleam +import gleam/int +import lustre +import lustre/element +import lustre/element/html +import lustre/event + +pub fn main() { + let app = lustre.simple(init, update, view) + let assert Ok(_) = lustre.start(app, "#app", Nil) + + Nil +} +``` + +There are three main building blocks to every interactive Lustre application: + +- A `Model` that represents your application's state and an `init` function + to create it. + +- A `Msg` type that represents all the different ways the outside world can + communicate with your application and an `update` function that modifies + your model in response to those messages. + +- A `view` function that renders your model to HTML. + +We'll build a simple counter application to demonstrate these concepts. Our +model can be an `Int` and our `init` function will initialise it to `0`: + +```gleam +pub type Model = Int + +fn init(_flags) -> Model { + 0 +} +``` + +> **Note**: The `init` function always takes a single argument! These are the "flags" +> or start arguments you can pass in when your application is started with +> `lustre.start`. For the time being, we can ignore them, but they're useful for +> passing in configuration or other data when your application starts. + +The main update loop in a Lustre application revolves around messages passed in +from the outside world. For our counter application, we'll have two messages to +increment and decrement the counter: + +```gleam +pub type Msg { + Increment + Decrement +} + +pub fn update(model: Model, msg: Msg) -> Model { + case msg { + Increment -> model + 1 + Decrement -> model - 1 + } +} +``` + +Each time a message is produced from an event listener, Lustre will call your +`update` function with the current model and the incoming message. The result +will be the new application state that is then passed to the `view` function: + +```gleam +pub fn view(model: Model) -> element.Element(Msg) { + let count = int.to_string(model) + + html.div([], [ + html.button([event.on_click(Increment)], [ + element.text("+") + ]), + element.text(count), + html.button([event.on_click(Decrement)], [ + element.text("-") + ]) + ]) +} +``` + +The above snippet attaches two click event listeners that produce an `Increment` +or `Decrement` message when clicked. The Lustre runtime is responsible for +attaching these event listeners and calling your `update` function with the +resulting message. + +> **Note**: notice that the return type of `view` is `element.Element(Msg)`. The +> type parameter `Msg` tells us the kinds of messages this element might produce +> from events: type safety to the rescue! + +This forms the core of every Lustre application: + +- A model produces some view. +- The view can produce messages in response to user interaction. +- Those messages are passed to the update function to produce a new model. +- ... and the cycle continues. + +## Talking to the outside world + +This "closed loop" of messages and updates works well if all we need is an +interactive document, but many applications will also need to talk to the outside +world – whether that's fetching data from an API, setting up a WebSocket connection, +or even just setting a timer. + +Lustre manages these side effects through an abstraction called an `Effect`. In +essence, effects are any functions that talk with the outside world and might +want to send messages back to your application. Lustre lets you write your own +effects, but for now we'll use a community package called +[`lustre_http`](https://hexdocs.pm/lustre_http/index.html) to fetch a new cat image +every time the counter is incremented. + +Because this is a separate package, make sure to add it to your project first. +While we're here, we'll also add `gleam_json` so we can decode the response from +the cat API: + +```sh +$ gleam add lustre_http +``` + +Now we are introducing side effects, we need to graduate from `lustre.simple` to +the more powerful `lustre.application` constructor. + +```gleam +import gleam/dynamic +import gleam/int +import gleam/list +import lustre +import lustre/attribute +import lustre/effect +import lustre/element +import lustre/element/html +import lustre/event +import lustre_http + +pub fn main() { + let app = lustre.application(init, update, view) + let assert Ok(_) = lustre.start(app, "#app", Nil) + + Nil +} +``` + +If you edited your previous counter app, you'll notice the program no longer +compiles. Specifically, the type of our `init` and `update` functions are wrong +for the new `lustre.application` constructor! + +In order to tell Lustre about what effects it should perform, these functions now +need to return a _tuple_ of the new model and any effects. We can amend our `init` +function like so: + +```gleam +pub type Model { + Model(count: Int, cats: List(String)) +} + +fn init(_flags) -> #(Model, effect.Effect(Msg)) { + #(Model(0, []), effect.none()) +} +``` + +The `effect.none` function is a way of saying "no effects" – we don't need to do +anything when the application starts. We've also changed our `Model` type from a +simple type alias to a Gleam [record](https://tour.gleam.run/data-types/records/) +that holds both the current count and a list of cat image URLs. + +In our `update` function, we want to fetch a new cat image every time the counter +is incremented. To do this we need two things: + +- An `Effect` to describe the request the runtime should perform. +- A variant of our `Msg` to handle the response. + +The `lustre_http` package has the effect side of things handled, so we just need +to modify our `Msg` type to include a new variant for the response: + +```gleam +pub type Msg { + UserIncrementedCount + UserDecrementedCount + ApiReturnedCat(Result(String, lustre_http.HttpError)) +} +``` + +> **Note**: Concerned your message type is too verbose? Read our thoughts on why +> this is a good thing in our [state management guide](./02-state-management.html). + +Finally, we can modify our `update` function to also fetch a cat image when the +counter is incremented and handle the response: + +```gleam +pub fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { + case msg { + UserIncrementedCount -> #(Model(..model, count: model.count + 1), get_cat()) + UserDecrementedCount -> #(Model(..model, count: model.count - 1), effect.none()) + ApiReturnedCat(Ok(cat)) -> #(Model(..model, cats: [cat, ..model.cats]), effect.none()) + ApiReturnedCat(Error(_)) -> #(model, effect.none()) + } +} + +fn get_cat() -> effect.Effect(Msg) { + let decoder = dynamic.field("_id", dynamic.string) + let expect = lustre_http.expect_json(decoder, ApiReturnedCat) + + lustre_http.get("https://cataas.com/cat?json=true", expect) +} +``` + +> **Note**: The `get_cat` function returns an `Effect` that tells the runtime how +> to fetch a cat image. It's important to know that the `get_cat` function doesn't +> perform the request directly! This is why we need to add the `ApiReturnedCat` message +> variant: the runtime needs to know what to do with the response when it arrives. + +This model of managed effects can feel cumbersome at first, but it comes with some +benefits. Forcing side effects to produce a message means our message type naturally +describes all the ways the world can communicate with our application; as an app +grows being able to get this kind of overview is invaluable! It also means we can +test our update loop in isolation from the runtime and side effects: we could write +tests that verify a particular sequence of messages produces an expected model +without needing to mock out HTTP requests or timers. + +Before we forget, let's also update our `view` function to actually display the +cat images we're fetching: + +```gleam +pub fn view(model: Model) -> element.Element(Msg) { + let count = int.to_string(model.count) + + html.div([], [ + html.button([event.on_click(UserIncrementedCount)], [ + element.text("+") + ]), + element.text(count), + html.button([event.on_click(UserDecrementedCount)], [ + element.text("-") + ]), + html.div( + [], + list.map(model.cats, fn(cat) { + html.img([attribute.src("https://cataas.com/cat/" <> cat)]) + }), + ), + ]) +} +``` + +## Where to go from here + +Believe it or not, you've already seen about 80% of what Lustre has to offer! From +these core concepts, you can build rich interactive applications that are predictable +and maintainable. Where to go from here depends on what you want to build, and +how you like to learn: + +- There are a number of [examples](https://github.com/lustre-labs/lustre/tree/main/examples) + if the Lustre repository that gradually introduce more complex applications + and ideas. + +- The [rest of this guide](./02-state-management.html) also continues to teach + Lustre's high-level concepts and best-practices. + +- Of course, if you want to dive in and start making things straight away, the + [API documentation](https://hexdocs.pm/lustre/lustre.html) is always handy to keep open. + +## Getting help + +If you're having trouble with Lustre or not sure what the right way to do +something is, the best place to get help is the [Gleam Discord server](https://discord.gg/Fm8Pwmy). +You could also open an issue on the [Lustre GitHub repository](https://github.com/lustre-labs/lustre/issues). + +While our docs are still a work in progress, the official [Elm guide](https://guide.elm-lang.org) +is also a great resource for learning about the Model-View-Update architecture +and the kinds of patterns that Lustre is built around. diff --git a/packages/lustre/pages/guide/02-state-management.md b/packages/lustre/pages/guide/02-state-management.md new file mode 100644 index 0000000..b29ac5c --- /dev/null +++ b/packages/lustre/pages/guide/02-state-management.md @@ -0,0 +1,244 @@ +# 02 State management + +We saw in the quickstart guide that all Lustre applications are built around the +Model-View-Update (MVU) architecture. This means that the state of the application +is stored in a single, immutable data structure called the model, and updated as +messages are dispatched to the runtime. + +The MVU architecture is an example of _unidirectional data flow_: + +- Your model describes the entire state of your application at a given point in + time. + +- The UI is a [pure](https://github.com/lustre-labs/lustre/blob/main/pages/hints/pure-functions.md) + function of that model: if the model doesn't change, the UI doesn't change. + +- Events from the outside world – user interaction, HTTP responses, ... – send + messages to an update function that constructs a new model. + +- The UI re-renders based on the new state. + +```text + +--------+ + | | + | update | + | | + +--------+ + ^ | + | | + Msg | | Model + | | + | v ++------+ +------------------------+ +| | Model | | +| init |------------------------>| Lustre Runtime | +| | | | ++------+ +------------------------+ + ^ | + | | + Msg | | Model + | | + | v + +--------+ + | | + | view | + | | + +--------+ +``` + +This is in contrast to _bidirectional_ approaches to state management, where the +UI can modify state directly. For some developers this can be a difficult idea +to get used to, but it brings a number of benefits: + +- A **single source of truth** makes it easier to reason about the state of your + application. State management is lifted _out_ of UI code, letting it focus just + on presentation and making it easier to test and refactor. + +- Message-driven **declarative state updates** give you a holistic view of how + your application can change over time. Tracking incoming messages gives you a + history of state updates and can be serialised and logged for debugging or + testing purposes. + +- State updates are **pure**. We will learn more about this in the [next guide](./03-side-effects.html), + but for now it is enough to know that this means testing your state changes is + much easier because mocking messages is simpler than mocking side effects! + +The rest of this guide contains some learned-wisdom and best practices for managing +state in Lustre applications. + +## The best model is not always a record + +It is overwhelmingly common to see the model of a Lustre application as a single +record. This is a sensible place to start, but there are other options! Gleam's +custom types allow us to model our data as disjoint variants. Using these as your +application's model can be particularly useful when you have different states that +do not need to persist across navigations: + +```gleam +type Model { + LoggedIn(LoggedInModel) + Public(PublicModel) +} + +type LoggedInModel { + ... +} + +type PublicModel { + ... +} +``` + +Here, we have a model that represents our application as either having a logged in +user or just one of the public routes. This pushes us towards the great practice of +[making impossible states impossible](https://github.com/stereobooster/pragmatic-types/blob/master/posts/making-impossible-states-impossible.md). +Now, we can write separate update and view functions that only handle the states +they care about. + +Another option is to use a _type alias_ to represent some state using existing +Gleam types. It's important to remember that your model represents _application_ +state and not necessarily _page_ state. This can manifest as simple as aliasing +Gleam's `Result` type or maybe a `Dict` representing loaded posts. + +## Messages not actions + +Lustre is not the first frontend framework to use the MVU architecture or to +focus on dispatching messages to update state. State management libraries like +Redux and Zustand follow a very similar pattern. The devil is in the details +though, and these libraries often talk in terms of _actions_ but you'll see +Elm and Lustre prefer the term _message_. + +Actions frame incoming events as _things to do_: "add a new todo", "make an HTTP +request", etc. This can work well in the beginning, but as your application grows +and the number of things you can do grows, naming messages as actions can become +problematic. + +In particular, it encourages you to recursively call your `update` function with +different messages when you want to compose behaviour. Gleam is a functional +programming language: we should use functions to update our state, not message +dispatching! Communicating through messages is a way for the _outside world_ to +talk to our application, not for our applications to talk to themselves. + +A recursive update function makes it difficult to see the consequences of any one +message as you need to trace through the recursive calls in your head to understand +which messages are being dispatched and in what order. + +Instead, we recommend you name your messages according to a **Subject Verb Object** +pattern. This frames messages based on who (or what) sent them, what state or +"thing" they're working on, and what they did or want to do. Imagine a password +reset form, the user can type in a new password and submit it and our app waits +for a response. As a first-pass we might end up with something like this: + +```gleam +type Msg { + SetPassword(String) + ResetPassword + PasswordReset(Result(Nil, String)) +} +``` + +This is quite muddled, and is compounded as we add more messages to our app +(especially if they also relate to the password!). It's hard to tell from looking +at our messages what our app might _really_ be doing: we'd have to dig into our +`update` function and possibly our `view` to work out what our intent was. One +super power of the MVU pattern is that we can look at our messages to get a +holistic view of what our app can handle. Things become much clearer if we refactor +this example to the Subject Verb Object naming pattern: + +```gleam +type Msg { + UserUpdatedPassword(String) + UserRequestedPasswordReset + BackendResetPassword(Result(Nil, String)) +} +``` + +It's now immediately obvious at a glance: + +1. Where these messages are coming from (user interaction, the network, ...) +2. What sort of event or intention they represent + +As our apps grow in size, we'll be thankful for this clarity! + +## View functions not components + +Although Lustre does have a way to create encapsulated stateful components (something +we sorely missed in Elm) it shouldn't be the default. The word "component" is a bit +overloaded in the frontend world, so for clarity Lustre considers _components_ +as stateful nested Model-View-Update applications and calls stateless functions +that return `Element`s _view functions_. + +The best Lustre code bases take the lessons learned from similar languages like +Elm, Erlang, and Elixir and keep the number of components low and the number of +simple view functions much higher. If you're coming from a typical frontend +framework the idea of eschewing stateful components might seem quite strange, but +there are some tangible benefits to this approach: + +- **Favouring view functions forces us to be intentional with state.** + + Frameworks often make it easy to add state to components, which in turn makes + it easy to add state without really thinking about whether we need it or whether + we're taking the best approach. + + View functions on the other hand _only_ have arguments, and adding a new argument + is a much more deliberate act. This gives us a chance to consider whether we're + modelling things the right way or whether we're trying to do too much. + +- **Components are bad for code organisation.** + + It can be tempting to use components as a way to organise code. You might see + this commonly in React and Vue codebases: you have a folder for components, a + folder for hooks, and so on. Using components as a means of organisation often + leads to us drawing weird boundaries around our code and spreading out things + that should be together. + + By sticking to view functions we're much more likely to keep code grouped based + on _what it does_ rather than what it _is_ and this approach is much more idiomatic + to Gleam on the whole, and also an approach favoured by Elm and Elixir alike. + +- **Avoiding components makes your code easier to test.** + + When we reach for components too soon or too frequently, we often end up needing + to pull in a complete E2E testing framework to make sure our code is behaving + correctly, or we might end up exposing our components' internals for testing: + defeating the purpose of encapsulation in the first place! + + By sticking to plain view functions and functions to transform data before + rendering, we end up with a codebase that is much easier to test with Gleam's + available testing tools. + +- **Overusing components makes refactoring more challenging.** + + Imagine you have a table component with tabs to switch between different views. + If some time in the future you decide to pull the tabs out so they can be + rendered elsewhere on the page you'll discover that the tabs' state was tightly + coupled to the table. Now we are forced to refactor the table component so the + tab state can be passed in as an attribute. We'll also need to refactor the + _parent_ to contain the state of the tabs so it can be passed down to both + components. + + By avoiding components this sort of refactoring becomes simpler: we were already + managing the state further up the component tree so moving things around is + much less painful. + +- **Creating components is more boilerplate.** + + Components share the same shape as any other Lustre application. That means for + any component you want to create, you also need to define an `init`, `update`, + and `view` function, a `Model` type, and a `Msg` type. If you find yourself + thinking "wow, this is a lot of boilerplate just to do X" then listen to your + gut! + +## Related examples + +If you'd like to see some of the ideas in action, we have a number of examples +that demonstrate how to use Lustre in practice: + +- [`02-interactivity`](https://github.com/lustre-labs/lustre/tree/main/examples/02-interactivity) +- [`03-controlled-inputs`](https://github.com/lustre-labs/lustre/tree/main/examples/03-controlled-inputs) + +## Getting help + +If you're having trouble with Lustre or not sure what the right way to do +something is, the best place to get help is the [Gleam Discord server](https://discord.gg/Fm8Pwmy). +You could also open an issue on the [Lustre GitHub repository](https://github.com/lustre-labs/lustre/issues). diff --git a/packages/lustre/pages/guide/03-side-effects.md b/packages/lustre/pages/guide/03-side-effects.md new file mode 100644 index 0000000..f873724 --- /dev/null +++ b/packages/lustre/pages/guide/03-side-effects.md @@ -0,0 +1,268 @@ +# 03 Side effects + +Lustre's implementation of the Model-View-Update architecture includes one +additional piece of the puzzle: managed side effects. If we take the MVU diagram +from the previous guide and upgrade it to include managed effects, it looks like +this: + +```text + +--------+ + | | + | update | + | | + +--------+ + ^ | + | | + Msg | | #(Model, Effect(msg)) + | | + | v ++------+ +------------------------+ +| | #(Model, Effect(msg)) | | +| init |------------------------>| Lustre Runtime | +| | | | ++------+ +------------------------+ + ^ | + | | + Msg | | Model + | | + | v + +--------+ + | | + | view | + | | + +--------+ +``` + +Well what does managed effects mean, exactly? In Lustre, we expect your `init`, +`update`, and `view` functions to be [_pure_](https://github.com/lustre-labs/lustre/blob/main/pages/hints/pure-functions.md). +That means they shouldn't perform side effects like making an HTTP request or writing +to local storage: we should be able to run your functions 100 times with the same +input and get the same output every time! + +Of course, in real applications performing HTTP requests and writing to local +storage turn out to be quite useful things to do. If we shouldn't perform side +effects in our code how do we do them then? Lustre has an [`Effect`](https://hexdocs.pm/lustre/lustre/effect.html) +type that _tells the runtime what side effects to perform_. So we say "Hey, I +want to make an HTTP request to this URL and when you get the response, dispatch +this message to me". The runtime takes care of performing the side effect and +turning the result into something our `update` function understands. + +## Why managed effects? + +This can feel like a lot of ceremony to go through just to make an HTTP request. +The natural question is: why not just let us make these requests ourselves? + +Managed effects have a number of benefits that come from _separating our programs +from the outside world_: + +1. **Predictability**: by keeping side effects out of our `update` function, we + can be confident that our application's state is only ever changed in one + place. This makes it easier to reason about our code and track down bugs. + +2. **Testability**: because our application code is pure, we can test it without + needing to mock out HTTP services or browser APIs. We can test our `update` + function, for example, by passing in a sequence of messages: no network mocks + required! + +3. **Reusability**: Lustre applications can run in a variety of environments and + contexts. The more we push platform-specific code into managed effects, the + easier time we'll have running our application as a [server component](https://hexdocs.pm/lustre/lustre/server_component.html) + or as a static site. + +## Packages for common effects + +The community has started to build packages that cover common side effects. For +many applications it's enough to drop these packages in and start using them +without needing to write any custom effects. + +> **Note**: _all_ of these packages are community maintained and unrelated to the +> core Lustre organisation. If you run into issues please open an issue on the +> package's repository! + +- [`lustre_http`](https://hexdocs.pm/lustre_http/) lets you make HTTP requests + and describe what responses to expect from them. + +- [`lustre_websocket`](https://hexdocs.pm/lustre_websocket/) handles WebSocket + connections and messages. + +- [`modem`](https://hexdocs.pm/modem/) and [`lustre_routed`](https://hexdocs.pm/lustre_routed/) + are two packages that help you manage navigation and routing. + +- [`lustre_animation`](https://hexdocs.pm/lustre_animation/) is a simple package + for interpolating between values over time. + +## Running effects + +We know that effects need to be performed by the runtime, but how does the runtime +know when we want it to run an effect? If you have been using the `lustre.simple` +application constructor until now, it is time to upgrade to +[`lustre.application`](https://hexdocs.pm/lustre/lustre.html#application)! + +Full Lustre applications differ from simple applications in one important way by +returning a tuple of `#(Model, Effect(Msg))` from your `init` and `update` +functions: + +```gleam +pub fn simple( + init: fn(flags) -> model, + update: fn(model, msg) -> model, + view: fn(model) -> Element(msg), +) -> App(flags, model, msg) + +pub fn application( + init: fn(flags) -> #(model, Effect(msg)), + update: fn(model, msg) -> #(model, Effect(msg)), + view: fn(model) -> Element(msg), +) -> App(flags, model, msg) +``` + +We can, for example, launch an HTTP request on application start by using `lustre_http.get` +in our `init` function: + +```gleam +fn init(_flags) { + let model = Model(...) + let get_ip = lustre_http.get( + "https://api.ipify.org", + ApiReturnedIpAddress + ) + + #(model, get_ip) +} +``` + +> **Note**: to tell the runtime we _don't_ want to perform any side effects this +> time, we can use [`effect.none()`](https://hexdocs.pm/lustre/lustre/effect.html#none). + +## Writing your own effects + +When you need to do something one of the existing packages doesn't cover, you need +to write your own effect. You can do that by passing a callback to +[`effect.from`](https://hexdocs.pm/lustre/lustre/effect.html#from). Custom effects +are called with an argument – commonly called `dispatch` – that you can use to +send messages back to your application's `update` function. + +Below is an example of a custom effect that reads a value from local storage: + +```js +// ffi.mjs +import { Ok, Error } from "./gleam.mjs"; + +export function read(key) { + const value = window.localStorage.getItem(key); + return value ? new Ok(value) : new Error(undefined); +} +``` + +```gleam +fn read(key: String, to_msg: fn(Result(String, Nil)) -> msg) -> Effect(msg) { + effect.from(fn(dispatch) { + do_read(key) + |> to_msg + |> dispatch + }) +} + +@external(javascript, "ffi.mjs", "read") +fn do_read(key: String) -> Result(String, Nil) { + Error(Nil) +} +``` + +> **Note**: we provide a default implementation of the `do_read` function that +> always fails. Where possible it's good to provide an implementation for all of +> Gleam's targets. This makes it much easier to run your code as a +> [server component](https://hexdocs.pm/lustre/lustre/server_component.html) in +> the future. + +### Effects that touch the DOM + +Lustre runs all your side effects after your `update` function returns but _before_ +your `view` function is called. A common bug folks run into is trying to interact +with a particular element in the DOM before it's had a chance to render. As a +rule of thumb, you should _always_ wrap custom effects that interact with the DOM +in a [`requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) +call to ensure the DOM has had a chance to update first. + +### Effects without dispatch + +So far, we have seen side effects that are expected to _return something_ to our +program. If we fire an HTTP request, it wouldn't be much use if we couldn't get +the response back! Sometimes folks wrongly assume effects _must_ use the `dispatch` +function they're given, but this isn't true! + +It's also totally valid to write effects that don't dispatch any messages. Earlier +we saw an example of how to read from local storage, we might also want an effect +to _write_ to local storage and there's not much to dispatch in that case! + +```js +// ffi.mjs +export function write(key, value) { + window.localStorage.setItem(key, value); +} +``` + +```gleam +// app.gleam +fn write(key: String, value: String) -> Effect(msg) { + effect.from(fn(_) { + do_write(key, value) + }) +} + +@external(javascript, "ffi.mjs", "write") +fn do_write(key: String, value: String) -> Nil { + Nil +} +``` + +### Effects with multiple dispatch + +Similar to effects that don't dispatch any messages, some folks skip over the fact +effects can dispatch _multiple_ messages. Packages like [`lustre_websocket`](https://hexdocs.pm/lustre_websocket/) +and [`modem`](https://hexdocs.pm/modem/) set up effects that will dispatch many +messages over the lifetime of your program. + +Once you have a reference to that `dispatch` function, you're free to call it as +many times as you want! + +```js +// ffi.mjs +export function every(interval, cb) { + window.setInterval(cb, interval); +} +``` + +```gleam +// app.gleam +fn every(interval: Int, tick: msg) -> Effect(msg) { + effect.from(fn(dispatch) { + do_every(interval, fn() { + dispatch(tick) + }) + }) +} + +@external(javascript, "ffi.mjs", "every") +fn do_every(interval: Int, cb: fn() -> Nil) -> Nil { + Nil +} +``` + +Here we set up an effect that will continuously dispatch a `tick` message at a +fixed interval. + +## Related examples + +If you'd like to see some of the ideas in action, we have a number of examples +that demonstrate how Lustre's effects system works in practice: + +- [`05-http-requests`](https://github.com/lustre-labs/lustre/tree/main/examples/05-http-requests) +- [`06-custom-effects`](https://github.com/lustre-labs/lustre/tree/main/examples/06-custom-effects) +- [`07-routing`](https://github.com/lustre-labs/lustre/tree/main/examples/07-routing) + +## Getting help + +If you're having trouble with Lustre or not sure what the right way to do +something is, the best place to get help is the [Gleam Discord server](https://discord.gg/Fm8Pwmy). +You could also open an issue on the [Lustre GitHub repository](https://github.com/lustre-labs/lustre/issues). diff --git a/packages/lustre/pages/guide/04-server-side-rendering.md b/packages/lustre/pages/guide/04-server-side-rendering.md new file mode 100644 index 0000000..27a2d89 --- /dev/null +++ b/packages/lustre/pages/guide/04-server-side-rendering.md @@ -0,0 +1,216 @@ +# 04 Server-side rendering + +Up until now, we have focused on Lustre's ability as a framework for building +Single Page Applications (SPAs). While Lustre's development and feature set is +primarily focused on SPA development, that doesn't mean it can't be used on the +backend as well! In this guide we'll set up a small [mist](https://hexdocs.pm/mist/) +server that renders some static HTML using Lustre. + +## Setting up the project + +We'll start by adding the dependencies we need and scaffolding the HTTP server. +Besides Lustre and Mist, we also need `gleam_erlang` (to keep our application +alive) and `gleam_http` (for types and functions to work with HTTP requests and +responses): + +```sh +gleam new app && cd app && gleam add gleam_erlang gleam_http lustre mist +``` + +Besides imports for `mist` and `gleam_http` modules, we also need to import some +modules to render HTML with Lustre. Importantly, we _don't_ need anything from the +main `lustre` module: we're not building an application with a runtime! + +```gleam +import gleam/bytes_builder +import gleam/erlang/process +import gleam/http/request.{type Request} +import gleam/http/response.{type Response} +import lustre/element +import lustre/element/html.{html} +import mist.{type Connection, type ResponseData} +``` + +We'll modify Mist's example and write a simple request handler that responds to +requests to `/greet/:name` with a greeting message: + +```gleam +pub fn main() { + let empty_body = mist.Bytes(bytes_builder.new()) + let not_found = response.set_body(response.new(404), empty_body) + + let assert Ok(_) = + fn(req: Request(Connection)) -> Response(ResponseData) { + case request.path_segments(req) { + ["greet", name] -> greet(name) + _ -> not_found + } + } + |> mist.new + |> mist.port(3000) + |> mist.start_http + + process.sleep_forever() +} +``` + +Let's take a peek inside that `greet` function: + +```gleam +fn greet(name: String) -> Response(ResponseData) { + let res = response.new(200) + let html = + html([], [ + html.head([], [html.title([], "Greetings!")]), + html.body([], [ + html.h1([], [html.text("Hey there, " <> name <> "!")]) + ]) + ]) + + response.set_body(res, + html + |> element.to_document_string + |> bytes_builder.from_string + |> mist.Bytes + ) +} +``` + +The `lustre/element` module has functions for rendering Lustre elements to a +string (or string builder); the `to_document_string` function helpfully prepends +the `` declaration to the output. + +It's important to realise that `element.to_string` and `element.to_document_string` +can render _any_ Lustre element! This means you could take the `view` function +from your client-side SPA and render it server-side, too. + +## Hydration + +If we know we can render our apps server-side, the next logical question is how +do we handle _hydration_? Hydration is the process of taking the static HTML +generated by the server and turning it into a fully interactive client application, +ideally doing as little work as possible. + +Most frameworks today support hydration or some equivalent, for example by +serialising the state of each component into the HTML and then picking up where +the server left off. Lustre doesn't have a built-in hydration mechanism, but +because of the way it works, it's easy to implement one yourself! + +We've said many times now that in Lustre, your `view` is just a +[pure function](https://github.com/lustre-labs/lustre/blob/main/pages/hints/pure-functions.md) +of your model. We should produce the same HTML every time we call `view` with the +same model, no matter how many times we call it. + +Let's use that to our advantage! We know our app's `init` function is responsible +for producing the initial model, so all we need is a way to make sure the initial +model on the client is the same as what the server used to render the page. + +```gleam +pub fn view(model: Int) -> Element(Msg) { + let count = int.to_string(model) + + html.div([], [ + html.button([event.on_click(Decr)], [html.text("-")]), + html.button([event.on_click(Incr)], [html.text("+")]), + html.p([], [html.text("Count: " <> count)]) + ]) +} +``` + +We've seen the counter example a thousand times over now, but it's a good example +to show off how simple hydration can be. The `view` function produces some HTML +with events attached, but we already know Lustre can render _any_ element to a +string so that shouldn't be a problem. + +Let's imagine our HTTP server responds with the following HTML: + +```gleam +import app/counter +import gleam/bytes_builder +import gleam/http/response.{type Response} +import gleam/json +import lustre/attribute +import lustre/element.{type Element} +import lustre/element/html.{html} +import mist.{type ResponseData} + +fn app() -> Response(ResponseData) { + let res = response.new(200) + + let model = 5 + let html = + html([], [ + html.head([], [ + html.script([attribute.type_("module"), attribute.src("...")], ""), + html.script([attribute.type_("application/json"), attribute.id("model")], + json.int(model) + |> json.to_string + ) + ]), + html.body([], [ + html.div([attribute.id("app")], [ + counter.view(model) + ]) + ]) + ]) + + response.set_body(res, + html + |> element.to_document_string + |> bytes_builder.from_string + |> mist.Bytes + ) +} +``` + +We've rendered the shell of our application, as well as the counter using `5` as +the initial model. Importantly, we've included a ` + + + +
+ + diff --git a/packages/lustre/test-apps/options-list/manifest.toml b/packages/lustre/test-apps/options-list/manifest.toml new file mode 100644 index 0000000..676354e --- /dev/null +++ b/packages/lustre/test-apps/options-list/manifest.toml @@ -0,0 +1,46 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birl", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "976CFF85D34D50F7775896615A71745FBE0C325E50399787088F941B539A0497" }, + { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "filespy", version = "0.4.0", build_tools = ["gleam"], requirements = ["fs", "gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "filespy", source = "hex", outer_checksum = "950469A2FA50265EB84530637D3E9597C2CA676A2EEABC98C69A83C77316709C" }, + { name = "fs", version = "8.6.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "61EA2BDAEDAE4E2024D0D25C63E44DCCF65622D4402DB4A2DF12868D1546503F" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, + { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, + { name = "gleam_json", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "8B197DD5D578EA6AC2C0D4BDC634C71A5BCA8E7DB5F47091C263ECB411A60DF3" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_package_interface", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "52A721BCA972C8099BB881195D821AAA64B9F2655BECC102165D5A1097731F01" }, + { name = "gleam_stdlib", version = "0.36.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "C0D14D807FEC6F8A08A7C9EF8DFDE6AE5C10E40E21325B2B29365965D82EB3D4" }, + { name = "glearray", version = "0.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "908154F695D330E06A37FAB2C04119E8F315D643206F8F32B6A6C14A8709FFF4" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "glint", version = "0.18.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "BB0F14643CC51C069A5DC6E9082EAFCD9967AFD1C9CC408803D1A40A3FD43B54" }, + { name = "glisten", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "CF3A9383E9BA4A8CBAF2F7B799716290D02F2AC34E7A77556B49376B662B9314" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "82C112ED9B6C30C1772A6FE2613B94B13F62EA35F5869A2630D13948D297BD39" }, + { name = "lustre", version = "4.1.7", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], source = "local", path = "../.." }, + { name = "lustre_dev_tools", version = "1.2.1", build_tools = ["gleam"], requirements = ["argv", "filepath", "filespy", "fs", "gleam_community_ansi", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_stdlib", "glint", "glisten", "mist", "simplifile", "spinner", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "930BBE8C4E92A16857C31B7B12616651433E1643304696FB93B69D659CE3ADC2" }, + { name = "lustre_ui", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_ui", source = "hex", outer_checksum = "FA1F9E89D89CDD5DF376ED86ABA8A38441CB2E664CD4D402F22A49DA4D7BB56D" }, + { name = "marceau", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "1AAD727A30BE0F95562C3403BB9B27C823797AD90037714255EEBF617B1CDA81" }, + { name = "mist", version = "1.0.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7765E53DCC9ACCACF217B8E0CA3DE7E848C783BFAE5118B75011E81C2C80385C" }, + { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, + { name = "repeatedly", version = "2.1.1", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "38808C3EC382B0CD981336D5879C24ECB37FCB9C1D1BD128F7A80B0F74404D79" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, + { name = "spinner", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "200BA3D4A04D468898E63C0D316E23F526E02514BC46454091975CB5BAE41E8F" }, + { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, + { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, + { name = "wisp", version = "0.14.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "9F5453AF1F9275E6F8707BC815D6A6A9DF41551921B16FBDBA52883773BAE684" }, +] + +[requirements] +gleam_stdlib = { version = "~> 0.36" } +gleeunit = { version = "~> 1.0" } +lustre = { path = "../../" } +lustre_dev_tools = { version = "~> 1.0" } +lustre_ui = { version = "~> 0.4" } diff --git a/packages/lustre/test-apps/options-list/src/app.gleam b/packages/lustre/test-apps/options-list/src/app.gleam new file mode 100644 index 0000000..3328e7d --- /dev/null +++ b/packages/lustre/test-apps/options-list/src/app.gleam @@ -0,0 +1,49 @@ +import lustre +import lustre/attribute +import lustre/element.{type Element} +import lustre/element/html +import lustre/event +import lustre/ui + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + let app = lustre.simple(init, update, view) + let assert Ok(_) = lustre.start(app, "#app", Nil) +} + +// MODEL ----------------------------------------------------------------------- + +type Model = + String + +fn init(_flags) -> Model { + "a" +} + +// UPDATE ---------------------------------------------------------------------- + +pub opaque type Msg { + Select(String) +} + +fn update(_model: Model, msg: Msg) -> Model { + case msg { + Select(tag) -> tag + } +} + +// VIEW ------------------------------------------------------------------------ + +fn view(model: Model) -> Element(Msg) { + let styles = [#("width", "100vw"), #("height", "100vh"), #("padding", "1rem")] + + ui.centre( + [attribute.style(styles)], + html.select([event.on_input(Select)], [ + html.option([attribute.value("a"), attribute.selected("a" == model)], "a"), + html.option([attribute.value("b"), attribute.selected("b" == model)], "b"), + html.option([attribute.value("c"), attribute.selected("c" == model)], "c"), + ]), + ) +} diff --git a/packages/lustre/test-apps/server-component-change-children/README.md b/packages/lustre/test-apps/server-component-change-children/README.md new file mode 100644 index 0000000..a6acb10 --- /dev/null +++ b/packages/lustre/test-apps/server-component-change-children/README.md @@ -0,0 +1,5 @@ +This example makes sure that patches that add or remove different children in a +server component are correctly applied on the client. At one point we realised +patches were being sent in reverse order and that meant the client ended up +incorrectly reusing newly-created children from the _current patch_ when diffing +new nodes. diff --git a/packages/lustre/test-apps/server-component-change-children/gleam.toml b/packages/lustre/test-apps/server-component-change-children/gleam.toml new file mode 100644 index 0000000..eb98040 --- /dev/null +++ b/packages/lustre/test-apps/server-component-change-children/gleam.toml @@ -0,0 +1,27 @@ +name = "app" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "username", repo = "project" } +# links = [{ title = "Website", href = "https://gleam.run" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = "~> 0.36" +lustre = { path = "../../" } +mist = "~> 0.17" +gleam_erlang = "~> 0.24" +gleam_otp = "~> 0.10" +gleam_http = "~> 3.6" +lustre_ui = "~> 0.4" +gleam_json = "~> 1.0" +simplifile = "~> 1.5" + +[dev-dependencies] +gleeunit = "~> 1.0" diff --git a/packages/lustre/test-apps/server-component-change-children/manifest.toml b/packages/lustre/test-apps/server-component-change-children/manifest.toml new file mode 100644 index 0000000..32f8931 --- /dev/null +++ b/packages/lustre/test-apps/server-component-change-children/manifest.toml @@ -0,0 +1,31 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_stdlib", version = "0.37.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "5398BD6C2ABA17338F676F42F404B9B7BABE1C8DC7380031ACB05BBE1BCF3742" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "glisten", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "73BC09C8487C2FFC0963BFAB33ED2F0D636FDFA43B966E65C1251CBAB8458099" }, + { name = "lustre", version = "4.1.8", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], source = "local", path = "../.." }, + { name = "lustre_ui", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_ui", source = "hex", outer_checksum = "FA1F9E89D89CDD5DF376ED86ABA8A38441CB2E664CD4D402F22A49DA4D7BB56D" }, + { name = "mist", version = "0.17.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten"], otp_app = "mist", source = "hex", outer_checksum = "DA8ACEE52C1E4892A75181B3166A4876D8CBC69D555E4770250BC84C80F75524" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "thoas", version = "1.2.0", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "540C8CB7D9257F2AD0A14145DC23560F91ACDCA995F0CCBA779EB33AF5D859D1" }, +] + +[requirements] +gleam_erlang = { version = "~> 0.24" } +gleam_http = { version = "~> 3.6" } +gleam_json = { version = "~> 1.0" } +gleam_otp = { version = "~> 0.10" } +gleam_stdlib = { version = "~> 0.36" } +gleeunit = { version = "~> 1.0" } +lustre = { path = "../../" } +lustre_ui = { version = "~> 0.4" } +mist = { version = "~> 0.17" } +simplifile = { version = "~> 1.5" } diff --git a/packages/lustre/test-apps/server-component-change-children/src/app.gleam b/packages/lustre/test-apps/server-component-change-children/src/app.gleam new file mode 100644 index 0000000..82a1658 --- /dev/null +++ b/packages/lustre/test-apps/server-component-change-children/src/app.gleam @@ -0,0 +1,167 @@ +import counter +import gleam/bytes_builder +import gleam/erlang +import gleam/erlang/process.{type Selector, type Subject} +import gleam/http/request.{type Request} +import gleam/http/response.{type Response} +import gleam/json +import gleam/option.{type Option, None} +import gleam/otp/actor +import gleam/result +import lustre +import lustre/attribute +import lustre/element +import lustre/element/html.{html} +import lustre/server_component +import mist.{ + type Connection, type ResponseData, type WebsocketConnection, + type WebsocketMessage, +} + +pub fn main() { + let assert Ok(_) = + fn(req: Request(Connection)) -> Response(ResponseData) { + case request.path_segments(req) { + // Set up the websocket connection to the client. This is how we send + // DOM updates to the browser and receive events from the client. + ["counter"] -> + mist.websocket( + request: req, + on_init: socket_init, + on_close: socket_close, + handler: socket_update, + ) + + // We need to serve the server component runtime. There's also a minified + // version of this script for production. + ["lustre-server-component.mjs"] -> { + let assert Ok(priv) = erlang.priv_directory("lustre") + let path = priv <> "/static/lustre-server-component.mjs" + + mist.send_file(path, offset: 0, limit: None) + |> result.map(fn(script) { + response.new(200) + |> response.prepend_header("content-type", "application/javascript") + |> response.set_body(script) + }) + |> result.lazy_unwrap(fn() { + response.new(404) + |> response.set_body(mist.Bytes(bytes_builder.new())) + }) + } + + // For all other requests we'll just serve some HTML that renders the + // server component. + _ -> + response.new(200) + |> response.prepend_header("content-type", "text/html") + |> response.set_body( + html([], [ + html.head([], [ + html.link([ + attribute.rel("stylesheet"), + attribute.href( + "https://cdn.jsdelivr.net/gh/lustre-labs/ui/priv/styles.css", + ), + ]), + html.script( + [ + attribute.type_("module"), + attribute.src("/lustre-server-component.mjs"), + ], + "", + ), + ]), + html.body([], [ + server_component.component([server_component.route("/counter")]), + ]), + ]) + |> element.to_document_string_builder + |> bytes_builder.from_string_builder + |> mist.Bytes, + ) + } + } + |> mist.new + |> mist.port(3000) + |> mist.start_http + + process.sleep_forever() +} + +// + +type Counter = + Subject(lustre.Action(counter.Msg, lustre.ServerComponent)) + +fn socket_init( + conn: WebsocketConnection, +) -> #(Counter, Option(Selector(lustre.Patch(counter.Msg)))) { + let app = counter.app() + let assert Ok(counter) = lustre.start_actor(app, 0) + + process.send( + counter, + server_component.subscribe( + // server components can have many connected clients, so we need a way to + // identify this client. + "ws", + // this callback is called whenever the server component has a new patch + // to send to the client. here we json encode that patch and send it to + // via the websocket connection. + // + // a more involved version would have us sending the patch to this socket's + // subject, and then it could be handled (perhaps with some other work) in + // the `mist.Custom` branch of `socket_update` below. + fn(patch) { + let assert Ok(_) = + patch + |> server_component.encode_patch + |> json.to_string + |> mist.send_text_frame(conn, _) + + Nil + }, + ), + ) + + #( + // we store the server component's `Subject` as this socket's state so we + // can shut it down when the socket is closed. + counter, + // the `None` here means we aren't planning on receiving any messages from + // elsewhere and dont need a `Selector` to handle them. + None, + ) +} + +import gleam/io + +fn socket_update( + counter: Counter, + _conn: WebsocketConnection, + msg: WebsocketMessage(lustre.Patch(counter.Msg)), +) { + case msg { + mist.Text(json) -> { + // we attempt to decode the incoming text as an action to send to our + // server component runtime. + let action = json.decode(json, server_component.decode_action) + + case action { + Ok(action) -> process.send(counter, action) + Error(_) -> Nil + } + + actor.continue(counter) + } + + mist.Binary(_) -> actor.continue(counter) + mist.Custom(_) -> actor.continue(counter) + mist.Closed | mist.Shutdown -> actor.Stop(process.Normal) + } +} + +fn socket_close(counter: Counter) { + process.send(counter, lustre.shutdown()) +} diff --git a/packages/lustre/test-apps/server-component-change-children/src/counter.gleam b/packages/lustre/test-apps/server-component-change-children/src/counter.gleam new file mode 100644 index 0000000..06131fc --- /dev/null +++ b/packages/lustre/test-apps/server-component-change-children/src/counter.gleam @@ -0,0 +1,88 @@ +import gleam/list +import lustre +import lustre/attribute +import lustre/element.{type Element} +import lustre/element/html +import lustre/event +import lustre/ui + +// MAIN ------------------------------------------------------------------------ + +pub fn app() { + lustre.simple(init, update, view) +} + +// MODEL ----------------------------------------------------------------------- + +pub type Model = + List(#(String, String, String, String)) + +fn init(_) -> Model { + [] +} + +// UPDATE ---------------------------------------------------------------------- + +pub opaque type Msg { + Incr + Decr +} + +fn update(_: Model, msg: Msg) -> Model { + case msg { + Incr -> [ + #("1", "1", "1", "1"), + #("2", "2", "2", "2"), + #("3", "3", "3", "3"), + #("4", "4", "4", "4"), + #("5", "5", "5", "5"), + ] + + Decr -> [ + #("3", "3", "3", "3"), + #("2", "2", "2", "2"), + #("1", "1", "1", "1"), + ] + } +} + +// VIEW ------------------------------------------------------------------------ + +fn view(model: Model) -> Element(Msg) { + let styles = [#("width", "100vw"), #("height", "100vh"), #("padding", "1rem")] + + ui.centre( + [attribute.style(styles)], + ui.stack([], [ + ui.button([event.on_click(Incr)], [element.text("ascending")]), + ui.button([event.on_click(Decr)], [element.text("descending")]), + html.div([], [ + ui.stack([], [ + html.table([], [ + html.thead([], [ + html.tr([attribute.style([])], [ + html.th([], [html.text("Part No")]), + html.th([], [html.text("Customer")]), + html.th([], [html.text("Job No")]), + html.th([], [html.text("Due Date")]), + ]), + ]), + { + // let rows = + html.tbody([], { + list.map(model, fn(tuple) { + html.tr([], [ + html.td([], [html.text(tuple.0)]), + html.td([], [html.text(tuple.1)]), + html.td([], [html.text(tuple.2)]), + html.td([], [html.text(tuple.3)]), + ]) + }) + }) + }, + ]), + ]), + ]), + ]), + ) +} diff --git a/packages/lustre/test-apps/svg/README.md b/packages/lustre/test-apps/svg/README.md new file mode 100644 index 0000000..ebe238b --- /dev/null +++ b/packages/lustre/test-apps/svg/README.md @@ -0,0 +1,3 @@ +This example exists because setting some SVG attributes like `viewBox` was causing +a runtime error. These attributes were mirrored as DOM properties but they were +marked as **read-only**. diff --git a/packages/lustre/test-apps/svg/gleam.toml b/packages/lustre/test-apps/svg/gleam.toml new file mode 100644 index 0000000..4a824db --- /dev/null +++ b/packages/lustre/test-apps/svg/gleam.toml @@ -0,0 +1,12 @@ +name = "app" +version = "1.0.0" +target = "javascript" + +[dependencies] +gleam_stdlib = "~> 0.36" +lustre = { path = "../../" } +lustre_ui = "~> 0.4" + +[dev-dependencies] +gleeunit = "~> 1.0" +lustre_dev_tools = "~> 1.0" diff --git a/packages/lustre/test-apps/svg/manifest.toml b/packages/lustre/test-apps/svg/manifest.toml new file mode 100644 index 0000000..74a1b75 --- /dev/null +++ b/packages/lustre/test-apps/svg/manifest.toml @@ -0,0 +1,33 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_json", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "8B197DD5D578EA6AC2C0D4BDC634C71A5BCA8E7DB5F47091C263ECB411A60DF3" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_package_interface", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "52A721BCA972C8099BB881195D821AAA64B9F2655BECC102165D5A1097731F01" }, + { name = "gleam_stdlib", version = "0.36.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "C0D14D807FEC6F8A08A7C9EF8DFDE6AE5C10E40E21325B2B29365965D82EB3D4" }, + { name = "glearray", version = "0.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "908154F695D330E06A37FAB2C04119E8F315D643206F8F32B6A6C14A8709FFF4" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "glint", version = "0.18.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "BB0F14643CC51C069A5DC6E9082EAFCD9967AFD1C9CC408803D1A40A3FD43B54" }, + { name = "lustre", version = "4.1.5", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], source = "local", path = "../.." }, + { name = "lustre_dev_tools", version = "1.1.5", build_tools = ["gleam"], requirements = ["argv", "filepath", "gleam_community_ansi", "gleam_erlang", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_stdlib", "glint", "simplifile", "spinner", "tom"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "CA6C10177B66C4FBE8F56B37973C7BB312A8622248D5489957B364FF2C0700AE" }, + { name = "lustre_ui", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "lustre_ui", source = "hex", outer_checksum = "FA1F9E89D89CDD5DF376ED86ABA8A38441CB2E664CD4D402F22A49DA4D7BB56D" }, + { name = "repeatedly", version = "2.1.1", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "38808C3EC382B0CD981336D5879C24ECB37FCB9C1D1BD128F7A80B0F74404D79" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, + { name = "spinner", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "200BA3D4A04D468898E63C0D316E23F526E02514BC46454091975CB5BAE41E8F" }, + { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, + { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, +] + +[requirements] +gleam_stdlib = { version = "~> 0.36" } +gleeunit = { version = "~> 1.0" } +lustre = { path = "../../" } +lustre_dev_tools = { version = "~> 1.0" } +lustre_ui = { version = "~> 0.4" } diff --git a/packages/lustre/test-apps/svg/src/app.gleam b/packages/lustre/test-apps/svg/src/app.gleam new file mode 100644 index 0000000..4b0b8b2 --- /dev/null +++ b/packages/lustre/test-apps/svg/src/app.gleam @@ -0,0 +1,35 @@ +import lustre +import lustre/attribute.{attribute} +import lustre/element/html +import lustre/element/svg +import lustre/ui +import lustre/ui/icon + +pub fn main() { + let styles = [#("width", "100vw"), #("height", "100vh"), #("padding", "1rem")] + + lustre.element(ui.centre( + [attribute.style(styles)], + html.svg( + [ + attribute("version", "1.1"), + attribute("viewBox", "0 0 300 200"), + attribute("width", "300"), + attribute("height", "200"), + ], + [ + svg.rect([ + attribute("width", "100%"), + attribute("height", "100%"), + attribute("fill", "red"), + ]), + svg.circle([ + attribute("cx", "150"), + attribute("cy", "100"), + attribute("r", "80"), + attribute("fill", "green"), + ]), + ], + ), + )) +} diff --git a/packages/lustre/test-apps/vdom-test-templates/gleam.toml b/packages/lustre/test-apps/vdom-test-templates/gleam.toml new file mode 100644 index 0000000..24c306c --- /dev/null +++ b/packages/lustre/test-apps/vdom-test-templates/gleam.toml @@ -0,0 +1,7 @@ +name = "app" +version = "1.0.0" +target = "javascript" + +[dependencies] +gleam_stdlib = "~> 0.36" +lustre = { path = "../../" } diff --git a/packages/lustre/test-apps/vdom-test-templates/manifest.toml b/packages/lustre/test-apps/vdom-test-templates/manifest.toml new file mode 100644 index 0000000..342a523 --- /dev/null +++ b/packages/lustre/test-apps/vdom-test-templates/manifest.toml @@ -0,0 +1,15 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_stdlib", version = "0.37.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "5398BD6C2ABA17338F676F42F404B9B7BABE1C8DC7380031ACB05BBE1BCF3742" }, + { name = "lustre", version = "4.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], source = "local", path = "../.." }, + { name = "thoas", version = "1.2.0", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "540C8CB7D9257F2AD0A14145DC23560F91ACDCA995F0CCBA779EB33AF5D859D1" }, +] + +[requirements] +gleam_stdlib = { version = "~> 0.36" } +lustre = { path = "../../" } diff --git a/packages/lustre/test-apps/vdom-test-templates/src/client_test.gleam b/packages/lustre/test-apps/vdom-test-templates/src/client_test.gleam new file mode 100644 index 0000000..ccbb65e --- /dev/null +++ b/packages/lustre/test-apps/vdom-test-templates/src/client_test.gleam @@ -0,0 +1,64 @@ +import gleam/int +import gleam/list +import lustre/attribute +import lustre/element +import lustre/element/html + +// VIEW HELPERS ----------------------------------------------------------------- +// Functions to get view definitions for testing client ffi, further testing could reuse examples +pub fn smoke_test() { + html.div([], [html.p([], [element.text("smoke test")])]) +} + +pub fn dynamic_content_test(number: Int, some_string: String) { + html.div([], [ + html.h1([], [element.text(some_string)]), + html.p([], [element.text(int.to_string(number))]), + ]) +} + +const mock_people = [ + #("1dfg", "Person One", 18), + #("abc3", "Person Two", 24), + #("ga4d", "Person Three", 30), +] + +pub fn fragment_test() { + let person_els = + element.fragment( + list.map(mock_people, fn(person) { + let #(_, person_name, age) = person + let person_el = [ + html.td([], [element.text(person_name)]), + html.td([], [element.text(int.to_string(age))]), + ] + element.fragment([html.tr([], person_el)]) + }), + ) + + html.table([], [ + html.head([], [ + html.tr([], [ + html.th([], [element.text("Person Name")]), + html.th([], [element.text("Person Age")]), + ]), + ]), + html.body([], [person_els]), + ]) +} + +pub fn keyed_test() { + element.keyed(html.ul([], _), { + use #(id, person_name, age) <- list.map(mock_people) + let child = + html.tr([], [ + html.td([], [element.text(person_name)]), + html.td([], [element.text(int.to_string(age))]), + ]) + #(id, child) + }) +} + +pub fn disabled_attr_test(is_disabled: Bool) { + html.div([], [html.input([attribute.disabled(is_disabled)])]) +} diff --git a/packages/lustre/test/02-interactivity.test.js b/packages/lustre/test/02-interactivity.test.js new file mode 100644 index 0000000..3355e94 --- /dev/null +++ b/packages/lustre/test/02-interactivity.test.js @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { setupDOM } from "./utils.js"; +// built via npm script "build:test:02" +import { main } from "@root/examples/02-interactivity/build/dev/javascript/app/app.mjs"; + +let appEl; +beforeEach(() => { + setupDOM(); + appEl = document.getElementById("app"); +}); + +describe("counter example", () => { + test("should render initially", () => { + main(); + + expect(document.toString()).toMatchSnapshot(); + }); + + test("should increment on button press", () => { + main(); + + const buttons = document.querySelectorAll("button.lustre-ui-button"); + const incrementButton = buttons[0]; + const count = document.querySelector("p"); + + expect(incrementButton).toBeTruthy(); + + incrementButton.click(); + + expect(count.innerText).toBe("1"); + + incrementButton.click(); + expect(count.innerText).toBe("2"); + + incrementButton.click(); + expect(count.innerText).toBe("3"); + + }); + + test("should decrement on button press", () => { + main(); + + const buttons = document.querySelectorAll("button.lustre-ui-button"); + const decrementButton = buttons[1]; + const count = document.querySelector("p"); + + expect(decrementButton).toBeTruthy(); + + decrementButton.click(); + expect(count.innerText).toBe("-1"); + + decrementButton.click(); + expect(count.innerText).toBe("-2"); + + decrementButton.click(); + expect(count.innerText).toBe("-3"); + }); + + test("should increment and decrement on button press", () => { + main(); + + const buttons = document.querySelectorAll("button.lustre-ui-button"); + const incrementButton = buttons[0]; + const decrementButton = buttons[1]; + const count = document.querySelector("p"); + + incrementButton.click(); + + expect(count.innerText).toBe("1"); + + incrementButton.click(); + expect(count.innerText).toBe("2"); + + incrementButton.click(); + expect(count.innerText).toBe("3"); + + expect(decrementButton).toBeTruthy(); + + decrementButton.click(); + expect(count.innerText).toBe("2"); + + decrementButton.click(); + expect(count.innerText).toBe("1"); + + decrementButton.click(); + expect(count.innerText).toBe("0"); + }); +}); diff --git a/packages/lustre/test/README.md b/packages/lustre/test/README.md new file mode 100644 index 0000000..6577c5c --- /dev/null +++ b/packages/lustre/test/README.md @@ -0,0 +1,29 @@ +# Client testing for Lustre runtime + +1. Build and test example projects +2. Build and test vdom + +Depends on: +- `linkedom` - headless DOM testing +- `npm-run-all` - run watch in parallel +- `vitest` - execute tests + + +### Commands + +Run from the `test` directory +Each command will run a `build` command to build project dependencies + +#### Benchmark + +- `npm run bench` + +#### Test + +- ##### Single + + - `npm run test` + +- ##### Watch + + - `npm run test:watch` diff --git a/packages/lustre/test/apps/counter.gleam b/packages/lustre/test/apps/counter.gleam new file mode 100644 index 0000000..b58b2ee --- /dev/null +++ b/packages/lustre/test/apps/counter.gleam @@ -0,0 +1,41 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/int +import lustre/element.{text} +import lustre/element/html.{button, div, p} +import lustre/event + +// MODEL ----------------------------------------------------------------------- + +pub type Model = + Int + +pub fn init(count) { + count +} + +// UPDATE ---------------------------------------------------------------------- + +pub type Msg { + Increment + Decrement +} + +pub fn update(model, msg) { + case msg { + Increment -> model + 1 + Decrement -> model - 1 + } +} + +// VIEW ------------------------------------------------------------------------ + +pub fn view(model) { + let count = int.to_string(model) + + div([], [ + p([], [text(count)]), + button([event.on_click(Decrement)], [text("-")]), + button([event.on_click(Increment)], [text("+")]), + ]) +} diff --git a/packages/lustre/test/apps/fragment.gleam b/packages/lustre/test/apps/fragment.gleam new file mode 100644 index 0000000..d20400e --- /dev/null +++ b/packages/lustre/test/apps/fragment.gleam @@ -0,0 +1,45 @@ +// Similar to count app, with fragments and edge cases + +// IMPORTS --------------------------------------------------------------------- + +import gleam/int +import lustre/element.{text} +import lustre/element/html.{button, p} +import lustre/event + +// MODEL ----------------------------------------------------------------------- + +pub type Model = + Int + +pub fn init(count) { + count +} + +// UPDATE ---------------------------------------------------------------------- + +pub type Msg { + Increment + Decrement +} + +pub fn update(model, msg) { + case msg { + Increment -> model + 1 + Decrement -> model - 1 + } +} + +// VIEW ------------------------------------------------------------------------ + +pub fn view(model) { + let count = int.to_string(model) + element.fragment([ + element.fragment([p([], [element.text("start fragment")])]), + element.fragment([p([], [element.text("middle fragment")])]), + element.fragment([p([], [element.text(count)])]), + button([event.on_click(Decrement)], [text("-")]), + button([event.on_click(Increment)], [text("+")]), + p([], [element.text("order check, last element")]), + ]) +} diff --git a/packages/lustre/test/apps/static.gleam b/packages/lustre/test/apps/static.gleam new file mode 100644 index 0000000..fcf52f3 --- /dev/null +++ b/packages/lustre/test/apps/static.gleam @@ -0,0 +1,29 @@ +// IMPORTS --------------------------------------------------------------------- + +import lustre/attribute.{attribute, class, disabled, src, style} +import lustre/element.{text} +import lustre/element/html.{body, div, h1, head, html, img, input, title} + +// VIEW ------------------------------------------------------------------------ + +pub fn view() { + html([], [ + head([], [title([], "Hello, World!")]), + body([], [ + h1([], [text("Hello, World!")]), + input([disabled(True)]), + img([src("https://source.unsplash.com/random")]), + ]), + ]) +} + +pub fn escaped_attribute() { + div( + [ + class("'badquotes'"), + style([#("background", "\">")]), + attribute("example", "{\"mykey\": \"myvalue\"}"), + ], + [], + ) +} diff --git a/packages/lustre/test/build.gleam b/packages/lustre/test/build.gleam new file mode 100644 index 0000000..b223027 --- /dev/null +++ b/packages/lustre/test/build.gleam @@ -0,0 +1,122 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/bool +import gleam/io +import gleam/regex.{Options} +import gleam/result +import gleam/string +import shellout +import simplifile + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + io.debug({ + use exists <- try(verify_esbuild(), SimplifileError) + use <- bool.guard(!exists, Error(MissingEsbuild)) + + use _ <- try(build_for_javascript(), ShelloutError) + use _ <- try(bundle_server_component(), ShelloutError) + use _ <- try(bundle_minified_server_component(), ShelloutError) + + use script <- try(read_script(), SimplifileError) + use module <- try(read_module(), SimplifileError) + use _ <- try(inject_script(script, module), SimplifileError) + + use _ <- try(format_project(), ShelloutError) + + Ok(Nil) + }) +} + +// CONSTANTS ------------------------------------------------------------------- + +const esbuild = "./build/.lustre/bin/esbuild" + +// STEPS ----------------------------------------------------------------------- + +fn verify_esbuild() { + simplifile.verify_is_file(esbuild) +} + +fn build_for_javascript() { + shellout.command( + run: "gleam", + with: ["build", "--target", "javascript"], + in: ".", + opt: [], + ) +} + +fn bundle_server_component() { + shellout.command( + run: esbuild, + with: [ + "./src/server-component.mjs", "--bundle", "--format=esm", + "--outfile=./priv/static/lustre-server-component.mjs", + ], + in: ".", + opt: [], + ) +} + +fn bundle_minified_server_component() { + shellout.command( + run: esbuild, + with: [ + "./src/server-component.mjs", "--bundle", "--minify", "--format=esm", + "--outfile=./priv/static/lustre-server-component.min.mjs", + ], + in: ".", + opt: [], + ) +} + +fn read_script() { + simplifile.read("./priv/static/lustre-server-component.min.mjs") + |> result.map(string.replace(_, "\"", "\\\"")) + |> result.map(string.trim) +} + +fn read_module() { + simplifile.read("./src/lustre/server_component.gleam") +} + +fn inject_script(script, module) { + let inject_regex = "// <>\\n.+\\n.+\\n \\)," + let options = Options(case_insensitive: False, multi_line: True) + let assert Ok(re) = regex.compile(inject_regex, options) + let assert [before, after] = regex.split(re, module) + + simplifile.write( + "./src/lustre/server_component.gleam", + before + <> "// <>\n element.text(\"" + <> script + <> "\")," + <> after, + ) +} + +fn format_project() { + shellout.command(run: "gleam", with: ["format"], in: ".", opt: []) +} + +// ERROR HANDLING -------------------------------------------------------------- + +pub type Error { + MissingEsbuild + ShelloutError(#(Int, String)) + SimplifileError(simplifile.FileError) +} + +fn try( + result: Result(a, e), + to_error: fn(e) -> Error, + then: fn(a) -> Result(b, Error), +) -> Result(b, Error) { + case result { + Ok(value) -> then(value) + Error(error) -> Error(to_error(error)) + } +} diff --git a/packages/lustre/test/lustre_test.gleam b/packages/lustre/test/lustre_test.gleam new file mode 100644 index 0000000..2330b3c --- /dev/null +++ b/packages/lustre/test/lustre_test.gleam @@ -0,0 +1,175 @@ +// IMPORTS --------------------------------------------------------------------- + +import apps/counter +import apps/fragment +import apps/static +import birdie +import gleam/erlang/process +import gleam/json +import gleeunit +import lustre +import lustre/element +import lustre/internals/patch +import lustre/internals/runtime.{Debug, Dispatch, Shutdown, View} + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + gleeunit.main() +} + +// TESTS ----------------------------------------------------------------------- + +pub fn static_test() { + let title = "Can render static HTML" + let el = static.view() + + birdie.snap(element.to_string(el), title) +} + +@target(erlang) +pub fn counter_init_test() { + let title = "Can render an application's initial state." + let app = lustre.simple(counter.init, counter.update, counter.view) + let assert Ok(runtime) = lustre.start_actor(app, 0) + let el = + process.call( + runtime, + fn(reply) { + process.send(reply, _) + |> View + |> Debug + }, + 100, + ) + + birdie.snap(element.to_string(el), title) + process.send(runtime, Shutdown) +} + +@target(erlang) +pub fn counter_update_test() { + let title = "Can render an application's state after some updates." + let app = lustre.simple(counter.init, counter.update, counter.view) + let assert Ok(runtime) = lustre.start_actor(app, 0) + + process.send(runtime, Dispatch(counter.Increment)) + process.send(runtime, Dispatch(counter.Increment)) + process.send(runtime, Dispatch(counter.Increment)) + + let el = + process.call( + runtime, + fn(reply) { + process.send(reply, _) + |> View + |> Debug + }, + 100, + ) + + birdie.snap(element.to_string(el), title) + process.send(runtime, Shutdown) +} + +@target(erlang) +pub fn counter_diff_test() { + let title = "Can compute a diff from one render to the next" + let app = lustre.simple(counter.init, counter.update, counter.view) + let assert Ok(runtime) = lustre.start_actor(app, 0) + + let prev = + process.call( + runtime, + fn(reply) { + process.send(reply, _) + |> View + |> Debug + }, + 100, + ) + + process.send(runtime, Dispatch(counter.Increment)) + process.send(runtime, Dispatch(counter.Increment)) + process.send(runtime, Dispatch(counter.Increment)) + + let next = + process.call( + runtime, + fn(reply) { + process.send(reply, _) + |> View + |> Debug + }, + 100, + ) + + let diff = patch.elements(prev, next) + + birdie.snap(json.to_string(patch.element_diff_to_json(diff)), title) + process.send(runtime, Shutdown) +} + +@target(erlang) +pub fn fragment_init_test() { + let title = "Can render an application's initial state when using fragments" + let app = lustre.simple(fragment.init, fragment.update, fragment.view) + let assert Ok(runtime) = lustre.start_actor(app, 0) + let el = + process.call( + runtime, + fn(reply) { + process.send(reply, _) + |> View + |> Debug + }, + 100, + ) + + birdie.snap(element.to_string(el), title) + process.send(runtime, Shutdown) +} + +@target(erlang) +pub fn fragment_counter_diff_test() { + let title = "Can compute a diff from one render to the next with fragments" + let app = lustre.simple(fragment.init, fragment.update, fragment.view) + let assert Ok(runtime) = lustre.start_actor(app, 0) + + let prev = + process.call( + runtime, + fn(reply) { + process.send(reply, _) + |> View + |> Debug + }, + 100, + ) + + process.send(runtime, Dispatch(fragment.Increment)) + process.send(runtime, Dispatch(fragment.Increment)) + process.send(runtime, Dispatch(fragment.Increment)) + + let next = + process.call( + runtime, + fn(reply) { + process.send(reply, _) + |> View + |> Debug + }, + 100, + ) + + let diff = patch.elements(prev, next) + + birdie.snap(json.to_string(patch.element_diff_to_json(diff)), title) + process.send(runtime, Shutdown) +} + +pub fn escaped_attribute_test() { + let title = "Can safely escape dangerous symbols in attributes" + let el = static.escaped_attribute() + birdie.snap(element.to_string(el), title) +} diff --git a/packages/lustre/test/utils.js b/packages/lustre/test/utils.js new file mode 100644 index 0000000..c3abaee --- /dev/null +++ b/packages/lustre/test/utils.js @@ -0,0 +1,29 @@ +import { parseHTML } from 'linkedom'; +import { vi } from 'vitest'; + +// Parse the starting state of the basic starting template +export function setupDOM() { + const result = parseHTML(` + + + + + + + 🚧 {app_name} + + + + +
+ +`); + + global.HTMLElement = result.HTMLElement; + global.Node = result.Node; + global.document = result.document; + global.window = result.window; + global.window.requestAnimationFrame = vi.fn().mockImplementation((cb) => cb()); + + return result; +} \ No newline at end of file diff --git a/packages/lustre/test/vdom.ffi.bench.js b/packages/lustre/test/vdom.ffi.bench.js new file mode 100644 index 0000000..a8e139e --- /dev/null +++ b/packages/lustre/test/vdom.ffi.bench.js @@ -0,0 +1,27 @@ +import { bench, describe } from "vitest"; +import { setupDOM } from "./utils"; +import { morph } from "../src/vdom.ffi.mjs"; +import { smoke_test } from "../test-apps/vdom-test-templates/build/dev/javascript/app/client_test.mjs"; + +// BENCH ------------------------------------------------------------------------ + +describe("vdom morph bench", () => { + let appEl; + let template; + bench( + "smoke test morph", + () => { + appEl = morph(appEl, template); + }, + { + setup: () => { + const result = setupDOM(); + + global.Node = result.Node; + global.document = result.document; + appEl = document.getElementById("app"); + template = smoke_test(); + } + } + ); +}); diff --git a/packages/lustre/test/vdom.ffi.test.js b/packages/lustre/test/vdom.ffi.test.js new file mode 100644 index 0000000..2614c71 --- /dev/null +++ b/packages/lustre/test/vdom.ffi.test.js @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { setupDOM } from "./utils.js"; + +import { morph } from "@root/src/vdom.ffi.mjs"; + +// built via npm script "build:test:vdom" +import { + disabled_attr_test, + dynamic_content_test, + fragment_test, + keyed_test, + smoke_test, +} from "../test-apps/vdom-test-templates/build/dev/javascript/app/client_test.mjs"; + +let appEl; +beforeEach(() => { + setupDOM(); + appEl = document.getElementById("app"); +}); + +// TEST ------------------------------------------------------------------------ + +const singleMorphSnapshot = (name, template) => { + appEl = morph(appEl, template); + + const currentState = document.toString(); + + expect(currentState).toMatchSnapshot(name); +}; + +describe("vdom morph", () => { + test(`should render smoke test with vdom morph`, () => { + const template = smoke_test(); + + singleMorphSnapshot("smoke_test", template); + }); + + test(`should render using vdom morph with fragments`, () => { + const template = fragment_test(); + + singleMorphSnapshot("fragment_test", template); + }); + + test(`should render using vdom morph with keys`, () => { + const template = keyed_test(); + + singleMorphSnapshot("fragment_test", template); + }); + + test(`should be stable when vdom morph is called multiple times with no changes using fragment`, () => { + const template = fragment_test(); + appEl = morph(appEl, template); + + const initialState = document.toString(); + + const states = []; + for (let i = 0; i < 5; i++) { + appEl = morph(appEl, template); + states.push(document.toString()); + } + + states.forEach((state) => { + expect(state).toEqual(initialState); + }); + }); + + test(`should be stable when vdom morph is called multiple times with no changes using keys`, () => { + const template = keyed_test(); + appEl = morph(appEl, template); + + const initialState = document.toString(); + + const states = []; + for (let i = 0; i < 5; i++) { + appEl = morph(appEl, template); + states.push(document.toString()); + } + + states.forEach((state) => { + expect(state).toEqual(initialState); + }); + }); + + test(`should render updated templates`, () => { + const initialTemplate = dynamic_content_test(0, "initial_name"); + + appEl = morph(appEl, initialTemplate); + + const initialState = document.toString(); + + expect(initialState).toContain("0"); + expect(initialState).toContain("initial_name"); + + const updatedtemplate = dynamic_content_test(56, "updated_name"); + + appEl = morph(appEl, updatedtemplate); + + const updatedState = document.toString(); + + expect(updatedState).toContain("56"); + expect(updatedState).toContain("updated_name"); + }); +}); + +describe("vdom morph attribute", () => { + describe("disabled", () => { + test("should not be disabled when is_disabled is false", () => { + const template = disabled_attr_test(false); + + appEl = morph(appEl, template); + + const domResult = document.toString(); + + expect(domResult).toContain("input"); + expect(domResult).not.toContain("disabled"); + }); + + test("should be disabled when is_disabled is true", () => { + const template = disabled_attr_test(true); + + appEl = morph(appEl, template); + + const domResult = document.toString(); + + expect(domResult).toContain("input"); + expect(domResult).toContain("disabled"); + }); + + + // this fails today + test.skip("should be stable when disabled attribute does not change", () => { + const template = disabled_attr_test(true); + + appEl = morph(appEl, template); + + const initialState = document.toString(); + + const states = []; + for (let i = 0; i < 5; i++) { + appEl = morph(appEl, template); + states.push(document.toString()); + } + + states.forEach((state) => { + expect(state).toEqual(initialState); + }); + }); + }); +}); + diff --git a/packages/lustre/vitest.config.js b/packages/lustre/vitest.config.js new file mode 100644 index 0000000..e0a777d --- /dev/null +++ b/packages/lustre/vitest.config.js @@ -0,0 +1,18 @@ +import { configDefaults, defineConfig } from "vitest/config"; +import { basename, dirname, join, resolve } from 'node:path'; + +export default defineConfig({ + test: { + alias: { + '@root': resolve(__dirname) + }, + benchmark: { + include: ["**/test/**/*.bench.js"], + exclude: [...configDefaults.exclude, "**/build/**/*"], + }, + include: ["**/test/**/*.test.js"], + exclude: [...configDefaults.exclude, "**/build/**/*"], + resolveSnapshotPath: (testPath, snapExtension) => + join(join(dirname(testPath), '../', 'vitest_snapshots'), `${basename(testPath)}${snapExtension}`) + }, +}); diff --git a/packages/lustre/vitest_snapshots/02-interactivity.test.js.snap b/packages/lustre/vitest_snapshots/02-interactivity.test.js.snap new file mode 100644 index 0000000..c7c7ce2 --- /dev/null +++ b/packages/lustre/vitest_snapshots/02-interactivity.test.js.snap @@ -0,0 +1,19 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`counter example > should render initially 1`] = ` +" + + + + + + + 🚧 {app_name} + + + + +

0

+ +" +`; diff --git a/packages/lustre/vitest_snapshots/vdom.ffi.test.js.snap b/packages/lustre/vitest_snapshots/vdom.ffi.test.js.snap new file mode 100644 index 0000000..fa11c73 --- /dev/null +++ b/packages/lustre/vitest_snapshots/vdom.ffi.test.js.snap @@ -0,0 +1,55 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`vdom morph > should render smoke test with vdom morph > smoke_test 1`] = ` +" + + + + + + + 🚧 {app_name} + + + + +

smoke test

+ +" +`; + +exports[`vdom morph > should render using vdom morph with fragments > fragment_test 1`] = ` +" + + + + + + + 🚧 {app_name} + + + + +
Person NamePerson Age
Person One18
Person Two24
Person Three30
+ +" +`; + +exports[`vdom morph > should render using vdom morph with keys > fragment_test 1`] = ` +" + + + + + + + 🚧 {app_name} + + + + +
    Person One18Person Two24Person Three30
+ +" +`;