├── .eslintignore ├── .eslintrc.json ├── .github ├── release-drafter.yml └── workflows │ ├── main.yml │ └── release-drafter.yml ├── .gitignore ├── .vscode └── settings.json ├── BACKGROUND.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.MD ├── LICENSE ├── README.md ├── cli └── mod.ts ├── demo ├── README.md ├── app.ts ├── environment.ts └── home │ ├── _assets │ └── index.css │ ├── _client.ts │ ├── _server.ts │ ├── about │ ├── _client.ts │ ├── _server.ts │ ├── counter.tsx │ ├── types.ts │ ├── update.ts │ └── view.tsx │ ├── api │ └── yo │ │ └── _server.ts │ ├── banana │ ├── _client.ts │ ├── _server.ts │ ├── types.ts │ ├── update.ts │ └── view.tsx │ ├── types.ts │ ├── update.ts │ ├── user │ ├── _ │ │ ├── _client.ts │ │ ├── _server.ts │ │ ├── types.ts │ │ ├── update.ts │ │ └── view.tsx │ └── xxx │ │ ├── _client.ts │ │ ├── _server.ts │ │ ├── types.ts │ │ ├── update.ts │ │ └── view.tsx │ └── view.tsx ├── levo-runtime-raw.ts ├── levo-runtime.tsconfig.json ├── lib └── lib.deno_runtime.d.ts ├── mod ├── levo-app.ts ├── levo-middlewares.ts ├── levo-serve-response.ts ├── levo-serve.ts └── levo-view.ts ├── package-lock.json ├── package.json ├── src ├── apply-patches.ts ├── array-diff.ts ├── babelstandalone.js ├── camel-to-kebab.ts ├── clean-css.js ├── compression.ts ├── compute-attributes-updates.ts ├── css-types.ts ├── deep-equal.ts ├── deps.ts ├── extract-attributes.ts ├── extract-dependencies.ts ├── get-directory-tree.ts ├── get-local-dependencies.ts ├── helmet.ts ├── levo-runtime.ts ├── levo-server.ts ├── levo-tsconfig-raw.ts ├── lispy-element-to-virtual-node.ts ├── lispy-elements.ts ├── memory-cache.ts ├── middleware.ts ├── mime-db.ts ├── mime-lookup.ts ├── minify-js.ts ├── mount.ts ├── patch.ts ├── regenerator-runtime-raw.ts ├── render-to-string.ts ├── replace-virtual-node.ts ├── resolve-import-map.ts ├── resolve-url.ts ├── run-command.ts ├── scripts │ └── scrape-attributes-type.js ├── set-event-handler.ts ├── terser.js ├── testing.ts ├── virtual-node-diff.ts ├── virtual-node-events.ts ├── virtual-node.ts ├── watch-dependencies.ts └── watch-file.ts ├── templates └── new-project │ ├── .gitignore │ ├── .vscode │ └── settings.json │ ├── README.md │ ├── app.ts │ ├── environment.ts │ ├── import_map.json │ ├── lib │ └── lib.deno_runtime.d.ts │ ├── root │ ├── _assets │ │ ├── favicon.ico │ │ ├── favicon.svg │ │ └── index.css │ ├── _client.ts │ ├── _server.ts │ ├── robots.txt │ │ └── _server.ts │ └── view.tsx │ ├── tools │ ├── start-development.sh │ └── start-production.sh │ └── tsconfig.json ├── test-levo-runtime ├── .vscode │ └── settings.json ├── package-lock.json ├── package.json └── test.js ├── test ├── cli │ └── main.test.ts ├── deps.ts ├── server │ ├── development-mode.test.ts │ ├── production-mode.test.ts │ ├── project-template.test.ts │ └── util.ts └── unit │ ├── array-diff.test.ts │ ├── compute-attributes-updates.test.ts │ ├── extract-dependencies.test.ts │ ├── get-directory-tree.test.ts │ ├── get-local-dependencies.test.ts │ ├── memory-cache.test.ts │ ├── mime-lookup.test.ts │ ├── render-to-string.test.tsx │ ├── resolve-import-map.test.ts │ ├── resolve-url.test.ts │ ├── test-files │ ├── a.ts │ ├── b.ts │ ├── c.ts │ ├── d.ts │ └── x.ts │ ├── watch-dependencies.test.ts │ └── watch-file.test.ts ├── tools ├── bundle-levo-runtime.sh ├── bundle-levo-runtime.ts ├── format.sh ├── format.ts ├── update-deno-types.sh └── verify-levo-runtime-raw.sh └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | lib.deno_runtime.d.ts 2 | terser.js 3 | levo-runtime-raw.ts -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2020": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": 11, 14 | "sourceType": "module" 15 | }, 16 | "plugins": [ 17 | "@typescript-eslint" 18 | ], 19 | "rules": { 20 | "no-unused-vars": "off", 21 | "no-undef": "off", 22 | "@typescript-eslint/no-explicit-any": "off", 23 | "@typescript-eslint/no-unused-vars": [ 24 | "error", 25 | { "varsIgnorePattern": "^h$" } 26 | ], 27 | "@typescript-eslint/no-empty-function": "off", 28 | "@typescript-eslint/no-namespace": "off" 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: v$NEXT_PATCH_VERSION 2 | tag-template: v$NEXT_PATCH_VERSION 3 | 4 | template: | 5 | ## Changes 6 | 7 | $CHANGES -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the master branch 7 | on: 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | release: 13 | types: [published] 14 | 15 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 16 | jobs: 17 | lint: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v2.3.1 21 | - name: Shellcheck 22 | run: shellcheck ./**/*.sh 23 | - name: Install node modules 24 | run: npm install 25 | - name: ES Lint 26 | run: npm run eslint 27 | 28 | format-check: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v2.3.1 32 | - uses: denolib/setup-deno@master 33 | with: 34 | deno-version: v1.2.0 35 | - name: Format Check 36 | run: sh ./tools/format.sh --check 37 | 38 | verify-deno-types: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v2.3.1 42 | - uses: denolib/setup-deno@master 43 | with: 44 | deno-version: v1.2.0 45 | - name: Write type into temp 46 | run: deno types > temp 47 | - name: Compare temp with ./lib/lib.deno_runtime.d.ts 48 | run: diff temp ./lib/lib.deno_runtime.d.ts 49 | - name: Compare temp with ./templates/new-project/lib/lib.deno_runtime.d.ts 50 | run: | 51 | deno fmt temp 52 | diff temp ./templates/new-project/lib/lib.deno_runtime.d.ts 53 | 54 | build: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - uses: actions/checkout@v2.3.1 58 | - uses: denolib/setup-deno@master 59 | with: 60 | deno-version: v1.2.0 61 | - name: Bundle levo-runtime 62 | run: sh ./tools/bundle-levo-runtime.sh 63 | - name: Verify levo-runtime-raw.ts is up-to-date 64 | run: sh ./tools/verify-levo-runtime-raw.sh 65 | 66 | unit-test: 67 | runs-on: ubuntu-latest 68 | steps: 69 | - uses: actions/checkout@v2.3.1 70 | - uses: denolib/setup-deno@master 71 | with: 72 | deno-version: v1.2.0 73 | - name: Unit Test 74 | run: deno test --allow-all --unstable ./test/unit/ 75 | 76 | dev-mode-server-test: 77 | runs-on: ubuntu-latest 78 | steps: 79 | - uses: actions/checkout@v2.3.1 80 | - uses: denolib/setup-deno@master 81 | with: 82 | deno-version: v1.2.0 83 | - name: Server Test 84 | run: deno test --allow-all --unstable ./test/server/development-mode.test.ts 85 | 86 | prod-mode-server-test: 87 | runs-on: ubuntu-latest 88 | steps: 89 | - uses: actions/checkout@v2.3.1 90 | - uses: denolib/setup-deno@master 91 | with: 92 | deno-version: v1.2.0 93 | - name: Server Test 94 | run: deno test --allow-all --unstable ./test/server/production-mode.test.ts 95 | 96 | template-test: 97 | runs-on: ubuntu-latest 98 | steps: 99 | - uses: actions/checkout@v2.3.1 100 | - uses: denolib/setup-deno@master 101 | with: 102 | deno-version: v1.2.0 103 | - name: Template Test 104 | run: deno test --allow-all --unstable ./test/server/project-template.test.ts 105 | 106 | cli-test: 107 | runs-on: ubuntu-latest 108 | 109 | steps: 110 | - uses: actions/checkout@v2.3.1 111 | - uses: denolib/setup-deno@master 112 | with: 113 | deno-version: v1.2.0 114 | - name: CLI Test 115 | run: deno test --allow-all --unstable ./test/cli/main.test.ts 116 | 117 | levo-runtime-test: 118 | runs-on: ubuntu-latest 119 | steps: 120 | - uses: actions/checkout@v2.3.1 121 | - uses: denolib/setup-deno@master 122 | with: 123 | deno-version: v1.2.0 124 | - uses: actions/setup-node@v1 125 | with: 126 | node-version: 10.x 127 | - name: Install npm packages 128 | run: npm install 129 | working-directory: ./test-levo-runtime 130 | - name: Run test 131 | run: npm test 132 | working-directory: ./test-levo-runtime -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | ## Refer: https://github.com/release-drafter/release-drafter 2 | name: Release Drafter 3 | 4 | on: 5 | push: 6 | # branches to consider in the event; optional, defaults to all 7 | branches: 8 | - master 9 | 10 | jobs: 11 | update_release_draft: 12 | runs-on: ubuntu-latest 13 | steps: 14 | # Drafts your next Release notes as Pull Requests are merged into "master" 15 | - uses: release-drafter/release-drafter@v5 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | levo.tsconfig.json 3 | *.cache -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.importmap": "import_map.json" 4 | } -------------------------------------------------------------------------------- /BACKGROUND.md: -------------------------------------------------------------------------------- 1 | # Background 2 | This section is very long, you can skip to tutorial by clicking [here](https://github.com/levo-framework/core/#tutorial) 3 | 4 | Why do I want to create another web framework? 5 | 6 | Here's my story. 7 | 8 | I've been using React for more than 3 years, and [my first React project](https://ttap.surge.sh/#/select) is written in Typescript using Redux, and I'm quite happy with that because of its success. Also, I use React for all the web projects in my current company, for example dashboard and the company's homepage. 9 | 10 | > So far so good, I felt like nothing is impossible with React. 11 | 12 | *Then, everything changed when the Fire Nation attacked*. 13 | 14 | Just joking. Recently, we found out that our homepage was not scrapped properly by Google Bot, and one of the solution is to render the page on server before returning to client. I thought that's going to be easy, turns out I'm very wrong, there's not much documentation about React SSR. Of course there are frameworks like [Next.js](https://nextjs.org/) or [After.js](https://github.com/jaredpalmer/after.js), but they are overkill. Then we leaped on to [React Static](https://github.com/react-static/react-static), but unfortunately it doesn't work well with dynamic pages (I should have known that from its name, it's not React Dynamic). In the end, we managed to deploy some very hacky magic to make it work, but to be honest the next maintainer of this project will definitely curse me, because it's just too freaking hacky, even me the author don't dare to peek at these foul babies. 15 | 16 | 17 | After that painful experience, I realized that it's actually not only React's problem, any frontend SPA frameworks will have this problem, they just cannot befriend SEO without you forcefully marry them. 18 | 19 | Now when I look back in my old days, I started to miss the bliss of using PHP (and Ruby, although I never use it) in which you don't have to do anything to make SEO works, they are SEO-d by default! 20 | Moreover, I really miss how I can just create a script under a directory and it's ready to be deployed, forget about the ReactRouter-Webpack-S3 trinity. 21 | 22 | > Ok, so that's the first problem: SEO is damn hard in React. 23 | 24 | --- 25 | 26 | The second major problem of SPA frameworks is that they tend to get heavier over-time, which means that the time-to-load increases when you add more pages to it. Of course you can use the [code splitting](https://webpack.js.org/guides/code-splitting/) magic to overcome this issue, but it's only effective if you always remember to use it. Obviously I shouldn't blame React for disciplinary issue, but then I ask myself, do I ever have to worry about PHP serving the whole freaking application to client? I almost forgot to talk about this, the compile time of my current dashboard project is heading towards infinity and beyond, the compile-debug-code loop is expanding faster than the universe. 27 | 28 | > That's the 2nd problem: scaling naturally is hard in SPA frameworks. 29 | 30 | --- 31 | 32 | Furthermore, most SPA frameworks don't have a fix style of coding, for example my React project can be very different from your React project, sometimes the difference is so big that I feel like I'm travelling to a different planet. 33 | 34 | Nonetheless there's one exception, Angular. It's coding style is pretty standardized, I guess most Angular developers will feel as if they are at home regardless of which Angular project. This is actually a pretty good advantage from the point of view of business, managers don't have to worry too much of being not able to hire the next developer that can truely swims in the pool of existing projects. That's probably why a lot of [corporates prefer Angular over other frameworks](https://www.techrepublic.com/article/angular-vs-react-who-wins-the-front-end-battle-in-the-enterprise/#:~:text=Angular%20is%20more%20popular%20than,React%20was%20mentioned%20in%2037%25.). 35 | 36 | > That's the 3rd problem: coding style is non-standardized in most SPA frameworks; bad for big fat projects 37 | 38 | --- 39 | 40 | Last but not least, most SPA frameworks like Vue, Svelte or React, even though they claimed to be functional, they are innately object-oriented. Why do I say so? Because they provide first class support for components with **private state**. Just when you think in React that a functional component is functional, no longer it is when you use hooks. But why is private state bad? Because of two reasons: firstly, it leads to inconsistent coding style, I might prefer child components to host their state themselves, but you might prefer to let parent components handle it. Second and most importantly, private state actually leads to problem of having to host the same state in both parent and child component. This always happen when you think that some state should be in child, then in the future you realized that the private state in child actually needs to be known or possibly manipulated by its parent and vice versa, eventually you end up with a bunch of parent and child components having a very disgusting incest party. 41 | 42 | Certainly, this problem can be solved by using [Redux](https://redux.js.org/) (like I mentioned previously that I'm quite happy with it), but, it's very hard to integrate it with existing React projects and it does not prevent future developers from creating components with private state after all. 43 | 44 | Just when I'm losing faith in frontend development, a messiah came and offered me salvation in the name of [Elm](https://elm-lang.org/). It is truly a haven for frontend sinners like me, the wonderful [Model-View-Update](https://guide.elm-lang.org/architecture/) architecture means you no longer have to deal with evil capitalism that promotes private ownership! May peace be upon those communistic Elm programmers. All jokes aside, I never truly became an Elm developer, because they have too many rules to be observed, the one that deters me the most is the rule of [No Promiximty with Javascript](https://www.reddit.com/r/elm/comments/5g3540/the_elm_alienation/), it means that you cannot interact freely with Javascript without using safety measures like ports or flags. 45 | 46 | > That's the last reason: no JS frameworks support The Elm Architecture (TEA) out of the box 47 | 48 | All of these makes me wonder why frontend development has evovled into a gorgon instead of getting simpler. That being said, I still believe there's hope, therefore I decided to give myself a try, and that's how Levo was born. 49 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at hou32hou@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.MD: -------------------------------------------------------------------------------- 1 | # Contributing 2 | Dear developers, if you wish to contribue to Levo, please kindly read this document. 3 | 4 | ## Rules 5 | - Any pull requests that will hinder the [goals of this project](https://github.com/levo-framework/core#goals-of-levo) will be rejected 6 | - All feature and bugfix should comes with its own set of test 7 | - Each pull request should be referenced to an issue 8 | - Pull request that do not passes CI/CD checking will not be merged 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Wong Jia Hau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Levo 2 | 3 | ## ATTENTION 4 | Please don't use Levo in production yet as it is still very experimental. 5 | 6 | ![](https://github.com/levo-framework/core/workflows/CI/badge.svg) 7 | 8 | ## Background 9 | You can find the background of Levo [here](https://github.com/levo-framework/core/BACKGROUND.md) 10 | 11 | ## What is Levo? 12 | Levo is a frontend framework that supports Server-Side Rendering (SSR) and The Elm Architecture (TEA) out of the box. 13 | 14 | ## What is Levo not good at? 15 | * API server, Levo is purely for serving web pages only. 16 | * Serving single page application (SPA) 17 | 18 | ## Goals of Levo 19 | * SEO friendly (based on [Google's Lighthouse](https://developers.google.com/web/tools/lighthouse) measurement) 20 | - don't serve pages that are blank initially 21 | * Scales naturally 22 | - compile time don't increase when project size increase 23 | * Promotes standardized coding style 24 | - All Levo projects should look similar 25 | * Backward compatible 26 | - After releasing v1.0.0, newer versions of Levo shouldn't break previous APIs 27 | * Strong type-safety 28 | - Prefer compile-time error over run-time error 29 | * Promote usage of browser native APIs 30 | - don't include abstractions that can already be achieved easily with native APIs 31 | - for example, 32 | - don't create abstraction over network request, just use `fetch` 33 | - don't abstract CSS into JS 34 | 35 | ## Features that are supported out of the box (a.k.a no setup required) 36 | * Hot reload (~2 seconds) 37 | * Type-safe JSX Template 38 | * Gzip/brotli compression 39 | * Security headers (based on [Helmet](https://helmetjs.github.io/)) 40 | * Javascript/CSS minification 41 | 42 | * Directory-based routing 43 | * Wildcard directory-based routing (for handling path params) 44 | * Asset serving (with MIME types) 45 | * Typechecking for HTML tags, attributes, events and style 46 | * Page memory caching 47 | * CLI tool for generating boilerplates 48 | * Virtual DOM diffing 49 | * Action logging at browser (inspired by [Redux Logger](https://github.com/LogRocket/redux-logger)) 50 | * Robots.txt 51 | 52 | ## Features that are NOT supported 53 | * Authentication and authorization 54 | * Database connection 55 | * Component with private states 56 | * Client-side routing 57 | 58 | ## Guidelines 59 | It's important to keep the following rules in mind in order to for Levo to performs best. 60 | * A lot of thin pages is better than a few fat pages 61 | * Avoid storing all dependencies into one file (usually `deps.ts`) 62 | * This is because firstly, Deno do not support tree-shaking yet, so unused dependencies will also be bundled 63 | * Secondly, a lot of compile time will be wasted by compiling unused dependencies 64 | * Component should never have private state, all state should be stored in one true global source 65 | * Routing is based on directory, so name them carefully 66 | * Never rename files or folder that starts with `_`, for example `_client.ts` 67 | * Never import server-specific code in `_client.ts` 68 | * Never import browser-specific code in `_server.ts` 69 | * Always create new pages using the CLI tool `levo` 70 | * Always write CSS in `index.css` instead of `view.ts` whenever possible 71 | 72 | # Tutorial 73 | ## Setup 74 | First of all, make you sure you installed Deno by following the [instruction here](https://deno.land/#installation). 75 | Secondly, make sure you are using Deno `v1.2.0`. To do this, run the following command: 76 | ``` 77 | deno upgrade --version=1.2.0 78 | ``` 79 | 80 | ## IDE 81 | It's highly recommended that you use VSCode, and install the the official Deno extension at https://marketplace.visualstudio.com/items?itemName=denoland.vscode-deno. 82 | 83 | 84 | ## Installation 85 | Run the following command to install `levo` CLI to get started. 86 | ``` 87 | deno install --allow-all --unstable --force --name levo https://raw.githubusercontent.com/levo-framework/core/master/cli/mod.ts 88 | ``` 89 | 90 | ## Getting started 91 | The first step to get start with Levo is to create a new project using the Levo CLI. 92 | ``` 93 | levo new-project my-levo-app 94 | ``` 95 | Then, to run the server: 96 | ``` 97 | cd my-levo-app 98 | ./tools/start-development.sh 99 | ``` 100 | Finally, visit `http://localhost:5000` to see your first Levo page! 101 | 102 | ## How to add a new Levo page? 103 | You can add a new page by using `levo new-page ` command. 104 | Note that this command must be ran at the project root. Also make sure you remember to specify the root directory (which is `root` by default). 105 | For example: 106 | ``` 107 | levo new-page root/about/terms-and-condition 108 | ``` 109 | 110 | ## What does each Levo page consist of? 111 | Each Levo page consists of the following files/folder: 112 | ``` 113 | _assets 114 | _server.ts 115 | _client.ts 116 | types.ts 117 | update.ts 118 | view.tsx 119 | ``` 120 | - `_assets` 121 | - This folder host assets such as CSS stylesheets, images etc, which can be imported by `view.tsx` 122 | 123 | - `_server.ts` 124 | - this file is the script that will be executed by the server when client requested a URL that correlates to the directory name (relative to root folder) of this page. 125 | - the purpose of this file is to query initial data that is required to render the page 126 | - this file can be also used to return a redirection or a customized response (e.g. json, txt etc) 127 | - `_client.ts` 128 | - this file is the entry file that will be executed by the browser 129 | - it is used to run initial setup at browser 130 | - for example, setting up socket event listener, initializing auth library etc. 131 | - this file is used to specify how the model should be updated give an action 132 | - `view.tsx` 133 | - this file is used to: 134 | - specify how the page should be rendered based on the model provided in `model.ts` 135 | - specify the `Model` type that will be used to render dynamic content on the page 136 | - specify the actions that can be executed by client on the page 137 | - note that the action type must be a [discriminated union](https://www.typescriptlang.org/docs/handbook/advanced-types.html#discriminated-unions) where the discriminated/tag must be named as `$`. 138 | 139 | 140 | ## Which file/folders should not be renamed? 141 | You can rename every file/folder in a Levo as long as it's not prefixed with `_`. 142 | For example, you shouldn't rename `_server.ts` and `_client.ts` etc. 143 | 144 | ## Do I need to restart the server when I add or modify some pages? 145 | No, provided that you are running Levo server in development mode (which is the default settings). This is because every page will be re-compiled on upon file changes. 146 | 147 | ## Routing 148 | Routing is purely based on directory structure, in other words you don't have to manually maintain a routing file. 149 | There are two types of routing in Levo: 150 | - exact path routing 151 | - wildcard path routing 152 | 153 | ### Exact path routing 154 | For example, suppose you have the following directory structure where `root` is specified as the site root: 155 | ``` 156 | root/ 157 | about/ 158 | policy/ 159 | ``` 160 | If client requested a page at `/root/about/policy`, the the `_server.ts` under the `policy` directory will be executed. 161 | 162 | ## Wildcard path routing 163 | This feature is useful when you want to design dynamic path. 164 | For example, if you want to setup a path like this `/[user]/profile`, then you should create a new page under the `_/profile` directory, as follows: 165 | ``` 166 | root/ 167 | _/ 168 | profile/ 169 | _server.ts 170 | ``` 171 | When client request for `/john/profile` or `/bob/profile`, the `_server.ts` script under `_/profile` will be executed. 172 | 173 | ### Exact path VS Wildcard path 174 | Note that exact path routing has a higher precedence than wildcard path routing. 175 | For example, if you have the following directory structure: 176 | ``` 177 | root/ 178 | admin/ 179 | profile/ 180 | _server.ts 181 | _/ 182 | profile/ 183 | _server.ts 184 | ``` 185 | If client request for `/admin/profile`, the `_server.ts` under `/admin/profile` will be executed. 186 | Otherwise if client request for `/X/profile` where `X` is any string other than `admin`, the `_server.ts` under `_/profile` will be executed. 187 | 188 | ### How do I change the site root to other folder? 189 | You can modified this at `app.ts` by chaging the `rootDir` value to the value you want. 190 | 191 | --- 192 | ## How to author a view in Levo? 193 | Levo uses JSX for templating. Basically, its very similar to React except event handlers must be created using `dispatch`: 194 | ```tsx 195 |
196 | 199 |
200 | ``` 201 | 202 | ## Drawback of JSX 203 | One of the biggest drawback of using JSX is that the type of children cannot be constrained properly at the time of writing. 204 | ```jsx 205 |
206 | {{x: 2}} 207 |
208 | ``` 209 | 210 | ## How to set environment variables? 211 | Unlike webpack etc, Levo don't magically replace specified names with environment variables value, instead, you have to do it explicitly at: 212 | - `app.ts` 213 | - `environment.ts` 214 | 215 | In `environment.ts`, you need to declare the environment object type, for example: 216 | ```ts 217 | export type Environment = { 218 | AUTH_SERVER_URL: string 219 | } 220 | ``` 221 | Then, in `app.ts` you have to specify the value depending on the shell arguments: 222 | ```ts 223 | const production = Deno.args.includes("--production"); 224 | LevoApp.start({ 225 | environment: { 226 | AUTH_SERVER_URL: 227 | production 228 | ? "https://wateracid/x/prod" 229 | : "https://wateracid/x/dev", 230 | }, 231 | // ...other options 232 | }) 233 | ``` 234 | The environment variables will be injected to `_server.ts` of each routes. Environment variables are not injected to client-side code for security reasons, thus to pass the values to browser, you can add a field in the `Model` type that should correspond to a property of the environment variable object. 235 | 236 | 237 | ## How do I run in Levo in production mode? 238 | ``` 239 | ./tools/start-production.sh 240 | ``` 241 | When Levo server is started in production mode, the server will generate every bundle for every Levo pages under the root directory. 242 | 243 | 244 | ## How to log action at browser? 245 | Run the server with the `./tools/start-development.sh` flag, or customise the `loggingOptions` object in `app.ts`. 246 | 247 | --- 248 | 249 | ## Advance topics 250 | ### How to create component with private state? 251 | It's possible to emulate component with private state despite the fact that Levo only supports one true global state for each page. However, creating an component with private state require some boilerplates. Therefore, before you consider creating a component like this, maybe try to make it a separate page instead. 252 | 253 | Basically, to create a private-state component, you have to define it's own: 254 | - model 255 | - action 256 | - update 257 | - view 258 | 259 | Then, to use the private-state component, the top-level model should has a property that hold the model type of the private-state component. 260 | Also, to pass `dispatch` to the private-state component from the parent component, you need to use the utility function `Levo.mapDispatch`, basically this function convert the top-level dispatch to the dispatch that is usable by the private-state component. 261 | 262 | To fully understand how to make this work, you can look at `./demo/home/about/view.tsx` and `./demo/home/about/counter.tsx`. In this case, `view.tsx` is the parent component, while `counter.tsx` is the child component with private state. 263 | 264 | ## Development 265 | ### How to re-bundle Levo runtime? 266 | ``` 267 | deno bundle --config levo-runtime.tsconfig.json src/levo-runtime.ts > levo-runtime.bundle.js 268 | ``` 269 | -------------------------------------------------------------------------------- /cli/mod.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "https://deno.land/std@0.53.0/flags/mod.ts"; 2 | import { exists, copy, path } from "../src/deps.ts"; 3 | import { runCommand } from "../src/run-command.ts"; 4 | 5 | const help = ` 6 | Levo CLI is a tool for generating boilerplates for Levo projects. 7 | 8 | Available commands: 9 | 10 | new-project --source= --version= 11 | - Creates a new Levo project under the directory 12 | - can be either commit hash or tag 13 | default value is the latest tag 14 | - defines where to clone the Levo repository 15 | default value is "https://github.com/levo-framework/core" 16 | 17 | new-page 18 | - Creates a new Levo page under the directory 19 | `; 20 | 21 | const main = async (): Promise => { 22 | const args = parse(Deno.args); 23 | if (Deno.args.length === 0 || args.help) { 24 | console.log(help); 25 | } else if (args._?.[0] === "new-project") { 26 | const projectName = args._?.[1]?.toString(); 27 | if (!projectName) { 28 | console.error(`Missing argument `); 29 | return Deno.exit(1); 30 | } else { 31 | const tempName = `tmp_${Date.now()}`; 32 | await runCommand( 33 | `git clone ${args.source ?? 34 | "https://github.com/levo-framework/core"} ${tempName}`, 35 | ); 36 | 37 | Deno.chdir(tempName); 38 | const { output: tag } = args.version 39 | ? { output: args.version as string } 40 | : await runCommand( 41 | "git describe --tags --abbrev=0", 42 | ); 43 | await runCommand(`git checkout ${tag} --quiet`); 44 | Deno.chdir(".."); 45 | if (!tag) { 46 | console.error(`Failed to obtain latest version of Levo.`); 47 | return Deno.exit(1); 48 | } 49 | 50 | console.log(`Using Levo ${tag}`); 51 | 52 | const importMapPath = [ 53 | tempName, 54 | "templates", 55 | "new-project", 56 | "import_map.json", 57 | ].join(path.SEP); 58 | 59 | const oldImportMap: { 60 | "imports": Record; 61 | } = JSON.parse( 62 | new TextDecoder().decode( 63 | await Deno.readFile(importMapPath), 64 | ), 65 | ); 66 | 67 | const newImportMap = { 68 | imports: { 69 | ...oldImportMap.imports, 70 | "levo/": args.source 71 | ? `${args.source}/mod/` 72 | : `https://deno.land/x/levo@${tag}/mod/`, 73 | }, 74 | }; 75 | 76 | await Deno.writeFile( 77 | importMapPath, 78 | new TextEncoder().encode(JSON.stringify(newImportMap, null, 2)), 79 | ); 80 | 81 | await copy( 82 | [tempName, "templates", "new-project"].join(path.SEP), 83 | projectName.toString(), 84 | ); 85 | await copy( 86 | tempName + path.SEP + "templates", 87 | projectName + path.SEP + ".levo.templates", 88 | ); 89 | 90 | // Remove robots.txt from new-page templates 91 | await Deno.remove( 92 | projectName + path.SEP + ".levo.templates/new-project/root/robots.txt", 93 | { recursive: true }, 94 | ); 95 | 96 | await Deno.remove(tempName, { recursive: true }); 97 | console.log(`Levo app successfully created at ${projectName}`); 98 | console.log(`Run the following command to get started:\n`); 99 | console.log(` cd ${projectName}`); 100 | console.log(` ./tools/start-development.sh`); 101 | } 102 | } else if (args._?.[0] === "new-page") { 103 | const dirname = args._?.[1]?.toString(); 104 | if (!dirname) { 105 | console.error(`Missing argument `); 106 | } else if (!await exists(".levo.templates")) { 107 | console.error( 108 | 'Cannot find directory ".levo.templates", make sure you are running this command in the project root.', 109 | ); 110 | } else if ((await exists(dirname))) { 111 | console.error( 112 | `Cannot create a new page at "${dirname}" as it already exists.`, 113 | ); 114 | } else { 115 | console.log(`Creating a new page at ${dirname}`); 116 | await copy(".levo.templates/new-project/root", dirname); 117 | } 118 | } else { 119 | console.error(`Unknown command '${Deno.args.join(" ")}'`); 120 | console.log(`Type 'levo --help' for more information.`); 121 | } 122 | }; 123 | 124 | if (import.meta.main) { 125 | await main(); 126 | } 127 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # levo 2 | -------------------------------------------------------------------------------- /demo/app.ts: -------------------------------------------------------------------------------- 1 | import { LevoApp } from "../mod/levo-app.ts"; 2 | import { Environment } from "./environment.ts"; 3 | import { compression, helmet } from "../mod/levo-middlewares.ts"; 4 | 5 | const production = Deno.args.includes("--production"); 6 | 7 | LevoApp.start({ 8 | serverOptions: { 9 | port: 3000, 10 | hostname: "0.0.0.0", 11 | }, 12 | environment: { 13 | VALUE_A: production ? "PROD_ENV" : "DEV_ENV", 14 | }, 15 | minifyCss: production, 16 | cacheDirectoryTree: production, 17 | hotReload: !production, 18 | rootDir: new URL("./home", import.meta.url), 19 | loggingOptions: production ? undefined : { 20 | action: true, 21 | patches: true, 22 | model: true, 23 | }, 24 | processRequestMiddlewares: [ 25 | (req) => console.log(new Date(), `${req.method} ${req.url}`), 26 | ], 27 | processResponseMiddlewares: [ 28 | compression, 29 | helmet, 30 | ], 31 | }); 32 | -------------------------------------------------------------------------------- /demo/environment.ts: -------------------------------------------------------------------------------- 1 | export type Environment = { 2 | VALUE_A: string; 3 | }; 4 | -------------------------------------------------------------------------------- /demo/home/_assets/index.css: -------------------------------------------------------------------------------- 1 | button { background-color: bisque; font-size: 24px; } 2 | .class1 { font-size: large; } 3 | .class2 { font-size: small; } -------------------------------------------------------------------------------- /demo/home/_client.ts: -------------------------------------------------------------------------------- 1 | import { Model, Action } from "./types.ts"; 2 | import { view } from "./view.tsx"; 3 | import { update } from "./update.ts"; 4 | import { Levo } from "../../mod/levo-view.ts"; 5 | 6 | const init: Levo.Init = ({ dispatch }) => { 7 | const intervalId = setInterval(() => { 8 | dispatch({ $: "add" }); 9 | }, 1000); 10 | dispatch({ $: "set_interval_id", intervalId }); 11 | }; 12 | 13 | Levo.register({ init, view, update }); 14 | -------------------------------------------------------------------------------- /demo/home/_server.ts: -------------------------------------------------------------------------------- 1 | import { view } from "./view.tsx"; 2 | import { serve } from "./../../mod/levo-serve.ts"; 3 | import { Model, Action } from "./types.ts"; 4 | import { Environment } from "../environment.ts"; 5 | 6 | export default serve({ 7 | getResponse: async (request, respond) => { 8 | return respond.page({ 9 | view, 10 | model: { 11 | currentValue: request.url.length + 99, 12 | intervalId: undefined, 13 | text: new URLSearchParams(request.search).get("title") ?? "", 14 | }, 15 | }); 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /demo/home/about/_client.ts: -------------------------------------------------------------------------------- 1 | import { Model, Action } from "./types.ts"; 2 | import { view } from "./view.tsx"; 3 | import { update } from "./update.ts"; 4 | import { Levo } from "../../../mod/levo-view.ts"; 5 | 6 | export const init: Levo.Init = () => { 7 | }; 8 | 9 | Levo.register({ init, view, update }); 10 | -------------------------------------------------------------------------------- /demo/home/about/_server.ts: -------------------------------------------------------------------------------- 1 | import { view } from "./view.tsx"; 2 | import { Model, Action } from "./types.ts"; 3 | import { serve } from "./../../../mod/levo-serve.ts"; 4 | import { Counter } from "./counter.tsx"; 5 | import { Environment } from "../../environment.ts"; 6 | 7 | export default serve({ 8 | getResponse: async (request, response) => { 9 | return response.page({ 10 | view, 11 | model: { 12 | randomNumber: Math.random(), 13 | counterModel: Counter.initialModel(), 14 | }, 15 | }); 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /demo/home/about/counter.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx h */ 2 | import { Levo, h } from "../../../mod/levo-view.ts"; 3 | 4 | export namespace Counter { 5 | export type Model = { 6 | count: number; 7 | }; 8 | 9 | export type Action = { $: "minus" } | { $: "add" }; 10 | export const initialModel = (): Model => { 11 | return { 12 | count: 0, 13 | }; 14 | }; 15 | 16 | export const update: Levo.Update = ( 17 | { model, action }, 18 | ) => { 19 | switch (action.$) { 20 | case "add": { 21 | return { 22 | newModel: { 23 | ...model, 24 | count: model.count + 1, 25 | }, 26 | }; 27 | } 28 | 29 | case "minus": { 30 | return { 31 | newModel: { 32 | ...model, 33 | count: model.count - 1, 34 | }, 35 | }; 36 | } 37 | } 38 | }; 39 | 40 | export const View = (props: { 41 | model: Model; 42 | dispatch: Levo.Dispatch; 43 | }): Levo.Element => { 44 | return ( 45 |
46 | 47 |
{props.model.count}
48 | 49 |
50 | ); 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /demo/home/about/types.ts: -------------------------------------------------------------------------------- 1 | import { Counter } from "./counter.tsx"; 2 | 3 | export type Model = { 4 | randomNumber: number; 5 | counterModel: Counter.Model; 6 | }; 7 | 8 | export type Action = 9 | | { $: "counter_action"; action: Counter.Action } 10 | | { $: "update_random_number" }; 11 | -------------------------------------------------------------------------------- /demo/home/about/update.ts: -------------------------------------------------------------------------------- 1 | import { Model, Action } from "./types.ts"; 2 | import { Counter } from "./counter.tsx"; 3 | import { Levo } from "../../../mod/levo-view.ts"; 4 | 5 | export const update: Levo.Update = ( 6 | { model, action, event }, 7 | ) => { 8 | switch (action.$) { 9 | case "update_random_number": { 10 | return { 11 | newModel: { 12 | ...model, 13 | randomNumber: Math.random(), 14 | }, 15 | }; 16 | } 17 | case "counter_action": { 18 | const { newModel: counterState, then } = Counter.update({ 19 | model: model.counterModel, 20 | action: action.action, 21 | event, 22 | }); 23 | return { 24 | newModel: { 25 | ...model, 26 | counterModel: counterState, 27 | }, 28 | then: then 29 | ? () => 30 | then().then((action) => ({ $: "counter_action" as const, action })) 31 | : undefined, 32 | }; 33 | } 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /demo/home/about/view.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx h */ 2 | import { Levo, h } from "../../../mod/levo-view.ts"; 3 | import { Model, Action } from "./types.ts"; 4 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 5 | import { Counter } from "./counter.tsx"; 6 | 7 | export const view = ( 8 | props: { model: Model; dispatch: Levo.Dispatch }, 9 | ): Levo.Element => { 10 | const { model, dispatch } = props; 11 | return ( 12 | 13 | 14 | {model.randomNumber} 15 | 18 | dispatch({ $: "counter_action", action })} 21 | /> 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /demo/home/api/yo/_server.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "./../../../../mod/levo-serve.ts"; 2 | 3 | export default serve({ 4 | getResponse: async (request, respond) => { 5 | return respond.custom({ 6 | status: 201, 7 | headers: { 8 | "custom-lol": "ha", 9 | }, 10 | body: JSON.stringify({ search: request.search }), 11 | }); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /demo/home/banana/_client.ts: -------------------------------------------------------------------------------- 1 | import { Model, Action } from "./types.ts"; 2 | import { view } from "./view.tsx"; 3 | import { update } from "./update.ts"; 4 | import { Levo } from "../../../mod/levo-view.ts"; 5 | 6 | export const init: Levo.Init = () => { 7 | }; 8 | 9 | Levo.register({ init, view, update }); 10 | -------------------------------------------------------------------------------- /demo/home/banana/_server.ts: -------------------------------------------------------------------------------- 1 | import { view } from "./view.tsx"; 2 | import { Model, Action } from "./types.ts"; 3 | import { serve } from "./../../../mod/levo-serve.ts"; 4 | import { Environment } from "../../environment.ts"; 5 | 6 | export default serve({ 7 | getResponse: async (request, respond) => { 8 | const params = new URLSearchParams(request.search); 9 | const redirect = params.get("redirect"); 10 | if (redirect !== null) { 11 | return respond.redirect({ url: redirect }); 12 | } 13 | return respond.page({ 14 | view, 15 | model: { 16 | word: "i am banana", 17 | word2: request.environment.VALUE_A + `(set from server)`, 18 | }, 19 | }); 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /demo/home/banana/types.ts: -------------------------------------------------------------------------------- 1 | export type Model = { 2 | word: string; 3 | word2: string; 4 | }; 5 | 6 | export type Action = { $: "" }; 7 | -------------------------------------------------------------------------------- /demo/home/banana/update.ts: -------------------------------------------------------------------------------- 1 | import { Model, Action } from "./types.ts"; 2 | import { Levo } from "../../../mod/levo-view.ts"; 3 | 4 | export const update: Levo.Update = ( 5 | { model }, 6 | ) => { 7 | return { newModel: model }; 8 | }; 9 | -------------------------------------------------------------------------------- /demo/home/banana/view.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx h */ 2 | import { Levo, h } from "../../../mod/levo-view.ts"; 3 | import { Model, Action } from "./types.ts"; 4 | 5 | export const view = ( 6 | props: { model: Model; dispatch: Levo.Dispatch }, 7 | ): Levo.Element => { 8 | const { model } = props; 9 | return ( 10 | 11 | 12 |
13 | {model.word} 14 |
15 |
16 | Word2: {model.word2} 17 |
18 |
19 | hello world 20 |
21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /demo/home/types.ts: -------------------------------------------------------------------------------- 1 | export type Model = { 2 | currentValue: number; 3 | intervalId: number | undefined; 4 | text: string; 5 | }; 6 | 7 | export type Action = 8 | | { $: "add" } 9 | | { $: "minus" } 10 | | { $: "set_interval_id"; intervalId: number } 11 | | { $: "stop_interval" } 12 | | { $: "fetch" } 13 | | { $: "text_fetched"; text: string }; 14 | -------------------------------------------------------------------------------- /demo/home/update.ts: -------------------------------------------------------------------------------- 1 | import { Model, Action } from "./types.ts"; 2 | import { Levo } from "../../mod/levo-view.ts"; 3 | 4 | export const update: Levo.Update = ( 5 | { model, action }, 6 | ) => { 7 | switch (action.$) { 8 | case "add": { 9 | return { 10 | newModel: { 11 | ...model, 12 | currentValue: model.currentValue + 1, 13 | }, 14 | }; 15 | } 16 | case "minus": { 17 | return { 18 | newModel: { 19 | ...model, 20 | currentValue: model.currentValue - 1, 21 | }, 22 | }; 23 | } 24 | case "fetch": { 25 | return { 26 | newModel: { 27 | ...model, 28 | text: "Loading", 29 | }, 30 | then: () => 31 | fetch( 32 | "https://raw.githubusercontent.com/denoland/deno/master/Cargo.toml", 33 | ) 34 | .then((res) => res.text()) 35 | .then((text) => ({ $: "text_fetched", text })), 36 | }; 37 | } 38 | case "text_fetched": { 39 | return { 40 | newModel: { 41 | ...model, 42 | text: action.text, 43 | }, 44 | }; 45 | } 46 | case "set_interval_id": { 47 | return { 48 | newModel: { 49 | ...model, 50 | intervalId: action.intervalId, 51 | }, 52 | }; 53 | } 54 | case "stop_interval": { 55 | if (model.intervalId) { 56 | clearInterval(model.intervalId); 57 | } 58 | return { 59 | newModel: { 60 | ...model, 61 | intervalId: undefined, 62 | }, 63 | }; 64 | } 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /demo/home/user/_/_client.ts: -------------------------------------------------------------------------------- 1 | import { Model, Action } from "./types.ts"; 2 | import { view } from "./view.tsx"; 3 | import { update } from "./update.ts"; 4 | import { Levo } from "../../../../mod/levo-view.ts"; 5 | 6 | export const init: Levo.Init = () => { 7 | }; 8 | 9 | Levo.register({ init, view, update }); 10 | -------------------------------------------------------------------------------- /demo/home/user/_/_server.ts: -------------------------------------------------------------------------------- 1 | import { view } from "./view.tsx"; 2 | import { Model, Action } from "./types.ts"; 3 | import { serve } from "./../../../../mod/levo-serve.ts"; 4 | import { Environment } from "../../../environment.ts"; 5 | 6 | export default serve({ 7 | getResponse: async (request, respond) => { 8 | return respond.page({ 9 | view, 10 | model: { 11 | name: request.url.split("/").slice(-1)[0], 12 | }, 13 | }); 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /demo/home/user/_/types.ts: -------------------------------------------------------------------------------- 1 | export type Model = { 2 | name: string; 3 | }; 4 | 5 | export type Action = { $: "say hello" }; 6 | -------------------------------------------------------------------------------- /demo/home/user/_/update.ts: -------------------------------------------------------------------------------- 1 | import { Model, Action } from "./types.ts"; 2 | import { Levo } from "../../../../mod/levo-view.ts"; 3 | 4 | export const update: Levo.Update = ( 5 | { model }, 6 | ) => { 7 | return { newModel: model }; 8 | }; 9 | -------------------------------------------------------------------------------- /demo/home/user/_/view.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx h */ 2 | import { Levo, h } from "../../../../mod/levo-view.ts"; 3 | import { Model, Action } from "./types.ts"; 4 | 5 | export const view = (props: { 6 | model: Model; 7 | dispatch: Levo.Dispatch; 8 | }): Levo.Element => { 9 | const { model } = props; 10 | return ( 11 | 12 | 13 | I am {model.name} 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /demo/home/user/xxx/_client.ts: -------------------------------------------------------------------------------- 1 | import { Model, Action } from "./types.ts"; 2 | import { view } from "./view.tsx"; 3 | import { update } from "./update.ts"; 4 | import { Levo } from "../../../../mod/levo-view.ts"; 5 | 6 | export const init: Levo.Init = () => { 7 | }; 8 | 9 | Levo.register({ init, view, update }); 10 | -------------------------------------------------------------------------------- /demo/home/user/xxx/_server.ts: -------------------------------------------------------------------------------- 1 | import { view } from "./view.tsx"; 2 | import { serve } from "./../../../../mod/levo-serve.ts"; 3 | import { Model, Action } from "./types.ts"; 4 | import { Environment } from "../../../environment.ts"; 5 | 6 | export default serve({ 7 | getResponse: async (request, respond) => { 8 | return respond.page({ 9 | view, 10 | model: {}, 11 | }); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /demo/home/user/xxx/types.ts: -------------------------------------------------------------------------------- 1 | export type Model = { 2 | something?: string; 3 | }; 4 | 5 | export type Action = { $: "say hello" }; 6 | -------------------------------------------------------------------------------- /demo/home/user/xxx/update.ts: -------------------------------------------------------------------------------- 1 | import { Model, Action } from "./types.ts"; 2 | import { Levo } from "../../../../mod/levo-view.ts"; 3 | 4 | export const update: Levo.Update = ( 5 | { model, action }, 6 | ) => { 7 | if (action.$ === "say hello") { 8 | alert("hello"); 9 | } 10 | return { newModel: model }; 11 | }; 12 | -------------------------------------------------------------------------------- /demo/home/user/xxx/view.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx h */ 2 | import { Levo, h } from "../../../../mod/levo-view.ts"; 3 | 4 | export const view = (): Levo.Element => { 5 | return ( 6 | 7 | 8 | I am xxx specifically 9 | 10 | 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /demo/home/view.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx h */ 2 | import { Levo, h } from "./../../mod/levo-view.ts"; 3 | import { Model, Action } from "./types.ts"; 4 | 5 | export const view = ( 6 | props: { model: Model; dispatch: Levo.Dispatch }, 7 | ): Levo.Element => { 8 | const { model, dispatch } = props; 9 | const items = [{ content: "spongebob" }, { content: "squarepants" }]; 10 | const isEven = model.currentValue % 2 === 0; 11 | return ( 12 | 13 | 14 | 15 | 16 |
17 | {model.currentValue} 18 |
19 | 26 | 33 | 36 | 39 | 45 | Fetched text 46 |
47 | {model.text} 48 |
49 |
50 | 55 | 56 |
57 | {isEven &&
Hello
} 58 | {items.map((item) =>
{item.content}
)} 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /levo-runtime-raw.ts: -------------------------------------------------------------------------------- 1 | export const levoRuntimeCode = 2 | `"use strict";function ownKeys(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,n)}return r}function _objectSpread(e){for(var t=1;t=0||Object.prototype.propertyIsEnumerable.call(e,r)&&(o[r]=e[r])}return o}function _objectWithoutPropertiesLoose(e,t){if(null==e)return{};var r,n,o={},i=Object.keys(e);for(n=0;n=0||(o[r]=e[r]);return o}function _typeof(e){return(_typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function _getRequireWildcardCache(){if("function"!=typeof WeakMap)return null;var e=new WeakMap;return _getRequireWildcardCache=function(){return e},e}function _interopRequireWildcard(e){if(e&&e.__esModule)return e;if(null===e||"object"!==_typeof(e)&&"function"!=typeof e)return{default:e};var t=_getRequireWildcardCache();if(t&&t.has(e))return t.get(e);var r={},n=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var o in e)if(Object.prototype.hasOwnProperty.call(e,o)){var i=n?Object.getOwnPropertyDescriptor(e,o):null;i&&(i.get||i.set)?Object.defineProperty(r,o,i):r[o]=e[o]}return r.default=e,t&&t.set(e,r),r}function _toConsumableArray(e){return _arrayWithoutHoles(e)||_iterableToArray(e)||_unsupportedIterableToArray(e)||_nonIterableSpread()}function _nonIterableSpread(){throw new TypeError("Invalid attempt to spread non-iterable instance. In order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _arrayWithoutHoles(e){if(Array.isArray(e))return _arrayLikeToArray(e)}function _toArray(e){return _arrayWithHoles(e)||_iterableToArray(e)||_unsupportedIterableToArray(e)||_nonIterableRest()}function _iterableToArray(e){if("undefined"!=typeof Symbol&&Symbol.iterator in Object(e))return Array.from(e)}function _createForOfIteratorHelper(e,t){var r;if("undefined"==typeof Symbol||null==e[Symbol.iterator]){if(Array.isArray(e)||(r=_unsupportedIterableToArray(e))||t&&e&&"number"==typeof e.length){r&&(e=r);var n=0,o=function(){};return{s:o,n:function(){return n>=e.length?{done:!0}:{done:!1,value:e[n++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance. In order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,a=!0,u=!1;return{s:function(){r=e[Symbol.iterator]()},n:function(){var e=r.next();return a=e.done,e},e:function(e){u=!0,i=e},f:function(){try{a||null==r.return||r.return()}finally{if(u)throw i}}}}function _slicedToArray(e,t){return _arrayWithHoles(e)||_iterableToArrayLimit(e,t)||_unsupportedIterableToArray(e,t)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance. In order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(e,t){if(e){if("string"==typeof e)return _arrayLikeToArray(e,t);var r=Object.prototype.toString.call(e).slice(8,-1);return"Object"===r&&e.constructor&&(r=e.constructor.name),"Map"===r||"Set"===r?Array.from(e):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?_arrayLikeToArray(e,t):void 0}}function _arrayLikeToArray(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=new Array(t);r-1)null===(s=t.originalNode.children)||void 0===s||s.splice(d,1);return e;case"replace_node":var f,p=r.mount({virtualNode:t.updatedVirtualNode}),v=p.virtualNode,y=p.node;if(null===(f=t.originalNode.ref.parentElement)||void 0===f||f.replaceChild(y,t.originalNode.ref),t.parentVirtualNode){if(t.parentVirtualNode.children){var b,m=null===(b=t.parentVirtualNode.children)||void 0===b?void 0:b.findIndex((function(e){return e.ref===t.originalNode.ref}));return t.parentVirtualNode.children[m]=v,e}return e}return v;case"update_attribute":var h,g;if("string"==typeof t.value)if(t.attributeName.startsWith("data-"))null===(h=(g=t.originalNode.ref).setAttribute)||void 0===h||h.call(g,t.attributeName,t.value);else"class"===t.attributeName?t.originalNode.ref.className=t.value:t.originalNode.ref[t.attributeName]=t.value;else n.setEventHandler({element:t.originalNode.ref,eventName:t.attributeName,action:t.value});return t.originalNode[t.attributeName]=t.value,e;case"remove_attribute":var _,w;if(t.attributeName.startsWith("data-"))null===(_=(w=t.originalNode.ref).removeAttribute)||void 0===_||_.call(w,t.attributeName);else"class"===t.attributeName?t.originalNode.ref.className="":t.originalNode.ref[t.attributeName]="";return delete t.originalNode[t.attributeName],e;case"update_style_attribute":return t.originalNode.ref.style[t.attributeName]=t.value,t.originalNode.style||(t.originalNode.style={}),t.originalNode.style[t.attributeName]=t.value,e;case"remove_style_attribute":return t.originalNode.ref.style[t.attributeName]="",t.originalNode.style&&delete t.originalNode.style[t.attributeName],e;default:return e}}),o)}))}}})),System.register("src/css-types",[],(function(e,t){t&&t.id;return{setters:[],execute:function(){}}})),System.register("src/virtual-node-events",[],(function(e,t){t&&t.id;return{setters:[],execute:function(){}}})),System.register("src/lispy-elements",[],(function(e,t){t&&t.id;return{setters:[],execute:function(){}}})),System.register("mod/levo-view",[],(function(e,t){var r;t&&t.id;return{setters:[],execute:function(){!function(e){e.register=function(e){var t=e.init,r=e.view,n=e.update;if(void 0!==("undefined"==typeof window?"undefined":_typeof(window)))try{window.$levo={init:t,view:r,update:n}}catch(e){console.error(e)}}}(r||(r={})),e("Levo",r),e("h",(function(e,t){for(var r=arguments.length,n=new Array(r>2?r-2:0),o=2;o = { 2 | $: "page"; 3 | model: Model; 4 | html: string; 5 | } | { 6 | $: "redirect"; 7 | url: string; 8 | } | { 9 | $: "custom"; 10 | response: CustomResponse; 11 | }; 12 | 13 | export type CustomResponse = { 14 | status?: number; 15 | headers?: Record; 16 | body?: string; 17 | }; 18 | -------------------------------------------------------------------------------- /mod/levo-serve.ts: -------------------------------------------------------------------------------- 1 | import { renderToString } from "../src/render-to-string.ts"; 2 | import { CustomResponse, LevoServeResponse } from "./levo-serve-response.ts"; 3 | import { Levo, createDispatch } from "./levo-view.ts"; 4 | 5 | export type LevoServe = ( 6 | request: LevoServeRequest, 7 | ) => Promise>; 8 | 9 | export type LevoServeRequest = { 10 | url: string; 11 | body: Uint8Array; 12 | contentLength: number | null; 13 | method: string; 14 | proto: string; 15 | protoMajor: number; 16 | protoMinor: number; 17 | headers: Headers; 18 | search: string; 19 | environment: Environment; 20 | }; 21 | 22 | export type Responder = { 23 | page: ( 24 | args: { 25 | model: Model; 26 | view: ( 27 | props: { model: Model; dispatch: Levo.Dispatch }, 28 | ) => Levo.Element; 29 | }, 30 | ) => LevoServeResponse; 31 | redirect: (args: { url: string }) => LevoServeResponse; 32 | custom: (response: CustomResponse) => LevoServeResponse; 33 | }; 34 | 35 | export const serve = ({ 36 | getResponse, 37 | }: { 38 | getResponse: ( 39 | request: LevoServeRequest, 40 | respond: Responder, 41 | ) => Promise>; 42 | }): LevoServe => { 43 | return async (request) => { 44 | const response = await getResponse(request, { 45 | page: ({ model, view }) => { 46 | const html = renderToString( 47 | (view({ model, dispatch: createDispatch() })), 48 | ); 49 | return { $: "page", model, html }; 50 | }, 51 | redirect: ({ url }) => ({ $: "redirect", url }), 52 | custom: (response) => { 53 | return { $: "custom", response }; 54 | }, 55 | }); 56 | return response; 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /mod/levo-view.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { LispyElements } from "../src/lispy-elements.ts"; 3 | import { Properties } from "../src/css-types.ts"; 4 | import { VirtualNodeEvents } from "../src/virtual-node-events.ts"; 5 | import { VirtualNode } from "../src/virtual-node.ts"; 6 | 7 | export namespace Levo { 8 | export type EventHandler = string & { _: "ev" }; // branded type 9 | export type Element = VirtualNode; 10 | export type CSSProperties = Properties; 11 | export type Events = VirtualNodeEvents; 12 | export type Dispatch = ( 13 | action: Action, 14 | ) => EventHandler; 15 | 16 | export type Init = (args: { 17 | model: Model; 18 | dispatch: (action: Action) => void; 19 | }) => void; 20 | 21 | export type Update = (args: { 22 | model: Model; 23 | action: Action; 24 | event?: unknown; 25 | }) => { 26 | newModel: Model; 27 | then?: () => Promise; 28 | }; 29 | 30 | export const register = ({ 31 | init, 32 | view, 33 | update, 34 | }: { 35 | init: Levo.Init; 36 | view: ( 37 | args: { model: Model; dispatch: Levo.Dispatch }, 38 | ) => Levo.Element; 39 | update: Levo.Update; 40 | }): void => { 41 | //@ts-ignore 42 | if (typeof window !== undefined) { 43 | // This is to prevent Deno from throwing error when some Worker tried to execute 44 | // this code, because `window` object does not exists in Worker scope 45 | try { 46 | //@ts-ignore 47 | window.$levo = { init, view, update }; 48 | } catch (error) { 49 | console.error(error); 50 | } 51 | } 52 | }; 53 | } 54 | 55 | export const h = ( 56 | // eslint-disable-next-line @typescript-eslint/ban-types 57 | tag: string | Function, 58 | props: Record, 59 | ...children: any[] 60 | ): Levo.Element => { 61 | if (typeof tag === "function") { 62 | return tag({ ...props, children: children }); 63 | } else { 64 | return { 65 | $: tag, 66 | ...props, 67 | children: children?.filter((x) => 68 | ["string", "number", "object"].includes(typeof x) 69 | ) 70 | .map(( 71 | x, 72 | ) => 73 | ["string", "number"].includes(typeof x) 74 | ? { $: "_text", value: x } 75 | : x 76 | ).flat(), 77 | } as any; 78 | } 79 | }; 80 | 81 | export const createDispatch = (): Levo.Dispatch< 82 | Action 83 | > => { 84 | return (x) => x as any; 85 | }; 86 | 87 | declare global { 88 | namespace JSX { 89 | type IntrinsicElements = { 90 | [P in Tag]: Props

; 91 | }; 92 | 93 | type Element = Levo.Element; 94 | 95 | // type ElementChildrenAttribute = { children?: any[]; } 96 | } 97 | } 98 | 99 | type Tag = LispyElements[0]; 100 | type Props = Extract< 101 | LispyElements, 102 | { 0: T } 103 | >[1]; 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "levo-framework", 3 | "version": "1.0.0", 4 | "description": "![](https://github.com/levo-framework/core/workflows/CI/badge.svg)", 5 | "main": "index.js", 6 | "directories": { 7 | "doc": "docs", 8 | "lib": "lib", 9 | "test": "test" 10 | }, 11 | "dependencies": { 12 | "babel-preset-env": "^1.7.0", 13 | "typescript": "^3.9.6", 14 | "typescript-deno-plugin": "^1.30.0" 15 | }, 16 | "devDependencies": { 17 | "@typescript-eslint/eslint-plugin": "^3.6.0", 18 | "@typescript-eslint/parser": "^3.6.0", 19 | "eslint": "^7.4.0" 20 | }, 21 | "scripts": { 22 | "test": "echo \"Error: no test specified\" && exit 1", 23 | "eslint": "eslint ." 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/levo-framework/core.git" 28 | }, 29 | "author": "", 30 | "license": "ISC", 31 | "bugs": { 32 | "url": "https://github.com/levo-framework/core/issues" 33 | }, 34 | "homepage": "https://github.com/levo-framework/core#readme" 35 | } 36 | -------------------------------------------------------------------------------- /src/apply-patches.ts: -------------------------------------------------------------------------------- 1 | import { Patch, MountedVirtualNode } from "./patch.ts"; 2 | import { mount } from "./mount.ts"; 3 | import { setEventHandler } from "./set-event-handler.ts"; 4 | 5 | /** 6 | * This function will mutate DOM and mountedVirtualNode 7 | */ 8 | export const applyPatches = ({ 9 | patches, 10 | mountedVirtualNode, 11 | }: { 12 | patches: Patch[]; 13 | mountedVirtualNode: MountedVirtualNode; 14 | }): MountedVirtualNode => { 15 | return patches.reduce((updatedMountedVirtualNode, patch) => { 16 | switch (patch.type) { 17 | case "add_node": { 18 | const { virtualNode, node } = mount({ virtualNode: patch.virtualNode }); 19 | patch.originalNode.ref.appendChild(node); 20 | patch.originalNode.children?.push(virtualNode); 21 | return updatedMountedVirtualNode; 22 | } 23 | case "remove_node": { 24 | patch.originalNode.ref.removeChild(patch.nodeToBeRemoved); 25 | const index = patch.originalNode.children 26 | ?.findIndex((child) => child.ref === patch.nodeToBeRemoved) ?? 0; 27 | if (index > -1) { 28 | patch.originalNode.children?.splice(index, 1); 29 | } 30 | return updatedMountedVirtualNode; 31 | } 32 | case "replace_node": { 33 | const { virtualNode, node } = mount( 34 | { virtualNode: patch.updatedVirtualNode }, 35 | ); 36 | patch.originalNode.ref.parentElement?.replaceChild( 37 | node, 38 | patch.originalNode.ref, 39 | ); 40 | if (!patch.parentVirtualNode) { 41 | return virtualNode; 42 | } else if (patch.parentVirtualNode.children) { 43 | const index = patch.parentVirtualNode.children 44 | ?.findIndex((child) => child.ref === patch.originalNode.ref); 45 | patch.parentVirtualNode.children[index] = virtualNode; 46 | return updatedMountedVirtualNode; 47 | } else { 48 | return updatedMountedVirtualNode; 49 | } 50 | } 51 | case "update_attribute": { 52 | if (typeof patch.value === "string") { 53 | if (patch.attributeName.startsWith("data-")) { 54 | (patch.originalNode.ref as HTMLElement).setAttribute?.( 55 | patch.attributeName, 56 | patch.value, 57 | ); 58 | } else if (patch.attributeName === "class") { 59 | (patch.originalNode.ref as any).className = patch.value; 60 | } else { 61 | (patch.originalNode.ref as any)[patch.attributeName] = patch.value; 62 | } 63 | } else { // must be event update 64 | setEventHandler( 65 | { 66 | element: ((patch.originalNode.ref as HTMLElement)), 67 | eventName: patch.attributeName, 68 | action: patch.value, 69 | }, 70 | ); 71 | } 72 | (patch.originalNode as any)[patch.attributeName] = patch.value; 73 | return updatedMountedVirtualNode; 74 | } 75 | case "remove_attribute": { 76 | if (patch.attributeName.startsWith("data-")) { 77 | (patch.originalNode.ref as HTMLElement).removeAttribute?.( 78 | patch.attributeName, 79 | ); 80 | } else if (patch.attributeName === "class") { 81 | // Cannot set className to undefined, as the result will be `class="undefined"` 82 | // Refer: https://stackoverflow.com/a/30299762/6587634 83 | (patch.originalNode.ref as any).className = ""; 84 | } else { 85 | (patch.originalNode.ref as any)[patch.attributeName] = ""; 86 | } 87 | delete (patch.originalNode as any)[patch.attributeName]; 88 | return updatedMountedVirtualNode; 89 | } 90 | case "update_style_attribute": { 91 | ((patch.originalNode.ref as HTMLElement).style as any)[ 92 | patch.attributeName 93 | ] = patch.value; 94 | if (!patch.originalNode.style) { 95 | patch.originalNode.style = {}; 96 | } 97 | patch.originalNode.style[patch.attributeName] = patch.value; 98 | return updatedMountedVirtualNode; 99 | } 100 | case "remove_style_attribute": { 101 | ((patch.originalNode.ref as HTMLElement).style as any)[ 102 | patch.attributeName 103 | ] = ""; 104 | if (patch.originalNode.style) { 105 | delete patch.originalNode.style[patch.attributeName]; 106 | } 107 | return updatedMountedVirtualNode; 108 | } 109 | default: 110 | return updatedMountedVirtualNode; 111 | } 112 | }, mountedVirtualNode); 113 | }; 114 | -------------------------------------------------------------------------------- /src/array-diff.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Return elements that are present in `left` but not in `right` 3 | */ 4 | export const arrayDiff = ( 5 | left: T[], 6 | right: T[], 7 | ): T[] => { 8 | const cache: Partial> = {}; 9 | const rightLength = right.length; 10 | for (let i = 0; i < rightLength; i++) { 11 | cache[right[i]] = true; 12 | } 13 | 14 | const diff: T[] = []; 15 | 16 | const leftLength = left.length; 17 | for (let i = 0; i < leftLength; i++) { 18 | const y = left[i]; 19 | if (!cache[y]) { 20 | diff.push(y); 21 | } 22 | } 23 | return diff; 24 | }; 25 | -------------------------------------------------------------------------------- /src/camel-to-kebab.ts: -------------------------------------------------------------------------------- 1 | export const camelToKebab = (s: string): string => 2 | s.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, "$1-$2").toLowerCase(); 3 | -------------------------------------------------------------------------------- /src/compression.ts: -------------------------------------------------------------------------------- 1 | import { ProcessResponseMiddleware } from "./middleware.ts"; 2 | import { gzipEncode } from "https://github.com/manyuanrong/wasm_gzip/raw/53d036/mod.ts"; 3 | import { compress as brotliCompress } from "https://deno.land/x/brotli@v0.1.3/mod.ts"; 4 | 5 | export const compression: ProcessResponseMiddleware = async ( 6 | request, 7 | response, 8 | ) => { 9 | const acceptEncoding = request.headers["accept-encoding"]; 10 | if (acceptEncoding?.includes("br")) { 11 | const compressedBody = brotliCompress(response.body); 12 | return { 13 | ...response, 14 | headers: { 15 | ...response.headers, 16 | "content-encoding": "br", 17 | "levo-content-encoding": "br", 18 | "content-length": compressedBody.length.toString(), 19 | }, 20 | body: compressedBody, 21 | }; 22 | } else if (acceptEncoding?.includes("gzip")) { 23 | const compressedBody = gzipEncode(response.body); 24 | return { 25 | ...response, 26 | headers: { 27 | ...response.headers, 28 | "content-encoding": "gzip", 29 | "levo-content-encoding": "gzip", 30 | "content-length": compressedBody.length.toString(), 31 | }, 32 | body: compressedBody, 33 | }; 34 | } else { 35 | return response; 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /src/compute-attributes-updates.ts: -------------------------------------------------------------------------------- 1 | import { arrayDiff } from "./array-diff.ts"; 2 | import { deepEqual } from "./deep-equal.ts"; 3 | 4 | export const computeAttributesUpdates = < 5 | RecordReturnType extends 6 | | boolean 7 | | string 8 | | number 9 | | Record 10 | | undefined, 11 | >({ 12 | originalAttrs, 13 | updatedAttrs, 14 | }: { 15 | originalAttrs: Record; 16 | updatedAttrs: Record; 17 | }): Array< 18 | | { 19 | type: "update_attribute"; 20 | attributeName: string; 21 | value: RecordReturnType; 22 | } 23 | | { 24 | type: "remove_attribute"; 25 | attributeName: string; 26 | } 27 | > => { 28 | const originalAttrsKeys = Object.keys(originalAttrs); 29 | const updatedAttrsKeys = Object.keys(updatedAttrs); 30 | const addedAttributes = arrayDiff(updatedAttrsKeys, originalAttrsKeys); 31 | return [ 32 | ...originalAttrsKeys.flatMap< 33 | { 34 | type: "update_attribute"; 35 | attributeName: string; 36 | value: RecordReturnType; 37 | } | { 38 | type: "remove_attribute"; 39 | attributeName: string; 40 | } 41 | >((key) => { 42 | const originalValue = originalAttrs[key]; 43 | const updatedValue = updatedAttrs[key]; 44 | if (updatedValue === undefined || updatedValue === false) { 45 | return [{ 46 | type: "remove_attribute", 47 | attributeName: key, 48 | }]; 49 | } else if (!deepEqual(originalValue, updatedValue)) { 50 | return [{ 51 | type: "update_attribute", 52 | attributeName: key, 53 | value: updatedValue, 54 | }]; 55 | } else { 56 | return []; 57 | } 58 | }), 59 | ...addedAttributes.flatMap((key) => { 60 | const value = updatedAttrs[key]; 61 | if (value) { 62 | return [{ 63 | type: "update_attribute" as const, 64 | attributeName: key, 65 | value, 66 | }]; 67 | } else { 68 | return []; 69 | } 70 | }), 71 | ]; 72 | }; 73 | -------------------------------------------------------------------------------- /src/deep-equal.ts: -------------------------------------------------------------------------------- 1 | // Modified from: https://raw.githubusercontent.com/epoberezkin/fast-deep-equal/master/src/index.jst 2 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 3 | // @ts-nocheck 4 | export function deepEqual(a: T, b: T): boolean { 5 | if (a === b) return true; 6 | 7 | if (a && b && typeof a === "object" && typeof b === "object") { 8 | if (a.constructor !== b.constructor) return false; 9 | 10 | let length, i; 11 | if (Array.isArray(a)) { 12 | length = a.length; 13 | if (length != b.length) return false; 14 | for (i = length; i-- !== 0;) { 15 | if (!deepEqual(a[i], b[i])) return false; 16 | } 17 | return true; 18 | } 19 | 20 | if ((a instanceof Map) && (b instanceof Map)) { 21 | if (a.size !== b.size) return false; 22 | for (i of a.entries()) { 23 | if (!b.has(i[0])) return false; 24 | } 25 | for (i of a.entries()) { 26 | if (!deepEqual(i[1], b.get(i[0]))) return false; 27 | } 28 | return true; 29 | } 30 | 31 | if ((a instanceof Set) && (b instanceof Set)) { 32 | if (a.size !== b.size) return false; 33 | for (i of a.entries()) { 34 | if (!b.has(i[0])) return false; 35 | } 36 | return true; 37 | } 38 | 39 | if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) { 40 | length = a.length; 41 | if (length != b.length) return false; 42 | for (i = length; i-- !== 0;) { 43 | if (a[i] !== b[i]) return false; 44 | } 45 | return true; 46 | } 47 | 48 | if (a.constructor === RegExp) { 49 | return a.source === b.source && a.flags === b.flags; 50 | } 51 | if ( 52 | a.valueOf !== Object.prototype.valueOf 53 | ) { 54 | return a.valueOf() === b.valueOf(); 55 | } 56 | if ( 57 | a.toString !== Object.prototype.toString 58 | ) { 59 | return a.toString() === b.toString(); 60 | } 61 | 62 | const keys = Object.keys(a); 63 | length = keys.length; 64 | if (length !== Object.keys(b).length) return false; 65 | 66 | for (i = length; i-- !== 0;) { 67 | if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false; 68 | } 69 | 70 | for (i = length; i-- !== 0;) { 71 | const key = keys[i]; 72 | if (!deepEqual(a[key], b[key])) return false; 73 | } 74 | 75 | return true; 76 | } 77 | 78 | // true if both NaN, false otherwise 79 | return a !== a && b !== b; 80 | } 81 | -------------------------------------------------------------------------------- /src/deps.ts: -------------------------------------------------------------------------------- 1 | export { 2 | assert, 3 | assertEquals, 4 | } from "https://deno.land/std@0.61.0/testing/asserts.ts"; 5 | 6 | export { 7 | exists, 8 | existsSync, 9 | copy, 10 | } from "https://deno.land/std@0.61.0/fs/mod.ts"; 11 | 12 | export * as server from "https://deno.land/std@0.61.0/http/mod.ts"; 13 | 14 | export * as path from "https://deno.land/std@0.61.0/path/mod.ts"; 15 | 16 | import _CleanCSS from "./clean-css.js"; 17 | const CleanCSS: { 18 | new (): { 19 | minify(input: string): { 20 | styles: string; 21 | warnings: string[]; 22 | errors: string[]; 23 | }; 24 | }; 25 | } = _CleanCSS as any; 26 | export { CleanCSS }; 27 | -------------------------------------------------------------------------------- /src/extract-attributes.ts: -------------------------------------------------------------------------------- 1 | import { VirtualNode } from "./virtual-node.ts"; 2 | import { MountedVirtualNode } from "./patch.ts"; 3 | 4 | export const extractAttributes = ( 5 | virtualNode: VirtualNode | MountedVirtualNode, 6 | ): Omit, "ref" | "style" | "events" | "$" | "children"> => { 7 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 8 | const { style, $, children, ...attributes } = virtualNode; 9 | if ("ref" in attributes) { 10 | delete attributes["ref"]; 11 | } 12 | return attributes; 13 | }; 14 | -------------------------------------------------------------------------------- /src/extract-dependencies.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Best effort method to extract dependencies from a Javascript/Typescript code using regular expressions. 3 | */ 4 | export const extractDependencies = (code: string): string[] => { 5 | return [ 6 | ...(code.match(/from\s*["].*["]/g) ?? []), 7 | ...(code.match(/from\s*['].*[']/g) ?? []), 8 | ] 9 | .map((line) => { 10 | const result = line.slice(4).trim(); 11 | return result.slice(1, result.length - 1); 12 | }) 13 | .filter(Boolean); 14 | }; 15 | -------------------------------------------------------------------------------- /src/get-directory-tree.ts: -------------------------------------------------------------------------------- 1 | import { DirectoryTree } from "./resolve-url.ts"; 2 | import { path } from "./deps.ts"; 3 | 4 | export const getDirectoryTree = (pathname: string, options: { 5 | ignoreFiles: string[]; 6 | }): DirectoryTree[] => { 7 | const dir = Array.from(Deno.readDirSync(pathname)) 8 | .sort((a, b) => a.name.localeCompare(b.name)); 9 | return dir.flatMap((dir) => { 10 | if (dir.isFile) { 11 | return options?.ignoreFiles?.includes(dir.name) ? [] : [[dir.name]]; 12 | } else { 13 | return [ 14 | [dir.name, getDirectoryTree(pathname + path.SEP + dir.name, options)], 15 | ]; 16 | } 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /src/get-local-dependencies.ts: -------------------------------------------------------------------------------- 1 | import { exists, path } from "./deps.ts"; 2 | import { extractDependencies } from "./extract-dependencies.ts"; 3 | import { resolveImportMap } from "./resolve-import-map.ts"; 4 | 5 | /** 6 | * Get the local dependencies of a Typescript file, not including remote dependencies 7 | * (e.g. files that are imported from https://deno.land). 8 | * 9 | * The resulting paths will be relative to the current working directory. 10 | */ 11 | export const getLocalDependencies = async ( 12 | { filename, importMap, truncateCommonPrefix }: { 13 | filename: string; 14 | importMap?: Record; 15 | 16 | /** 17 | * For testing purpose 18 | */ 19 | truncateCommonPrefix?: boolean; 20 | }, 21 | ): Promise => { 22 | const currentDirectory = Deno.cwd(); 23 | const dependencies = (await _getLocalDependencies( 24 | { filename, currentDirectory, rootDirectory: currentDirectory, importMap }, 25 | )) 26 | .map((line) => line.trim()) 27 | .filter(Boolean) 28 | .filter((line) => !line.startsWith("http")) 29 | .filter((x, i, xs) => i === xs.indexOf(x)); 30 | 31 | if (!truncateCommonPrefix) { 32 | return dependencies.sort(); 33 | } else { 34 | const commonPrefix = commonParentDirectory(dependencies); 35 | return dependencies.map((line) => line.slice(commonPrefix.length)).sort(); 36 | } 37 | }; 38 | 39 | const commonParentDirectory = (directories: string[]): string => { 40 | const [minRow, ...rows] = directories 41 | .map((dir) => dir.split(path.SEP)) 42 | .sort((a, b) => a.length - b.length); 43 | 44 | const result: string[] = []; 45 | for (let i = 0; i < (minRow.length ?? 0); i++) { 46 | if (rows.every((row) => row[i] === minRow[i])) { 47 | result.push(minRow[i]); 48 | } else { 49 | return result.join(path.SEP); 50 | } 51 | } 52 | return result.join(path.SEP); 53 | }; 54 | 55 | const _getLocalDependencies = async ( 56 | { filename, currentDirectory, rootDirectory, importMap }: { 57 | filename: string; 58 | currentDirectory: string; 59 | rootDirectory: string; 60 | importMap: Record | undefined; 61 | }, 62 | ): Promise => { 63 | const absolutePath = filename.startsWith(path.SEP) 64 | ? filename 65 | : filename.startsWith("." + path.SEP) || 66 | filename.startsWith(".." + path.SEP) 67 | ? currentDirectory + (currentDirectory.endsWith(path.SEP) ? "" : path.SEP) + 68 | filename 69 | : resolveImportMap(importMap ?? {}, filename); 70 | 71 | if (!absolutePath) { 72 | console.error( 73 | `(get-local-dependencies.ts) WARNING: Cannot resolve path for "${filename}"`, 74 | ); 75 | return []; 76 | } 77 | if (absolutePath.trim().startsWith("http")) { 78 | return []; 79 | } 80 | 81 | if (!(await exists(absolutePath))) { 82 | console.error( 83 | `(get-local-dependencies.ts) WARNING: Cannot find file "${absolutePath}"`, 84 | ); 85 | return []; 86 | } else { 87 | const content = new TextDecoder().decode(await Deno.readFile(absolutePath)); 88 | const realPath = await Deno.realPath(absolutePath); 89 | const parentDirectory = realPath.substring( 90 | 0, 91 | realPath.lastIndexOf(path.SEP) + 1, 92 | ); 93 | return Promise.all( 94 | extractDependencies(content).map((dependency) => { 95 | return _getLocalDependencies( 96 | { 97 | filename: dependency, 98 | currentDirectory: parentDirectory, 99 | rootDirectory, 100 | importMap, 101 | }, 102 | ); 103 | }), 104 | ) 105 | .then((result) => [ 106 | realPath, 107 | ...result.flat(), 108 | ]); 109 | } 110 | }; 111 | -------------------------------------------------------------------------------- /src/helmet.ts: -------------------------------------------------------------------------------- 1 | import { ProcessResponseMiddleware } from "./middleware.ts"; 2 | 3 | /** 4 | * Based on https://helmetjs.github.io/ 5 | */ 6 | export const helmet: ProcessResponseMiddleware = async (request, response) => { 7 | return { 8 | ...response, 9 | headers: { 10 | ...response.headers, 11 | "X-DNS-Prefetch-Control": "off", 12 | "X-Frame-Options": "SAMEORIGIN", 13 | "X-Download-Options": "noopen", 14 | "X-Content-Type-Options": "nosniff", 15 | "X-XSS-Protection": "1; mode=block", 16 | }, 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/levo-runtime.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { VirtualNode } from "./virtual-node.ts"; 3 | import { diff } from "./virtual-node-diff.ts"; 4 | import { mount } from "./mount.ts"; 5 | import { applyPatches } from "./apply-patches.ts"; 6 | import { createDispatch } from "../mod/levo-view.ts"; 7 | 8 | declare global { 9 | interface Window { 10 | $$h?: unknown; 11 | $levo?: { 12 | model?: unknown; 13 | view: unknown; 14 | update?: unknown; 15 | init?: unknown; 16 | log?: unknown; 17 | }; 18 | } 19 | } 20 | 21 | const start = ({ 22 | at, 23 | view, 24 | update, 25 | initialModel, 26 | onMount, 27 | log, 28 | }: { 29 | initialModel: Model; 30 | view: (model: Model) => VirtualNode; 31 | update: ( 32 | args: { model: Model; action: Action; event: Event | undefined }, 33 | ) => { 34 | newModel: Model; 35 | then?: () => Promise; 36 | }; 37 | onMount: (args: { model: Model; dispatch: (action: Action) => void }) => void; 38 | at: HTMLElement | Document | null; 39 | log?: { 40 | action?: boolean; 41 | patches?: boolean; 42 | model?: boolean; 43 | }; 44 | }) => { 45 | if (!at) { 46 | throw new Error("Root element is undefined"); 47 | } 48 | const mounted = mount({ 49 | virtualNode: view(initialModel), 50 | }); 51 | const node = mounted.node; 52 | let currentVirtualNode = mounted.virtualNode; 53 | let currentModel = initialModel; 54 | 55 | // Make root node child-less 56 | if (at.firstElementChild) { 57 | at.removeChild(at.firstElementChild); 58 | } 59 | at.appendChild(node); 60 | const handler = (action: Action | undefined) => { 61 | const justNow = window.performance.now(); 62 | const event = window.event; 63 | if (action) { 64 | const { newModel, then: promise } = update( 65 | { model: currentModel, action, event }, 66 | ); 67 | const newVirtualNode = view(newModel); 68 | 69 | const patches = diff({ 70 | original: currentVirtualNode, 71 | updated: newVirtualNode, 72 | parentVirtualNode: undefined, 73 | }); 74 | currentVirtualNode = applyPatches({ 75 | patches, 76 | mountedVirtualNode: currentVirtualNode, 77 | }); 78 | 79 | if (log?.action) { 80 | const timeTaken = (window.performance.now() - justNow).toFixed(2); 81 | const d = new Date(); 82 | const hours = d.getHours().toString().padStart(2, "0"); 83 | const minutes = d.getMinutes().toString().padStart(2, "0"); 84 | const seconds = d.getSeconds().toString().padStart(2, "0"); 85 | const currentTime = `${hours}:${minutes}:${seconds}`; 86 | console.groupCollapsed( 87 | `%caction %c${action.$} %c@ ${currentTime} (in ${timeTaken} ms)`, 88 | "font-weight: normal; color: grey", 89 | "font-weight: bold", 90 | "font-weight: normal; color: grey", 91 | ); 92 | log.model && 93 | console.log( 94 | `%cprev model`, 95 | "color: grey; font-weight: bold", 96 | currentModel, 97 | ); 98 | console.log(`%caction `, "color: blue; font-weight: bold", action); 99 | log.model && 100 | console.log( 101 | `%cnext model`, 102 | "color: green; font-weight: bold", 103 | newModel, 104 | ); 105 | log.patches && 106 | console.log( 107 | `%cpatches `, 108 | "color: pink; font-weight: bold", 109 | patches, 110 | ); 111 | console.groupEnd(); 112 | } 113 | // console.log("currentVirtualNode", currentVirtualNode) 114 | 115 | currentModel = newModel; 116 | promise?.().then(handler); 117 | } 118 | }; 119 | 120 | onMount({ model: currentModel, dispatch: handler }); 121 | 122 | window.$$h = handler as (action: Record | undefined) => void; 123 | }; 124 | 125 | if (!window.$levo?.view) { 126 | throw new Error( 127 | "You might have forgot to call Levo.registerView at levo.view.ts", 128 | ); 129 | } 130 | 131 | if (!window.$levo?.update) { 132 | throw new Error( 133 | "You might have forgot to call Levo.registerUpdater at levo.updater.ts", 134 | ); 135 | } 136 | 137 | start({ 138 | at: document, 139 | initialModel: window.$levo.model, 140 | view: ( 141 | model, 142 | ) => ((window.$levo?.view as any)({ model, dispatch: createDispatch() })), 143 | update: window.$levo.update as any, 144 | onMount: window.$levo.init as any, 145 | log: window.$levo?.log as any, 146 | }); 147 | -------------------------------------------------------------------------------- /src/levo-server.ts: -------------------------------------------------------------------------------- 1 | import { levoTsconfigRaw } from "./levo-tsconfig-raw.ts"; 2 | import { mimeLookup } from "./mime-lookup.ts"; 3 | import { server, path, exists, CleanCSS } from "./deps.ts"; 4 | import { levoRuntimeCode } from "../levo-runtime-raw.ts"; 5 | import { minifyJavascript } from "./minify-js.ts"; 6 | import { resolveUrl } from "./resolve-url.ts"; 7 | import { getDirectoryTree } from "./get-directory-tree.ts"; 8 | import { LevoServeResponse } from "../mod/levo-serve-response.ts"; 9 | import { LevoServe } from "../mod/levo-serve.ts"; 10 | import { regeneratorRuntimeCode } from "./regenerator-runtime-raw.ts"; 11 | import { MemoryCache } from "./memory-cache.ts"; 12 | import { 13 | MiddlewareResponse, 14 | MiddlewareRequest, 15 | ProcessRequestMiddleware, 16 | ProcessResponseMiddleware, 17 | } from "./middleware.ts"; 18 | import { watchDependencies } from "./watch-dependencies.ts"; 19 | 20 | export const LevoApp = { 21 | start: async ({ 22 | serverOptions, 23 | environment, 24 | minifyCss, 25 | cacheDirectoryTree, 26 | rootDir, 27 | loggingOptions, 28 | memoryCache: { 29 | maxNumberOfPages = 1024, 30 | } = {}, 31 | hotReload, 32 | processRequestMiddlewares, 33 | processResponseMiddlewares, 34 | importMapPath, 35 | }: { 36 | /** 37 | * Options for configuring server, for example hostname and port number. 38 | */ 39 | serverOptions: server.HTTPOptions; 40 | 41 | /** 42 | * Environment variables that will be passed to client and server files. 43 | */ 44 | environment: Environment; 45 | 46 | /** 47 | * Root directory for serving web pages. 48 | * For example, if you want to specify `./src/root`, 49 | * the value should be `new URL('./src/root', import.meta.url)` 50 | * 51 | */ 52 | rootDir: URL; 53 | 54 | /** 55 | * Path to import map that should be used when bundling client code. 56 | */ 57 | importMapPath?: URL; 58 | 59 | /** 60 | * Minify CSS files that will be served to client. 61 | * Should be set to true in production environment, while false in development. 62 | * Default value is false. 63 | */ 64 | minifyCss?: boolean; 65 | 66 | /** 67 | * Cache the directory tree to improve route searching performance. 68 | * Should be set to true in production environment, while false in development. 69 | * Default value is false. 70 | */ 71 | cacheDirectoryTree?: boolean; 72 | 73 | /** 74 | * Settings for memory cache 75 | */ 76 | memoryCache?: { 77 | /** 78 | * Max number of pages to be cached in memory. 79 | * Default value is 1024. 80 | */ 81 | maxNumberOfPages?: number; 82 | }; 83 | 84 | /** 85 | * Logging option at browser. 86 | */ 87 | loggingOptions?: { 88 | /** 89 | * Log dispatched action. 90 | */ 91 | action?: boolean; 92 | 93 | /** 94 | * Log resulting patches from the action 95 | */ 96 | patches?: boolean; 97 | 98 | /** 99 | * Log model that is updated before/after action dispatched 100 | */ 101 | model?: boolean; 102 | }; 103 | 104 | /** 105 | * Watch for file changes. Default value is false. 106 | * Should be set to true during development, but false in production. 107 | */ 108 | hotReload?: boolean; 109 | 110 | /** 111 | * List of middlewares that will be used to process request. 112 | */ 113 | processRequestMiddlewares: ProcessRequestMiddleware[]; 114 | 115 | /** 116 | * List of middlewares that will be used to process response. 117 | */ 118 | processResponseMiddlewares: ProcessResponseMiddleware[]; 119 | }): Promise => { 120 | const serverInstance = server.serve(serverOptions); 121 | const decoder = new TextDecoder("utf-8"); 122 | const encoder = new TextEncoder(); 123 | 124 | const clientPageCache = new MemoryCache( 125 | { maxNumberOfKeys: maxNumberOfPages }, 126 | ); 127 | 128 | const serverFunctionCache = new MemoryCache< 129 | Promise<{ 130 | default?: LevoServe; 131 | }> 132 | >({ 133 | maxNumberOfKeys: Number.POSITIVE_INFINITY, 134 | }); 135 | 136 | await Deno.writeFile("levo.tsconfig.json", encoder.encode(levoTsconfigRaw)); 137 | 138 | const importMap: Record | undefined = importMapPath 139 | ? await (async () => { 140 | try { 141 | const result = JSON.parse( 142 | new TextDecoder().decode( 143 | await Deno.readFile(importMapPath?.pathname), 144 | ), 145 | ); 146 | if (!("imports" in result)) { 147 | throw new Error(`Missing "imports" property`); 148 | } else if (typeof result.imports !== "object") { 149 | throw new Error(`"imports" should have type of object.`); 150 | } 151 | return result.imports; 152 | } catch (error) { 153 | console.error(`Error parsing import map: `, error); 154 | } 155 | })() 156 | : undefined; 157 | 158 | const bundle = async ({ 159 | filename, 160 | includeLevoTsconfig, 161 | minifyBundle, 162 | }: { 163 | filename: string; 164 | includeLevoTsconfig: boolean; 165 | minifyBundle: boolean; 166 | }) => { 167 | const now = Date.now(); 168 | const bundled = decoder.decode( 169 | await Deno.run({ 170 | cmd: [ 171 | "deno", 172 | "bundle", 173 | ...(includeLevoTsconfig 174 | ? [ 175 | "--config=levo.tsconfig.json", 176 | ] 177 | : []), 178 | ...(importMapPath 179 | ? ["--unstable", `--importmap=${importMapPath.pathname}`] 180 | : []), 181 | filename, 182 | ], 183 | stdout: "piped", 184 | }) 185 | .output(), 186 | ); 187 | console.log( 188 | `Finish bundle (${ 189 | ((Date.now() - now) / 1000).toFixed(2) 190 | }s): ${filename}`, 191 | ); 192 | 193 | if (!minifyBundle) { 194 | return bundled; 195 | } else { 196 | const { code: minified, error } = minifyJavascript( 197 | bundled.replace(/export const/gi, "const"), 198 | ); 199 | if (error) { 200 | console.error(`Failed to minify, using unminified code`, error); 201 | } 202 | return error ? bundled : minified; 203 | } 204 | }; 205 | 206 | const bundleClientCode = async (filename: string): Promise => { 207 | const cachePath = filename + ".cache"; 208 | const cache = clientPageCache.get(cachePath); 209 | if (cache || (!hotReload && await exists(cachePath))) { 210 | return cache ?? decoder.decode(await Deno.readFile(cachePath)); 211 | } 212 | const execute = async () => { 213 | const bundled = await bundle( 214 | { 215 | filename, 216 | includeLevoTsconfig: true, 217 | minifyBundle: true, 218 | }, 219 | ); 220 | clientPageCache.set(cachePath, bundled); 221 | await Deno.writeFile(cachePath, encoder.encode(bundled)); 222 | return bundled; 223 | }; 224 | if (hotReload) { 225 | watchDependencies( 226 | { 227 | filename, 228 | onChange: execute, 229 | importMap, 230 | }, 231 | ); 232 | } 233 | return execute(); 234 | }; 235 | 236 | const bundleServerCode = async (filename: string): Promise< 237 | { 238 | default?: LevoServe | undefined; 239 | } | undefined 240 | > => { 241 | const cache = serverFunctionCache.get(filename); 242 | if (cache) { 243 | return cache; 244 | } 245 | const execute = async () => { 246 | const bundled = await bundle( 247 | { filename, includeLevoTsconfig: false, minifyBundle: false }, 248 | ); 249 | const tempPath = filename + Date.now() + ".cache"; 250 | await Deno.writeFile( 251 | tempPath, 252 | new TextEncoder().encode(bundled), 253 | ); 254 | const imported = await import("file://" + tempPath); 255 | serverFunctionCache.set(filename, Promise.resolve(imported)); 256 | await Deno.remove(tempPath); 257 | return serverFunctionCache.get(filename); 258 | }; 259 | 260 | if (hotReload) { 261 | watchDependencies( 262 | { 263 | filename, 264 | onChange: execute, 265 | importMap, 266 | }, 267 | ); 268 | } 269 | return execute(); 270 | }; 271 | 272 | const scanDir = (dirname: string) => 273 | Array.from(Deno.readDirSync(dirname)).forEach((dir) => { 274 | if (dir.isDirectory) { 275 | scanDir(dirname + path.SEP + dir.name); 276 | } else if (dir.isFile && dir.name === "_client.ts") { 277 | const filename = dirname + path.SEP + dir.name; 278 | bundleClientCode(filename); 279 | } else if (dir.isFile && dir.name === "_server.ts") { 280 | const filename = dirname + path.SEP + dir.name; 281 | bundleServerCode(filename); 282 | } 283 | }); 284 | 285 | if (!hotReload) { 286 | scanDir(rootDir.pathname); 287 | } 288 | 289 | console.log( 290 | `Server listening on ${serverOptions.hostname ?? 291 | "0.0.0.0"}:${serverOptions.port}`, 292 | ); 293 | 294 | if (!(await exists(rootDir.pathname))) { 295 | throw new Error(`Root path '${rootDir.pathname}' does not exists.`); 296 | } 297 | const cachedDirectoryTree = getDirectoryTree( 298 | rootDir.pathname, 299 | { ignoreFiles: [] }, 300 | ); 301 | 302 | const toMiddlewareRequest = async ( 303 | request: server.ServerRequest, 304 | ): Promise => ({ 305 | proto: request.proto, 306 | protoMajor: request.protoMajor, 307 | protoMinor: request.protoMinor, 308 | url: request.url, 309 | method: request.method, 310 | headers: (() => { 311 | const result: Record = {}; 312 | request.headers.forEach((value, key) => 313 | result[key.toLowerCase()] = value 314 | ); 315 | return result; 316 | })(), 317 | body: await Deno.readAll(request.body), 318 | }); 319 | 320 | const processRequest = async ( 321 | request: server.ServerRequest, 322 | ): Promise => { 323 | const middlewareRequest = await toMiddlewareRequest(request); 324 | return processRequestMiddlewares.reduce( 325 | (promise, middleware) => 326 | promise.then(() => middleware(middlewareRequest)), 327 | Promise.resolve(), 328 | ); 329 | }; 330 | 331 | const processResponse = async ( 332 | request: server.ServerRequest, 333 | response: MiddlewareResponse, 334 | ): Promise => { 335 | const middlewareRequest = await toMiddlewareRequest(request); 336 | return processResponseMiddlewares.reduce( 337 | (promise, middleware) => 338 | promise.then((response) => middleware(middlewareRequest, response)), 339 | Promise.resolve(response), 340 | ) 341 | .then((response) => { 342 | return { 343 | status: response.status, 344 | headers: new Headers(response.headers), 345 | body: response.body, 346 | trailers: response.trailers, 347 | }; 348 | }); 349 | }; 350 | 351 | for await (const req of serverInstance) { 352 | try { 353 | await processRequest(req); 354 | const url = new URL("http://x/" + req.url); 355 | const resolvedUrl = resolveUrl( 356 | cacheDirectoryTree 357 | ? cachedDirectoryTree 358 | : getDirectoryTree(rootDir.pathname, { ignoreFiles: [] }), 359 | url.pathname, 360 | ); 361 | 362 | if (resolvedUrl === undefined) { 363 | console.error( 364 | new Date(), 365 | `${req.url} does not point to a directory, responding with 404`, 366 | ); 367 | await req.respond({ status: 404 }); 368 | continue; 369 | } 370 | 371 | const pathname = (rootDir.pathname.endsWith(path.SEP) 372 | ? rootDir.pathname 373 | : rootDir.pathname + path.SEP) + resolvedUrl; 374 | 375 | if (pathname.includes("_assets")) { 376 | if (!(await exists(pathname))) { 377 | await req.respond({ status: 404 }); 378 | continue; 379 | } 380 | const file = await Deno.readFile(pathname); 381 | const contentType = mimeLookup(pathname); 382 | const body = (() => { 383 | if (minifyCss && contentType === "text/css") { 384 | const result = new CleanCSS().minify( 385 | new TextDecoder().decode(file), 386 | ); 387 | if (result.warnings.length > 0) { 388 | result.warnings.forEach((warning): void => 389 | console.warn(warning) 390 | ); 391 | } 392 | if (result.errors.length > 0) { 393 | result.errors.forEach((error): void => 394 | console.error(error) 395 | ); 396 | } 397 | return new TextEncoder().encode(result.styles); 398 | } else { 399 | return file; 400 | } 401 | })(); 402 | await req.respond( 403 | await processResponse(req, { 404 | status: 200, 405 | body, 406 | headers: contentType 407 | ? { 408 | "content-type": contentType, 409 | } 410 | : {}, 411 | }), 412 | ); 413 | continue; 414 | } 415 | 416 | const dirname = pathname.endsWith(path.SEP) 417 | ? pathname 418 | : pathname + path.SEP; 419 | 420 | const handlerPath = new URL( 421 | `_server.ts`, 422 | `file://` + dirname, 423 | ); 424 | 425 | if (!(await exists(handlerPath.pathname))) { 426 | console.error( 427 | `_server.ts not found under at ${handlerPath.pathname}`, 428 | ); 429 | await req.respond({ status: 404 }); 430 | continue; 431 | } 432 | 433 | const handleRequest = await bundleServerCode(handlerPath.pathname); 434 | if (!handleRequest?.default) { 435 | throw new Error( 436 | `No default export found at "${handlerPath.pathname}"`, 437 | ); 438 | } 439 | const response: LevoServeResponse = await handleRequest 440 | ?.default?.({ 441 | url: req.url, 442 | body: await Deno.readAll(req.body), 443 | method: req.method, 444 | contentLength: req.contentLength, 445 | headers: req.headers, 446 | search: url.search, 447 | proto: req.proto, 448 | protoMajor: req.protoMajor, 449 | protoMinor: req.protoMinor, 450 | environment, 451 | }); 452 | 453 | switch (response.$) { 454 | case "redirect": { 455 | await req.respond({ 456 | body: encoder.encode( 457 | `` 458 | .trim(), 459 | ), 460 | }); 461 | break; 462 | } 463 | case "custom": { 464 | const headers = new Headers(); 465 | Object.entries(response.response.headers ?? {}).forEach( 466 | ([key, value]) => { 467 | headers.set(key, value); 468 | }, 469 | ); 470 | await req.respond({ 471 | headers, 472 | status: response.response.status, 473 | body: encoder.encode(response.response.body), 474 | }); 475 | break; 476 | } 477 | case "page": { 478 | const filename = dirname + "_client.ts"; 479 | const code = await bundleClientCode(filename); 480 | await req.respond( 481 | await processResponse(req, { 482 | status: 200, 483 | headers: { 484 | "content-type": "text/html", 485 | }, 486 | body: encoder.encode(` 487 | 488 | ${response.html} 489 | 496 | `.trim()), 497 | }), 498 | ); 499 | } 500 | } 501 | } catch (error) { 502 | // NOTE: Please dont use `req.respond` here 503 | // If not the loop will be broken 504 | console.error("Caught error: ", error); 505 | } 506 | } 507 | }, 508 | }; 509 | -------------------------------------------------------------------------------- /src/levo-tsconfig-raw.ts: -------------------------------------------------------------------------------- 1 | export const levoTsconfigRaw = ` 2 | { 3 | "compilerOptions": { 4 | "allowJs": false, 5 | "allowUnreachableCode": false, 6 | "allowUnusedLabels": false, 7 | "alwaysStrict": true, 8 | "checkJs": false, 9 | "disableSizeLimit": false, 10 | "jsx": "react", 11 | "jsxFactory": "React.createElement", 12 | "lib": ["dom", "DOM", "ES2016", "ES2017", "ES2018", "ES2019"], 13 | "noFallthroughCasesInSwitch": false, 14 | "noImplicitAny": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noImplicitUseStrict": false, 18 | "noStrictGenericChecks": false, 19 | "noUnusedLocals": false, 20 | "noUnusedParameters": false, 21 | "preserveConstEnums": false, 22 | "removeComments": false, 23 | "resolveJsonModule": true, 24 | "strict": true, 25 | "strictBindCallApply": true, 26 | "strictFunctionTypes": true, 27 | "strictNullChecks": true, 28 | "strictPropertyInitialization": true, 29 | "suppressExcessPropertyErrors": false, 30 | "suppressImplicitAnyIndexErrors": false, 31 | "useDefineForClassFields": false 32 | } 33 | } 34 | `; 35 | -------------------------------------------------------------------------------- /src/lispy-element-to-virtual-node.ts: -------------------------------------------------------------------------------- 1 | import { LispyElements } from "./lispy-elements.ts"; 2 | import { VirtualNode } from "./virtual-node.ts"; 3 | 4 | /** 5 | * @deprecated Currently not being used anywhere 6 | */ 7 | export const lispyElementToVirtualNode = ( 8 | node: LispyElements, 9 | ): VirtualNode => { 10 | return { 11 | $: node[0], 12 | ...node[1], 13 | children: node[2]?.filter(Boolean).map((x) => 14 | typeof x === "string" 15 | ? { $: "_text", value: x } 16 | : lispyElementToVirtualNode(x) 17 | ), 18 | } as any; 19 | }; 20 | -------------------------------------------------------------------------------- /src/memory-cache.ts: -------------------------------------------------------------------------------- 1 | export class MemoryCache { 2 | private cache: Record = {}; 3 | private maxNumberOfKeys: number; 4 | private retrievalFrequency: Record = 5 | {}; 6 | constructor({ maxNumberOfKeys }: { maxNumberOfKeys: number }) { 7 | this.maxNumberOfKeys = maxNumberOfKeys; 8 | } 9 | 10 | /** 11 | * For testing purposes only 12 | */ 13 | public _getCache(): Record { 14 | return this.cache; 15 | } 16 | 17 | public _getRetrievalFrequency(): Record { 18 | return this.retrievalFrequency; 19 | } 20 | 21 | public get(key: string): T | undefined { 22 | this.retrievalFrequency[key] = (this.retrievalFrequency[key] ?? 0) + 1; 23 | return this.cache[key]; 24 | } 25 | 26 | public set(key: string, value: T): void { 27 | if (Object.keys(this.cache).length >= this.maxNumberOfKeys) { 28 | // Replace the key with the least retrieval frequency 29 | const [leastRetrievedKey] = Object.keys(this.cache).reduce( 30 | ([minKey, minFrequency], key) => { 31 | const frequency = this.retrievalFrequency[key] ?? 0; 32 | if (frequency < minFrequency) { 33 | return [key, frequency]; 34 | } else { 35 | return [minKey, minFrequency]; 36 | } 37 | }, 38 | ["", Number.MAX_SAFE_INTEGER], 39 | ); 40 | if (leastRetrievedKey) { 41 | delete this.cache[leastRetrievedKey]; 42 | } 43 | this.cache[key] = value; 44 | } else { 45 | this.cache[key] = value; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | export type MiddlewareRequest = { 2 | url: string; 3 | method: string; 4 | proto: string; 5 | protoMinor: number; 6 | protoMajor: number; 7 | body: Uint8Array; 8 | headers: Record; 9 | }; 10 | 11 | export type MiddlewareResponse = { 12 | status: number; 13 | headers: Record; 14 | body: Uint8Array; 15 | trailers?: () => Promise | Headers; 16 | }; 17 | export type ProcessRequestMiddleware = ( 18 | request: MiddlewareRequest, 19 | ) => void; 20 | 21 | export type ProcessResponseMiddleware = ( 22 | request: MiddlewareRequest, 23 | response: MiddlewareResponse, 24 | ) => Promise; 25 | -------------------------------------------------------------------------------- /src/mime-lookup.ts: -------------------------------------------------------------------------------- 1 | import { mimeDB } from "./mime-db.ts"; 2 | 3 | export const mimeLookup = (fileExtension: string): string | undefined => { 4 | return Object.entries(mimeDB).find(([, value]) => { 5 | const extensions = (value as any).extensions ?? []; 6 | return extensions.some((extension: string) => 7 | fileExtension.endsWith(extension) 8 | ); 9 | })?.[0]; 10 | }; 11 | -------------------------------------------------------------------------------- /src/minify-js.ts: -------------------------------------------------------------------------------- 1 | import Terser from "./terser.js"; 2 | import Babel from "./babelstandalone.js"; 3 | 4 | export const minifyJavascript = (code: string): { 5 | code: string; 6 | error?: string; 7 | } => { 8 | const babelled = Babel.transform( 9 | code, 10 | { presets: [["env"]] }, 11 | ); 12 | if (babelled.error) { 13 | return babelled.error; 14 | } 15 | return Terser.minify(babelled.code); 16 | }; 17 | -------------------------------------------------------------------------------- /src/mount.ts: -------------------------------------------------------------------------------- 1 | import { VirtualNode } from "./virtual-node.ts"; 2 | import { MountedVirtualNode } from "./patch.ts"; 3 | import { extractAttributes } from "./extract-attributes.ts"; 4 | import { setEventHandler } from "./set-event-handler.ts"; 5 | 6 | export const mount = ({ virtualNode }: { 7 | virtualNode: VirtualNode; 8 | }): { 9 | node: Node; 10 | virtualNode: MountedVirtualNode; 11 | } => { 12 | if (virtualNode.$ === "_text") { 13 | const node = document.createTextNode(virtualNode.value as string); 14 | return { node, virtualNode: { ...virtualNode, ref: node } }; 15 | } 16 | const node = document.createElement(virtualNode.$); 17 | 18 | const attributes = extractAttributes(virtualNode); 19 | Object.entries(attributes).map(([key, value]) => { 20 | if (value) { 21 | if (typeof value === "string") { 22 | node.setAttribute(key, value as string); 23 | } else { 24 | setEventHandler({ element: node, eventName: key, action: value }); 25 | } 26 | } 27 | }); 28 | Object.entries(virtualNode.style ?? {}).forEach(([key, value]) => { 29 | if (value) { 30 | node.style[key as any] = value; 31 | } 32 | }); 33 | const updatedVirtualNode = { 34 | ...virtualNode, 35 | children: virtualNode.children?.map((childVirtualNode) => { 36 | const { node: childNode, virtualNode } = mount( 37 | { virtualNode: childVirtualNode }, 38 | ); 39 | node.appendChild(childNode); // side-effect 40 | return virtualNode; 41 | }), 42 | ref: node, 43 | }; 44 | return { node, virtualNode: updatedVirtualNode }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/patch.ts: -------------------------------------------------------------------------------- 1 | import { VirtualNode } from "./virtual-node.ts"; 2 | 3 | export type MountedVirtualNode = 4 | & Omit, "ref" | "children"> 5 | & { 6 | ref: Node; 7 | children?: MountedVirtualNode[]; 8 | }; 9 | export type Patch = 10 | & { 11 | originalNode: MountedVirtualNode; 12 | } 13 | & ( 14 | | { 15 | type: "replace_node"; 16 | updatedVirtualNode: VirtualNode; 17 | parentVirtualNode: MountedVirtualNode | undefined; 18 | } 19 | | { 20 | type: "add_node"; 21 | virtualNode: VirtualNode; 22 | } 23 | | { 24 | type: "remove_node"; 25 | nodeToBeRemoved: Node; 26 | } 27 | | { 28 | type: "update_attribute"; 29 | attributeName: string; 30 | value: string | Action | undefined; 31 | } 32 | | { 33 | type: "remove_attribute"; 34 | attributeName: string; 35 | } 36 | | { 37 | type: "update_style_attribute"; 38 | attributeName: string; 39 | value: string | undefined; 40 | } 41 | | { 42 | type: "remove_style_attribute"; 43 | attributeName: string; 44 | } 45 | ); 46 | -------------------------------------------------------------------------------- /src/regenerator-runtime-raw.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2014-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | /** 9 | * Copied from https://unpkg.com/regenerator-runtime@0.13.1/runtime.js. 10 | * Minified using https://xem.github.io/terser-online/ 11 | */ 12 | export const regeneratorRuntimeCode = 13 | `!function(t){"use strict";var r,e=Object.prototype,n=e.hasOwnProperty,o="function"==typeof Symbol?Symbol:{},i=o.iterator||"@@iterator",a=o.asyncIterator||"@@asyncIterator",c=o.toStringTag||"@@toStringTag";function u(t,r,e,n){var o=r&&r.prototype instanceof v?r:v,i=Object.create(o.prototype),a=new k(n||[]);return i._invoke=function(t,r,e){var n=f;return function(o,i){if(n===l)throw new Error("Generator is already running");if(n===p){if("throw"===o)throw i;return N()}for(e.method=o,e.arg=i;;){var a=e.delegate;if(a){var c=_(a,e);if(c){if(c===y)continue;return c}}if("next"===e.method)e.sent=e._sent=e.arg;else if("throw"===e.method){if(n===f)throw n=p,e.arg;e.dispatchException(e.arg)}else"return"===e.method&&e.abrupt("return",e.arg);n=l;var u=h(t,r,e);if("normal"===u.type){if(n=e.done?p:s,u.arg===y)continue;return{value:u.arg,done:e.done}}"throw"===u.type&&(n=p,e.method="throw",e.arg=u.arg)}}}(t,e,a),i}function h(t,r,e){try{return{type:"normal",arg:t.call(r,e)}}catch(t){return{type:"throw",arg:t}}}t.wrap=u;var f="suspendedStart",s="suspendedYield",l="executing",p="completed",y={};function v(){}function d(){}function g(){}var m={};m[i]=function(){return this};var w=Object.getPrototypeOf,L=w&&w(w(G([])));L&&L!==e&&n.call(L,i)&&(m=L);var x=g.prototype=v.prototype=Object.create(m);function E(t){["next","throw","return"].forEach(function(r){t[r]=function(t){return this._invoke(r,t)}})}function b(t){var r;this._invoke=function(e,o){function i(){return new Promise(function(r,i){!function r(e,o,i,a){var c=h(t[e],t,o);if("throw"!==c.type){var u=c.arg,f=u.value;return f&&"object"==typeof f&&n.call(f,"__await")?Promise.resolve(f.__await).then(function(t){r("next",t,i,a)},function(t){r("throw",t,i,a)}):Promise.resolve(f).then(function(t){u.value=t,i(u)},function(t){return r("throw",t,i,a)})}a(c.arg)}(e,o,r,i)})}return r=r?r.then(i,i):i()}}function _(t,e){var n=t.iterator[e.method];if(n===r){if(e.delegate=null,"throw"===e.method){if(t.iterator.return&&(e.method="return",e.arg=r,_(t,e),"throw"===e.method))return y;e.method="throw",e.arg=new TypeError("The iterator does not provide a 'throw' method")}return y}var o=h(n,t.iterator,e.arg);if("throw"===o.type)return e.method="throw",e.arg=o.arg,e.delegate=null,y;var i=o.arg;return i?i.done?(e[t.resultName]=i.value,e.next=t.nextLoc,"return"!==e.method&&(e.method="next",e.arg=r),e.delegate=null,y):i:(e.method="throw",e.arg=new TypeError("iterator result is not an object"),e.delegate=null,y)}function j(t){var r={tryLoc:t[0]};1 in t&&(r.catchLoc=t[1]),2 in t&&(r.finallyLoc=t[2],r.afterLoc=t[3]),this.tryEntries.push(r)}function O(t){var r=t.completion||{};r.type="normal",delete r.arg,t.completion=r}function k(t){this.tryEntries=[{tryLoc:"root"}],t.forEach(j,this),this.reset(!0)}function G(t){if(t){var e=t[i];if(e)return e.call(t);if("function"==typeof t.next)return t;if(!isNaN(t.length)){var o=-1,a=function e(){for(;++o=0;--i){var a=this.tryEntries[i],c=a.completion;if("root"===a.tryLoc)return o("end");if(a.tryLoc<=this.prev){var u=n.call(a,"catchLoc"),h=n.call(a,"finallyLoc");if(u&&h){if(this.prev=0;--e){var o=this.tryEntries[e];if(o.tryLoc<=this.prev&&n.call(o,"finallyLoc")&&this.prev=0;--r){var e=this.tryEntries[r];if(e.finallyLoc===t)return this.complete(e.completion,e.afterLoc),O(e),y}},catch:function(t){for(var r=this.tryEntries.length-1;r>=0;--r){var e=this.tryEntries[r];if(e.tryLoc===t){var n=e.completion;if("throw"===n.type){var o=n.arg;O(e)}return o}}throw new Error("illegal catch attempt")},delegateYield:function(t,e,n){return this.delegate={iterator:G(t),resultName:e,nextLoc:n},"next"===this.method&&(this.arg=r),y}}}("object"==typeof module?module.exports:{})`; 14 | -------------------------------------------------------------------------------- /src/render-to-string.ts: -------------------------------------------------------------------------------- 1 | import { camelToKebab } from "./camel-to-kebab.ts"; 2 | import { VirtualNode } from "./virtual-node.ts"; 3 | 4 | export const renderToString = ( 5 | virtualNode: VirtualNode, 6 | ): string => { 7 | switch (virtualNode.$) { 8 | case "_text": 9 | return virtualNode.value; 10 | 11 | default: { 12 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 13 | const { $, ref, children, style, ...attributes } = virtualNode; 14 | const tag = virtualNode.$; 15 | const childrenString = children?.map(renderToString).join("") || ""; 16 | const attributesAndEventString = (() => { 17 | const s = Object.entries(attributes ?? {}).map(([key, value]) => { 18 | if (value === undefined) { 19 | return ""; 20 | } else if (typeof value === "string") { 21 | return `${key}='${value}'`; 22 | } else { // is event 23 | return ""; 24 | } 25 | }).join(" "); 26 | return s ? ` ${s}` : ""; 27 | })(); 28 | 29 | const styleString = (() => { 30 | const s = Object.entries(style ?? {}).map(([key, value]) => { 31 | return `${camelToKebab(key)}:${value}`; 32 | }).join(";"); 33 | return s ? ` style='${s}'` : ""; 34 | })(); 35 | 36 | return `<${tag}${attributesAndEventString}${styleString}>${childrenString}`; 37 | } 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /src/replace-virtual-node.ts: -------------------------------------------------------------------------------- 1 | import { MountedVirtualNode } from "./virtual-node.ts"; 2 | 3 | export const replaceVirtualNode = ({ 4 | mountedVirtualNode, 5 | at: ref, 6 | replaceWith, 7 | }: { 8 | mountedVirtualNode: MountedVirtualNode; 9 | at: Node; 10 | replaceWith: MountedVirtualNode; 11 | }): MountedVirtualNode => { 12 | if (mountedVirtualNode.ref === ref) { 13 | return replaceWith; 14 | } else { 15 | return { 16 | ...mountedVirtualNode, 17 | children: mountedVirtualNode.children?.reduce<{ 18 | updated: boolean; 19 | children: MountedVirtualNode[]; 20 | }>(({ updated, children }, child) => { 21 | if (updated) { 22 | return { 23 | children: [...children, child], 24 | updated, 25 | }; 26 | } else { 27 | const updatedChild = replaceVirtualNode({ 28 | mountedVirtualNode: child, 29 | at: ref, 30 | replaceWith, 31 | }); 32 | return { 33 | children: [...children, updatedChild], 34 | updated: updatedChild === replaceWith, 35 | }; 36 | } 37 | }, { updated: false, children: [] }).children, 38 | }; 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/resolve-import-map.ts: -------------------------------------------------------------------------------- 1 | export const resolveImportMap = ( 2 | importMap: Record, 3 | path: string, 4 | ): string | undefined => { 5 | const keys = Object.keys(importMap); 6 | const matchingKey = keys.find((key) => path.startsWith(key)); 7 | if (!matchingKey) { 8 | return undefined; 9 | } else { 10 | return importMap[matchingKey] + path.slice(matchingKey.length); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/resolve-url.ts: -------------------------------------------------------------------------------- 1 | export const resolveUrl = ( 2 | tree: DirectoryTree[], 3 | url: string, 4 | ): string | undefined => { 5 | const tokens = url.split("/").filter((x) => x.length > 0); 6 | const result = tokens.map((token, index): string | undefined => { 7 | const paths = nthGeneration({ tree, generation: index }); 8 | return paths.find((path) => path === token) ?? 9 | paths.find((path) => path === "_"); 10 | }); 11 | return result.some((x) => !x) ? undefined : result.join("/"); 12 | }; 13 | 14 | const nthGeneration = ({ 15 | tree, 16 | generation, 17 | }: { 18 | tree: DirectoryTree[]; 19 | generation: number; 20 | }): string[] => { 21 | if (generation === 0) { 22 | return tree.map(([path]) => path); 23 | } else { 24 | return nthGeneration({ 25 | tree: tree.flatMap(([, children]) => children ?? []), 26 | generation: generation - 1, 27 | }); 28 | } 29 | }; 30 | 31 | export type DirectoryTree = [string, DirectoryTree[]?]; 32 | -------------------------------------------------------------------------------- /src/run-command.ts: -------------------------------------------------------------------------------- 1 | export const runCommand = async ( 2 | command: string, 3 | ): Promise<{ output: string; error: string }> => { 4 | console.log(`Executing "${command}"`); 5 | const process = Deno.run({ 6 | cmd: command.split(" "), 7 | stdout: "piped", 8 | stderr: "piped", 9 | }); 10 | const decoder = new TextDecoder(); 11 | const [output, error] = await Promise.all( 12 | [ 13 | process.output().then((output) => decoder.decode(output).trim()), 14 | process.stderrOutput().then((error) => decoder.decode(error).trim()), 15 | ], 16 | ); 17 | if (error) { 18 | console.error("[error]", error); 19 | } 20 | process.close(); 21 | return { output, error }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/scripts/scrape-attributes-type.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This script is for scrapping attribute-element relations 3 | * from https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes 4 | */ 5 | 6 | (() => { 7 | const data = Array.from(document.getElementsByTagName("tr")).slice(1).map( 8 | (tr) => { 9 | const td1 = tr.children[0]; 10 | const a = td1.children[0]?.children[0]; 11 | const elements = tr.children[1].innerText.replace(/(<|>|,)/g, "").split( 12 | " ", 13 | ); 14 | const description = tr.children[2].innerText; 15 | return { 16 | attributeName: a?.innerText ?? td1.innerText, 17 | link: a?.href, 18 | elements, 19 | description, 20 | }; 21 | }, 22 | ); 23 | 24 | const elements = data 25 | .flatMap(({ attributeName, elements }) => 26 | elements.map((element) => ({ element, attributeName })) 27 | ) 28 | .reduce((result, { attributeName, element }) => { 29 | if (!element) return result; 30 | return { 31 | ...result, 32 | [element]: [...(result[element] ?? []), attributeName], 33 | }; 34 | }, {}); 35 | console.log(elements); 36 | const attrTypeName = (attr) => `Attribute_${attr.replace(/(-|\*)/g, "_")}`; 37 | const elementTypes = Object.entries(elements) 38 | .map(([element, attributes]) => { 39 | return ` 40 | type Element_${element} = 41 | & { $: '${element}' } 42 | ${attributes.map((attr) => ` & ${attrTypeName(attr)}`).join("\n")} 43 | 44 | `.trim(); 45 | }) 46 | .join("\n\n"); 47 | const attributeTypes = data.map(({ 48 | attributeName, 49 | link, 50 | description, 51 | }) => { 52 | return ` 53 | type ${attrTypeName(attributeName)} = { 54 | /** 55 | ${description.split("\n").map((d) => " * " + d.trim()).join("\n")} 56 | * ${link ? `Reference: ${link}` : ""} 57 | */ 58 | '${attributeName}'?: string 59 | }`; 60 | }) 61 | .join("\n"); 62 | const allElements = ` 63 | export type AllElements = 64 | ${Object.keys(elements).map((element) => ` | Element_${element}`).join("\n")} 65 | 66 | `; 67 | 68 | return allElements + elementTypes + "\n" + attributeTypes; 69 | })(); 70 | -------------------------------------------------------------------------------- /src/set-event-handler.ts: -------------------------------------------------------------------------------- 1 | declare const $$h: (action: Action) => void; 2 | export const setEventHandler = ({ 3 | element, 4 | eventName, 5 | action, 6 | }: { 7 | element: HTMLElement; 8 | eventName: string; 9 | action: Action; 10 | }): void => { 11 | if (action !== undefined) { 12 | (element as any)[eventName] = () => $$h(action); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/testing.ts: -------------------------------------------------------------------------------- 1 | import { runtime } from "./runtime.ts"; 2 | import jsdom from "https://dev.jspm.io/jsdom@16.2.2"; 3 | import babelstandalone from "https://dev.jspm.io/@babel/standalone@7.10.1"; 4 | 5 | const now = Date.now(); 6 | const JSDOM = jsdom.JSDOM; 7 | 8 | const code = new TextDecoder("utf-8").decode( 9 | await Deno.run({ 10 | cmd: ["deno", "bundle", "--config", "tsconfig.json", "src/main.ts"], 11 | stdout: "piped", 12 | }) 13 | .output(), 14 | ); 15 | 16 | const transformed = babelstandalone.transform(code, { 17 | presets: [["env"]], 18 | }).code; 19 | 20 | const dom = new JSDOM( 21 | ` 22 |

23 | 27 | `, 28 | { runScripts: "dangerously" }, 29 | ); 30 | 31 | const document = dom.window.document; 32 | 33 | console.log(document.getElementById("root").innerHTML); 34 | 35 | console.log((Date.now() - now) / 1000); 36 | -------------------------------------------------------------------------------- /src/virtual-node-diff.ts: -------------------------------------------------------------------------------- 1 | import { MountedVirtualNode } from "./patch.ts"; 2 | import { VirtualNode } from "./virtual-node.ts"; 3 | import { Patch } from "./patch.ts"; 4 | import { computeAttributesUpdates } from "./compute-attributes-updates.ts"; 5 | import { extractAttributes } from "./extract-attributes.ts"; 6 | 7 | export const diff = ({ 8 | original, 9 | updated, 10 | parentVirtualNode, 11 | }: { 12 | original: MountedVirtualNode; 13 | updated: VirtualNode; 14 | parentVirtualNode: MountedVirtualNode | undefined; 15 | }): Patch[] => { 16 | if ( 17 | original.$ !== updated.$ || 18 | (original.$ === "_text" && updated.$ === "_text" && 19 | updated.value !== (original as any).value) 20 | ) { 21 | return [{ 22 | type: "replace_node", 23 | updatedVirtualNode: updated, 24 | originalNode: original, 25 | parentVirtualNode, 26 | }]; 27 | } else { 28 | // Compare attributes 29 | const originalAttrs = extractAttributes(original); 30 | const updatedAttrs = extractAttributes(updated); 31 | const attributesUpdates = computeAttributesUpdates({ 32 | originalAttrs, 33 | updatedAttrs, 34 | }) 35 | .map((update) => ({ 36 | originalNode: original, 37 | ...update, 38 | })); 39 | 40 | // Compare styles 41 | const styleAttributesUpdates = computeAttributesUpdates( 42 | { 43 | // TODO: No need to cast to `unknown` when negated types is released 44 | // Reference: https://github.com/microsoft/TypeScript/pull/29317 45 | originalAttrs: original.style as unknown as Record ?? 46 | {}, 47 | updatedAttrs: updated.style as Record ?? {}, 48 | }, 49 | ) 50 | .map>((update) => { 51 | switch (update.type) { 52 | case "remove_attribute": 53 | return { 54 | ...update, 55 | originalNode: original, 56 | type: "remove_style_attribute", 57 | }; 58 | 59 | case "update_attribute": 60 | return { 61 | ...update, 62 | originalNode: original, 63 | type: "update_style_attribute", 64 | }; 65 | } 66 | }); 67 | 68 | // Compare child 69 | // TODO: use optimized diff algorithm 70 | // Now is using naive method 71 | 72 | const addedChildren = 73 | updated.children?.slice(original.children?.length ?? 0) ?? []; 74 | const removedChildren = 75 | original.children?.slice(updated.children?.length ?? 0) 76 | .map((child) => child.ref) ?? []; 77 | 78 | const originalChildrenLength = original.children?.length ?? 0; 79 | const updatedChildrenLength = updated.children?.length ?? 0; 80 | const minLength = originalChildrenLength < updatedChildrenLength 81 | ? originalChildrenLength 82 | : updatedChildrenLength; 83 | 84 | const childrenUpdates = original.children?.slice(0, minLength) 85 | .flatMap((child, index) => { 86 | const updatedChild = updated.children?.[index]; 87 | if (updatedChild) { 88 | return diff({ 89 | original: child, 90 | updated: updatedChild, 91 | parentVirtualNode: original, 92 | }); 93 | } else { 94 | return []; 95 | } 96 | }) ?? []; 97 | 98 | return [ 99 | ...attributesUpdates, 100 | ...styleAttributesUpdates, 101 | ...childrenUpdates, 102 | ...addedChildren.map((child) => ({ 103 | type: "add_node" as const, 104 | virtualNode: child, 105 | originalNode: original, 106 | })), 107 | ...removedChildren.map((nodeToBeRemoved) => ({ 108 | type: "remove_node" as const, 109 | nodeToBeRemoved, 110 | originalNode: original, 111 | })), 112 | ]; 113 | } 114 | }; 115 | -------------------------------------------------------------------------------- /src/virtual-node-events.ts: -------------------------------------------------------------------------------- 1 | export type VirtualNodeEvents = { 2 | onabort?: Action; 3 | onafterprint?: Action; 4 | onanimationcancel?: Action; 5 | onanimationend?: Action; 6 | onanimationiteration?: Action; 7 | onanimationstart?: Action; 8 | onappinstalled?: Action; 9 | onaudioprocess?: Action; 10 | onaudioend?: Action; 11 | onaudiostart?: Action; 12 | onbeforeprint?: Action; 13 | onbeforeunload?: Action; 14 | onbeginEvent?: Action; 15 | onblocked?: Action; 16 | onblur?: Action; 17 | onboundary?: Action; 18 | oncanplay?: Action; 19 | oncanplaythrough?: Action; 20 | onchange?: Action; 21 | onchargingchange?: Action; 22 | onchargingtimechange?: Action; 23 | onclick?: Action; 24 | onclose?: Action; 25 | oncomplete?: Action; 26 | oncompositionend?: Action; 27 | oncompositionstart?: Action; 28 | oncompositionupdate?: Action; 29 | oncontextmenu?: Action; 30 | oncopy?: Action; 31 | oncut?: Action; 32 | ondblclick?: Action; 33 | ondevicechange?: Action; 34 | ondevicemotion?: Action; 35 | ondeviceorientation?: Action; 36 | ondischargingtimechange?: Action; 37 | onDOMActivate?: Action; 38 | onDOMAttributeNameChanged?: Action; 39 | onDOMAttrModified?: Action; 40 | onDOMCharacterDataModified?: Action; 41 | onDOMContentLoaded?: Action; 42 | onDOMElementNameChanged?: Action; 43 | onDOMFocusIn?: Action; 44 | onDOMFocusOut?: Action; 45 | onDOMNodeInserted?: Action; 46 | onDOMNodeInsertedIntoDocument?: Action; 47 | onDOMNodeRemoved?: Action; 48 | onDOMNodeRemovedFromDocument?: Action; 49 | onDOMSubtreeModified?: Action; 50 | ondrag?: Action; 51 | ondragend?: Action; 52 | ondragenter?: Action; 53 | ondragleave?: Action; 54 | ondragover?: Action; 55 | ondragstart?: Action; 56 | ondrop?: Action; 57 | ondurationchange?: Action; 58 | onemptied?: Action; 59 | onend?: Action; 60 | onended?: Action; 61 | onendEvent?: Action; 62 | onerror?: Action; 63 | onfocus?: Action; 64 | onfocusin?: Action; 65 | onfocusout?: Action; 66 | onfullscreenchange?: Action; 67 | onfullscreenerror?: Action; 68 | ongamepadconnected?: Action; 69 | ongamepaddisconnected?: Action; 70 | ongotpointercapture?: Action; 71 | onhashchange?: Action; 72 | onlostpointercapture?: Action; 73 | oninput?: Action; 74 | oninvalid?: Action; 75 | onkeydown?: Action; 76 | onkeypress?: Action; 77 | onkeyup?: Action; 78 | onlanguagechange?: Action; 79 | onlevelchange?: Action; 80 | onload?: Action; 81 | onloadeddata?: Action; 82 | onloadedmetadata?: Action; 83 | onloadend?: Action; 84 | onloadstart?: Action; 85 | onmark?: Action; 86 | onmessage?: Action; 87 | onmessageerror?: Action; 88 | onmousedown?: Action; 89 | onmouseenter?: Action; 90 | onmouseleave?: Action; 91 | onmousemove?: Action; 92 | onmouseout?: Action; 93 | onmouseover?: Action; 94 | onmouseup?: Action; 95 | onnomatch?: Action; 96 | onnotificationclick?: Action; 97 | onoffline?: Action; 98 | ononline?: Action; 99 | onopen?: Action; 100 | onorientationchange?: Action; 101 | onpagehide?: Action; 102 | onpageshow?: Action; 103 | onpaste?: Action; 104 | onpause?: Action; 105 | onpointercancel?: Action; 106 | onpointerdown?: Action; 107 | onpointerenter?: Action; 108 | onpointerleave?: Action; 109 | onpointerlockchange?: Action; 110 | onpointerlockerror?: Action; 111 | onpointermove?: Action; 112 | onpointerout?: Action; 113 | onpointerover?: Action; 114 | onpointerup?: Action; 115 | onplay?: Action; 116 | onplaying?: Action; 117 | onpopstate?: Action; 118 | onprogress?: Action; 119 | onpush?: Action; 120 | onpushsubscriptionchange?: Action; 121 | onratechange?: Action; 122 | onreadystatechange?: Action; 123 | onrepeatEvent?: Action; 124 | onreset?: Action; 125 | onresize?: Action; 126 | onresourcetimingbufferfull?: Action; 127 | onresult?: Action; 128 | onresume?: Action; 129 | onscroll?: Action; 130 | onseeked?: Action; 131 | onseeking?: Action; 132 | onselect?: Action; 133 | onselectstart?: Action; 134 | onselectionchange?: Action; 135 | onshow?: Action; 136 | onslotchange?: Action; 137 | onsoundend?: Action; 138 | onsoundstart?: Action; 139 | onspeechend?: Action; 140 | onspeechstart?: Action; 141 | onstalled?: Action; 142 | onstart?: Action; 143 | onstorage?: Action; 144 | onsubmit?: Action; 145 | onsuccess?: Action; 146 | onsuspend?: Action; 147 | onSVGAbort?: Action; 148 | onSVGError?: Action; 149 | onSVGLoad?: Action; 150 | onSVGResize?: Action; 151 | onSVGScroll?: Action; 152 | onSVGUnload?: Action; 153 | onSVGZoom?: Action; 154 | ontimeout?: Action; 155 | ontimeupdate?: Action; 156 | ontouchcancel?: Action; 157 | ontouchend?: Action; 158 | ontouchmove?: Action; 159 | ontouchstart?: Action; 160 | ontransitionend?: Action; 161 | onunload?: Action; 162 | onupgradeneeded?: Action; 163 | onuserproximity?: Action; 164 | onvoiceschanged?: Action; 165 | onversionchange?: Action; 166 | onvisibilitychange?: Action; 167 | onvolumechange?: Action; 168 | onwaiting?: Action; 169 | onwheel?: Action; 170 | }; 171 | -------------------------------------------------------------------------------- /src/virtual-node.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Note: 'on*' and '*' do not represent the actual type, they are just to ease typechecking on 3 | * levo-runtime.ts 4 | */ 5 | export type VirtualNode = ( 6 | | { 7 | $: "_text"; // not a tag, but a text node 8 | value: string; 9 | 10 | style?: undefined; 11 | children?: undefined; 12 | ref?: undefined; 13 | "on*"?: undefined; 14 | "*"?: undefined; 15 | } 16 | | ({ 17 | $: "_other"; 18 | 19 | style?: Partial>; 20 | children?: VirtualNode[]; 21 | ref?: undefined; 22 | "*"?: string; 23 | }) 24 | ); 25 | -------------------------------------------------------------------------------- /src/watch-dependencies.ts: -------------------------------------------------------------------------------- 1 | import { getLocalDependencies } from "./get-local-dependencies.ts"; 2 | import { watchFile } from "./watch-file.ts"; 3 | 4 | const handlers: Record = {}; 5 | 6 | type WatchDependenciesHandler = { 7 | stop?: () => Promise; 8 | }; 9 | 10 | /** 11 | * Watch for changes of the Typescript file and its dependencies. 12 | */ 13 | export const watchDependencies = async ( 14 | { filename, onChange, importMap }: { 15 | filename: string; 16 | onChange: (event: Deno.FsEvent) => void; 17 | importMap?: Record; 18 | }, 19 | ): Promise => { 20 | const watch = async () => { 21 | await handlers[filename]?.stop?.(); 22 | const dependencies = await getLocalDependencies({ filename, importMap }); 23 | console.log("Watching dependencies of " + filename); 24 | handlers[filename] = await watchFile({ 25 | paths: dependencies, 26 | onChange: async (event) => { 27 | onChange(event); 28 | await watch(); 29 | }, 30 | }); 31 | }; 32 | await watch(); 33 | return { 34 | stop: async () => { 35 | await handlers[filename]?.stop?.(); 36 | }, 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/watch-file.ts: -------------------------------------------------------------------------------- 1 | import { exists } from "./deps.ts"; 2 | 3 | export const watchFile = async ( 4 | { 5 | paths, 6 | onChange, 7 | log, 8 | }: { 9 | paths: string[]; 10 | onChange: (event: Deno.FsEvent) => void; 11 | log?: boolean; 12 | }, 13 | ): Promise<{ 14 | stop: () => Promise; 15 | }> => { 16 | if (log) { 17 | console.log("(watch-file.ts) Watching: " + paths.join(", ")); 18 | } 19 | const sanitisedPaths = (await Promise.all(paths.map(async (path) => { 20 | if (await exists(path)) { 21 | return path; 22 | } else { 23 | console.warn(`(watch-file.ts) Cannot find dependency "${path}"`); 24 | return undefined; 25 | } 26 | }))) 27 | .filter((path): path is string => path !== undefined); 28 | 29 | // Throttling is necessary because somehow more than 30 | // one events will be fired for each file changes 31 | let handled = false; 32 | const throttle = (event: Deno.FsEvent): void => { 33 | if (!handled) { 34 | handled = true; 35 | onChange(event); 36 | setTimeout(() => { 37 | handled = false; 38 | }, 100); 39 | } 40 | }; 41 | 42 | const iterator = Deno.watchFs(sanitisedPaths); 43 | setTimeout(async () => { 44 | try { 45 | for await (const event of iterator) { 46 | throttle(event); 47 | } 48 | } // eslint-disable-next-line no-empty 49 | catch { 50 | // TODO: remove this try-catch block after the `return` on 51 | // Deno.watchFs is fixed by nayeerm 52 | } 53 | }, 100); 54 | 55 | return { 56 | stop: async () => { 57 | try { 58 | await iterator.return?.(); 59 | } catch (error) { 60 | console.error(error); 61 | } 62 | }, 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /templates/new-project/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | levo.tsconfig.json 3 | *.cache 4 | .levo.templates -------------------------------------------------------------------------------- /templates/new-project/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.importmap": "import_map.json" 4 | } -------------------------------------------------------------------------------- /templates/new-project/README.md: -------------------------------------------------------------------------------- 1 | # My Levo Project 2 | 3 | ## How to run the server? 4 | ``` 5 | deno run --allow-all --unstable app.ts 6 | ``` -------------------------------------------------------------------------------- /templates/new-project/app.ts: -------------------------------------------------------------------------------- 1 | import { LevoApp } from "levo/levo-app.ts"; 2 | import { compression, helmet } from "levo/levo-middlewares.ts"; 3 | import { Environment } from "environment"; 4 | 5 | const PRODUCTION = Deno.args.includes("--production"); 6 | LevoApp.start({ 7 | serverOptions: { 8 | hostname: "0.0.0.0", 9 | port: 5000, 10 | }, 11 | environment: PRODUCTION 12 | ? { 13 | VALUE_A: "xxx", 14 | VALUE_B: "yyy", 15 | } 16 | : { 17 | VALUE_A: "aaa", 18 | VALUE_B: "bbb", 19 | }, 20 | minifyCss: PRODUCTION, 21 | cacheDirectoryTree: PRODUCTION, 22 | hotReload: !PRODUCTION, 23 | rootDir: new URL("./root", import.meta.url), 24 | importMapPath: new URL("./import_map.json", import.meta.url), 25 | loggingOptions: PRODUCTION ? undefined : { model: true, action: true }, 26 | processRequestMiddlewares: [ 27 | (req) => console.log(new Date(), `${req.method} ${req.url}`), 28 | ], 29 | processResponseMiddlewares: [ 30 | compression, 31 | helmet, 32 | ], 33 | }); 34 | -------------------------------------------------------------------------------- /templates/new-project/environment.ts: -------------------------------------------------------------------------------- 1 | export type Environment = { 2 | VALUE_A: string; 3 | VALUE_B: string; 4 | }; 5 | -------------------------------------------------------------------------------- /templates/new-project/import_map.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "levo/": "../../mod/", 4 | "asserts": "https://deno.land/std@0.61.0/testing/asserts.ts", 5 | "environment": "./environment.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /templates/new-project/root/_assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levo-framework/core/02c29d1c6242a2e80d3209df877279688a44d80e/templates/new-project/root/_assets/favicon.ico -------------------------------------------------------------------------------- /templates/new-project/root/_assets/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/new-project/root/_assets/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | div { 5 | background-color: antiquewhite; 6 | } 7 | img { 8 | transition: transform 0.3s; 9 | } -------------------------------------------------------------------------------- /templates/new-project/root/_client.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { Levo } from "levo/levo-view.ts"; 4 | import { Model, Action } from "./view.tsx"; 5 | import { view } from "./view.tsx"; 6 | 7 | export const init: Levo.Init = () => { 8 | }; 9 | 10 | export const update: Levo.Update = ( 11 | { model, action }, 12 | ) => { 13 | switch (action.$) { 14 | case "do nothing": { 15 | return { 16 | newModel: { 17 | ...model, 18 | }, 19 | }; 20 | } 21 | } 22 | }; 23 | 24 | Levo.register({ init, view, update }); 25 | -------------------------------------------------------------------------------- /templates/new-project/root/_server.ts: -------------------------------------------------------------------------------- 1 | import { view, Model, Action, initialModel } from "./view.tsx"; 2 | import { serve } from "levo/levo-serve.ts"; 3 | import { Environment } from "environment"; 4 | 5 | export default serve({ 6 | getResponse: async (request, response) => { 7 | return response.page({ 8 | view, 9 | model: initialModel(), 10 | }); 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /templates/new-project/root/robots.txt/_server.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "levo/levo-serve.ts"; 2 | 3 | export default serve({ 4 | getResponse: async (request, response) => { 5 | return response.custom({ 6 | body: ` 7 | User-agent: * 8 | Allow: / 9 | `.trim(), 10 | }); 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /templates/new-project/root/view.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx h */ 2 | import { Levo, h } from "levo/levo-view.ts"; 3 | 4 | export type Model = { 5 | message: string; 6 | }; 7 | 8 | export const initialModel = (): Model => { 9 | return { 10 | message: "click me", 11 | }; 12 | }; 13 | 14 | export type Action = { 15 | $: "do nothing"; 16 | }; 17 | 18 | export const view = ( 19 | props: { model: Model; dispatch: Levo.Dispatch }, 20 | ): Levo.Element => { 21 | const { model, dispatch } = props; 22 | const og = { 23 | title: "My Levo Page", 24 | url: "https://mydomain.com", 25 | description: "This is a page.", 26 | image: "image.png", 27 | }; 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | {/* Open Graph / Facebook */} 36 | 37 | 38 | 39 | 40 | 41 | 42 | {/* Open Graph / Twitter */} 43 | 44 | 45 | 46 | 47 | 48 | My Page 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /templates/new-project/tools/start-development.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | deno run --allow-all --unstable --importmap=import_map.json app.ts -------------------------------------------------------------------------------- /templates/new-project/tools/start-production.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | deno run --allow-all --unstable --importmap=import_map.json app.ts --production -------------------------------------------------------------------------------- /templates/new-project/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "plugins": [ 4 | { 5 | "name": "typescript-deno-plugin", 6 | "enable": true, // default is `true` 7 | "importmap": "import_map.json" 8 | } 9 | ], 10 | "allowJs": false, 11 | "allowUmdGlobalAccess": false, 12 | "allowUnreachableCode": false, 13 | "allowUnusedLabels": false, 14 | "alwaysStrict": true, 15 | "assumeChangesOnlyAffectDirectDependencies": false, 16 | "checkJs": false, 17 | "disableSizeLimit": false, 18 | "generateCpuProfile": "profile.cpuprofile", 19 | "jsx": "react", 20 | "jsxFactory": "React.createElement", 21 | "lib": ["dom", "DOM", "ES2016", "ES2017", "ES2018", "ES2019"], 22 | "noFallthroughCasesInSwitch": false, 23 | "noImplicitAny": true, 24 | "noImplicitReturns": true, 25 | "noImplicitThis": true, 26 | "noImplicitUseStrict": false, 27 | "noStrictGenericChecks": false, 28 | "noUnusedLocals": false, 29 | "noUnusedParameters": false, 30 | "preserveConstEnums": false, 31 | "removeComments": false, 32 | "resolveJsonModule": true, 33 | "strict": true, 34 | "strictBindCallApply": true, 35 | "strictFunctionTypes": true, 36 | "strictNullChecks": true, 37 | "strictPropertyInitialization": true, 38 | "suppressExcessPropertyErrors": false, 39 | "suppressImplicitAnyIndexErrors": false, 40 | "useDefineForClassFields": false 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test-levo-runtime/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": false 3 | } -------------------------------------------------------------------------------- /test-levo-runtime/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "levo-runtime", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest test.js --detectOpenHandles" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "jest": "^26.1.0", 13 | "puppeteer": "^4.0.1" 14 | }, 15 | "devDependencies": { 16 | "@types/jest": "^26.0.2", 17 | "@types/puppeteer": "^3.0.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test-levo-runtime/test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const puppeteer = require("puppeteer"); 3 | const { spawn } = require("child_process"); 4 | 5 | const server = spawn("deno", [ 6 | "run", 7 | "--allow-all", 8 | "--unstable", 9 | "../demo/app.ts", 10 | ]); 11 | server.stdout.on("data", (data) => { 12 | console.log(`stdout: ${data}`); 13 | }); 14 | 15 | server.stderr.on("data", (data) => { 16 | console.log(`stderr: ${data}`); 17 | }); 18 | 19 | server.on("close", (code) => { 20 | console.log(`child process exited with code ${code}`); 21 | }); 22 | 23 | console.log(`stderr: ${server.stderr.toString()}`); 24 | console.log(`stdout: ${server.stdout.toString()}`); 25 | 26 | /** 27 | * @param {number} seconds 28 | */ 29 | const waitForSeconds = (seconds) => { 30 | return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); 31 | }; 32 | 33 | let browser; 34 | beforeAll(async (done) => { 35 | await waitForSeconds(30); 36 | browser = await puppeteer.launch({ headless: true }); 37 | done(); 38 | }, 40000); 39 | 40 | describe("", () => { 41 | test("case 1", async () => { 42 | const page = await browser.newPage(); 43 | await page.goto("http://localhost:3000"); 44 | 45 | // Try to crash the server by reloading 10 times 46 | // Expect there's error but the server will still run 47 | await Promise.all(new Array(10).fill(0).map(() => page.reload())); 48 | 49 | // Test timer that constantly dispatch "add" action 50 | const getCurrentValue = async () => { 51 | const currentValueDiv = await page.$("#current-value"); 52 | return (await currentValueDiv.getProperty("innerText")).jsonValue(); 53 | }; 54 | const currentValue1 = await getCurrentValue(); 55 | await waitForSeconds(1.5); 56 | const currentValue2 = await getCurrentValue(); 57 | expect(currentValue2 > currentValue1).toEqual(true); 58 | 59 | // Test stop timer, expect current value won't be added anymore 60 | await page.click("#stop-timer-button"); 61 | const currentValue3 = await getCurrentValue(); 62 | await waitForSeconds(1.5); 63 | const currentValue4 = await getCurrentValue(); 64 | expect(currentValue3).toEqual(currentValue4); 65 | 66 | // Test fetching 67 | const getFetchedText = async () => { 68 | const fetchedTextDiv = await page.$("#fetched-text"); 69 | return (await fetchedTextDiv.getProperty("innerText")).jsonValue(); 70 | }; 71 | const fetchedText1 = await getFetchedText(); 72 | expect(fetchedText1).toEqual(""); 73 | await page.click("#fetch-button"); 74 | await waitForSeconds(2); 75 | const fetchedText2 = await getFetchedText(); 76 | expect(fetchedText2.includes("[workspace]")).toEqual(true); 77 | 78 | // Test add button. 79 | // Expect tag attributes and style will be updated properly 80 | const getAddButtonStyle = async () => { 81 | return page.$eval("#add-button", (el) => el.style.backgroundColor); 82 | }; 83 | const getInputValue = async () => { 84 | return page.$eval("#input", (el) => el.value); 85 | }; 86 | const getCheckboxValue = async () => { 87 | return page.$eval("#checkbox", (el) => el.checked); 88 | }; 89 | const addButtonStyle1 = await getAddButtonStyle(); 90 | const inputValue1 = await getInputValue(); 91 | const checkboxValue1 = await getCheckboxValue(); 92 | const currentValue5 = await getCurrentValue(); 93 | await page.click("#add-button"); 94 | const addButtonStyle2 = await getAddButtonStyle(); 95 | const inputValue2 = await getInputValue(); 96 | const checkboxValue2 = await getCheckboxValue(); 97 | const currentValue6 = await getCurrentValue(); 98 | expect(addButtonStyle1).not.toEqual(addButtonStyle2); 99 | expect(inputValue1).not.toEqual(inputValue2); 100 | expect(checkboxValue1).not.toEqual(checkboxValue2); 101 | expect(currentValue6 - currentValue5).toEqual(1); 102 | 103 | // Test "Click Me" button 104 | // Expect "Click Me" button dispatch different action 105 | const currentValue7 = await getCurrentValue(); 106 | await page.click("#click-me-button"); 107 | const currentValue8 = await getCurrentValue(); 108 | const diff1 = currentValue8 - currentValue7; 109 | 110 | const currentValue9 = await getCurrentValue(); 111 | await page.click("#click-me-button"); 112 | const currentValue10 = await getCurrentValue(); 113 | const diff2 = currentValue10 - currentValue9; 114 | expect(diff1).not.toEqual(diff2); 115 | 116 | // Test conditonal rendering 117 | const getHelloDivExists = async () => { 118 | const div = await page.$("#hello"); 119 | return Boolean(div); 120 | }; 121 | const helloDivExists1 = await getHelloDivExists(); 122 | await page.click("#click-me-button"); 123 | const helloDivExists2 = await getHelloDivExists(); 124 | expect(helloDivExists1).not.toEqual(helloDivExists2); 125 | 126 | // Test style removal 127 | const getMinusButtonStyleColor = async () => { 128 | return page.$eval("#minus-button", (el) => el.style.color); 129 | }; 130 | const minusButtonStyleColor1 = await getMinusButtonStyleColor(); 131 | console.log(minusButtonStyleColor1); 132 | await page.click("#click-me-button"); 133 | const minusButtonStyleColor2 = await getMinusButtonStyleColor(); 134 | console.log(minusButtonStyleColor2); 135 | expect( 136 | [minusButtonStyleColor1, minusButtonStyleColor2].some((color) => 137 | color === "green" 138 | ), 139 | ).toEqual(true); 140 | expect( 141 | [minusButtonStyleColor1, minusButtonStyleColor2].some((color) => 142 | color === "" 143 | ), 144 | ).toEqual(true); 145 | }, 60000); 146 | 147 | test("environment variables", async () => { 148 | const page = await browser.newPage(); 149 | await page.goto("http://localhost:3000/banana"); 150 | 151 | const text = await page.$eval("#content", (el) => el.innerText); 152 | expect(text.includes("Word2: DEV_ENV(set from server)")).toEqual(true); 153 | }); 154 | }); 155 | 156 | afterAll(async (done) => { 157 | console.log("Tear down . . ."); 158 | server.kill("SIGINT"); 159 | await browser.close(); 160 | await new Promise((resolve) => setTimeout(resolve, 10000)); 161 | done(); 162 | }, 30000); 163 | -------------------------------------------------------------------------------- /test/cli/main.test.ts: -------------------------------------------------------------------------------- 1 | import { getDirectoryTree } from "../../src/get-directory-tree.ts"; 2 | import { assertEquals, assert, path, exists } from "../../src/deps.ts"; 3 | import { runCommand } from "../../src/run-command.ts"; 4 | 5 | Deno.test({ 6 | name: "new-project and new-page command", 7 | fn: async () => { 8 | const projectName = "hello"; 9 | 10 | console.log("\nInstalling Levo CLI"); 11 | const output = await runCommand( 12 | `deno install --allow-all --unstable --force --root . --name levo cli/mod.ts`, 13 | ); 14 | console.log("output", output); 15 | 16 | assert( 17 | ((await runCommand(`which ./bin/levo`)).output).endsWith("./bin/levo"), 18 | ); 19 | 20 | console.log("Creating new project using Levo CLI"); 21 | const latestCommitHash = (await runCommand("git rev-parse HEAD")).output; 22 | const sourceDir = Deno.cwd(); 23 | const { output: newProjectOutput } = await runCommand( 24 | `./bin/levo new-project ${projectName} --source=${sourceDir} --version=${latestCommitHash}`, 25 | ); 26 | console.log(newProjectOutput); 27 | 28 | await new Promise((resolve) => setTimeout(resolve, 2000)); 29 | const tree = getDirectoryTree( 30 | `./${projectName}`, 31 | { ignoreFiles: [] }, 32 | ); 33 | assertEquals(tree, [ 34 | [".gitignore"], 35 | [".levo.templates", [ 36 | ["new-project", [ 37 | [".gitignore"], 38 | [".vscode", [ 39 | ["settings.json"], 40 | ]], 41 | ["README.md"], 42 | ["app.ts"], 43 | ["environment.ts"], 44 | ["import_map.json"], 45 | ["lib", [ 46 | ["lib.deno_runtime.d.ts"], 47 | ]], 48 | ["root", [ 49 | ["_assets", [ 50 | ["favicon.ico"], 51 | ["favicon.svg"], 52 | ["index.css"], 53 | ]], 54 | ["_client.ts"], 55 | ["_server.ts"], 56 | ["view.tsx"], 57 | ]], 58 | ["tools", [ 59 | ["start-development.sh"], 60 | ["start-production.sh"], 61 | ]], 62 | ["tsconfig.json"], 63 | ]], 64 | ]], 65 | [".vscode", [ 66 | ["settings.json"], 67 | ]], 68 | ["README.md"], 69 | ["app.ts"], 70 | ["environment.ts"], 71 | ["import_map.json"], 72 | ["lib", [ 73 | ["lib.deno_runtime.d.ts"], 74 | ]], 75 | ["root", [ 76 | ["_assets", [ 77 | ["favicon.ico"], 78 | ["favicon.svg"], 79 | ["index.css"], 80 | ]], 81 | ["_client.ts"], 82 | ["_server.ts"], 83 | ["robots.txt", [ 84 | ["_server.ts"], 85 | ]], 86 | ["view.tsx"], 87 | ]], 88 | ["tools", [ 89 | ["start-development.sh"], 90 | ["start-production.sh"], 91 | ]], 92 | ["tsconfig.json"], 93 | ]); 94 | 95 | console.log("Verify import map"); 96 | const importMapContent = new TextDecoder().decode( 97 | await Deno.readFile([projectName, "import_map.json"].join(path.SEP)), 98 | ); 99 | assertEquals( 100 | importMapContent, 101 | `{ 102 | "imports": { 103 | "levo/": "${sourceDir}/mod/", 104 | "asserts": "https://deno.land/std@0.61.0/testing/asserts.ts", 105 | "environment": "./environment.ts" 106 | } 107 | }`, 108 | ); 109 | const importMap = JSON.parse(importMapContent); 110 | assertEquals(importMap, { 111 | "imports": { 112 | "levo/": `${sourceDir}/mod/`, 113 | "asserts": "https://deno.land/std@0.61.0/testing/asserts.ts", 114 | "environment": "./environment.ts", 115 | }, 116 | }); 117 | 118 | console.log("Test if the server created with the templates work"); 119 | Deno.chdir(projectName); 120 | const server = Deno.run({ 121 | cmd: [`./tools/start-development.sh`], 122 | }); 123 | Deno.chdir(".."); 124 | 125 | await new Promise((resolve) => setTimeout(resolve, 25000)); 126 | 127 | const response1 = await fetch("http://localhost:5000"); 128 | assertEquals(response1.status, 200); 129 | assertEquals(response1.headers.get("content-type"), "text/html"); 130 | 131 | console.log(`Test new-page command`); 132 | Deno.chdir(projectName); 133 | await runCommand( 134 | `../bin/levo new-page ./root/about`, 135 | ); 136 | assertEquals(await exists("./root/about/robots.txt"), false); 137 | Deno.chdir(".."); 138 | const response2 = await fetch("http://localhost:5000/about"); 139 | assertEquals(response2.status, 200); 140 | assertEquals(response2.headers.get("content-type"), "text/html"); 141 | 142 | console.log(`Test hot reload with new pages`); 143 | const encoder = new TextEncoder(); 144 | const decoder = new TextDecoder(); 145 | const aboutPagePath = `${projectName}/root/about/view.tsx`; 146 | const aboutPageContent = decoder.decode(await Deno.readFile(aboutPagePath)); 147 | const replacedWord = Date.now().toString(); 148 | await Deno.writeFile( 149 | aboutPagePath, 150 | encoder.encode( 151 | aboutPageContent.replace(/click me/gi, replacedWord), 152 | ), 153 | ); 154 | 155 | await new Promise((resolve) => setTimeout(resolve, 3000)); 156 | 157 | const resultText = await (await fetch("http://localhost:5000/about")) 158 | .text(); 159 | assert(resultText.includes(replacedWord)); 160 | 161 | console.log(`Test new-page command with nested wildcard path`); 162 | Deno.chdir(projectName); 163 | await runCommand( 164 | `../bin/levo new-page ./root/_/profile`, 165 | ); 166 | Deno.chdir(".."); 167 | 168 | const response3 = await fetch("http://localhost:5000/john/profile"); 169 | assertEquals(response3.status, 200); 170 | assertEquals(response3.headers.get("content-type"), "text/html"); 171 | 172 | console.log("Tear down"); 173 | console.log("Terminate server"); 174 | 175 | server.kill(Deno.Signal.SIGTERM); 176 | 177 | await new Promise((resolve) => setTimeout(resolve, 3000)); 178 | 179 | console.log("Delete created project folder"); 180 | await Deno.remove(projectName, { recursive: true }); 181 | 182 | console.log(`Uninstall Levo CLI`); 183 | await Deno.remove("./bin/levo"); 184 | }, 185 | sanitizeOps: false, 186 | sanitizeResources: false, 187 | }); 188 | -------------------------------------------------------------------------------- /test/deps.ts: -------------------------------------------------------------------------------- 1 | export { 2 | assert, 3 | assertEquals, 4 | } from "https://deno.land/std@0.61.0/testing/asserts.ts"; 5 | -------------------------------------------------------------------------------- /test/server/development-mode.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assert } from "../../src/deps.ts"; 2 | 3 | const server = await Deno.run({ 4 | cmd: "deno run --allow-all --unstable demo/app.ts".split(" "), 5 | }); 6 | 7 | await new Promise((resolve) => setTimeout(resolve, 25000)); 8 | 9 | const tests: { 10 | name: string; 11 | fn: () => Promise; 12 | }[] = [ 13 | { 14 | name: "serve webpage based on directory that has _client.ts (top-level)", 15 | fn: async () => { 16 | const result = await fetch("http://localhost:3000"); 17 | assertEquals(result.headers.get("content-type"), "text/html"); 18 | assertEquals(result.status, 200); 19 | }, 20 | }, 21 | { 22 | name: 23 | "serve webpage based on directory that has _client.ts (nested directory)", 24 | fn: async () => { 25 | const result = await fetch("http://localhost:3000/about"); 26 | assertEquals(result.headers.get("content-type"), "text/html"); 27 | assertEquals(result.status, 200); 28 | }, 29 | }, 30 | { 31 | name: "handle URL with trailing slash", 32 | fn: async () => { 33 | const result = await fetch("http://localhost:3000"); 34 | assertEquals(result.headers.get("content-type"), "text/html"); 35 | assertEquals(result.status, 200); 36 | }, 37 | }, 38 | { 39 | name: "handle URL with query params", 40 | fn: async () => { 41 | const result = await fetch("http://localhost:3000?title=spongebob"); 42 | assert((await result.text()).includes("spongebob")); 43 | assertEquals(result.headers.get("content-type"), "text/html"); 44 | assertEquals(result.status, 200); 45 | }, 46 | }, 47 | { 48 | name: "handle path param wildcard", 49 | fn: async () => { 50 | const result1 = await fetch("http://localhost:3000/user/xxx"); 51 | assert((await result1.text()).includes("I am xxx")); 52 | 53 | const result2 = await fetch("http://localhost:3000/user/jojo"); 54 | assert((await result2.text()).includes("I am jojo")); 55 | }, 56 | }, 57 | { 58 | name: "serve asset under _assets", 59 | fn: async () => { 60 | const result = await fetch( 61 | "http://localhost:3000/_assets/index.css", 62 | ); 63 | assertEquals( 64 | await result.text(), 65 | ` 66 | button { background-color: bisque; font-size: 24px; } 67 | .class1 { font-size: large; } 68 | .class2 { font-size: small; } 69 | `.trim(), 70 | ); 71 | assertEquals(result.headers.get("content-type"), "text/css"); 72 | assertEquals(result.status, 200); 73 | }, 74 | }, 75 | { 76 | name: "serve compressed body whenever possible (br)", 77 | fn: async () => { 78 | const headers = new Headers(); 79 | headers.set("Accept-Encoding", "br, gzip"); 80 | const result = await fetch( 81 | "http://localhost:3000/_assets/index.css", 82 | { 83 | headers, 84 | }, 85 | ); 86 | assertEquals(result.headers.get("content-type"), "text/css"); 87 | assertEquals(result.headers.get("levo-content-encoding"), "br"); 88 | assertEquals(result.status, 200); 89 | }, 90 | }, 91 | { 92 | name: "serve compressed body whenever possible (gzip)", 93 | fn: async () => { 94 | const headers = new Headers(); 95 | headers.set("Accept-Encoding", "gzip"); 96 | const result = await fetch( 97 | "http://localhost:3000/_assets/index.css", 98 | { 99 | headers, 100 | }, 101 | ); 102 | assertEquals(result.headers.get("content-type"), "text/css"); 103 | assertEquals(result.headers.get("levo-content-encoding"), "gzip"); 104 | assertEquals(result.status, 200); 105 | }, 106 | }, 107 | { 108 | name: "securiy header (helmet middleware)", 109 | fn: async () => { 110 | const result = await fetch("http://localhost:3000"); 111 | assertEquals(result.headers.get("X-DNS-Prefetch-Control"), "off"); 112 | assertEquals(result.headers.get("X-Frame-Options"), "SAMEORIGIN"); 113 | assertEquals(result.headers.get("X-Download-Options"), "noopen"); 114 | assertEquals(result.headers.get("X-Content-Type-Options"), "nosniff"); 115 | assertEquals(result.headers.get("X-XSS-Protection"), "1; mode=block"); 116 | }, 117 | }, 118 | { 119 | name: "return 404 when querying directory without _client.ts", 120 | fn: async () => { 121 | const headers = new Headers(); 122 | headers.set("Accept-Encoding", "gzip"); 123 | const result = await fetch("http://localhost:3000/dummy", { 124 | headers, 125 | }); 126 | assertEquals(result.status, 404); 127 | }, 128 | }, 129 | { 130 | name: "redirection", 131 | fn: async () => { 132 | const result = await fetch("http://localhost:3000/banana?redirect=boom"); 133 | assertEquals( 134 | await result.text(), 135 | ``, 136 | ); 137 | }, 138 | }, 139 | { 140 | name: "custom handlers", 141 | fn: async () => { 142 | const result = await fetch("http://localhost:3000/api/yo?x=5&y=6"); 143 | assertEquals(await result.json(), { search: "?x=5&y=6" }); 144 | assertEquals(result.headers.get("custom-lol"), "ha"); 145 | assertEquals(result.status, 201); 146 | }, 147 | }, 148 | { 149 | name: "environment variables", 150 | fn: async () => { 151 | const result = await fetch("http://localhost:3000/banana"); 152 | const text = await result.text(); 153 | assert(text.includes("Word2: DEV_ENV(set from server)")); 154 | }, 155 | }, 156 | { 157 | name: 158 | "updating _server.ts should work when server is running in hotReloadChanges mode", 159 | fn: async () => { 160 | const result = await fetch("http://localhost:3000/banana"); 161 | assert((await result.text()).includes("i am banana")); 162 | const decoder = new TextDecoder(); 163 | const encoder = new TextEncoder(); 164 | const path = new URL("../../demo/home/banana/_server.ts", import.meta.url) 165 | .pathname; 166 | const fileContent = decoder.decode(await Deno.readFile(path)); 167 | const replacedWord = "coconut"; 168 | assert(!fileContent.includes(replacedWord)); 169 | await Deno.writeFile( 170 | path, 171 | encoder.encode(fileContent.replace(/banana/, replacedWord)), 172 | ); 173 | 174 | await new Promise((resolve) => setTimeout(resolve, 3000)); 175 | 176 | const result2 = await fetch("http://localhost:3000/banana"); 177 | 178 | // reset the file 179 | await Deno.writeFile(path, encoder.encode(fileContent)); 180 | assert((await result2.text()).includes(replacedWord)); 181 | }, 182 | }, 183 | { 184 | name: 185 | "updating _client.ts (or its dependencies) should work when server is running in hotReloadChanges mode", 186 | fn: async () => { 187 | await new Promise((resolve) => setTimeout(resolve, 3000)); 188 | 189 | const result = await fetch("http://localhost:3000/banana"); 190 | assert((await result.text()).includes("hello world")); 191 | const decoder = new TextDecoder(); 192 | const encoder = new TextEncoder(); 193 | const path = new URL("../../demo/home/banana/view.tsx", import.meta.url) 194 | .pathname; 195 | const fileContent = decoder.decode(await Deno.readFile(path)); 196 | const replacedWord = "salam dunia"; 197 | assert(!fileContent.includes(replacedWord)); 198 | await Deno.writeFile( 199 | path, 200 | encoder.encode(fileContent.replace(/hello world/, replacedWord)), 201 | ); 202 | 203 | await new Promise((resolve) => setTimeout(resolve, 3000)); 204 | 205 | const result2 = await fetch("http://localhost:3000/banana"); 206 | 207 | // reset the file 208 | await Deno.writeFile(path, encoder.encode(fileContent)); 209 | assert((await result2.text()).includes(replacedWord)); 210 | }, 211 | }, 212 | ]; 213 | 214 | let numberOfRanTest = 0; 215 | const done = () => { 216 | numberOfRanTest++; 217 | if (numberOfRanTest === tests.length) { 218 | const SIGTERM = 15; 219 | server.kill(SIGTERM); 220 | } 221 | }; 222 | 223 | tests.forEach((test) => { 224 | Deno.test({ 225 | name: test.name, 226 | fn: () => test.fn().then(done), 227 | sanitizeOps: false, 228 | sanitizeResources: false, 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /test/server/production-mode.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This test file is meant for testing the production version of Levo server 3 | */ 4 | 5 | import { assertEquals } from "../../src/deps.ts"; 6 | 7 | const process = Deno.run({ 8 | cmd: "deno run --allow-all --unstable ./demo/app.ts --production".split(" "), 9 | }); 10 | 11 | await new Promise((resolve) => setTimeout(resolve, 25000)); 12 | 13 | const tests: { 14 | name: string; 15 | fn: () => Promise; 16 | }[] = [ 17 | { 18 | name: "async multiple requests", 19 | fn: async () => { 20 | const numberOfRequests = 100; 21 | const statuses: number[] = []; 22 | try { 23 | await Promise.all( 24 | new Array(numberOfRequests).fill(0).map(() => 25 | fetch("http://localhost:3000/") 26 | .then((response) => statuses.push(response.status)) 27 | ), 28 | ); 29 | } catch (error) { 30 | console.error(error); 31 | } 32 | await new Promise((resolve) => setTimeout(resolve, 5000)); 33 | assertEquals(statuses, new Array(numberOfRequests).fill(200)); 34 | await new Promise((resolve) => setTimeout(resolve, 10000)); 35 | }, 36 | }, 37 | ]; 38 | 39 | let numberOfRanTest = 0; 40 | const done = () => { 41 | numberOfRanTest++; 42 | if (numberOfRanTest === tests.length) { 43 | const SIGTERM = 15; 44 | process.kill(SIGTERM); 45 | } 46 | }; 47 | 48 | tests.forEach((test) => { 49 | Deno.test({ 50 | name: test.name, 51 | fn: () => test.fn().then(done), 52 | sanitizeOps: false, 53 | sanitizeResources: false, 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/server/project-template.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, assertEquals } from "../../src/deps.ts"; 2 | 3 | Deno.chdir("./templates/new-project/"); 4 | const process = Deno.run({ 5 | cmd: ["./tools/start-development.sh"], 6 | }); 7 | 8 | await new Promise((resolve) => setTimeout(resolve, 25000)); 9 | 10 | const tests: { 11 | name: string; 12 | fn: () => Promise; 13 | }[] = [ 14 | { 15 | name: "root page should work", 16 | fn: async () => { 17 | const result = await fetch("http://localhost:5000"); 18 | assertEquals(result.headers.get("content-type"), "text/html"); 19 | assertEquals(result.status, 200); 20 | }, 21 | }, 22 | { 23 | name: "template should have default robots.txt", 24 | fn: async () => { 25 | const result = await fetch("http://localhost:5000/robots.txt"); 26 | assertEquals( 27 | (await result.text()).split("\n"), 28 | ["User-agent: *", "Allow: /"], 29 | ); 30 | assertEquals(result.status, 200); 31 | }, 32 | }, 33 | { 34 | name: "hot reload should work", 35 | fn: async () => { 36 | const text1 = await (await fetch("http://localhost:5000")).text(); 37 | assert(!text1.includes("spongebob")); 38 | 39 | // Update the content of view.tsx 40 | const path = "./root/view.tsx"; 41 | const encoder = new TextEncoder(); 42 | const decoder = new TextDecoder(); 43 | const originalContent = decoder.decode(await Deno.readFile(path)); 44 | 45 | await Deno.writeFile( 46 | path, 47 | encoder.encode(originalContent.replace("click me", "spongebob")), 48 | ); 49 | 50 | await new Promise((resolve) => setTimeout(resolve, 5000)); 51 | 52 | const text2 = await (await fetch("http://localhost:5000")).text(); 53 | assert(text2.includes("spongebob")); 54 | 55 | // Reset the file 56 | await Deno.writeFile(path, encoder.encode(originalContent)); 57 | }, 58 | }, 59 | ]; 60 | 61 | let numberOfRanTest = 0; 62 | const done = () => { 63 | numberOfRanTest++; 64 | if (numberOfRanTest === tests.length) { 65 | const SIGTERM = 15; 66 | process.kill(SIGTERM); 67 | } 68 | }; 69 | 70 | tests.forEach((test) => { 71 | Deno.test({ 72 | name: test.name, 73 | fn: () => test.fn().then(done), 74 | sanitizeOps: false, 75 | sanitizeResources: false, 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/server/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copied from https://github.com/github/fetch/issues/175#issuecomment-216791333 3 | */ 4 | export function timeoutPromise(ms: number, promise: Promise): Promise { 5 | return new Promise((resolve, reject) => { 6 | const timeoutId = setTimeout(() => { 7 | reject(new Error("promise timeout")); 8 | }, ms); 9 | promise.then( 10 | (res) => { 11 | clearTimeout(timeoutId); 12 | resolve(res); 13 | }, 14 | (err) => { 15 | clearTimeout(timeoutId); 16 | reject(err); 17 | }, 18 | ); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /test/unit/array-diff.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "../../src/deps.ts"; 2 | import { arrayDiff } from "../../src/array-diff.ts"; 3 | 4 | Deno.test("array-diff", () => { 5 | assertEquals(arrayDiff([5, 4, 3, 2, 1], [1, 2, 3]), [5, 4]); 6 | 7 | assertEquals(arrayDiff([2, 3, 4], [3, 2, 1, 4]), []); 8 | }); 9 | -------------------------------------------------------------------------------- /test/unit/compute-attributes-updates.test.ts: -------------------------------------------------------------------------------- 1 | import { computeAttributesUpdates } from "../../src/compute-attributes-updates.ts"; 2 | import { assertEquals } from "../../src/deps.ts"; 3 | 4 | Deno.test("compute-attributes-updates", () => { 5 | assertEquals( 6 | computeAttributesUpdates({ 7 | originalAttrs: { 8 | x: 1, 9 | z: 4, 10 | a: 0, 11 | }, 12 | updatedAttrs: { 13 | a: 0, 14 | x: 2, 15 | y: 3, 16 | }, 17 | }), 18 | [ 19 | { type: "update_attribute", attributeName: "x", value: 2 }, 20 | { type: "remove_attribute", attributeName: "z" }, 21 | { type: "update_attribute", attributeName: "y", value: 3 }, 22 | ], 23 | ); 24 | 25 | assertEquals( 26 | computeAttributesUpdates({ 27 | originalAttrs: {}, 28 | updatedAttrs: { 29 | x: undefined, 30 | }, 31 | }), 32 | [], 33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /test/unit/extract-dependencies.test.ts: -------------------------------------------------------------------------------- 1 | import { extractDependencies } from "../../src/extract-dependencies.ts"; 2 | import { assertEquals } from "../deps.ts"; 3 | 4 | Deno.test("extract dependencies", () => { 5 | const result = extractDependencies(` 6 | import * as from "a.ts" 7 | import {b} from"b.ts" 8 | import c from "c.ts" 9 | export d from 10 | "d.ts" 11 | 12 | import * as from 'e.ts' 13 | import {f} from'f.ts' 14 | import g from 'g.ts' 15 | export h from 16 | 'h.ts' 17 | `); 18 | assertEquals(result, [ 19 | "a.ts", 20 | "b.ts", 21 | "c.ts", 22 | "d.ts", 23 | "e.ts", 24 | "f.ts", 25 | "g.ts", 26 | "h.ts", 27 | ]); 28 | }); 29 | -------------------------------------------------------------------------------- /test/unit/get-directory-tree.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "../../src/deps.ts"; 2 | import { getDirectoryTree } from "../../src/get-directory-tree.ts"; 3 | 4 | Deno.test("get directory tree", () => { 5 | assertEquals( 6 | getDirectoryTree("./demo/home/user", { 7 | ignoreFiles: ["_client.ts.cache"], 8 | }), 9 | [ 10 | ["_", [ 11 | ["_client.ts"], 12 | ["_server.ts"], 13 | ["types.ts"], 14 | ["update.ts"], 15 | ["view.tsx"], 16 | ]], 17 | ["xxx", [ 18 | ["_client.ts"], 19 | ["_server.ts"], 20 | ["types.ts"], 21 | ["update.ts"], 22 | ["view.tsx"], 23 | ]], 24 | ], 25 | ); 26 | }); 27 | -------------------------------------------------------------------------------- /test/unit/get-local-dependencies.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "../deps.ts"; 2 | import { getLocalDependencies } from "../../src/get-local-dependencies.ts"; 3 | 4 | Deno.test("get local dependencies (relative path)", async () => { 5 | assertEquals( 6 | await getLocalDependencies( 7 | { filename: "./test/unit/test-files/a.ts", truncateCommonPrefix: true }, 8 | ), 9 | [ 10 | "/a.ts", 11 | "/b.ts", 12 | "/c.ts", 13 | ], 14 | ); 15 | 16 | Deno.chdir("./test"); 17 | assertEquals( 18 | await getLocalDependencies( 19 | { filename: "../test/unit/test-files/a.ts", truncateCommonPrefix: true }, 20 | ), 21 | [ 22 | "/a.ts", 23 | "/b.ts", 24 | "/c.ts", 25 | ], 26 | ); 27 | Deno.chdir(".."); 28 | }); 29 | 30 | Deno.test("get local dependencies (absolute path)", async () => { 31 | assertEquals( 32 | await getLocalDependencies( 33 | { 34 | filename: Deno.cwd() + "/test/unit/test-files/a.ts", 35 | truncateCommonPrefix: true, 36 | }, 37 | ), 38 | [ 39 | "/a.ts", 40 | "/b.ts", 41 | "/c.ts", 42 | ], 43 | ); 44 | }); 45 | 46 | Deno.test("get local dependencies (with import map)", async () => { 47 | const path = Deno.cwd() + "/test/unit/test-files/"; 48 | assertEquals( 49 | await getLocalDependencies( 50 | { 51 | filename: path + "d.ts", 52 | truncateCommonPrefix: true, 53 | importMap: { "foo": path + "a.ts" }, 54 | }, 55 | ), 56 | [ 57 | "/a.ts", 58 | "/b.ts", 59 | "/c.ts", 60 | "/d.ts", 61 | ], 62 | ); 63 | }); 64 | 65 | Deno.test("get local dependencies (shouldnt include remote dependencies)", async () => { 66 | const path = Deno.cwd() + "/test/unit/test-files/"; 67 | assertEquals( 68 | await getLocalDependencies( 69 | { 70 | filename: path + "d.ts", 71 | truncateCommonPrefix: false, 72 | importMap: { "foo": "https://hello.world" }, 73 | }, 74 | ), 75 | [ 76 | Deno.cwd() + "/test/unit/test-files/d.ts", 77 | ], 78 | ); 79 | }); 80 | 81 | Deno.test("get local dependencies (real example)", async () => { 82 | Deno.chdir("./templates/new-project"); 83 | const result = await getLocalDependencies({ 84 | filename: "./root/_server.ts", 85 | truncateCommonPrefix: true, 86 | importMap: { 87 | "levo/": "../../mod/", 88 | "asserts": "https://deno.land/std@0.61.0/testing/asserts.ts", 89 | "environment": "./environment.ts", 90 | }, 91 | }); 92 | Deno.chdir("../.."); 93 | assertEquals(result, [ 94 | "/mod/levo-serve-response.ts", 95 | "/mod/levo-serve.ts", 96 | "/mod/levo-view.ts", 97 | "/src/camel-to-kebab.ts", 98 | "/src/css-types.ts", 99 | "/src/lispy-elements.ts", 100 | "/src/render-to-string.ts", 101 | "/src/virtual-node-events.ts", 102 | "/src/virtual-node.ts", 103 | "/templates/new-project/environment.ts", 104 | "/templates/new-project/root/_server.ts", 105 | "/templates/new-project/root/view.tsx", 106 | ]); 107 | }); 108 | -------------------------------------------------------------------------------- /test/unit/memory-cache.test.ts: -------------------------------------------------------------------------------- 1 | import { MemoryCache } from "../../src/memory-cache.ts"; 2 | import { assertEquals } from "../deps.ts"; 3 | 4 | Deno.test("memory cache (case 1)", () => { 5 | const memoryCache = new MemoryCache({ maxNumberOfKeys: 3 }); 6 | memoryCache.set("a", 0); 7 | assertEquals(memoryCache._getCache(), { "a": 0 }); 8 | memoryCache.set("b", 1); 9 | assertEquals(memoryCache._getCache(), { "a": 0, "b": 1 }); 10 | memoryCache.set("c", 2); 11 | assertEquals(memoryCache._getCache(), { "a": 0, "b": 1, "c": 2 }); 12 | 13 | assertEquals(memoryCache.get("a"), 0); 14 | assertEquals(memoryCache.get("b"), 1); 15 | assertEquals(memoryCache.get("c"), 2); 16 | 17 | // Retrieve C another two times 18 | memoryCache.get("c"); 19 | memoryCache.get("c"); 20 | 21 | // Retrieve A another one time 22 | memoryCache.get("a"); 23 | 24 | assertEquals(memoryCache._getRetrievalFrequency(), { 25 | "a": 2, 26 | "b": 1, 27 | "c": 3, 28 | }); 29 | 30 | // Set "d", expect "b" is replaced, since "b" has the least retrieval frequency 31 | memoryCache.set("d", 3); 32 | assertEquals(memoryCache._getCache(), { "a": 0, "c": 2, "d": 3 }); 33 | assertEquals(memoryCache.get("b"), undefined); 34 | 35 | // Retrieve "b", although "b" is gone, expect it's frequency to be still counted 36 | const bFreq1 = memoryCache._getRetrievalFrequency()["b"]; 37 | memoryCache.get("b"); 38 | const bFreq2 = memoryCache._getRetrievalFrequency()["b"]; 39 | assertEquals(bFreq2 - bFreq1, 1); 40 | }); 41 | -------------------------------------------------------------------------------- /test/unit/mime-lookup.test.ts: -------------------------------------------------------------------------------- 1 | import { mimeLookup } from "../../src/mime-lookup.ts"; 2 | import { assertEquals } from "../../src/deps.ts"; 3 | 4 | Deno.test("mime-lookup", () => { 5 | assertEquals(mimeLookup("home/test.js"), "application/javascript"); 6 | assertEquals(mimeLookup("home/test.pdf"), "application/pdf"); 7 | assertEquals(mimeLookup("home/test.css"), "text/css"); 8 | assertEquals(mimeLookup("home/test.html"), "text/html"); 9 | assertEquals(mimeLookup("home/.git"), "text/troff"); 10 | }); 11 | -------------------------------------------------------------------------------- /test/unit/render-to-string.test.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx h */ 2 | import { h, createDispatch } from "../../mod/levo-view.ts"; 3 | import { renderToString } from "../../src/render-to-string.ts"; 4 | import { assertEquals } from "../../src/deps.ts"; 5 | 6 | Deno.test("render-to-string", () => { 7 | const dispatch = createDispatch<{ $: "click"; index: number }>(); 8 | const element = ( 9 |
10 | 11 | 12 |
13 | ); 14 | 15 | assertEquals( 16 | renderToString(element), 17 | ` 18 |
19 | `.trim(), 20 | ); 21 | }); 22 | -------------------------------------------------------------------------------- /test/unit/resolve-import-map.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "../deps.ts"; 2 | import { resolveImportMap } from "../../src/resolve-import-map.ts"; 3 | 4 | Deno.test("resolve import map", () => { 5 | const importMap = { 6 | "foo/": "./bar/", 7 | "levo/": "../../mod/", 8 | "asserts": "https://deno.land/std@0.61.0/testing/asserts.ts", 9 | "environment": "./environment.ts", 10 | }; 11 | assertEquals(resolveImportMap(importMap, "foo/yo"), "./bar/yo"); 12 | assertEquals(resolveImportMap(importMap, "foo/"), "./bar/"); 13 | assertEquals( 14 | resolveImportMap(importMap, "levo/view.tsx"), 15 | "../../mod/view.tsx", 16 | ); 17 | assertEquals( 18 | resolveImportMap(importMap, "asserts"), 19 | "https://deno.land/std@0.61.0/testing/asserts.ts", 20 | ); 21 | assertEquals(resolveImportMap(importMap, "environment"), "./environment.ts"); 22 | assertEquals(resolveImportMap(importMap, "x"), undefined); 23 | }); 24 | -------------------------------------------------------------------------------- /test/unit/resolve-url.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveUrl, DirectoryTree } from "../../src/resolve-url.ts"; 2 | import { assertEquals } from "../../src/deps.ts"; 3 | 4 | Deno.test("resolve URL (case 1)", () => { 5 | const tree: DirectoryTree[] = [ 6 | ["xxx"], 7 | ["_"], 8 | ]; 9 | assertEquals(resolveUrl(tree, "/xxx"), "xxx"); 10 | assertEquals(resolveUrl(tree, "/hello"), "_"); 11 | 12 | assertEquals(resolveUrl(tree, "xxx"), "xxx"); 13 | assertEquals(resolveUrl(tree, "hello"), "_"); 14 | }); 15 | 16 | Deno.test("resolve URL (case 2)", () => { 17 | const tree: DirectoryTree[] = [ 18 | ["user", [ 19 | ["xxx"], 20 | ["_"], 21 | ]], 22 | ]; 23 | assertEquals(resolveUrl(tree, "/user/xxx"), "user/xxx"); 24 | assertEquals(resolveUrl(tree, "/user/hello"), "user/_"); 25 | }); 26 | 27 | Deno.test("resolve URL (case 3: hole in middle)", () => { 28 | const tree: DirectoryTree[] = [ 29 | ["user", [ 30 | ["xxx", [ 31 | ["profile"], 32 | ]], 33 | ["_", [ 34 | ["profile"], 35 | ]], 36 | ]], 37 | ]; 38 | assertEquals(resolveUrl(tree, "/user/xxx/profile"), "user/xxx/profile"); 39 | assertEquals(resolveUrl(tree, "/user/hello/profile"), "user/_/profile"); 40 | }); 41 | 42 | Deno.test("resolve URL (case : hole surround exact)", () => { 43 | const tree: DirectoryTree[] = [ 44 | ["_", [ 45 | ["hello", [ 46 | ["_"], 47 | ]], 48 | ["yo", [ 49 | ["_"], 50 | ]], 51 | ]], 52 | ]; 53 | assertEquals(resolveUrl(tree, "/yo/hello/hey"), "_/hello/_"); 54 | assertEquals(resolveUrl(tree, "/hello/yo/jo"), "_/yo/_"); 55 | assertEquals(resolveUrl(tree, "/hello/jak/jo"), undefined); 56 | }); 57 | 58 | Deno.test("resolve URL (case : consequtive hole)", () => { 59 | const tree: DirectoryTree[] = [ 60 | ["_", [ 61 | ["_", [ 62 | ["yo"], 63 | ]], 64 | ]], 65 | ]; 66 | assertEquals(resolveUrl(tree, "/x/y/yo"), "_/_/yo"); 67 | assertEquals(resolveUrl(tree, "/x/y/yos"), undefined); 68 | }); 69 | 70 | Deno.test("resolve URL (case : negative cases)", () => { 71 | const tree: DirectoryTree[] = [ 72 | ["xxx"], 73 | ]; 74 | assertEquals(resolveUrl(tree, "xxx"), "xxx"); 75 | assertEquals(resolveUrl(tree, ""), ""); 76 | assertEquals(resolveUrl(tree, "/x/y/yos"), undefined); 77 | }); 78 | -------------------------------------------------------------------------------- /test/unit/test-files/a.ts: -------------------------------------------------------------------------------- 1 | import * as b from "./b.ts"; 2 | console.log(b); 3 | -------------------------------------------------------------------------------- /test/unit/test-files/b.ts: -------------------------------------------------------------------------------- 1 | import * as c from "./c.ts"; 2 | export { c }; 3 | -------------------------------------------------------------------------------- /test/unit/test-files/c.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levo-framework/core/02c29d1c6242a2e80d3209df877279688a44d80e/test/unit/test-files/c.ts -------------------------------------------------------------------------------- /test/unit/test-files/d.ts: -------------------------------------------------------------------------------- 1 | import * as h from "foo"; 2 | -------------------------------------------------------------------------------- /test/unit/test-files/x.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levo-framework/core/02c29d1c6242a2e80d3209df877279688a44d80e/test/unit/test-files/x.ts -------------------------------------------------------------------------------- /test/unit/watch-dependencies.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "../deps.ts"; 2 | import { watchDependencies } from "../../src/watch-dependencies.ts"; 3 | 4 | Deno.test({ 5 | name: "watch dependencies", 6 | fn: async () => { 7 | await new Promise((resolve) => setTimeout(resolve, 2000)); 8 | 9 | const aPath = "./test/unit/test-files/a.ts"; 10 | const bPath = "./test/unit/test-files/b.ts"; 11 | const cPath = "./test/unit/test-files/c.ts"; 12 | const xPath = "./test/unit/test-files/x.ts"; 13 | 14 | const events: Deno.FsEvent[] = []; 15 | 16 | // Watch xPath 17 | const handler = await watchDependencies( 18 | { 19 | filename: xPath, 20 | onChange: (event) => { 21 | events.push(event); 22 | }, 23 | }, 24 | ); 25 | 26 | await new Promise((resolve) => setTimeout(resolve, 1000)); 27 | 28 | const read = async (filename: string): Promise => { 29 | return new TextDecoder().decode(await Deno.readFile(filename)); 30 | }; 31 | const write = async (filename: string, content: string): Promise => { 32 | return Deno.writeFile(filename, new TextEncoder().encode(content)); 33 | }; 34 | 35 | const aContent = await read(aPath); 36 | const bContent = await read(bPath); 37 | const cContent = await read(cPath); 38 | const xContent = await read(xPath); 39 | 40 | // Change file x.ts to include a.ts 41 | await write(xPath, `import * as a from "./a.ts"`); 42 | await new Promise((resolve) => setTimeout(resolve, 1000)); 43 | 44 | // Change file b.ts 45 | await write(bPath, `export * from './c.ts'`); 46 | await new Promise((resolve) => setTimeout(resolve, 1000)); 47 | 48 | // Change file c.ts 49 | await write(cPath, `export const c = 3`); 50 | await new Promise((resolve) => setTimeout(resolve, 1000)); 51 | 52 | assertEquals(events.map((event) => event.paths), [ 53 | [Deno.cwd() + "/test/unit/test-files/x.ts"], 54 | [Deno.cwd() + "/test/unit/test-files/b.ts"], 55 | [Deno.cwd() + "/test/unit/test-files/c.ts"], 56 | ]); 57 | 58 | await handler.stop?.(); 59 | 60 | // Reset the files 61 | await write(aPath, aContent); 62 | await write(bPath, bContent); 63 | await write(cPath, cContent); 64 | await write(xPath, xContent); 65 | 66 | await new Promise((resolve) => setTimeout(resolve, 2000)); 67 | }, 68 | }); 69 | -------------------------------------------------------------------------------- /test/unit/watch-file.test.ts: -------------------------------------------------------------------------------- 1 | import { watchFile } from "../../src/watch-file.ts"; 2 | import { assertEquals } from "../deps.ts"; 3 | 4 | Deno.test({ 5 | name: "watch file", 6 | fn: async () => { 7 | await new Promise((resolve) => setTimeout(resolve, 2000)); 8 | 9 | let counter = 0; 10 | const path = "./test/unit/test-files/x.ts"; 11 | const handler = await watchFile({ 12 | paths: [path], 13 | onChange: () => { 14 | counter++; 15 | }, 16 | }); 17 | 18 | await new Promise((resolve) => setTimeout(resolve, 2000)); 19 | 20 | const initialContent = await Deno.readFile(path); 21 | 22 | // Change the file x.ts 23 | await Deno.writeFile(path, new TextEncoder().encode("hello")); 24 | 25 | await new Promise((resolve) => setTimeout(resolve, 2000)); 26 | 27 | // Expect onChange handlers is olny triggered once 28 | assertEquals(counter, 1); 29 | 30 | // Reset the file 31 | await Deno.writeFile(path, initialContent); 32 | 33 | await handler.stop(); 34 | 35 | await new Promise((resolve) => setTimeout(resolve, 2000)); 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /tools/bundle-levo-runtime.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | deno run --allow-all --unstable ./tools/bundle-levo-runtime.ts -------------------------------------------------------------------------------- /tools/bundle-levo-runtime.ts: -------------------------------------------------------------------------------- 1 | import { runCommand } from "../src/run-command.ts"; 2 | import { minifyJavascript } from "../src/minify-js.ts"; 3 | 4 | const main = async () => { 5 | const { output: bundled } = await runCommand( 6 | "deno bundle --config levo-runtime.tsconfig.json src/levo-runtime.ts", 7 | ); 8 | if (bundled) { 9 | const { code, error } = minifyJavascript(bundled); 10 | if (error) { 11 | console.error(error); 12 | return Deno.exit(1); 13 | } else if (code.includes("`")) { 14 | console.error(`Minified bundle of levo-runtime.ts contains backtick!`); 15 | Deno.exit(1); 16 | } else { 17 | const sanitizedCode = code.replace(/\\n/g, " "); 18 | await Deno.writeFile( 19 | "./levo-runtime-raw.ts", 20 | new TextEncoder() 21 | .encode(`export const levoRuntimeCode = \`${sanitizedCode}\``), 22 | ); 23 | await runCommand("deno fmt levo-runtime-raw.ts"); 24 | } 25 | } 26 | }; 27 | 28 | if (import.meta.main) { 29 | await main(); 30 | } 31 | -------------------------------------------------------------------------------- /tools/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | deno run --allow-run ./tools/format.ts "$1" -------------------------------------------------------------------------------- /tools/format.ts: -------------------------------------------------------------------------------- 1 | import { runCommand } from "../src/run-command.ts"; 2 | 3 | const dirs = [ 4 | "demo", 5 | "mod", 6 | "src", 7 | "test", 8 | "templates", 9 | "cli", 10 | "tools", 11 | "./test-levo-runtime/test.js", 12 | ]; 13 | 14 | const command = Deno.args.includes("--check") ? "deno fmt --check" : "deno fmt"; 15 | 16 | await dirs.reduce((promise, dir) => { 17 | return promise.then(async () => { 18 | const { output, error } = await runCommand(command + " " + dir); 19 | if (error) { 20 | console.error(output); 21 | Deno.exit(1); 22 | } else { 23 | console.log(output); 24 | } 25 | }); 26 | }, Promise.resolve()); 27 | -------------------------------------------------------------------------------- /tools/update-deno-types.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | deno types > './lib/lib.deno_runtime.d.ts' 3 | deno types > './templates/new-project/lib/lib.deno_runtime.d.ts' -------------------------------------------------------------------------------- /tools/verify-levo-runtime-raw.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | git status | grep "levo-runtime-raw.ts" \ 3 | && echo "levo-runtime-raw.ts is not up to date" 1>&2 \ 4 | && exit 1 5 | exit 0 -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "plugins": [ 4 | { 5 | "name": "typescript-deno-plugin", 6 | "enable": true, // default is `true` 7 | "importmap": "import_map.json" 8 | } 9 | ], 10 | "allowJs": false, 11 | "allowUmdGlobalAccess": false, 12 | "allowUnreachableCode": false, 13 | "allowUnusedLabels": false, 14 | "alwaysStrict": true, 15 | "assumeChangesOnlyAffectDirectDependencies": false, 16 | "checkJs": false, 17 | "disableSizeLimit": false, 18 | "generateCpuProfile": "profile.cpuprofile", 19 | "jsx": "react", 20 | "jsxFactory": "React.createElement", 21 | "lib": ["dom", "DOM", "ES2016", "ES2017", "ES2018", "ES2019"], 22 | "noFallthroughCasesInSwitch": false, 23 | "noImplicitAny": true, 24 | "noImplicitReturns": true, 25 | "noImplicitThis": true, 26 | "noImplicitUseStrict": false, 27 | "noStrictGenericChecks": false, 28 | "noUnusedLocals": false, 29 | "noUnusedParameters": false, 30 | "preserveConstEnums": false, 31 | "removeComments": false, 32 | "resolveJsonModule": true, 33 | "strict": true, 34 | "strictBindCallApply": true, 35 | "strictFunctionTypes": true, 36 | "strictNullChecks": true, 37 | "strictPropertyInitialization": true, 38 | "suppressExcessPropertyErrors": false, 39 | "suppressImplicitAnyIndexErrors": false, 40 | "useDefineForClassFields": false 41 | } 42 | } 43 | --------------------------------------------------------------------------------