├── .github
├── CODEOWNERS
├── FUNDING.yml
├── release.yml
└── workflows
│ ├── deploy-npm.yml
│ └── deploy-website.yml
├── .gitignore
├── .nvmrc
├── .prettierrc
├── .vscode
├── launch.json
└── settings.json
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── admin
├── .env.development
├── .eslintrc
├── .gitignore
├── index.html
├── package.json
├── postcss.config.js
├── public
│ └── zed-mono
│ │ ├── zed-mono-bold.woff2
│ │ ├── zed-mono-italic.woff2
│ │ ├── zed-mono-medium.woff2
│ │ └── zed-mono-regular.woff2
├── src
│ ├── app.tsx
│ ├── client
│ │ ├── README.md
│ │ ├── app.ts
│ │ └── collection.ts
│ ├── components
│ │ ├── button.tsx
│ │ ├── chart.tsx
│ │ ├── chip.tsx
│ │ ├── chips-input.tsx
│ │ ├── cm-dark.ts
│ │ ├── code-editor.tsx
│ │ ├── collection-form.tsx
│ │ ├── collections-empty-state.tsx
│ │ ├── copy.tsx
│ │ ├── date.tsx
│ │ ├── dev-dialog.tsx
│ │ ├── field.tsx
│ │ ├── filter-input.tsx
│ │ ├── flow-handle.tsx
│ │ ├── general-error.tsx
│ │ ├── hook-node.tsx
│ │ ├── hooks-search.tsx
│ │ ├── id-tag.tsx
│ │ ├── input.tsx
│ │ ├── nav-links.tsx
│ │ ├── popover.tsx
│ │ ├── schema-fields.tsx
│ │ ├── select.tsx
│ │ ├── service-node.tsx
│ │ ├── snacks.tsx
│ │ └── value.tsx
│ ├── data
│ │ ├── app-developers.ts
│ │ ├── collections.ts
│ │ ├── hooks-registry.ts
│ │ ├── logs.ts
│ │ └── schema-refs.ts
│ ├── extras.css
│ ├── index.css
│ ├── layouts
│ │ ├── AdminLayout.tsx
│ │ └── NavContentLayout.tsx
│ ├── lib
│ │ ├── app-error.ts
│ │ ├── append-index-fields.ts
│ │ ├── append-schema-fields.ts
│ │ ├── avatar-colors.ts
│ │ ├── clean-date.ts
│ │ ├── definition-from-field.ts
│ │ ├── field-types.ts
│ │ ├── get-new-field-name.ts
│ │ ├── get-schema.tsx
│ │ ├── indexed.ts
│ │ ├── node-types.ts
│ │ ├── random-str.ts
│ │ ├── remove-fields-item.ts
│ │ ├── request-status.ts
│ │ ├── schema-from-fields.ts
│ │ ├── slugify.ts
│ │ ├── snacks.ts
│ │ └── use-color-scheme.ts
│ ├── main.tsx
│ ├── mangobase-app.ts
│ ├── pages
│ │ ├── collections
│ │ │ ├── [name]
│ │ │ │ ├── _layout.tsx
│ │ │ │ ├── edit.tsx
│ │ │ │ ├── hooks.tsx
│ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ ├── login.tsx
│ │ ├── logs.tsx
│ │ ├── notfound.tsx
│ │ └── settings
│ │ │ ├── devs.tsx
│ │ │ ├── index.tsx
│ │ │ ├── profile.tsx
│ │ │ └── schemas
│ │ │ ├── [name].tsx
│ │ │ └── index.tsx
│ ├── routes.tsx
│ └── vite-env.d.ts
├── tailwind.config.js
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
├── assets
├── ss-dark.png
└── ss-light.png
├── base
├── .eslintrc
├── .gitignore
├── CHANGELOG.md
├── README.md
├── build.mjs
├── package.json
├── src
│ ├── app.spec.ts
│ ├── app.ts
│ ├── authentication.spec.ts
│ ├── authentication.ts
│ ├── collection-service.ts
│ ├── collection.test.ts
│ ├── collection.ts
│ ├── context.ts
│ ├── database.ts
│ ├── db-migrations.ts
│ ├── errors.ts
│ ├── hook.ts
│ ├── hooks-registry.ts
│ ├── hooks.ts
│ ├── index.ts
│ ├── lib
│ │ ├── __snapshots__
│ │ │ └── export-schema.test.ts.snap
│ │ ├── api-paths.ts
│ │ ├── clone.ts
│ │ ├── export-schema.test.ts
│ │ ├── export-schema.ts
│ │ ├── get-collection.ts
│ │ ├── get-ref-usage.test.ts
│ │ ├── get-ref-usage.ts
│ │ ├── index.ts
│ │ ├── method-from-http.ts
│ │ ├── random-str.ts
│ │ ├── set-with-path.test.ts
│ │ └── set-with-path.ts
│ ├── logger.ts
│ ├── manifest.ts
│ ├── method.ts
│ ├── schema.test.ts
│ ├── schema.ts
│ └── users.ts
├── tsconfig.build.json
├── tsconfig.json
└── vitest.config.ts
├── bun-server
├── .gitignore
├── CHANGELOG.md
├── README.md
├── build.js
├── package.json
├── src
│ └── index.ts
├── tsconfig.build.json
└── tsconfig.json
├── create-mango
├── .editorconfig
├── .gitattributes
├── .gitignore
├── .prettierignore
├── CHANGELOG.md
├── package-lock.json
├── package.json
├── readme.md
├── source
│ ├── app.tsx
│ ├── cli.tsx
│ └── create-project.ts
├── templates
│ ├── _common
│ │ ├── .env
│ │ ├── Dockerfile
│ │ ├── _gitattributes
│ │ ├── _gitignore
│ │ ├── _prettierignore
│ │ └── readme.md
│ ├── js
│ │ ├── _package.json
│ │ └── src
│ │ │ └── index.mjs
│ └── ts
│ │ ├── _package.json
│ │ ├── build.js
│ │ ├── src
│ │ └── index.ts
│ │ └── tsconfig.json
├── tsconfig.json
└── yarn.lock
├── eslint-config-base
├── index.js
├── package.json
└── yarn.lock
├── examples
├── base-bun-mongo
│ ├── .gitignore
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ └── index.ts
│ └── tsconfig.json
└── base-express-mongo
│ ├── .gitignore
│ ├── index.ts
│ └── package.json
├── express-server
├── .gitignore
├── CHANGELOG.md
├── README.md
├── build.mjs
├── package.json
├── src
│ └── index.ts
├── tsconfig.build.json
└── tsconfig.json
├── lerna.json
├── mongo-db
├── .eslintrc
├── .gitignore
├── CHANGELOG.md
├── README.md
├── build.mjs
├── global.setup.ts
├── package.json
├── src
│ ├── index.ts
│ ├── mongodb.test.ts
│ └── mongodb.ts
├── tsconfig.build.json
├── tsconfig.json
└── vitest.config.ts
├── package.json
├── turbo.json
├── website
├── .gitignore
├── .vitepress
│ ├── config.mts
│ └── theme
│ │ ├── index.js
│ │ ├── mango-spline.vue
│ │ └── styles.css
├── api
│ └── index.md
├── generate-api.mjs
├── guide
│ ├── authentication.md
│ ├── context.md
│ ├── dashboard.md
│ ├── database-adapters.md
│ ├── deployment.md
│ ├── faqs.md
│ ├── getting-started.md
│ ├── hooks.md
│ ├── index.md
│ ├── plugins.md
│ ├── query.md
│ ├── rest.md
│ └── server-adapters.md
├── index.md
└── package.json
└── yarn.lock
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @blackmann
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [blackmann]
2 |
--------------------------------------------------------------------------------
/.github/release.yml:
--------------------------------------------------------------------------------
1 | changelog:
2 | exclude:
3 | labels:
4 | - ignore-for-release
5 | authors:
6 | - octocat
7 | categories:
8 | - title: Breaking Changes 🛠
9 | labels:
10 | - Semver-Major
11 | - breaking-change
12 | - title: Exciting New Features 🎉
13 | labels:
14 | - Semver-Minor
15 | - enhancement
16 | - title: Other Changes
17 | labels:
18 | - "*"
19 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-npm.yml:
--------------------------------------------------------------------------------
1 | name: Publish packages to npm
2 | on:
3 | workflow_dispatch:
4 | inputs:
5 | version:
6 | type: choice
7 | description: 'Version to publish'
8 | required: true
9 | default: patch
10 | options:
11 | - patch
12 | - minor
13 | - major
14 |
15 | permissions:
16 | contents: write
17 |
18 | jobs:
19 | build:
20 | runs-on: ubuntu-latest
21 | env:
22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
23 | steps:
24 | - uses: actions/checkout@v2
25 | - name: Setup Node.js
26 | uses: actions/setup-node@v1
27 | with:
28 | node-version: 18
29 | registry-url: 'https://registry.npmjs.org'
30 | - run: git config --global user.email "mail@degreat.co.uk"
31 | - run: git config --global user.name "De-Great Yartey"
32 | - name: Install dependencies
33 | run: yarn boot
34 | - name: Turbo+Build
35 | run: yarn build
36 | - name: Test
37 | run: yarn test
38 | - name: Bump version
39 | run: npx lerna version ${{ github.event.inputs.version }} --conventional-commits --yes --no-private
40 | - name: Publish
41 | run: npx lerna publish from-package --yes --no-private
42 | env:
43 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
44 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-website.yml:
--------------------------------------------------------------------------------
1 | # Sample workflow for building and deploying a VitePress site to GitHub Pages
2 | #
3 | name: Deploy VitePress site to Pages
4 |
5 | on:
6 | # Runs on pushes targeting the `main` branch. Change this to `master` if you're
7 | # using the `master` branch as the default branch.
8 | push:
9 | branches: [master]
10 | paths:
11 | - website/**
12 | - base/**
13 | - mongodb/**
14 | - express
15 | - bun-server
16 |
17 | # Allows you to run this workflow manually from the Actions tab
18 | workflow_dispatch:
19 |
20 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
21 | permissions:
22 | contents: read
23 | pages: write
24 | id-token: write
25 |
26 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
27 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
28 | concurrency:
29 | group: pages
30 | cancel-in-progress: false
31 |
32 | jobs:
33 | # Build job
34 | build:
35 | runs-on: ubuntu-latest
36 | steps:
37 | - name: Checkout
38 | uses: actions/checkout@v3
39 | with:
40 | fetch-depth: 0 # Not needed if lastUpdated is not enabled
41 | # - uses: pnpm/action-setup@v2 # Uncomment this if you're using pnpm
42 | # - uses: oven-sh/setup-bun@v1 # Uncomment this if you're using Bun
43 | - name: Setup Node
44 | uses: actions/setup-node@v3
45 | with:
46 | node-version: 18
47 | cache: npm # or pnpm / yarn
48 | - name: Setup Pages
49 | uses: actions/configure-pages@v3
50 | - name: Install dependencies
51 | run: yarn --frozen-lockfile
52 | - name: Build with VitePress
53 | run: |
54 | yarn build
55 | touch website/.vitepress/dist/.nojekyll
56 | - name: Upload artifact
57 | uses: actions/upload-pages-artifact@v2
58 | with:
59 | path: website/.vitepress/dist
60 |
61 | # Deployment job
62 | deploy:
63 | environment:
64 | name: github-pages
65 | url: ${{ steps.deployment.outputs.page_url }}
66 | needs: build
67 | runs-on: ubuntu-latest
68 | name: Deploy
69 | steps:
70 | - name: Deploy to GitHub Pages
71 | id: deployment
72 | uses: actions/deploy-pages@v2
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | yarn-error.log
2 |
3 | node_modules/
4 | coverage/
5 | .turbo
6 | .mangobase
7 |
8 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 18
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "node",
9 | "runtimeExecutable": "yarn",
10 | "runtimeArgs": ["dev:admin"],
11 | "name": "dev:admin",
12 | "request": "launch",
13 | },
14 | {
15 | "type": "node",
16 | "runtimeExecutable": "yarn",
17 | "runtimeArgs": ["dev:express-mongo"],
18 | "name": "Example - express/mongo",
19 | "request": "launch"
20 | }
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.workingDirectories": [{ "mode": "auto" }]
3 | }
4 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Development
2 |
3 | > 💽 You should have [nvm](https://github.com/nvm-sh/nvm) installed; then run `nvm use`. If you can't use nvm, you should use Node 18 for development for consistency sake.
4 |
5 | This is a monorepo. Run `yarn boot` to install all dependencies.
6 |
7 | ### Design summary
8 |
9 | The `base` package contains the core of the project. In there, you'll find code related to dealing with collections, plugins, handling requests (aka contexts), etc.
10 |
11 | You'll need to interface with the base using a server. So there are two implementations: `@mangobase/express` and `@mangobase/bun`. You'll also need to provide a database but we have an implementation: `@mangobase/mongodb`.
12 |
13 | ### Contributions
14 |
15 | It's recommended to create a separate branch for your contributions. First, fork this project then make your changes. Submit a PR when you think its ready to be merged.
16 |
17 | See: https://docs.github.com/en/get-started/quickstart/contributing-to-projects
18 |
19 | #### base
20 |
21 | When contributing to the `base`, you many need to test your implementation using unit tests/specs.
22 |
23 | To actually validate your code, run `yarn build:base && yarn copy-admin`. You can then use one of the example projects to test your code. Do `yarn dev:express-mongo` or `bun dev:bun-mongo`
24 |
25 | > You should have [bun](https://bun.sh) installed to try the bun example.
26 |
27 | You can then use an HTTP client to test endpoints.
28 |
29 | Another way to validate your addition is to use the [admin/dev](#admin) dashboard.
30 |
31 | #### admin
32 |
33 | The [dev] admin panel is where the low-code experience happens. It's developed with Vite/Preact.
34 |
35 | Run it with `yarn dev:admin`. You need to have a server running. Run one with `yarn dev:express-mongo`
36 |
37 | Whenever you make changes to `base`, you'll need to restart `express-mongo` for backend changes to reflect.
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 De-Great Yartey
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 | # mangobase
2 |
3 | [](https://www.npmjs.com/package/mangobase)
4 | [](https://www.npmjs.com/package/mangobase)
5 | [](https://www.npmjs.com/package/mangobase)
6 |
7 | Low-code Javascript backend framework for Node and Bun runtimes. Docs [here](https://degreat.co.uk/mangobase).
8 |
9 | ```sh
10 | npm create mango@latest
11 | ```
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | See [Contributing](CONTRIBUTING.md) for development guide on how contribute.
--------------------------------------------------------------------------------
/admin/.env.development:
--------------------------------------------------------------------------------
1 | VITE_API_URL=http://localhost:3000/api
--------------------------------------------------------------------------------
/admin/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["base"],
3 | "parserOptions": {
4 | "project": "./tsconfig.json"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/admin/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/admin/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
9 | Mangobase
10 |
11 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/admin/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@mangobase/admin",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "clean": "rm -rf dist",
8 | "dev": "vite",
9 | "build": "tsc && vite build",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@codemirror/autocomplete": "^6.11.1",
14 | "@codemirror/commands": "^6.3.3",
15 | "@codemirror/lang-json": "^6.0.1",
16 | "@codemirror/language": "^6.10.0",
17 | "@codemirror/state": "^6.4.0",
18 | "@codemirror/view": "^6.23.0",
19 | "@lezer/highlight": "^1.2.0",
20 | "@monaco-editor/react": "4.5.2",
21 | "@preact/signals": "^1.1.3",
22 | "@radix-ui/react-popover": "^1.0.7",
23 | "autoprefixer": "^10.4.14",
24 | "boring-avatars": "^1.10.1",
25 | "chart.js": "^4.4.0",
26 | "clsx": "^1.2.1",
27 | "dayjs": "^1.11.9",
28 | "mangobase": "*",
29 | "preact": "^10.13.2",
30 | "qs": "^6.11.2",
31 | "react-hook-form": "^7.44.3",
32 | "react-router-dom": "^6.13.0",
33 | "reactflow": "^11.7.4",
34 | "redaxios": "^0.5.1"
35 | },
36 | "devDependencies": {
37 | "@preact/preset-vite": "^2.5.0",
38 | "@tailwindcss/forms": "^0.5.7",
39 | "@testing-library/preact": "^3.2.3",
40 | "@types/jsdom": "^21.1.5",
41 | "@types/react": "npm:@preact/compat@*",
42 | "@types/react-dom": "npm:@preact/compat@*",
43 | "eslint-config-base": "*",
44 | "eslint-config-preact": "^1.3.0",
45 | "jsdom": "^22.1.0",
46 | "monaco-editor": "^0.41.0",
47 | "postcss": "^8.4.28",
48 | "tailwindcss": "^3.4.0",
49 | "typescript": "^5.0.2",
50 | "vite": "^4.3.9",
51 | "vitest": "^0.34.6",
52 | "vitest-dom": "^0.1.1"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/admin/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | autoprefixer: {},
4 | tailwindcss: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/admin/public/zed-mono/zed-mono-bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackmann/mangobase/ddf71b8b4e80945e84e7e0f0c4db9714cbfed300/admin/public/zed-mono/zed-mono-bold.woff2
--------------------------------------------------------------------------------
/admin/public/zed-mono/zed-mono-italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackmann/mangobase/ddf71b8b4e80945e84e7e0f0c4db9714cbfed300/admin/public/zed-mono/zed-mono-italic.woff2
--------------------------------------------------------------------------------
/admin/public/zed-mono/zed-mono-medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackmann/mangobase/ddf71b8b4e80945e84e7e0f0c4db9714cbfed300/admin/public/zed-mono/zed-mono-medium.woff2
--------------------------------------------------------------------------------
/admin/public/zed-mono/zed-mono-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackmann/mangobase/ddf71b8b4e80945e84e7e0f0c4db9714cbfed300/admin/public/zed-mono/zed-mono-regular.woff2
--------------------------------------------------------------------------------
/admin/src/app.tsx:
--------------------------------------------------------------------------------
1 | import { RouterProvider } from 'react-router-dom'
2 | import { Snacks } from './components/snacks'
3 | import routes from './routes'
4 |
5 | export function App() {
6 | return (
7 | <>
8 |
9 |
10 | >
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/admin/src/client/README.md:
--------------------------------------------------------------------------------
1 | # @mangobase/client
2 |
3 | This is the client library for mangobase and can be used with any Javascript framework or vanilla.
4 |
5 | Currently being developed inside the `admin` project, but will moved out when it's mature.
6 |
--------------------------------------------------------------------------------
/admin/src/client/app.ts:
--------------------------------------------------------------------------------
1 | import Collection, { type CollectionProps } from './collection'
2 | import axios, { RequestHeaders } from 'redaxios'
3 |
4 | type Req = typeof axios
5 | interface Auth {
6 | auth: { token: string; type: 'Bearer' }
7 | user: {
8 | fullname: string
9 | username: string
10 | }
11 | }
12 |
13 | class App {
14 | host: string
15 | req: Req
16 | auth?: Auth
17 |
18 | constructor(host: string) {
19 | this.host = host
20 | this.auth = this.get('auth')
21 | this.req = this.getRequests()
22 | }
23 |
24 | private getRequests() {
25 | const headers: RequestHeaders = {}
26 |
27 | if (this.auth) {
28 | headers[
29 | 'authorization'
30 | ] = `${this.auth.auth.type} ${this.auth.auth.token}`
31 | }
32 |
33 | return axios.create({ baseURL: this.host, headers })
34 | }
35 |
36 | async collections(): Promise {
37 | const { data } = await this.req.get('collections?$sort[name]=1')
38 | return data.map((it: CollectionProps) => new Collection(this, it))
39 | }
40 |
41 | async collection(name: string): Promise {
42 | const { data } = await this.req.get(`collections/${name}`)
43 | return new Collection(this, data)
44 | }
45 |
46 | async addCollection(collection: any) {
47 | const { data } = await this.req.post('collections', collection)
48 | return new Collection(this, data)
49 | }
50 |
51 | async editCollection(name: string, collection: any) {
52 | const { data } = await this.req.patch(`collections/${name}`, collection)
53 | return new Collection(this, data)
54 | }
55 |
56 | async hookRegistry() {
57 | const { data } = await this.req.get('_dev/hooks-registry')
58 | return data
59 | }
60 |
61 | set(key: string, value: any) {
62 | localStorage.setItem(key, JSON.stringify(value))
63 |
64 | if (key === 'auth') {
65 | this.auth = value
66 | this.req = this.getRequests()
67 | }
68 | }
69 |
70 | get(key: string) {
71 | const value = localStorage.getItem(key)
72 | return value && JSON.parse(value)
73 | }
74 |
75 | remove(key: string) {
76 | localStorage.removeItem(key)
77 | }
78 | }
79 |
80 | export default App
81 |
--------------------------------------------------------------------------------
/admin/src/client/collection.ts:
--------------------------------------------------------------------------------
1 | import type { Index, SchemaDefinitions } from 'mangobase'
2 | import type App from './app'
3 | import { ReactFlowJsonObject } from 'reactflow'
4 | import qs from 'qs'
5 |
6 | type Editor = ReactFlowJsonObject
7 |
8 | interface CollectionProps {
9 | name: string
10 | schema: SchemaDefinitions
11 | exposed: boolean
12 | readOnlySchema?: boolean
13 | template: boolean
14 | indexes: Index[]
15 | }
16 |
17 | class Collection {
18 | app: App
19 | name: string
20 | schema: SchemaDefinitions
21 | exposed: boolean
22 | template: boolean
23 | readOnlySchema?: boolean
24 | indexes: Index[]
25 |
26 | constructor(app: App, data: CollectionProps) {
27 | this.app = app
28 |
29 | this.name = data.name
30 | this.schema = data.schema
31 | this.exposed = data.exposed
32 | this.template = data.template
33 | this.readOnlySchema = data.readOnlySchema
34 | this.indexes = data.indexes
35 | }
36 |
37 | private get base() {
38 | if (this.exposed) {
39 | return this.name
40 | }
41 |
42 | return `_x/${this.name}`
43 | }
44 |
45 | async find(query: Record = {}) {
46 | const endpoint = this.getEndpoint(`/${this.base}`, query)
47 |
48 | const { data } = await this.app.req.get(endpoint)
49 | return data
50 | }
51 |
52 | async delete(id: string) {
53 | const endpoint = `/${this.base}/${id}`
54 | await this.app.req.delete(endpoint)
55 | }
56 |
57 | async hooks(): Promise {
58 | const { data } = await this.app.req.get(`/_dev/hooks/${this.name}`)
59 | return data
60 | }
61 |
62 | async editor() {
63 | const { data } = await this.app.req.get(`/_dev/editors/${this.name}`)
64 | return data as Editor
65 | }
66 |
67 | async setHooks(hooks: HooksConfig) {
68 | await this.app.req.patch(`/_dev/hooks/${this.name}`, hooks)
69 | }
70 |
71 | async setEditor(editor: Editor) {
72 | await this.app.req.patch(`/_dev/editors/${this.name}`, editor)
73 | }
74 |
75 | private getEndpoint(path: string, filter: Record = {}) {
76 | const endpointParts = [path]
77 | const queryString = qs.stringify(filter)
78 |
79 | if (queryString) {
80 | endpointParts.push(queryString)
81 | }
82 |
83 | return endpointParts.join('?')
84 | }
85 | }
86 |
87 | const HOOK_STAGES = ['before', 'after'] as const
88 | const METHODS = ['find', 'get', 'create', 'patch', 'remove'] as const
89 |
90 | type Stage = `${(typeof HOOK_STAGES)[number]}`
91 | type Method = `${(typeof METHODS)[number]}`
92 | type HookId = string
93 | type HookOptions = Record
94 | type Hook = [HookId, HookOptions?]
95 | type HooksConfig = Record>>
96 |
97 | export default Collection
98 | export { HOOK_STAGES, METHODS }
99 | export type { CollectionProps, Stage, Method, HookOptions, Hook, HooksConfig }
100 |
--------------------------------------------------------------------------------
/admin/src/components/button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'preact/compat'
2 | import clsx from 'clsx'
3 |
4 | type Variant = 'primary' | 'secondary' | 'muted'
5 |
6 | interface Props extends React.ComponentProps<'button'> {
7 | variant?: Variant
8 | }
9 |
10 | const styles: Record = {
11 | muted: 'bg-zinc-300 dark:bg-neutral-500 dark:text-neutral-100',
12 | primary:
13 | 'bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-75 disabled:hover:bg-blue-600',
14 | secondary: 'bg-green-600 text-white hover:bg-green-700',
15 | }
16 |
17 | function Button({ className, variant = 'muted', ...props }: Props) {
18 | const style = styles[variant]
19 | return (
20 |
28 | )
29 | }
30 |
31 | export default Button
32 |
--------------------------------------------------------------------------------
/admin/src/components/chart.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | BarController,
3 | BarElement,
4 | CategoryScale,
5 | Chart,
6 | LinearScale,
7 | } from 'chart.js'
8 | import React from 'preact/compat'
9 |
10 | Chart.register(BarController, BarElement, CategoryScale, LinearScale)
11 |
12 | interface Props {
13 | data: { x: string; y: number }[]
14 | }
15 |
16 | const fillData = Array.from({ length: 48 }, (_, i) => ({
17 | x: i.toString(),
18 | y: 0,
19 | }))
20 |
21 | function BarChart({ data }: Props) {
22 | const ref = React.useRef(null)
23 | const [chart, setChart] = React.useState()
24 |
25 | React.useEffect(() => {
26 | const chart = new Chart(ref.current!, {
27 | data: {
28 | datasets: [
29 | {
30 | backgroundColor: '#3b82f6',
31 | data: fillData.map((value) => value.y),
32 | },
33 | ],
34 | labels: fillData.map((value) => value.x),
35 | },
36 | options: {
37 | maintainAspectRatio: false,
38 | responsive: true,
39 | },
40 | type: 'bar',
41 | })
42 |
43 | setChart(chart)
44 | }, [])
45 |
46 | React.useEffect(() => {
47 | if (!chart) {
48 | return
49 | }
50 |
51 | chart.data.datasets[0].data = data.map((value) => value.y)
52 | chart.data.labels = data.map((value) => value.x)
53 | chart.update()
54 | }, [chart, data])
55 |
56 | return
57 | }
58 |
59 | export default BarChart
60 |
--------------------------------------------------------------------------------
/admin/src/components/chip.tsx:
--------------------------------------------------------------------------------
1 | import React from 'preact/compat'
2 | import clsx from 'clsx'
3 |
4 | interface Props extends React.PropsWithChildren {
5 | className?: string
6 | title?: string
7 | }
8 |
9 | function Chip({ children, className, title }: Props) {
10 | return (
11 |
18 | {children}
19 |
20 | )
21 | }
22 |
23 | export default Chip
24 |
--------------------------------------------------------------------------------
/admin/src/components/code-editor.tsx:
--------------------------------------------------------------------------------
1 | import { Editor, EditorProps } from '@monaco-editor/react'
2 | import React from 'preact/compat'
3 |
4 | function CodeEditor(props: EditorProps) {
5 | const [editor, setEditor] = React.useState(null)
6 |
7 | React.useEffect(() => {
8 | if (!editor) {
9 | return
10 | }
11 |
12 | function onThemeChange(e: MediaQueryListEvent) {
13 | if (e.matches) {
14 | editor.updateOptions({ theme: 'vs-dark' })
15 | return
16 | }
17 |
18 | editor.updateOptions({ theme: 'vs' })
19 | }
20 |
21 | const MEDIA = '(prefers-color-scheme: dark)'
22 | const matchDark = window.matchMedia(MEDIA)
23 |
24 | if (matchDark.matches) {
25 | editor.updateOptions({ theme: 'vs-dark' })
26 | }
27 |
28 | matchDark.addEventListener('change', onThemeChange)
29 |
30 | return () => {
31 | window.matchMedia(MEDIA).removeEventListener('change', onThemeChange)
32 | }
33 | }, [editor])
34 |
35 | return setEditor(editor)} {...props} />
36 | }
37 |
38 | export default CodeEditor
39 |
--------------------------------------------------------------------------------
/admin/src/components/collections-empty-state.tsx:
--------------------------------------------------------------------------------
1 | const tips = [
2 | {
3 | icon: 'add',
4 | title: 'Add a collection',
5 | },
6 | {
7 | icon: 'mouse',
8 | title: 'Click on collection to see data',
9 | },
10 | {
11 | icon: 'bug_report',
12 | title: 'View app logs',
13 | },
14 | ]
15 |
16 | function CollectionEmptyState() {
17 | return (
18 |
19 |
20 |
21 | {tips.map((tip) => (
22 | -
26 |
27 | {tip.icon}
28 |
29 | {tip.title}
30 |
31 | ))}
32 |
33 |
34 |
35 | )
36 | }
37 |
38 | export default CollectionEmptyState
39 |
--------------------------------------------------------------------------------
/admin/src/components/copy.tsx:
--------------------------------------------------------------------------------
1 | import React from 'preact/compat'
2 | import clsx from 'clsx'
3 |
4 | interface Props {
5 | className?: string
6 | title?: string
7 | value: string
8 | }
9 |
10 | function Copy({ value, className, title }: Props) {
11 | const [copied, setCopied] = React.useState(false)
12 |
13 | function copy() {
14 | navigator.clipboard.writeText(value)
15 | setCopied(true)
16 | setTimeout(() => setCopied(false), 2000)
17 | }
18 |
19 | return (
20 |
29 | {copied ? 'done' : 'content_copy'}
30 |
31 | )
32 | }
33 |
34 | export default Copy
35 |
--------------------------------------------------------------------------------
/admin/src/components/date.tsx:
--------------------------------------------------------------------------------
1 | import { cleanDate, cleanTime } from '@/lib/clean-date'
2 |
3 | interface Props {
4 | date: Date
5 | }
6 |
7 | function CleanDate({ date }: Props) {
8 | return (
9 |
10 |
{cleanDate(date)}
11 |
12 | {cleanTime(date)}
13 |
14 |
15 | )
16 | }
17 |
18 | export default CleanDate
19 |
--------------------------------------------------------------------------------
/admin/src/components/filter-input.tsx:
--------------------------------------------------------------------------------
1 | import { Compartment, EditorState, Extension, Prec } from '@codemirror/state'
2 | import { EditorView, ViewUpdate, keymap, placeholder } from '@codemirror/view'
3 | import {
4 | bracketMatching,
5 | defaultHighlightStyle,
6 | syntaxHighlighting,
7 | } from '@codemirror/language'
8 | import Button from './button'
9 | import React from 'preact/compat'
10 | import { closeBrackets } from '@codemirror/autocomplete'
11 | import clsx from 'clsx'
12 | import { cmDark } from './cm-dark'
13 | import { defaultKeymap } from '@codemirror/commands'
14 | import { json } from '@codemirror/lang-json'
15 | import { useColorScheme } from '@/lib/use-color-scheme'
16 |
17 | interface Props {
18 | className?: string
19 | onSubmit?: (value: Record | null) => void
20 | placeholder?: string
21 | }
22 |
23 | const light: Extension = [syntaxHighlighting(defaultHighlightStyle)]
24 |
25 | function FilterInput({
26 | className,
27 | onSubmit,
28 | placeholder: placeholderText,
29 | }: Props) {
30 | const parent = React.useRef(null)
31 | const theme = React.useMemo(() => new Compartment(), [])
32 | const view = React.useRef()
33 | const scheme = useColorScheme()
34 |
35 | const [value, setValue] = React.useState('')
36 |
37 | function handleClear() {
38 | if (view.current) {
39 | const transaction = view.current.state.update({
40 | changes: { from: 0, insert: '', to: view.current.state.doc.length },
41 | })
42 | view.current.dispatch(transaction)
43 | }
44 |
45 | onSubmit?.(null)
46 | }
47 |
48 | React.useEffect(() => {
49 | view.current = new EditorView({
50 | parent: parent.current!,
51 | state: EditorState.create({
52 | doc: '',
53 | extensions: [
54 | keymap.of(defaultKeymap),
55 | Prec.high(
56 | keymap.of([
57 | {
58 | key: 'Enter',
59 | run(view) {
60 | const query = parseQuery(view.state.doc.toString())
61 |
62 | onSubmit?.(query)
63 | return true
64 | },
65 | },
66 | ])
67 | ),
68 | json(),
69 | bracketMatching(),
70 | closeBrackets(),
71 | EditorView.lineWrapping,
72 | placeholder(placeholderText ?? '{name: "John", age: {$gt: 4}}'),
73 | theme.of(light),
74 | EditorView.updateListener.of((update: ViewUpdate) => {
75 | if (update.docChanged) {
76 | const newState = update.state.doc.toString()
77 | setValue(newState)
78 | }
79 | }),
80 | ],
81 | }),
82 | })
83 |
84 | return () => view.current?.destroy()
85 | }, [onSubmit, placeholderText, theme, view])
86 |
87 | React.useEffect(() => {
88 | view.current?.dispatch({
89 | effects: theme.reconfigure(scheme === 'dark' ? cmDark : light),
90 | })
91 | }, [scheme, theme, view])
92 |
93 | const hasValue = value.trim().length > 0
94 |
95 | return (
96 |
102 |
103 |
104 | search
105 |
106 |
107 |
108 |
109 |
110 |
118 |
↵ to search
119 |
122 |
123 |
124 | )
125 | }
126 |
127 | function parseQuery(query: string) {
128 | if (!/^\{.*:.*\}$/.test(query)) {
129 | return null
130 | }
131 | const evalQuery = new Function(`return ${query}`)() as Record
132 |
133 | return JSON.parse(JSON.stringify(evalQuery))
134 | }
135 |
136 | export default FilterInput
137 |
--------------------------------------------------------------------------------
/admin/src/components/flow-handle.tsx:
--------------------------------------------------------------------------------
1 | import { Handle, HandleProps, Position } from 'reactflow'
2 | import clsx from 'clsx'
3 |
4 | interface Props extends HandleProps {
5 | className?: string
6 | }
7 |
8 | function FlowHandle({ className, ...props }: Props) {
9 | return (
10 |
21 |
22 |
23 | )
24 | }
25 |
26 | export default FlowHandle
27 |
--------------------------------------------------------------------------------
/admin/src/components/general-error.tsx:
--------------------------------------------------------------------------------
1 | import AppError from '@/lib/app-error'
2 | import { Link } from 'react-router-dom'
3 | import { useRouteError } from 'react-router-dom'
4 |
5 | interface Props {
6 | error: AppError
7 | }
8 |
9 | function GeneralErrorBoundary({ error }: Props) {
10 | console.error(error)
11 |
12 | return (
13 |
14 |
15 | {error.data?.status ? (
16 |
17 | ) : (
18 |
19 | )}
20 |
21 |
22 |
23 |
24 | )
25 | }
26 |
27 | function ResponseError({ error }: Props) {
28 | return (
29 | <>
30 |
31 |
32 | release_alert
33 |
34 |
35 | An API error occurred
36 |
37 | Status code: {error.data.status}
38 |
39 |
40 | {error.data.detail && (
41 | <>
42 |
43 | {error.data.detail}
44 |
45 | >
46 | )}
47 |
48 | {error.data.status === 401 && (
49 |
50 |
51 | You may need to login again. If you think this is a mistake, check{' '}
52 |
56 | our issues
57 | {' '}
58 | or raise one.{' '}
59 |
60 |
61 |
62 |
68 | Login
69 |
70 |
71 |
72 | )}
73 |
74 | {error.data.status === 404 && (
75 |
76 |
The resource you are looking for does not exist.
77 |
78 | )}
79 |
80 | {![401, 404].includes(error.data?.status) && (
81 |
82 | This error is unexpected. Check the console logs to get an idea of
83 | what could be happening.
84 |
85 | )}
86 | >
87 | )
88 | }
89 |
90 | function UnknownErrorContent() {
91 | const error = useRouteError() as Error
92 |
93 | return (
94 | <>
95 |
96 |
97 | release_alert
98 |
99 |
100 | An error occurred
101 |
102 | The app encountered an error it could not handle
103 |
104 |
105 | Message
106 | {error.message}
107 |
108 | Stacktrace
109 |
110 | Check stacktrace from the browser's developer Console. If you think this
111 | is unexpected, please check{' '}
112 |
118 | our issues
119 | {' '}
120 | or raise one.
121 |
122 | >
123 | )
124 | }
125 |
126 | function LoaderErrorBoundary() {
127 | const error = useRouteError() as AppError
128 |
129 | return
130 | }
131 |
132 | export { LoaderErrorBoundary, GeneralErrorBoundary }
133 |
--------------------------------------------------------------------------------
/admin/src/components/hook-node.tsx:
--------------------------------------------------------------------------------
1 | import { FieldValues, useForm } from 'react-hook-form'
2 | import { NodeProps, Position, useReactFlow } from 'reactflow'
3 | import React, { memo } from 'preact/compat'
4 | import hooksRegistry, { Hook } from '@/data/hooks-registry'
5 | import Button from './button'
6 | import FlowHandle from './flow-handle'
7 | import SchemaFields from './schema-fields'
8 | import clsx from 'clsx'
9 |
10 | interface Data {
11 | id: string
12 | config?: any
13 | }
14 |
15 | function HookNode({ data, id: nodeId, selected }: NodeProps) {
16 | const reactFlow = useReactFlow()
17 | const hookInfo = hooksRegistry.value.find(({ id }) => data.id === id)
18 |
19 | function handleConfigChange(config: any) {
20 | reactFlow.setNodes((nodes) =>
21 | nodes.map((node) => {
22 | if (node.id !== nodeId) {
23 | return node
24 | }
25 |
26 | return {
27 | ...node,
28 | data: { ...node.data, config },
29 | }
30 | })
31 | )
32 | }
33 |
34 | if (!hookInfo) {
35 | return Loading hook…
36 | }
37 |
38 | return (
39 |
45 |
46 |
51 |
52 |
61 |
62 |
67 |
68 | {hookInfo.configSchema && (
69 |
74 | )}
75 |
76 | )
77 | }
78 |
79 | interface ConfigFormProps {
80 | hookInfo: Hook
81 | config: any
82 | onSave: (config: any) => void
83 | }
84 |
85 | const ConfigForm = memo(({ config, hookInfo, onSave }: ConfigFormProps) => {
86 | const { control, handleSubmit, register, setValue, watch } = useForm()
87 |
88 | const showSave =
89 | config !== null && JSON.stringify(watch()) !== JSON.stringify(config)
90 |
91 | function saveConfig(form: FieldValues) {
92 | onSave(form)
93 | }
94 |
95 | React.useEffect(() => {
96 | if (!config) {
97 | return
98 | }
99 |
100 | for (const [key] of Object.entries(hookInfo.configSchema)) {
101 | setValue(key, config[key])
102 | }
103 | }, [config, hookInfo, setValue])
104 |
105 | return (
106 |
121 | )
122 | })
123 |
124 | const HOOK_NODE_TYPE = 'hook-node-type'
125 |
126 | export default memo(HookNode)
127 | export { HOOK_NODE_TYPE }
128 |
--------------------------------------------------------------------------------
/admin/src/components/hooks-search.tsx:
--------------------------------------------------------------------------------
1 | import hooksRegistry, { loadHooksRegistry } from '@/data/hooks-registry'
2 | import Input from './input'
3 | import React from 'preact/compat'
4 | import clsx from 'clsx'
5 |
6 | interface Props {
7 | onSelect?: (hookId: string) => void
8 | }
9 |
10 | function HooksSearch({ onSelect }: Props) {
11 | const [searchKey, setSearchKey] = React.useState('')
12 | const [focused, setFocused] = React.useState(false)
13 |
14 | function selectHook(id: string) {
15 | setSearchKey('')
16 | onSelect?.(id)
17 | }
18 |
19 | const results = React.useMemo(() => {
20 | const key = searchKey.trim().toLocaleLowerCase()
21 | if (!key) return hooksRegistry.value
22 |
23 | return hooksRegistry.value.filter(
24 | (hook) =>
25 | hook.name.toLowerCase().includes(key) ||
26 | hook.description.toLowerCase().includes(key)
27 | )
28 | }, [searchKey, hooksRegistry.value])
29 |
30 | const showResults = focused && Boolean(results.length)
31 |
32 | React.useEffect(() => {
33 | loadHooksRegistry()
34 | }, [])
35 |
36 | return (
37 |
38 |
setSearchKey((e.target as HTMLInputElement).value)}
41 | placeholder="Search and add hook"
42 | type="search"
43 | value={searchKey}
44 | onFocus={() => setFocused(true)}
45 | onBlur={() => setFocused(false)}
46 | />
47 |
48 | {showResults && (
49 |
67 | )}
68 |
69 | )
70 | }
71 |
72 | export default HooksSearch
73 |
--------------------------------------------------------------------------------
/admin/src/components/id-tag.tsx:
--------------------------------------------------------------------------------
1 | import Chip from './chip'
2 | import Copy from './copy'
3 |
4 | interface Props {
5 | id: string
6 | }
7 |
8 | function IdTag({ id }: Props) {
9 | if (!id) return null
10 | return (
11 |
12 | {id.substring(14)}
13 |
14 |
15 | )
16 | }
17 |
18 | export default IdTag
19 |
--------------------------------------------------------------------------------
/admin/src/components/input.tsx:
--------------------------------------------------------------------------------
1 | import React from 'preact/compat'
2 | import clsx from 'clsx'
3 |
4 | const Input = React.forwardRef>(
5 | ({ className, ...props }, ref) => {
6 | return (
7 |
15 | )
16 | }
17 | )
18 |
19 | export default Input
20 |
--------------------------------------------------------------------------------
/admin/src/components/nav-links.tsx:
--------------------------------------------------------------------------------
1 | import { NavLink } from 'react-router-dom'
2 | import React from 'preact/compat'
3 | import clsx from 'clsx'
4 |
5 | type LinkProp = {
6 | leading?: React.ReactNode
7 | path: string
8 | title: React.ReactNode
9 | }
10 |
11 | interface Props {
12 | links: (LinkProp & {
13 | // no nesting above 2 levels
14 | children?: LinkProp[]
15 | })[]
16 | }
17 |
18 | function L({
19 | expanded,
20 | hasChildren,
21 | link,
22 | onClick,
23 | }: {
24 | expanded?: boolean
25 | hasChildren?: boolean
26 | link: LinkProp
27 | onClick?: () => void
28 | }) {
29 | return (
30 |
32 | clsx(
33 | 'group flex justify-between items-center text-secondary no-underline px-0 hover:text-zinc-800 dark:hover:!text-neutral-200',
34 | {
35 | 'text-zinc-800 dark:!text-neutral-200 is-active': isActive,
36 | }
37 | )
38 | }
39 | to={link.path}
40 | onClick={onClick}
41 | >
42 |
43 | {link.leading}
44 |
45 |
46 | {link.title}
47 |
48 |
49 |
50 | {hasChildren && (
51 |
52 | {expanded ? 'expand_less' : 'expand_more'}
53 |
54 | )}
55 |
56 | )
57 | }
58 |
59 | function LinkItem({
60 | link,
61 | children,
62 | }: {
63 | link: LinkProp
64 | children?: LinkProp[]
65 | }) {
66 | const [expanded, setExpanded] = React.useState(false)
67 |
68 | return (
69 |
70 |
setExpanded((v) => !v)}
73 | hasChildren={Boolean(children?.length)}
74 | expanded={expanded}
75 | />
76 |
77 | {children && expanded && (
78 |
79 | {children.map((link) => (
80 | -
81 |
82 |
83 | ))}
84 |
85 | )}
86 |
87 | )
88 | }
89 |
90 | function NavLinks({ links }: Props) {
91 | return (
92 |
93 | {links.map((link) => (
94 | -
95 |
96 |
97 | ))}
98 |
99 | )
100 | }
101 |
102 | export default NavLinks
103 |
--------------------------------------------------------------------------------
/admin/src/components/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as RadixPopover from '@radix-ui/react-popover'
2 | import React from 'preact/compat'
3 |
4 | interface Props extends React.PropsWithChildren {
5 | trigger: React.ReactNode
6 | }
7 |
8 | function Popover({ children, trigger }: Props) {
9 | return (
10 |
11 | {trigger}
12 |
13 | {children}
14 |
15 |
16 | )
17 | }
18 |
19 | export { Popover }
20 |
--------------------------------------------------------------------------------
/admin/src/components/schema-fields.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Control,
3 | Controller,
4 | FieldValues,
5 | UseFormRegister,
6 | } from 'react-hook-form'
7 | import CodeEditor from './code-editor'
8 | import Input from './input'
9 | import type { SchemaDefinitions } from 'mangobase'
10 | import clsx from 'clsx'
11 |
12 | interface Props {
13 | schema: SchemaDefinitions
14 | register: UseFormRegister
15 | control: Control
16 | }
17 |
18 | const EDITOR_OPTIONS = {
19 | acceptSuggestionOnEnter: 'off',
20 | codeLens: false,
21 | contextmenu: false,
22 | foldingHighlight: false,
23 | fontFamily: 'IBM Plex Mono, Zed Mono, monospace',
24 | fontLigatures: true,
25 | fontSize: 14,
26 | hover: {
27 | enabled: false,
28 | },
29 | lineDecorationsWidth: 0,
30 | lineNumbersMinChars: 2,
31 | minimap: { enabled: false },
32 | overviewRulerBorder: false,
33 | overviewRulerLanes: 0,
34 | parameterHints: {
35 | enabled: false,
36 | },
37 | quickSuggestions: {
38 | comments: false,
39 | other: false,
40 | strings: false,
41 | },
42 | renderLineHighlight: 'none',
43 | scrollbar: {
44 | horizontalSliderSize: 5,
45 | verticalSliderSize: 5,
46 | },
47 | suggestOnTriggerCharacters: false,
48 | tabCompletion: 'off',
49 | wordBasedSuggestions: false,
50 | } as const
51 |
52 | function SchemaFields({ control, register, schema }: Props) {
53 | return (
54 |
138 | )
139 | }
140 |
141 | export default SchemaFields
142 |
--------------------------------------------------------------------------------
/admin/src/components/select.tsx:
--------------------------------------------------------------------------------
1 | import React from 'preact/compat'
2 | import clsx from 'clsx'
3 |
4 | type Props = React.ComponentProps<'select'>
5 |
6 | const Select = React.forwardRef(
7 | (
8 | { className, ...props }: Props,
9 | ref: React.ForwardedRef
10 | ) => {
11 | return (
12 |
20 | )
21 | }
22 | )
23 |
24 | export default Select
25 |
--------------------------------------------------------------------------------
/admin/src/components/service-node.tsx:
--------------------------------------------------------------------------------
1 | import { Connection, Position } from 'reactflow'
2 | import FlowHandle from './flow-handle'
3 | import { METHODS } from '../client/collection'
4 |
5 | function ServiceNode() {
6 | function checkSourceConnection(connection: Connection) {
7 | return connection.source !== 'service'
8 | }
9 |
10 | function checkTargetConnection(connection: Connection) {
11 | return connection.target !== 'service'
12 | }
13 |
14 | return (
15 |
16 |
17 |
18 | sync_alt
19 |
20 |
21 |
22 |
Service node
23 |
Methods
24 |
25 |
26 |
27 |
28 | {METHODS.map((method) => (
29 | -
30 |
36 |
37 |
{method}
38 |
39 |
45 |
46 | ))}
47 |
48 |
49 | )
50 | }
51 |
52 | const SERVICE_NODE_TYPE = 'service-node-type'
53 |
54 | export default ServiceNode
55 | export { SERVICE_NODE_TYPE }
56 |
--------------------------------------------------------------------------------
/admin/src/components/snacks.tsx:
--------------------------------------------------------------------------------
1 | import { removeSnack, snacks } from '@/lib/snacks'
2 | import clsx from 'clsx'
3 |
4 | const ICONS = {
5 | error: 'emergency_home',
6 | neutral: 'stat_2',
7 | success: 'verified',
8 | }
9 |
10 | function Snacks() {
11 | return (
12 |
13 |
14 | {snacks.value.map((snack) => {
15 | if (snack.type === 'custom') return snack.content
16 |
17 | return (
18 | -
19 |
29 |
30 |
31 | {ICONS[snack.type]}
32 |
33 |
{snack.content}
34 |
35 |
36 |
41 |
42 |
43 |
44 | )
45 | })}
46 |
47 |
48 | )
49 | }
50 |
51 | export { Snacks }
52 |
--------------------------------------------------------------------------------
/admin/src/components/value.tsx:
--------------------------------------------------------------------------------
1 | import CleanDate from './date'
2 | import type { DefinitionType } from 'mangobase'
3 | import IdTag from './id-tag'
4 |
5 | function Value({ type, value }: { type: DefinitionType; value: any }) {
6 | if (value === undefined) {
7 | return null
8 | }
9 |
10 | switch (type) {
11 | case 'id':
12 | return
13 |
14 | case 'date':
15 | return
16 |
17 | case 'object':
18 | case 'array':
19 | return (
20 |
21 | {ellipsize(JSON.stringify(value), 48)}
22 |
23 | )
24 |
25 | case 'boolean':
26 | return (
27 |
28 | {JSON.stringify(value)}
29 |
30 | )
31 |
32 | default: {
33 | if (typeof value === 'string') {
34 | return {ellipsize(value, 48)}
35 | }
36 |
37 | return (
38 |
39 | {ellipsize(JSON.stringify(value), 48)}
40 |
41 | )
42 | }
43 | }
44 | }
45 |
46 | function ellipsize(text: string | undefined, length: number) {
47 | if (!text) {
48 | return null
49 | }
50 |
51 | if (text.length <= length) {
52 | return text
53 | }
54 |
55 | return `${text.slice(0, length)}…`
56 | }
57 |
58 | export default Value
59 |
--------------------------------------------------------------------------------
/admin/src/data/app-developers.ts:
--------------------------------------------------------------------------------
1 | import app from '../mangobase-app'
2 | import { signal } from '@preact/signals'
3 |
4 | interface User {
5 | _id: string
6 | fullname: string
7 | username: string
8 | }
9 |
10 | const appDevelopers = signal([])
11 |
12 | async function loadAppDevelopers() {
13 | const users = await app.collection('users')
14 | const { data } = await users.find({ role: 'dev' })
15 |
16 | appDevelopers.value = data
17 | }
18 |
19 | export default appDevelopers
20 | export { loadAppDevelopers }
21 |
--------------------------------------------------------------------------------
/admin/src/data/collections.ts:
--------------------------------------------------------------------------------
1 | import Collection from '../client/collection'
2 | import app from '../mangobase-app'
3 | import { signal } from '@preact/signals'
4 |
5 | const collections = signal([])
6 |
7 | async function loadCollections() {
8 | const cols = await app.collections()
9 | collections.value = cols
10 | }
11 |
12 | export default collections
13 | export { loadCollections }
14 |
--------------------------------------------------------------------------------
/admin/src/data/hooks-registry.ts:
--------------------------------------------------------------------------------
1 | import type { Definition } from 'mangobase'
2 | import app from '../mangobase-app'
3 | import { signal } from '@preact/signals'
4 |
5 | interface Hook {
6 | configSchema: Record
7 | description: string
8 | id: string
9 | name: string
10 | }
11 |
12 | const hooksRegistry = signal([])
13 |
14 | async function loadHooksRegistry() {
15 | hooksRegistry.value = ((await app.hookRegistry()) as Hook[]).sort((a, b) =>
16 | a.name.localeCompare(b.name)
17 | )
18 | }
19 |
20 | export default hooksRegistry
21 | export { loadHooksRegistry }
22 | export type { Hook }
23 |
--------------------------------------------------------------------------------
/admin/src/data/logs.ts:
--------------------------------------------------------------------------------
1 | import app from '../mangobase-app'
2 | import dayjs from 'dayjs'
3 | import { signal } from '@preact/signals'
4 |
5 | interface Log {
6 | _id: string
7 | category: string
8 | created_at: string
9 | data: any
10 | label: string
11 | status: number
12 | time: number
13 | }
14 |
15 | interface LogStat {
16 | _id: string
17 | date: string
18 | requests: number
19 | }
20 |
21 | const logs = signal([])
22 | const logStats = signal([])
23 |
24 | async function loadLogs() {
25 | const { data } = await app.req.get('_dev/logs?$sort[created_at]=-1')
26 | logs.value = data.data
27 | }
28 |
29 | function fillMissingHours(logs: LogStat[]) {
30 | const maxDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000 // 48 hours ago
31 |
32 | const now = Date.now()
33 | const res: LogStat[] = []
34 | let logsCursor = 0
35 |
36 | let currentTime = maxDaysAgo
37 |
38 | while (currentTime <= now) {
39 | const log = logs[logsCursor]
40 |
41 | if (dayjs(log.date).isSame(currentTime, 'hour')) {
42 | logsCursor++
43 | res.push(log)
44 | } else {
45 | const date = dayjs(currentTime).format('YYYY-MM-DDTHH:00:00')
46 | res.push({ _id: date, date, requests: 0 })
47 | }
48 |
49 | currentTime += 60 * 60 * 1000 // add an hour
50 | }
51 |
52 | return res
53 | }
54 |
55 | async function loadLogStats() {
56 | const { data } = await app.req.get('_dev/log-stats')
57 | logStats.value = fillMissingHours(data)
58 | }
59 |
60 | export default logs
61 | export { loadLogs, logStats, loadLogStats }
62 | export type { Log }
63 |
--------------------------------------------------------------------------------
/admin/src/data/schema-refs.ts:
--------------------------------------------------------------------------------
1 | import { Ref } from 'mangobase'
2 | import app from '../mangobase-app'
3 | import { signal } from '@preact/signals'
4 |
5 | const schemaRefs = signal[([])
6 |
7 | async function loadSchemaRefs() {
8 | schemaRefs.value = (await app.req.get('_dev/schema-refs')).data
9 | schemaRefs.value.sort((a, b) => a.name.localeCompare(b.name))
10 | }
11 |
12 | export default schemaRefs
13 | export { loadSchemaRefs }
14 |
--------------------------------------------------------------------------------
/admin/src/extras.css:
--------------------------------------------------------------------------------
1 | .text-secondary {
2 | @apply text-zinc-500 dark:text-neutral-400;
3 | }
4 |
5 | th {
6 | text-align: start;
7 | color: var(--secondary-color);
8 | padding: 0.5rem;
9 | margin-bottom: 0.5rem;
10 | }
11 |
12 | td {
13 | padding-left: 0.5rem;
14 | padding-right: 0.5rem;
15 | }
16 |
17 | fieldset {
18 | border: 0;
19 | padding: 0;
20 | }
21 |
22 | ::-webkit-scrollbar {
23 | width: 5px;
24 | height: 5px;
25 | }
26 |
27 | ::-webkit-scrollbar-thumb {
28 | background: #bfc3c6;
29 | border-radius: 0.25rem;
30 | }
31 |
32 | @media (prefers-color-scheme: dark) {
33 | ::-webkit-scrollbar-thumb {
34 | background-color: #5e6062;
35 | }
36 | }
37 |
38 | ::-webkit-scrollbar-track {
39 | background: transparent;
40 | }
41 |
--------------------------------------------------------------------------------
/admin/src/index.css:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&display=swap");
2 |
3 | @tailwind base;
4 | @tailwind components;
5 | @tailwind utilities;
6 |
7 | @font-face {
8 | font-family: "Zed Mono";
9 | src: url("/zed-mono/zed-mono-regular.woff2");
10 | font-weight: 400;
11 | }
12 |
13 | @font-face {
14 | font-family: "Zed Mono";
15 | src: url("/zed-mono/zed-mono-medium.woff2");
16 | font-weight: 500;
17 | }
18 |
19 | @font-face {
20 | font-family: "Zed Mono";
21 | src: url("/zed-mono/zed-mono-bold.woff2");
22 | font-weight: 700;
23 | }
24 |
25 | @font-face {
26 | font-family: "Zed Mono";
27 | src: url("/zed-mono/zed-mono-italic.woff2");
28 | font-style: italic;
29 | }
30 |
31 | .material-symbols-rounded {
32 | font-display: block;
33 | }
34 |
35 | :root {
36 | color-scheme: light dark;
37 | accent-color: #0E9544;
38 |
39 | font-size: 14px;
40 | font-family: "Zed Mono", sans-serif;
41 | }
42 |
43 | input[type="checkbox"] {
44 | @apply rounded-md bg-zinc-200 dark:bg-neutral-700 w-5 h-4 focus:ring-zinc-100 dark:focus:ring-neutral-500 focus:ring-1 focus:ring-offset-0;
45 | }
46 |
47 | .react-flow__controls-button {
48 | @apply dark:!bg-neutral-600 dark:!border-b-neutral-700 dark:!text-white dark:hover:!bg-neutral-500;
49 | }
50 |
51 | .react-flow__controls-button svg path {
52 | fill: currentColor;
53 | }
54 |
55 | .react-flow__attribution {
56 | @apply dark:!bg-neutral-700 rounded rounded-b-none;
57 | }
58 |
59 | code {
60 | @apply bg-zinc-200 dark:bg-neutral-600 text-zinc-600 dark:text-neutral-200 font-['IBM_Plex_Mono'] rounded px-1;
61 | }
62 |
63 | label {
64 | display: block
65 | }
66 |
67 | .material-symbols-rounded {
68 | @apply inline-flex items-center align-middle;
69 | }
70 |
71 | .cm-editor.cm-focused {
72 | @apply outline-none;
73 | }
74 |
75 | .cm-line {
76 | @apply font-['IBM_Plex_Mono'];
77 | }
78 |
--------------------------------------------------------------------------------
/admin/src/layouts/AdminLayout.tsx:
--------------------------------------------------------------------------------
1 | import { NavLink, Outlet, useNavigate } from 'react-router-dom'
2 | import AVATAR_COLORS from '@/lib/avatar-colors'
3 | import Avatar from 'boring-avatars'
4 | import React from 'preact/compat'
5 | import app from '../mangobase-app'
6 | import clsx from 'clsx'
7 |
8 | const navLinks = [
9 | {
10 | href: '/collections',
11 | icon: toolbar,
12 | title: 'Collections',
13 | },
14 | {
15 | href: '/logs',
16 | icon: bug_report,
17 | title: 'Logs',
18 | },
19 | {
20 | href: '/settings',
21 | icon: page_info,
22 | title: 'Settings',
23 | },
24 | {
25 | href: 'https://degreat.co.uk/mangobase/guide',
26 | icon: article,
27 | title: 'Docs',
28 | },
29 | ]
30 |
31 | function AdminLayout() {
32 | const navigate = useNavigate()
33 | const auth = app.get('auth')
34 |
35 | React.useEffect(() => {
36 | !auth && navigate('/login')
37 | }, [auth, navigate])
38 |
39 | if (!auth) {
40 | return null
41 | }
42 |
43 | const { user } = auth
44 |
45 | return (
46 | ]
47 |
79 |
80 |
81 |
82 |
83 |
84 | )
85 | }
86 |
87 | export default AdminLayout
88 |
--------------------------------------------------------------------------------
/admin/src/layouts/NavContentLayout.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentChild } from 'preact'
2 | import React from 'preact/compat'
3 |
4 | interface Props extends React.PropsWithChildren {
5 | nav: ComponentChild
6 | }
7 | function NavContentLayout({ children, nav }: Props) {
8 | return (
9 |
10 |
11 |
12 |
13 | {children}
14 |
15 |
16 | )
17 | }
18 |
19 | export default NavContentLayout
20 |
--------------------------------------------------------------------------------
/admin/src/lib/app-error.ts:
--------------------------------------------------------------------------------
1 | class AppError extends Error {
2 | data: any
3 |
4 | constructor(message: string, data: any) {
5 | super(message)
6 | this.data = data
7 | }
8 | }
9 |
10 | export default AppError
11 |
--------------------------------------------------------------------------------
/admin/src/lib/append-index-fields.ts:
--------------------------------------------------------------------------------
1 | import type { FieldValues, UseFieldArrayAppend } from 'react-hook-form'
2 | import { Index } from 'mangobase'
3 | import { slugify } from './slugify'
4 |
5 | function appendIndexFields(
6 | append: UseFieldArrayAppend,
7 | indexes: Index[]
8 | ) {
9 | for (const index of indexes) {
10 | append({
11 | constraint: index.options!.unique
12 | ? 'unique'
13 | : index.options!.sparse
14 | ? 'sparse'
15 | : 'none',
16 | existing: true,
17 | fields: index.fields.map((field) => {
18 | if (typeof field === 'string') {
19 | return {
20 | id: slugify(field),
21 | sort: 1,
22 | text: field,
23 | }
24 | }
25 |
26 | return {
27 | id: slugify(field[0]),
28 | sort: field[1],
29 | text: field[0],
30 | }
31 | }),
32 | })
33 | }
34 | }
35 |
36 | export { appendIndexFields }
37 |
--------------------------------------------------------------------------------
/admin/src/lib/append-schema-fields.ts:
--------------------------------------------------------------------------------
1 | import { FieldValues, UseFieldArrayAppend } from 'react-hook-form'
2 | import type { SchemaDefinitions } from 'mangobase'
3 | import { slugify } from './slugify'
4 |
5 | function appendSchemaFields(
6 | append: UseFieldArrayAppend,
7 | schema: SchemaDefinitions
8 | ) {
9 | for (const [field, options] of Object.entries(schema)) {
10 | const relation = options.type === 'id' ? options.relation : undefined
11 | const schema = options.type === 'object' ? options.schema : undefined
12 | const items = options.type === 'array' ? options.items : undefined
13 | const enums =
14 | options.type === 'string'
15 | ? options.enum?.map((e) => ({ id: slugify(e), text: e }))
16 | : undefined
17 |
18 | append({
19 | enum: enums,
20 | existing: true,
21 | items,
22 | name: field,
23 | relation,
24 | required: options.required,
25 | schema,
26 | type: options.type,
27 | unique: options.unique,
28 | })
29 | }
30 | }
31 |
32 | export default appendSchemaFields
33 |
--------------------------------------------------------------------------------
/admin/src/lib/avatar-colors.ts:
--------------------------------------------------------------------------------
1 | const AVATAR_COLORS = [
2 | '#facc15',
3 | '#4ade80',
4 | '#fbbf24',
5 | '#2dd4bf',
6 | '#38bdf8',
7 | '#3b82f6',
8 | '#f87171',
9 | '#f43f5e',
10 | ]
11 |
12 | export default AVATAR_COLORS
13 |
--------------------------------------------------------------------------------
/admin/src/lib/clean-date.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs'
2 |
3 | function cleanDate(date: Date | string) {
4 | return dayjs(date).format('DD/MM/YYYY')
5 | }
6 |
7 | function cleanTime(date: Date | string) {
8 | return dayjs(date).format('hh:mm:ss A')
9 | }
10 |
11 | export { cleanDate, cleanTime }
12 |
--------------------------------------------------------------------------------
/admin/src/lib/definition-from-field.ts:
--------------------------------------------------------------------------------
1 | import { FieldProps } from '@/components/collection-form'
2 |
3 | function definitionFromField(field: FieldProps) {
4 | const { name, existing, removed, ...definition } = field
5 | return [name, definition] as const
6 | }
7 |
8 | export { definitionFromField }
9 |
--------------------------------------------------------------------------------
/admin/src/lib/field-types.ts:
--------------------------------------------------------------------------------
1 | const fieldTypes = [
2 | { title: 'string', value: 'string' },
3 | { title: 'number', value: 'number' },
4 | { title: 'boolean', value: 'boolean' },
5 | { title: 'relation', value: 'id' },
6 | { title: 'date', value: 'date' },
7 | { title: 'array', value: 'array' },
8 | { title: 'object', value: 'object' },
9 | { title: 'any', value: 'any' },
10 | ] as const
11 |
12 | type FieldType = `${(typeof fieldTypes)[number]['value']}`
13 |
14 | export default fieldTypes
15 | export type { FieldType }
16 |
--------------------------------------------------------------------------------
/admin/src/lib/get-new-field-name.ts:
--------------------------------------------------------------------------------
1 | function getNewFieldName(fields: { name: string }[]) {
2 | const unnamedFields = fields
3 | .filter((field) => /^field\d*$/.test(field.name))
4 | .sort((a, b) => a.name.localeCompare(b.name))
5 |
6 | let fieldName = 'field1'
7 | let i = 0
8 | while (unnamedFields[i]?.name === fieldName) {
9 | i += 1
10 | fieldName = `field${i + 1}`
11 | }
12 |
13 | return fieldName
14 | }
15 |
16 | export default getNewFieldName
17 |
--------------------------------------------------------------------------------
/admin/src/lib/get-schema.tsx:
--------------------------------------------------------------------------------
1 | import schemaRefs, { loadSchemaRefs } from '../data/schema-refs'
2 | import AppError from '@/lib/app-error'
3 | import type { Ref } from 'mangobase'
4 | import app from '../mangobase-app'
5 | import { loadCollections } from '../data/collections'
6 |
7 | export async function getSchema(name: string): Promise[ {
8 | if (!schemaRefs.value?.length) {
9 | await loadCollections()
10 | await loadSchemaRefs()
11 | }
12 |
13 | if (name === 'new') {
14 | return {
15 | name: 'Add new schema',
16 | schema: {},
17 | }
18 | }
19 |
20 | const nameParts = name.split('/')
21 | const refName = nameParts.pop()!
22 | const [scope] = nameParts
23 |
24 | try {
25 | const { data: schema } = await app.req.get(
26 | `_dev/schema-refs/${refName}?$scope=${scope || ''}`
27 | )
28 |
29 | return schema
30 | } catch (err) {
31 | throw new AppError((err as any).message, err)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/admin/src/lib/indexed.ts:
--------------------------------------------------------------------------------
1 | function* indexed(array: Array) {
2 | let i = 0
3 | while (i < array.length) {
4 | yield [array[i], i] as const
5 | i++
6 | }
7 | }
8 |
9 | export default indexed
10 |
--------------------------------------------------------------------------------
/admin/src/lib/node-types.ts:
--------------------------------------------------------------------------------
1 | import HookNode, { HOOK_NODE_TYPE } from '@/components/hook-node'
2 | import ServiceNode, { SERVICE_NODE_TYPE } from '@/components/service-node'
3 |
4 | const nodeTypes = {
5 | [HOOK_NODE_TYPE]: HookNode,
6 | [SERVICE_NODE_TYPE]: ServiceNode,
7 | }
8 |
9 | export default nodeTypes
10 |
--------------------------------------------------------------------------------
/admin/src/lib/random-str.ts:
--------------------------------------------------------------------------------
1 | const candidates =
2 | 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
3 |
4 | function randomStr(length = 8) {
5 | return Array.from({ length })
6 | .map(() => candidates[Math.floor(Math.random() * candidates.length)])
7 | .join('')
8 | }
9 |
10 | export default randomStr
11 |
--------------------------------------------------------------------------------
/admin/src/lib/remove-fields-item.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FieldValues,
3 | UseFieldArrayRemove,
4 | UseFormSetValue,
5 | } from 'react-hook-form'
6 | import { FieldProps } from '@/components/collection-form'
7 |
8 | interface Options {
9 | index: number
10 | fields: FieldProps[]
11 | remove: UseFieldArrayRemove
12 | setValue: UseFormSetValue
13 | }
14 |
15 | function removeFieldsItem({ index, fields, remove, setValue }: Options) {
16 | const field = fields[index]
17 | if (field.existing) {
18 | setValue(`fields.${index}.removed`, true)
19 | return
20 | }
21 |
22 | remove(index)
23 | }
24 |
25 | export default removeFieldsItem
26 |
--------------------------------------------------------------------------------
/admin/src/lib/request-status.ts:
--------------------------------------------------------------------------------
1 | type RequestStatus = 'idle' | 'in-progress' | 'success' | 'failed'
2 |
3 | export default RequestStatus
4 |
--------------------------------------------------------------------------------
/admin/src/lib/schema-from-fields.ts:
--------------------------------------------------------------------------------
1 | import type { FieldProps } from '@/components/collection-form'
2 | import { definitionFromField } from './definition-from-field'
3 |
4 | function schemaFromFields(fields: FieldProps[]) {
5 | const schema: Record = {}
6 | for (const field of fields) {
7 | if (field.removed) {
8 | continue
9 | }
10 |
11 | const [name, definition] = definitionFromField(field)
12 |
13 | if (definition.type === 'string') {
14 | if (definition.enum) {
15 | if (definition.enum.length === 0) {
16 | // remove empty enum list
17 | definition.enum = undefined
18 | } else {
19 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
20 | // @ts-ignore
21 | definition.enum = definition.enum.map((it) => it.text)
22 | }
23 | }
24 | }
25 |
26 | schema[name] = definition
27 | }
28 |
29 | return schema
30 | }
31 |
32 | export { schemaFromFields }
33 |
--------------------------------------------------------------------------------
/admin/src/lib/slugify.ts:
--------------------------------------------------------------------------------
1 | function slugify(text: string, mangle = false) {
2 | let slug = text
3 | .toString()
4 | .toLowerCase()
5 | .trim()
6 | .replace(/\s+/g, '-')
7 | .replace(/&/g, '-and-')
8 | .replace(/[^\w\-]+/g, '')
9 | .replace(/\-\-+/g, '-')
10 |
11 | if (mangle) {
12 | slug += `-${Math.random().toString(36).substring(2, 7)}`
13 | }
14 |
15 | return slug
16 | }
17 |
18 | export { slugify }
19 |
--------------------------------------------------------------------------------
/admin/src/lib/snacks.ts:
--------------------------------------------------------------------------------
1 | import React from 'preact/compat'
2 | import randomStr from './random-str'
3 | import { signal } from '@preact/signals'
4 |
5 | interface Snack {
6 | id: string
7 | type: 'success' | 'error' | 'neutral' | 'custom'
8 | content: string | React.ReactNode
9 | duration: number
10 | }
11 |
12 | const snacks = signal([])
13 |
14 | function addSnack(snack: Omit) {
15 | const id = randomStr(6)
16 | snacks.value = [...snacks.value, { ...snack, id }]
17 |
18 | setTimeout(() => {
19 | removeSnack(id)
20 | }, snack.duration)
21 |
22 | return id
23 | }
24 |
25 | function removeSnack(id: string) {
26 | snacks.value = snacks.value.filter((snack) => snack.id !== id)
27 | }
28 |
29 | export { addSnack, removeSnack, snacks }
30 |
--------------------------------------------------------------------------------
/admin/src/lib/use-color-scheme.ts:
--------------------------------------------------------------------------------
1 | import React from 'preact/compat'
2 |
3 | type ColorScheme = 'light' | 'dark'
4 |
5 | function useColorScheme() {
6 | const [scheme, setScheme] = React.useState('light')
7 |
8 | React.useEffect(() => {
9 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
10 |
11 | if (mediaQuery.matches) {
12 | setScheme('dark')
13 | }
14 |
15 | const listener = (e: MediaQueryListEvent) => {
16 | if (e.matches) {
17 | setScheme('dark')
18 | } else {
19 | setScheme('light')
20 | }
21 | }
22 |
23 | mediaQuery.addEventListener('change', listener)
24 |
25 | return () => {
26 | mediaQuery.removeEventListener('change', listener)
27 | }
28 | }, [])
29 |
30 | return scheme
31 | }
32 |
33 | export { useColorScheme }
34 |
--------------------------------------------------------------------------------
/admin/src/main.tsx:
--------------------------------------------------------------------------------
1 | import './index.css'
2 | import './extras.css'
3 | import { App } from './app.tsx'
4 | import { render } from 'preact'
5 |
6 | render(, document.getElementById('app') as HTMLElement)
7 |
--------------------------------------------------------------------------------
/admin/src/mangobase-app.ts:
--------------------------------------------------------------------------------
1 | import App from './client/app'
2 |
3 | const app = new App(import.meta.env.VITE_API_URL || '/api')
4 |
5 | export default app
6 |
--------------------------------------------------------------------------------
/admin/src/pages/collections/[name]/_layout.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | LoaderFunctionArgs,
3 | NavLink,
4 | Outlet,
5 | useLoaderData,
6 | } from 'react-router-dom'
7 | import AppError from '@/lib/app-error'
8 | import Chip from '@/components/chip'
9 | import Copy from '@/components/copy'
10 | import { DevDialog } from '@/components/dev-dialog'
11 | import { Popover } from '@/components/popover'
12 | import { RouteData } from '.'
13 | import app from '../../../mangobase-app'
14 | import clsx from 'clsx'
15 |
16 | function Component() {
17 | const { collection } = useLoaderData() as RouteData
18 |
19 | const links = [
20 | {
21 | href: '',
22 | icon: 'table_rows',
23 | title: 'Records',
24 | },
25 | {
26 | href: 'hooks',
27 | icon: 'phishing',
28 | title: 'Hooks',
29 | },
30 | {
31 | href: `/logs/?label[$startswith]=/api/${collection.name}`,
32 | icon: 'bug_report',
33 | title: 'Logs',
34 | },
35 | {
36 | href: 'edit',
37 | icon: 'stylus',
38 | title: 'Edit',
39 | },
40 | ]
41 |
42 | const path = `/api${collection.exposed ? '/' : '/_x/'}${collection.name}`
43 | const endpoint = `${window.location.protocol}//${window.location.host}${path}`
44 |
45 | return (
46 | ]
47 |
114 |
115 |
116 |
117 |
118 |
119 | )
120 | }
121 |
122 | const loader = async ({ params }: LoaderFunctionArgs) => {
123 | try {
124 | const collection = await app.collection(params.name!)
125 | return { collection }
126 | } catch (err) {
127 | throw new AppError((err as any).message || '', err)
128 | }
129 | }
130 |
131 | export { Component, loader }
132 |
--------------------------------------------------------------------------------
/admin/src/pages/collections/[name]/edit.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useNavigate,
3 | useRevalidator,
4 | useRouteLoaderData,
5 | } from 'react-router-dom'
6 | import Collection from '../../../client/collection'
7 | import CollectionForm from '@/components/collection-form'
8 | import type { CollectionRouteData } from '../../../routes'
9 |
10 | function Component() {
11 | const { collection } = useRouteLoaderData('collection') as CollectionRouteData
12 | const revalidator = useRevalidator()
13 |
14 | const navigate = useNavigate()
15 | function goBack(collection?: Collection) {
16 | if (!collection) {
17 | navigate(-1)
18 | return
19 | }
20 |
21 | revalidator.revalidate()
22 | navigate(`/collections/${collection.name}`, { replace: true })
23 | }
24 |
25 | return (
26 |
31 | )
32 | }
33 |
34 | export { Component }
35 |
--------------------------------------------------------------------------------
/admin/src/pages/collections/index.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet, useNavigate } from 'react-router-dom'
2 | import collections, { loadCollections } from '../../data/collections'
3 | import AppError from '@/lib/app-error'
4 | import Collection from '../../client/collection'
5 | import CollectionForm from '@/components/collection-form'
6 | import Input from '@/components/input'
7 | import NavContentLayout from '../../layouts/NavContentLayout'
8 | import NavLinks from '@/components/nav-links'
9 | import React from 'preact/compat'
10 | import { loadSchemaRefs } from '../../data/schema-refs'
11 |
12 | function Component() {
13 | const navigate = useNavigate()
14 |
15 | const formDialog = React.useRef(null)
16 | const [showingForm, setShowingForm] = React.useState(false)
17 |
18 | const collectionLinks = React.useMemo(
19 | () =>
20 | [...collections.value]
21 | .sort((a, b) => {
22 | if (a.exposed && !b.exposed) {
23 | return -1
24 | }
25 |
26 | return a.name.localeCompare(b.name)
27 | })
28 | .map((collection) => ({
29 | path: collection.name,
30 | title: `${collection.exposed ? '' : '-'}${collection.name}`,
31 | })),
32 | [collections.value]
33 | )
34 |
35 | function showFormDialog() {
36 | formDialog.current?.showModal()
37 | setShowingForm(true)
38 | }
39 |
40 | function handleOnFormHide(collection?: Collection) {
41 | formDialog.current?.close()
42 | setShowingForm(false)
43 |
44 | if (collection) {
45 | navigate(`/collections/${collection.name}`)
46 | }
47 | }
48 |
49 | return (
50 |
53 |
54 | Collections
55 |
61 |
62 |
63 |
71 |
72 |
81 |
82 |
83 | >
84 | }
85 | >
86 |
87 |
88 | )
89 | }
90 |
91 | const loader = async () => {
92 | try {
93 | // [ ]: move away from using signals for state management
94 | await loadSchemaRefs()
95 | await loadCollections()
96 | return null
97 | } catch (err) {
98 | throw new AppError((err as any).message, err)
99 | }
100 | }
101 |
102 | export { Component, loader }
103 |
--------------------------------------------------------------------------------
/admin/src/pages/login.tsx:
--------------------------------------------------------------------------------
1 | import { FieldValues, useForm } from 'react-hook-form'
2 | import AVATAR_COLORS from '@/lib/avatar-colors'
3 | import Avatar from 'boring-avatars'
4 | import Button from '@/components/button'
5 | import Input from '@/components/input'
6 | import React from 'preact/compat'
7 | import RequestStatus from '@/lib/request-status'
8 | import { addSnack } from '@/lib/snacks'
9 | import app from '../mangobase-app'
10 | import { useNavigate } from 'react-router-dom'
11 |
12 | function Login() {
13 | const { handleSubmit, register, watch } = useForm()
14 | const [username, setUsername] = React.useState('')
15 | const [isNewEnv, setIsNewEnv] = React.useState(false)
16 | const [status, setStatus] = React.useState('idle')
17 |
18 | const navigate = useNavigate()
19 |
20 | const nameChangeTime = React.useRef>()
21 |
22 | async function login(form: FieldValues) {
23 | setStatus('in-progress')
24 | try {
25 | if (isNewEnv) {
26 | await app.req.post('users', {
27 | ...form,
28 | fullname: form.username,
29 | role: 'dev',
30 | })
31 | }
32 |
33 | const { data } = await app.req.post('login', { ...form })
34 | app.set('auth', data)
35 |
36 | navigate('/collections', { replace: true })
37 | } catch (err: any) {
38 | setStatus('failed')
39 | const message = err?.data
40 | ? err.data.error
41 | : err.message || 'failed to login. check logs or try again!'
42 |
43 | addSnack({ content: message, duration: 2500, type: 'error' })
44 | }
45 | }
46 |
47 | const $username = watch('username')
48 | React.useEffect(() => {
49 | clearTimeout(nameChangeTime.current)
50 | nameChangeTime.current = setTimeout(() => {
51 | setUsername($username)
52 | }, 1000)
53 | }, [$username])
54 |
55 | React.useEffect(() => {
56 | app.req.get('_dev/dev-setup').then((res) => setIsNewEnv(!res.data))
57 | }, [])
58 |
59 | return (
60 |
113 | )
114 | }
115 |
116 | export default Login
117 |
--------------------------------------------------------------------------------
/admin/src/pages/logs.tsx:
--------------------------------------------------------------------------------
1 | import logs, { Log, loadLogStats, loadLogs, logStats } from '@/data/logs'
2 | import BarChart from '@/components/chart'
3 | import Chip from '@/components/chip'
4 | import CleanDate from '@/components/date'
5 | import FilterInput from '@/components/filter-input'
6 | import React from 'preact/compat'
7 |
8 | function Component() {
9 | React.useEffect(() => {
10 | loadLogs()
11 | loadLogStats()
12 | }, [])
13 |
14 | return (
15 |
16 |
Logs
17 |
18 |
19 |
20 | ({
22 | x: new Date(value._id).getHours().toString(),
23 | y: value.requests,
24 | }))}
25 | />
26 |
27 |
28 |
29 |
30 |
31 |
32 | Date |
33 | Category |
34 | Label |
35 | Status |
36 | Time |
37 |
38 |
39 |
40 |
41 | {logs.value.map((log) => (
42 |
46 |
47 |
48 | |
49 |
50 | {log.category}
51 | |
52 |
53 |
54 | |
55 |
56 |
57 | |
58 |
59 | {typeof log.time === 'number' ? `${log.time}ms` : ''}
60 | |
61 |
62 | ))}
63 |
64 |
65 |
66 |
67 | )
68 | }
69 |
70 | interface StatusProps {
71 | status: number
72 | }
73 |
74 | function Status({ status }: StatusProps) {
75 | if (status >= 200 && status < 400) {
76 | return <>{status}>
77 | }
78 |
79 | if (status >= 400 && status < 500) {
80 | return (
81 |
82 | {status}
83 |
84 |
85 | )
86 | }
87 |
88 | return (
89 |
90 | {status}
91 |
92 |
93 | )
94 | }
95 |
96 | interface LogContentProps {
97 | log: Log
98 | }
99 |
100 | function LogContent({ log }: LogContentProps) {
101 | const [expanded, setExpanded] = React.useState(false)
102 |
103 | return (
104 |
105 |
106 | {log.label}{' '}
107 | {log.data && (
108 |
117 | )}
118 |
119 |
120 | {expanded && (
121 |
122 | {JSON.stringify(log.data, null, 2)}
123 |
124 | )}
125 |
126 | )
127 | }
128 |
129 | export { Component }
130 |
--------------------------------------------------------------------------------
/admin/src/pages/notfound.tsx:
--------------------------------------------------------------------------------
1 | function NotFound() {
2 | return (
3 |
4 |
5 |
6 | join_right
7 |
8 |
It's not clear what should be here.
9 |
You must be missing.
10 |
11 |
— The Router
12 |
13 |
14 | )
15 | }
16 |
17 | export default NotFound
18 |
--------------------------------------------------------------------------------
/admin/src/pages/settings/devs.tsx:
--------------------------------------------------------------------------------
1 | import appDevelopers, { loadAppDevelopers } from '../../data/app-developers'
2 | import AVATAR_COLORS from '@/lib/avatar-colors'
3 | import Avatar from 'boring-avatars'
4 | import React from 'preact/compat'
5 |
6 | function Devs() {
7 | React.useEffect(() => {
8 | loadAppDevelopers()
9 | }, [])
10 |
11 | return (
12 |
13 |
Devs
14 |
15 | Accounts with developer permissions for the app
16 |
17 |
18 |
19 | {appDevelopers.value.map((dev) => {
20 | return (
21 |
22 |
27 |
28 |
29 | @
30 | {dev.username}
31 |
32 |
33 | )
34 | })}
35 |
36 |
37 | )
38 | }
39 |
40 | export default Devs
41 |
--------------------------------------------------------------------------------
/admin/src/pages/settings/index.tsx:
--------------------------------------------------------------------------------
1 | import schemaRefs, { loadSchemaRefs } from '../../data/schema-refs'
2 | import NavContentLayout from '../../layouts/NavContentLayout'
3 | import NavLinks from '@/components/nav-links'
4 | import { Outlet } from 'react-router-dom'
5 | import React from 'preact/compat'
6 |
7 | function Settings() {
8 | const links = React.useMemo(
9 | () => [
10 | {
11 | leading: (
12 |
13 | supervised_user_circle
14 |
15 | ),
16 | path: 'devs',
17 | title: 'Devs',
18 | },
19 | {
20 | children: schemaRefs.value.map((ref) => ({
21 | path: `schemas/${ref.name}`,
22 | title: ref.name,
23 | })),
24 | leading: (
25 |
26 | code_blocks
27 |
28 | ),
29 | path: 'schemas',
30 | title: 'Validation Schemas',
31 | },
32 | {
33 | leading: (
34 |
35 | face_4
36 |
37 | ),
38 | path: 'profile',
39 | title: 'Profile',
40 | },
41 | ],
42 | [schemaRefs.value]
43 | )
44 |
45 | React.useEffect(() => {
46 | loadSchemaRefs()
47 | }, [])
48 |
49 | return (
50 |
53 |
54 |
55 |
56 | >
57 | }
58 | >
59 |
60 |
61 | )
62 | }
63 |
64 | export default Settings
65 |
--------------------------------------------------------------------------------
/admin/src/pages/settings/profile.tsx:
--------------------------------------------------------------------------------
1 | function Profile() {
2 | return (
3 |
4 |
Your profile
5 |
6 | To edit your profile, use the /users
endpoint in the
7 | meantime.
8 |
9 |
10 | )
11 | }
12 |
13 | export default Profile
14 |
--------------------------------------------------------------------------------
/admin/src/pages/settings/schemas/index.tsx:
--------------------------------------------------------------------------------
1 | import Button from '@/components/button'
2 | import { useNavigate } from 'react-router-dom'
3 |
4 | function Schemas() {
5 | const navigate = useNavigate()
6 |
7 | function addNew() {
8 | navigate('/settings/schemas/new')
9 | }
10 | return (
11 |
12 |
13 |
14 | reply
15 |
16 |
17 | Expand Validations Schema and select a schema name to view or edit.
18 |
19 |
20 |
21 |
24 |
25 |
26 |
27 | )
28 | }
29 |
30 | export default Schemas
31 |
--------------------------------------------------------------------------------
/admin/src/routes.tsx:
--------------------------------------------------------------------------------
1 | import { Navigate, createBrowserRouter } from 'react-router-dom'
2 | import AdminLayout from '@/layouts/AdminLayout'
3 | import Collection from './client/collection'
4 | import CollectionEmptyState from './components/collections-empty-state'
5 | import Devs from './pages/settings/devs'
6 | import { LoaderErrorBoundary } from './components/general-error'
7 | import Login from './pages/login'
8 | import NotFound from './pages/notfound'
9 | import Profile from './pages/settings/profile'
10 | import Schemas from './pages/settings/schemas'
11 | import Settings from './pages/settings'
12 |
13 | interface CollectionRouteData {
14 | collection: Collection
15 | }
16 |
17 | const routes = createBrowserRouter(
18 | [
19 | {
20 | children: [
21 | {
22 | children: [
23 | {
24 | children: [
25 | {
26 | lazy: () => import('./pages/collections/[name]/index.tsx'),
27 | path: '',
28 | },
29 | {
30 | lazy: () => import('./pages/collections/[name]/hooks'),
31 | path: 'hooks',
32 | },
33 | {
34 | lazy: () => import('./pages/collections/[name]/edit'),
35 | path: 'edit',
36 | },
37 | ],
38 | id: 'collection',
39 | lazy: () => import('./pages/collections/[name]/_layout.tsx'),
40 | path: ':name',
41 | },
42 | {
43 | element: ,
44 | path: '',
45 | },
46 | ],
47 | lazy: () => import('./pages/collections'),
48 | path: 'collections',
49 | },
50 | {
51 | lazy: () => import('./pages/logs'),
52 | path: 'logs',
53 | },
54 | {
55 | children: [
56 | {
57 | element: ,
58 | path: 'schemas',
59 | },
60 | {
61 | lazy: () => import('./pages/settings/schemas/[name].tsx'),
62 | path: 'schemas/:name',
63 | },
64 | {
65 | lazy: () => import('./pages/settings/schemas/[name].tsx'),
66 | path: 'schemas/collections/:name',
67 | },
68 | {
69 | element: ,
70 | path: 'profile',
71 | },
72 | {
73 | element: ,
74 | path: 'devs',
75 | },
76 | {
77 | element: ,
78 | path: '',
79 | },
80 | ],
81 | element: ,
82 | path: 'settings',
83 | },
84 | {
85 | element: ,
86 | path: '',
87 | },
88 | ],
89 | element: ,
90 | errorElement: ,
91 | path: '',
92 | },
93 | {
94 | element: ,
95 | path: 'login',
96 | },
97 | {
98 | element: ,
99 | path: '*',
100 | },
101 | ],
102 | {
103 | basename: '/_',
104 | }
105 | )
106 |
107 | export default routes
108 | export type { CollectionRouteData }
109 |
--------------------------------------------------------------------------------
/admin/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/admin/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
4 | plugins: [require('@tailwindcss/forms')],
5 | theme: {
6 | extend: {},
7 | },
8 | }
9 |
--------------------------------------------------------------------------------
/admin/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "module": "ESNext",
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 | "jsxImportSource": "preact",
17 | "paths": {
18 | "@/*": ["./src/*"],
19 | },
20 |
21 | /* Linting */
22 | "strict": true,
23 | "noUnusedLocals": true,
24 | "noUnusedParameters": true,
25 | "noFallthroughCasesInSwitch": true,
26 | "types": ["preact/compat"]
27 | },
28 | "include": ["src", "./*.js", "./*.ts"],
29 | "references": [{ "path": "./tsconfig.node.json" }]
30 | }
31 |
--------------------------------------------------------------------------------
/admin/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "types": ["preact/compat"]
9 | },
10 | "include": ["vite.config.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/admin/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import { defineConfig } from 'vite'
3 | import preact from '@preact/preset-vite'
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | base: '/_',
8 | plugins: [preact()],
9 | resolve: {
10 | alias: {
11 | '@': '/src',
12 | },
13 | },
14 | build: {
15 | rollupOptions: {
16 | external: ['node:buffer', 'node:crypto', 'node:util'],
17 | },
18 | },
19 | test: {
20 | environment: 'jsdom',
21 | },
22 | })
23 |
--------------------------------------------------------------------------------
/assets/ss-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackmann/mangobase/ddf71b8b4e80945e84e7e0f0c4db9714cbfed300/assets/ss-dark.png
--------------------------------------------------------------------------------
/assets/ss-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/blackmann/mangobase/ddf71b8b4e80945e84e7e0f0c4db9714cbfed300/assets/ss-light.png
--------------------------------------------------------------------------------
/base/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["base"],
3 | "parserOptions": {
4 | "project": "./tsconfig.json"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/base/.gitignore:
--------------------------------------------------------------------------------
1 | html
2 | dist/
3 | .mangobase
--------------------------------------------------------------------------------
/base/README.md:
--------------------------------------------------------------------------------
1 | # mangobase
2 |
3 | [](https://www.npmjs.com/package/mangobase)
4 | [](https://www.npmjs.com/package/mangobase)
5 | [](https://www.npmjs.com/package/mangobase)
6 |
7 | This is the core library of the [mangobase](https://degreat.co.uk/mangobase) suite.
8 |
9 | This package basically exposes a Mangobase [`App`](https://degreat.co.uk/mangobase/api/base/App.html) class which provides the [`api`](https://degreat.co.uk/mangobase/api/base/App.html#api) and [`admin`](https://degreat.co.uk/mangobase/api/base/App.html#admin) methods to interact with.
10 |
11 | Visit https://degreat.co.uk/mangobase to get started.
12 |
13 | ```bash
14 | npm create mango@latest
15 | yarn create mango
16 | ```
17 |
18 | 
--------------------------------------------------------------------------------
/base/build.mjs:
--------------------------------------------------------------------------------
1 | import esbuild from 'esbuild'
2 |
3 | const commonConfig = {
4 | bundle: true,
5 | external: ['bcrypt', 'jose'],
6 | format: 'esm',
7 | platform: 'node',
8 | target: 'esnext',
9 | }
10 |
11 | // package
12 | await esbuild.build({
13 | ...commonConfig,
14 | entryPoints: ['src/index.ts'],
15 | outdir: 'dist/',
16 | })
17 |
18 | // utitlities
19 | await esbuild.build({
20 | ...commonConfig,
21 | entryPoints: ['src/lib/index.ts'],
22 | outdir: 'dist/lib',
23 | })
24 |
--------------------------------------------------------------------------------
/base/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mangobase",
3 | "version": "0.9.14",
4 | "description": "Low-code backend framework",
5 | "main": "dist/index.js",
6 | "repository": "https://github.com/blackmann/mangobase",
7 | "homepage": "https://degreat.co.uk/mangobase",
8 | "type": "module",
9 | "files": [
10 | "dist",
11 | "README.md"
12 | ],
13 | "exports": {
14 | ".": "./dist/index.js",
15 | "./lib": "./dist/lib/index.js"
16 | },
17 | "scripts": {
18 | "build": "yarn clean && node ./build.mjs && tsc -p tsconfig.build.json",
19 | "clean": "rm -rf dist",
20 | "copy-admin": "cp -R ../admin/dist dist/admin",
21 | "prepare": "yarn build && yarn copy-admin",
22 | "test": "vitest"
23 | },
24 | "author": "De-Great Yartey ",
25 | "license": "MIT",
26 | "devDependencies": {
27 | "@types/bcrypt": "^5.0.2",
28 | "@vitest/coverage-v8": "^0.34.4",
29 | "@vitest/ui": "^0.31.0",
30 | "esbuild": "^0.17.19",
31 | "eslint-config-base": "*",
32 | "mongodb-memory-server-core": "8.15.1",
33 | "radix3": "^1.0.1",
34 | "typescript": "^5.0.4",
35 | "vitest": "^0.34.4"
36 | },
37 | "dependencies": {
38 | "bcrypt": "^5.1.1",
39 | "jose": "^5.1.0"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/base/src/authentication.spec.ts:
--------------------------------------------------------------------------------
1 | import { afterAll, assert, beforeAll, describe, expect, it } from 'vitest'
2 | import { App } from './app.js'
3 | import { MongoDb } from '@mangobase/mongodb'
4 | import { MongoMemoryServer } from 'mongodb-memory-server-core'
5 | import { context } from './context.js'
6 | import fs from 'fs'
7 |
8 | let app: App
9 | let mongod: MongoMemoryServer
10 |
11 | async function setup() {
12 | process.env.SECRET_KEY = 'test'
13 | mongod = await MongoMemoryServer.create()
14 | app = new App({
15 | db: new MongoDb(mongod.getUri('auth-test')),
16 | })
17 | }
18 |
19 | async function teardown() {
20 | await mongod.stop()
21 | fs.rmSync('./.mangobase', { force: true, recursive: true })
22 | }
23 |
24 | beforeAll(setup, 30_000)
25 | afterAll(teardown)
26 |
27 | describe('authentication', () => {
28 | beforeAll(async () => {
29 | const { result } = await app.api(
30 | context({
31 | data: {
32 | email: 'mock-1@mail.com',
33 | fullname: 'Mock User',
34 | password: 'helloworld',
35 | username: 'mock-1',
36 | },
37 | method: 'create',
38 | path: 'users',
39 | })
40 | )
41 |
42 | assert(result._id)
43 | })
44 |
45 | describe('login with username', () => {
46 | it('should return a token', async () => {
47 | const { result } = await app.api(
48 | context({
49 | data: {
50 | password: 'helloworld',
51 | username: 'mock-1',
52 | },
53 | method: 'create',
54 | path: 'login',
55 | })
56 | )
57 |
58 | expect(result.auth.token).toBeDefined()
59 | expect(result.user).toBeDefined()
60 | })
61 | })
62 |
63 | describe('login with email', () => {
64 | it('should return a token', async () => {
65 | const { result } = await app.api(
66 | context({
67 | data: {
68 | email: 'mock-1@mail.com',
69 | password: 'helloworld',
70 | },
71 | method: 'create',
72 | path: 'login',
73 | })
74 | )
75 |
76 | expect(result.auth.token).toBeDefined()
77 | expect(result.user).toBeDefined()
78 | })
79 | })
80 | })
81 |
--------------------------------------------------------------------------------
/base/src/context.ts:
--------------------------------------------------------------------------------
1 | import { type Method } from './method.js'
2 |
3 | interface Context {
4 | data?: any
5 | headers: Record
6 |
7 | /**
8 | * Use this to store any abitrary data that can be accessed from another
9 | * hook.
10 | */
11 | locals: Record
12 | method: `${Method}`
13 |
14 | /**
15 | * The path part of the URL without the query parameters. If you want the full URL,
16 | * see {@link Context.url}
17 | */
18 | path: string
19 |
20 | /** Params are values parsed from the URL path. For example, if a service handles the
21 | * path pattern `/songs/:songId`, you get params for a url `/songs/123` as
22 | *
23 | * ```javascript
24 | * {
25 | * songId: '123'
26 | * }
27 | * ```
28 | */
29 | params?: Record
30 |
31 | /** Query are request query parameters */
32 | query: Record
33 |
34 | /**
35 | * Set this with the result of processing a context. When set, subsequent `before` hooks are not called.
36 | */
37 | result?: any
38 |
39 | /**
40 | * The status code the server adapter sets on the response.
41 | */
42 | statusCode?: number
43 |
44 | url: string
45 |
46 | /**
47 | * The authenticated user. This is normally set by an authentication hook.
48 | */
49 | user?: any
50 | }
51 |
52 | function context(ctx: Partial): Context {
53 | return {
54 | headers: {},
55 | locals: {},
56 | method: 'find',
57 | path: '',
58 | query: {},
59 | url: '/',
60 | ...ctx,
61 | }
62 | }
63 |
64 | export { context }
65 | export type { Context }
66 |
--------------------------------------------------------------------------------
/base/src/database.ts:
--------------------------------------------------------------------------------
1 | import type { Definition, DefinitionType, SchemaDefinitions } from './schema.js'
2 |
3 | type SortOrder = -1 | 1
4 |
5 | interface Cursor {
6 | exec(): Promise
7 | limit(n: number): Cursor
8 | populate(
9 | fields: (string | { collection: string; field: string })[]
10 | ): Cursor
11 | select(fields: string[]): Cursor
12 | skip(n: number): Cursor
13 | sort(config: Record): Cursor
14 | }
15 |
16 | interface Filter {
17 | limit?: number
18 | populate?: string[]
19 | select?: string[]
20 | skip?: number
21 | sort?: Record
22 | }
23 |
24 | type Data = Record
25 |
26 | interface Index {
27 | // when sort is not specified, it defaults to ascending (1)
28 | fields: [string, SortOrder][] | string[]
29 | options?: {
30 | unique?: boolean
31 | sparse?: boolean
32 | }
33 | }
34 |
35 | interface RenameField {
36 | type: 'rename-field'
37 | collection: string
38 | from: string
39 | to: string
40 | }
41 |
42 | interface RemoveField {
43 | type: 'remove-field'
44 | collection: string
45 | field: string
46 | }
47 |
48 | interface AddField {
49 | collection: string
50 | type: 'add-field'
51 | name: string
52 | definition: Definition
53 | }
54 |
55 | interface RenameCollection {
56 | type: 'rename-collection'
57 | collection: string
58 | to: string
59 | }
60 |
61 | interface CreateCollection {
62 | type: 'create-collection'
63 | name: string
64 | collection: { name: string; schema: SchemaDefinitions; indexes: Index[] }
65 | }
66 |
67 | interface AddIndex {
68 | type: 'add-index'
69 | collection: string
70 | index: Index
71 | }
72 |
73 | interface RemoveIndex {
74 | type: 'remove-index'
75 | collection: string
76 | index: Index
77 | }
78 |
79 | interface UpdateConstraints {
80 | type: 'update-constraints'
81 | collection: string
82 | field: string
83 | constraints: {
84 | unique?: boolean
85 | }
86 | }
87 |
88 | // When adding support for RDBMS, we need to add `CreateCollection`, `DropCollection`, etc.
89 | type MigrationStep =
90 | | CreateCollection
91 | | RenameField
92 | | RemoveField
93 | | AddField
94 | | RenameCollection
95 | | AddIndex
96 | | RemoveIndex
97 | | UpdateConstraints
98 |
99 | interface Migration {
100 | /**
101 | * This is an autogenerated randomstring of the migration. This is useful especially in
102 | * version control. When there's a conflict on this ID in the manifest file, it signals
103 | * a clash in the migration, so `version` number should be bumped
104 | */
105 | id: string
106 | version: number
107 | steps: MigrationStep[]
108 | }
109 |
110 | interface Database {
111 | /**
112 | * Casting is an affordance to convert data in to formats that database prefers.
113 | * This is normally called during schema validation.
114 | */
115 | cast(value: any, type: DefinitionType): any
116 | count(collection: string, query: Record): Promise
117 | find(collection: string, query: Record): Cursor
118 | /**
119 | * @param data data or array of data to be inserted
120 | */
121 | create(collection: string, data: Data | Data[]): Cursor
122 | patch(
123 | collection: string,
124 | id: string | string[],
125 | data: Record
126 | ): Cursor
127 | remove(collection: string, id: string | string[]): Promise
128 | migrate(migration: Migration): Promise
129 | addIndexes(collection: string, indexes: Index[]): Promise
130 | removeIndexes(collection: string, indexes: Index[]): Promise
131 |
132 | // [ ] Properly standardize this API
133 | aggregate(
134 | collection: string,
135 | query: Record,
136 | filter: Filter,
137 | operations: Record[]
138 | ): Promise
139 | }
140 |
141 | export type {
142 | Cursor,
143 | Database,
144 | Filter as DatabaseFilter,
145 | Index,
146 | Migration,
147 | MigrationStep,
148 | SortOrder,
149 | }
150 |
--------------------------------------------------------------------------------
/base/src/db-migrations.ts:
--------------------------------------------------------------------------------
1 | import { type App } from './app.js'
2 | import type { Migration } from './database.js'
3 | import { type SchemaDefinitions } from './schema.js'
4 | import getCollection from './lib/get-collection.js'
5 |
6 | const migrationSchema: SchemaDefinitions = {
7 | id: { required: true, type: 'string' },
8 | version: { required: true, type: 'number' },
9 | }
10 |
11 | const collectionName = '_migrations'
12 |
13 | async function dbMigrations(app: App) {
14 | if (!(await app.manifest.collection(collectionName))) {
15 | const indexes = [{ fields: ['version'], options: { unique: true } }]
16 | await app.manifest.collection(collectionName, {
17 | exposed: false,
18 | indexes,
19 | name: collectionName,
20 | schema: migrationSchema,
21 | })
22 |
23 | await app.database.addIndexes(collectionName, indexes)
24 | }
25 |
26 | const migrationsCollection = getCollection(
27 | app,
28 | collectionName,
29 | migrationSchema
30 | )
31 |
32 | const {
33 | data: [latestMigration],
34 | } = await migrationsCollection.find({
35 | filter: { $limit: 1, $sort: { created_at: -1 } },
36 | query: {},
37 | })
38 |
39 | let latestMigrationVersion = latestMigration?.version || 0
40 | const latestCommitVersion =
41 | (await app.manifest.getLastMigrationCommit())?.version || 0
42 |
43 | latestMigrationVersion++
44 |
45 | if (latestMigrationVersion <= latestCommitVersion) {
46 | console.log('applying migrations')
47 | }
48 |
49 | while (latestMigrationVersion <= latestCommitVersion) {
50 | const migration = await app.manifest.getMigration(latestMigrationVersion)
51 | await app.database.migrate(migration)
52 |
53 | await saveMigration(app, migration)
54 |
55 | console.log(`[ ] migration ${migration.id} applied`)
56 | latestMigrationVersion++
57 | }
58 | }
59 |
60 | async function saveMigration(app: App, migration: Migration) {
61 | const migrationsCollection = getCollection(
62 | app,
63 | collectionName,
64 | migrationSchema
65 | )
66 |
67 | await migrationsCollection.create({
68 | id: migration.id,
69 | version: migration.version,
70 | })
71 | }
72 |
73 | export default dbMigrations
74 | export { saveMigration }
75 |
--------------------------------------------------------------------------------
/base/src/errors.ts:
--------------------------------------------------------------------------------
1 | class ServiceError extends Error {
2 | name = 'GeneralError'
3 | statusCode = 500
4 | data: any
5 |
6 | constructor(message?: string, data?: any) {
7 | super(message)
8 | this.data = data
9 | }
10 | }
11 |
12 | class BadRequest extends ServiceError {
13 | name = 'BadRequest'
14 | statusCode = 400
15 | }
16 |
17 | class Conflict extends ServiceError {
18 | name = 'Conflict'
19 | statusCode = 409
20 | }
21 |
22 | class InternalServerError extends ServiceError {
23 | name = 'InternalServerError'
24 | statusCode = 500
25 | }
26 |
27 | class MethodNotAllowed extends ServiceError {
28 | name = 'MethodNotAllowed'
29 | statusCode = 405
30 | }
31 |
32 | class NotFound extends ServiceError {
33 | name = 'NotFound'
34 | statusCode = 404
35 | }
36 |
37 | class Unauthorized extends ServiceError {
38 | name = 'Unauthorized'
39 | statusCode = 401
40 | }
41 |
42 | class AppError extends Error {}
43 |
44 | export {
45 | AppError,
46 | BadRequest,
47 | Conflict,
48 | InternalServerError,
49 | MethodNotAllowed,
50 | NotFound,
51 | ServiceError,
52 | Unauthorized,
53 | }
54 |
--------------------------------------------------------------------------------
/base/src/hook.ts:
--------------------------------------------------------------------------------
1 | import { App } from './app.js'
2 | import type { Context } from './context.js'
3 | import { type Definition } from './schema.js'
4 | import { type Method } from './method.js'
5 |
6 | type Config = Record
7 |
8 | type HookFn = (
9 | ctx: Context,
10 | config: Config | undefined,
11 | app: App
12 | ) => Promise
13 |
14 | interface Hook {
15 | id: string
16 | name: string
17 | description?: string
18 | configSchema?: Record
19 | run: HookFn
20 | }
21 |
22 | type Hooks = {
23 | after: Record<`${Method}`, [HookFn, Config?][]>
24 | before: Record<`${Method}`, [HookFn, Config?][]>
25 | }
26 |
27 | export type { Hook, HookFn, Hooks, Config as HookConfig }
28 |
--------------------------------------------------------------------------------
/base/src/hooks-registry.ts:
--------------------------------------------------------------------------------
1 | import type { Hook } from './hook.js'
2 | import { Schema } from './schema.js'
3 | import allHooks from './hooks.js'
4 |
5 | class HooksRegistry {
6 | private registry: Record = {}
7 |
8 | installCommon() {
9 | for (const hook of allHooks) {
10 | this.registry[hook.id] = hook
11 | }
12 | }
13 |
14 | register(...hooks: Hook[]) {
15 | for (const hook of hooks) {
16 | if (hook.configSchema) {
17 | Schema.validateSchema(hook.configSchema)
18 | }
19 |
20 | if (this.registry[hook.id]) {
21 | throw new Error(`A hook with id ${hook.id} is already registered`)
22 | }
23 |
24 | this.registry[hook.id] = hook
25 | }
26 | }
27 |
28 | get(id: string): Hook {
29 | const hook = this.registry[id]
30 | if (!hook) {
31 | throw new Error(`Hook ${id} not found`)
32 | }
33 | return hook
34 | }
35 |
36 | list(): Hook[] {
37 | return Object.values(this.registry)
38 | }
39 | }
40 |
41 | export { HooksRegistry }
42 |
--------------------------------------------------------------------------------
/base/src/hooks.ts:
--------------------------------------------------------------------------------
1 | import type { Hook, HookFn } from './hook.js'
2 | import { MethodNotAllowed } from './errors.js'
3 |
4 | const LogData: Hook = {
5 | description: 'Logs data. Check logs console.',
6 | id: 'log-data',
7 | name: 'Log data',
8 | run: async (ctx) => {
9 | console.log(`[${new Date().toISOString()}]`, ctx.path, ctx.method, ctx.data)
10 | return ctx
11 | },
12 | }
13 |
14 | const RestrictMethod: Hook = {
15 | configSchema: {
16 | allowDevs: { type: 'boolean' },
17 | },
18 | description: 'Restrict access to connected method',
19 | id: 'restrict-method',
20 | name: 'Restrict method',
21 | run: async (ctx, config) => {
22 | if (config?.allowDevs && ctx.user?.role === 'dev') {
23 | return ctx
24 | }
25 |
26 | throw new MethodNotAllowed()
27 | },
28 | }
29 |
30 | const CustomCode: Hook = {
31 | configSchema: {
32 | code: {
33 | defaultValue: `return async (ctx, app) => {
34 | return ctx
35 | }`,
36 | required: true,
37 | treatAs: 'code',
38 | type: 'string',
39 | },
40 | },
41 | description: 'Run custom code',
42 | id: 'custom-code',
43 | name: 'Custom Code',
44 | async run(ctx, config, app) {
45 | if (!config) {
46 | return ctx
47 | }
48 |
49 | // we need to warm up the code
50 | // config stays the same between runs, unless the hook is
51 | // updated
52 | if (!config.exec) {
53 | config.exec = new Function(config.code)() as HookFn
54 | }
55 |
56 | return await config.exec(ctx, app)
57 | },
58 | }
59 |
60 | const allHooks = [LogData, CustomCode, RestrictMethod]
61 |
62 | export default allHooks
63 |
64 | export { LogData }
65 |
--------------------------------------------------------------------------------
/base/src/index.ts:
--------------------------------------------------------------------------------
1 | export { App, Pipeline } from './app.js'
2 | export { CollectionService } from './collection-service.js'
3 | export { Collection } from './collection.js'
4 | export { context } from './context.js'
5 | export { HooksRegistry } from './hooks-registry.js'
6 | export { Manifest } from './manifest.js'
7 | export { Schema, ValidationError } from './schema.js'
8 |
9 | export { methodFromHttp } from './lib/method-from-http.js'
10 | export { getRefUsage } from './lib/get-ref-usage.js'
11 | export { exportSchema } from './lib/export-schema.js'
12 |
13 | export type { Handle, Service } from './app.js'
14 | export type { Filter, FilterOperators, Query } from './collection.js'
15 | export type { Context } from './context.js'
16 | export * from './database.js'
17 | export * as errors from './errors.js'
18 | export type { Hook, HookFn, Hooks, HookConfig } from './hook.js'
19 | export type { CollectionConfig, Ref } from './manifest.js'
20 | export { Method } from './method.js'
21 | export type { SchemaDefinitions, Definition, DefinitionType } from './schema.js'
22 |
--------------------------------------------------------------------------------
/base/src/lib/__snapshots__/export-schema.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`export-schema > typescript > should return typescript definition [include object schema] 1`] = `
4 | "interface Test {
5 | _id: string
6 | address: Address
7 | age?: number
8 | alive: boolean
9 | connection?: string
10 | created_at: Date
11 | friends_contacts: FriendsContacts[]
12 | name: string
13 | tags?: string[]
14 | gender?: \\"male\\" | \\"female\\"
15 | }
16 |
17 | interface Address {
18 | country: Country
19 | line1: string
20 | }
21 |
22 | interface Country {
23 | code?: string
24 | title: string
25 | }
26 |
27 | interface FriendsContacts {
28 | email: string
29 | name: string
30 | }"
31 | `;
32 |
33 | exports[`export-schema > typescript > should return typescript definition [inline object] 1`] = `
34 | "interface Test {
35 | _id: string
36 | address: {
37 | country: {
38 | code?: string
39 | title: string
40 | }
41 | line1: string
42 | }
43 | age?: number
44 | alive: boolean
45 | connection?: string
46 | created_at: Date
47 | friends_contacts: {
48 | email: string
49 | name: string
50 | }[]
51 | name: string
52 | tags?: string[]
53 | gender?: \\"male\\" | \\"female\\"
54 | }
55 |
56 | "
57 | `;
58 |
--------------------------------------------------------------------------------
/base/src/lib/api-paths.ts:
--------------------------------------------------------------------------------
1 | function onDev(path: string) {
2 | return `_dev/${path}`
3 | }
4 |
5 | function unexposed(path: string) {
6 | return `_x/${path}`
7 | }
8 |
9 | export { onDev, unexposed }
10 |
--------------------------------------------------------------------------------
/base/src/lib/clone.ts:
--------------------------------------------------------------------------------
1 | function clone(obj: T) {
2 | return JSON.parse(JSON.stringify(obj)) as T
3 | }
4 |
5 | export { clone }
6 |
--------------------------------------------------------------------------------
/base/src/lib/export-schema.test.ts:
--------------------------------------------------------------------------------
1 | import { type ExportResult, exportSchema } from './export-schema.js'
2 | import { describe, expect, it } from 'vitest'
3 | import { SchemaDefinitions } from '../schema.js'
4 |
5 | function render({ definition, includes }: ExportResult) {
6 | return [definition, Object.values(includes).join('\n\n')].join('\n\n')
7 | }
8 |
9 | describe('export-schema', () => {
10 | describe('typescript', () => {
11 | const schema: SchemaDefinitions = {
12 | address: {
13 | required: true,
14 | schema: {
15 | country: {
16 | required: true,
17 | schema: {
18 | code: { defaultValue: 'GH', type: 'string' },
19 | title: { required: true, type: 'string' },
20 | },
21 | type: 'object',
22 | },
23 | line1: { required: true, type: 'string' },
24 | },
25 | type: 'object',
26 | },
27 | age: { type: 'number' },
28 | alive: { required: true, type: 'boolean' },
29 | connection: { relation: 'something', type: 'id' },
30 | created_at: { required: true, type: 'date' },
31 | friends_contacts: {
32 | items: {
33 | schema: {
34 | email: { required: true, type: 'string' },
35 | name: { required: true, type: 'string' },
36 | },
37 | type: 'object',
38 | },
39 | required: true,
40 | type: 'array',
41 | },
42 | name: { required: true, type: 'string' },
43 | tags: { items: { type: 'string' }, type: 'array' },
44 | gender: { type: 'string', enum: ['male', 'female'] },
45 | }
46 |
47 | it('should return typescript definition [include object schema]', async () => {
48 | expect(
49 | render(
50 | await exportSchema({
51 | getRef: async () => ({}),
52 | includeObjectSchema: true,
53 | language: 'typescript',
54 | name: 'test',
55 | schema,
56 | })
57 | )
58 | ).toMatchSnapshot()
59 | })
60 |
61 | it('should return typescript definition [inline object]', async () => {
62 | expect(
63 | render(
64 | await exportSchema({
65 | getRef: async () => ({}),
66 | includeObjectSchema: true,
67 | inlineObjectSchema: true,
68 | language: 'typescript',
69 | name: 'test',
70 | schema,
71 | })
72 | )
73 | ).toMatchSnapshot()
74 | })
75 | })
76 | })
77 |
--------------------------------------------------------------------------------
/base/src/lib/get-collection.ts:
--------------------------------------------------------------------------------
1 | import { Schema, type SchemaDefinitions } from '../schema.js'
2 | import { type App } from '../app.js'
3 | import { Collection } from '../collection.js'
4 |
5 | function getCollection(app: App, name: string, schema: SchemaDefinitions) {
6 | return new Collection(name, {
7 | db: app.database,
8 | schema: Promise.resolve(new Schema(schema, { parser: app.database.cast })),
9 | })
10 | }
11 |
12 | export default getCollection
13 |
--------------------------------------------------------------------------------
/base/src/lib/get-ref-usage.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { SchemaDefinitions } from '../schema.js'
3 | import { getRefUsage } from './get-ref-usage.js'
4 |
5 | describe('getRefUsage', () => {
6 | const schema: SchemaDefinitions = {
7 | arr1: {
8 | items: {
9 | schema: 'ref1',
10 | type: 'object',
11 | },
12 | type: 'array',
13 | },
14 | arr2: {
15 | items: [
16 | {
17 | schema: 'ref1',
18 | type: 'object',
19 | },
20 | {
21 | schema: {
22 | prop1: {
23 | schema: 'ref1',
24 | type: 'object',
25 | },
26 | },
27 | type: 'object',
28 | },
29 | ],
30 | type: 'array',
31 | },
32 | obj1: {
33 | schema: {
34 | obj2: {
35 | schema: {
36 | ref1: {
37 | schema: 'ref1',
38 | type: 'object',
39 | },
40 | },
41 | type: 'object',
42 | },
43 | },
44 | type: 'object',
45 | },
46 | ref1: {
47 | schema: {
48 | prop1: {
49 | type: 'string',
50 | },
51 | },
52 | type: 'object',
53 | },
54 | }
55 |
56 | it('should return an empty array if refName is not found in schema', () => {
57 | const refName = 'ref2'
58 | const usage = getRefUsage(refName, schema)
59 | expect(usage).toEqual([])
60 | })
61 |
62 | it('should return an array of paths where refName is used in schema', () => {
63 | const refName = 'ref1'
64 | const usage = getRefUsage(refName, schema)
65 | expect(usage).toEqual([
66 | ['arr1', 'items', 'schema'],
67 | ['arr2', 'items', '0', 'schema'],
68 | ['arr2', 'items', '1', 'schema', 'prop1', 'schema'],
69 | ['obj1', 'schema', 'obj2', 'schema', 'ref1', 'schema'],
70 | ])
71 | })
72 | })
73 |
--------------------------------------------------------------------------------
/base/src/lib/get-ref-usage.ts:
--------------------------------------------------------------------------------
1 | import type { SchemaDefinitions } from '../schema.js'
2 |
3 | function getRefUsage(refName: string, schema: SchemaDefinitions) {
4 | const usage: string[][] = []
5 |
6 | for (const [key, definition] of Object.entries(schema)) {
7 | if (definition.type === 'object') {
8 | if (typeof definition.schema === 'string') {
9 | if (definition.schema === refName) {
10 | usage.push([key, 'schema'])
11 | }
12 | continue
13 | }
14 |
15 | const nestedUsage = getRefUsage(refName, definition.schema)
16 | for (const path of nestedUsage) {
17 | usage.push([key, 'schema', ...path])
18 | }
19 | }
20 |
21 | if (definition.type === 'array') {
22 | if (Array.isArray(definition.items)) {
23 | for (const [i, itemDefinition] of definition.items.entries()) {
24 | if (itemDefinition.type === 'object') {
25 | if (typeof itemDefinition.schema === 'string') {
26 | if (itemDefinition.schema === refName) {
27 | usage.push([key, 'items', i.toString(), 'schema'])
28 | }
29 | continue
30 | }
31 |
32 | const nestedUsage = getRefUsage(refName, itemDefinition.schema)
33 | for (const path of nestedUsage) {
34 | usage.push([key, 'items', i.toString(), 'schema', ...path])
35 | }
36 | }
37 | }
38 | } else if (definition.items.type === 'object') {
39 | if (typeof definition.items.schema === 'string') {
40 | if (definition.items.schema === refName) {
41 | usage.push([key, 'items', 'schema'])
42 | }
43 | } else {
44 | const nestedUsage = getRefUsage(refName, definition.items.schema)
45 | for (const path of nestedUsage) {
46 | usage.push([key, 'items', ...path])
47 | }
48 | }
49 | }
50 | }
51 | }
52 |
53 | return usage
54 | }
55 |
56 | export { getRefUsage }
57 |
--------------------------------------------------------------------------------
/base/src/lib/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | exportSchema,
3 | type ExportOptions,
4 | type ExportResult,
5 | } from './export-schema.js'
6 |
--------------------------------------------------------------------------------
/base/src/lib/method-from-http.ts:
--------------------------------------------------------------------------------
1 | import { Method } from '../method.js'
2 |
3 | const lookup: Record = {
4 | DELETE: Method.remove,
5 | GET: Method.find,
6 | PATCH: Method.patch,
7 | POST: Method.create,
8 | }
9 |
10 | function methodFromHttp(method: string) {
11 | return lookup[method] || Method.find
12 | }
13 |
14 | export { methodFromHttp }
15 |
--------------------------------------------------------------------------------
/base/src/lib/random-str.ts:
--------------------------------------------------------------------------------
1 | const candidates =
2 | 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
3 |
4 | function randomStr(length = 8) {
5 | return Array.from({ length })
6 | .map(() => candidates[Math.floor(Math.random() * candidates.length)])
7 | .join('')
8 | }
9 |
10 | export default randomStr
11 |
--------------------------------------------------------------------------------
/base/src/lib/set-with-path.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import setWithPath from './set-with-path.js'
3 |
4 | const data = {
5 | address: {
6 | city: 'Accra',
7 | country: 'Ghana',
8 | },
9 | name: 'mock',
10 | }
11 |
12 | describe('set-with-path', () => {
13 | it('should set value of field', () => {
14 | const path = ['name']
15 | const value = 'mock-change'
16 |
17 | setWithPath(data, path, value)
18 |
19 | expect(data.name).toBe(value)
20 | })
21 |
22 | it('should set a value in a nested object', () => {
23 | const path = ['address', 'city']
24 | const value = 'Kumasi'
25 |
26 | setWithPath(data, path, value)
27 |
28 | expect(data.address.city).toBe(value)
29 | })
30 | })
31 |
--------------------------------------------------------------------------------
/base/src/lib/set-with-path.ts:
--------------------------------------------------------------------------------
1 | function setWithPath(
2 | obj: Record,
3 | path: (string | number)[],
4 | value: any
5 | ) {
6 | let current = obj
7 | let i = 0
8 | for (; i < path.length - 1; i++) {
9 | current = current[path[i]]
10 | }
11 |
12 | current[path[i]] = value
13 | }
14 |
15 | export default setWithPath
16 |
--------------------------------------------------------------------------------
/base/src/logger.ts:
--------------------------------------------------------------------------------
1 | import { type App } from './app.js'
2 | import { CollectionService } from './collection-service.js'
3 | import { HookFn } from './hook.js'
4 | import { MethodNotAllowed } from './errors.js'
5 | import { Schema } from './schema.js'
6 | import { onDev } from './lib/api-paths.js'
7 |
8 | const getPath = () => onDev('logs')
9 | const getStatsPath = () => onDev('log-stats')
10 |
11 | async function logger(app: App) {
12 | const collectionName = '_logs'
13 |
14 | const schema = new Schema(
15 | {
16 | category: { required: true, type: 'string' },
17 | data: { type: 'any' },
18 | label: { required: true, type: 'string' },
19 | status: { type: 'number' },
20 | time: { type: 'number' },
21 | },
22 | { parser: app.database.cast }
23 | )
24 | const service = new CollectionService(app, collectionName, { schema })
25 |
26 | app.use(getStatsPath(), async (ctx, app) => {
27 | if (ctx.method !== 'find') {
28 | throw new MethodNotAllowed()
29 | }
30 |
31 | const maxDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000 // 48 hours ago
32 |
33 | const query = {
34 | created_at: { $gte: new Date(maxDaysAgo) },
35 | }
36 |
37 | const ops = [
38 | {
39 | $group: {
40 | _id: {
41 | $dateToString: {
42 | date: '$created_at',
43 | format: '%Y-%m-%dT%H:00:00',
44 | },
45 | },
46 | date: { $first: '$created_at' },
47 | requests: { $sum: 1 },
48 | },
49 | },
50 | {
51 | $sort: { date: 1 },
52 | },
53 | ]
54 |
55 | const results = await app.database.aggregate(
56 | collectionName,
57 | query,
58 | { sort: { created_at: 1 } },
59 | ops
60 | )
61 | ctx.result = results
62 |
63 | return ctx
64 | })
65 |
66 | app.use(getPath(), service)
67 | }
68 |
69 | const logStart: HookFn = async (ctx) => {
70 | ctx.locals['reqstart'] = Date.now()
71 | return ctx
72 | }
73 |
74 | const logEnd: HookFn = async (ctx, _, app) => {
75 | const service = app.service(getPath()) as CollectionService
76 | await service.collection.create({
77 | category: ctx.method,
78 | data: (ctx.statusCode || 200) > 399 ? ctx.result : undefined,
79 | label: ctx.url,
80 | status: ctx.statusCode,
81 | time: Date.now() - ctx.locals['reqstart'],
82 | })
83 |
84 | return ctx
85 | }
86 |
87 | export default logger
88 | export { logEnd, logStart }
89 |
--------------------------------------------------------------------------------
/base/src/method.ts:
--------------------------------------------------------------------------------
1 | enum Method {
2 | create = 'create',
3 | find = 'find',
4 | get = 'get',
5 | patch = 'patch',
6 | remove = 'remove',
7 | }
8 |
9 | export { Method }
10 |
--------------------------------------------------------------------------------
/base/src/users.ts:
--------------------------------------------------------------------------------
1 | import { App } from './app.js'
2 | import { SchemaDefinitions } from './schema.js'
3 |
4 | const usersSchema: SchemaDefinitions = {
5 | avatar: { type: 'string' },
6 | email: { required: true, type: 'string', unique: true },
7 | fullname: { required: true, type: 'string' },
8 | // [ ] Prevent anyone from just creating a 'dev' account. Use hook
9 | role: {
10 | defaultValue: 'basic',
11 | enum: ['dev', 'basic'],
12 | required: true,
13 | type: 'string',
14 | },
15 | username: { required: true, type: 'string', unique: true },
16 | verified: { type: 'boolean' },
17 | }
18 |
19 | async function users(app: App) {
20 | if (!(await app.manifest.collection('users'))) {
21 | const indexes = [
22 | { fields: ['username'], options: { unique: true } },
23 | { fields: ['email'], options: { unique: true } },
24 | ]
25 |
26 | await app.manifest.collection('users', {
27 | exposed: true,
28 | indexes,
29 | name: 'users',
30 | readOnlySchema: true,
31 | schema: usersSchema,
32 | })
33 |
34 | await app.database.addIndexes('users', indexes)
35 | }
36 | }
37 |
38 | export default users
39 |
--------------------------------------------------------------------------------
/base/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": [
4 | "global.setup.ts",
5 | "vitest.config.ts",
6 | "src/**/*.test.ts",
7 | "src/**/*.spec.ts",
8 | "dist/**/**"
9 | ]
10 | }
--------------------------------------------------------------------------------
/base/vitest.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import { defineConfig } from 'vite'
3 |
4 | export default defineConfig({
5 | test: {
6 | clearMocks: true,
7 | coverage: {
8 | enabled: true,
9 | },
10 | reporters: ['default', 'html'],
11 | threads: false,
12 | },
13 | })
14 |
--------------------------------------------------------------------------------
/bun-server/.gitignore:
--------------------------------------------------------------------------------
1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
2 |
3 | # Logs
4 |
5 | logs
6 | _.log
7 | npm-debug.log_
8 | yarn-debug.log*
9 | yarn-error.log*
10 | lerna-debug.log*
11 | .pnpm-debug.log*
12 |
13 | # Diagnostic reports (https://nodejs.org/api/report.html)
14 |
15 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
16 |
17 | # Runtime data
18 |
19 | pids
20 | _.pid
21 | _.seed
22 | \*.pid.lock
23 |
24 | # Directory for instrumented libs generated by jscoverage/JSCover
25 |
26 | lib-cov
27 |
28 | # Coverage directory used by tools like istanbul
29 |
30 | coverage
31 | \*.lcov
32 |
33 | # nyc test coverage
34 |
35 | .nyc_output
36 |
37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
38 |
39 | .grunt
40 |
41 | # Bower dependency directory (https://bower.io/)
42 |
43 | bower_components
44 |
45 | # node-waf configuration
46 |
47 | .lock-wscript
48 |
49 | # Compiled binary addons (https://nodejs.org/api/addons.html)
50 |
51 | build/Release
52 |
53 | # Dependency directories
54 |
55 | node_modules/
56 | jspm_packages/
57 |
58 | # Snowpack dependency directory (https://snowpack.dev/)
59 |
60 | web_modules/
61 |
62 | # TypeScript cache
63 |
64 | \*.tsbuildinfo
65 |
66 | # Optional npm cache directory
67 |
68 | .npm
69 |
70 | # Optional eslint cache
71 |
72 | .eslintcache
73 |
74 | # Optional stylelint cache
75 |
76 | .stylelintcache
77 |
78 | # Microbundle cache
79 |
80 | .rpt2_cache/
81 | .rts2_cache_cjs/
82 | .rts2_cache_es/
83 | .rts2_cache_umd/
84 |
85 | # Optional REPL history
86 |
87 | .node_repl_history
88 |
89 | # Output of 'npm pack'
90 |
91 | \*.tgz
92 |
93 | # Yarn Integrity file
94 |
95 | .yarn-integrity
96 |
97 | # dotenv environment variable files
98 |
99 | .env
100 | .env.development.local
101 | .env.test.local
102 | .env.production.local
103 | .env.local
104 |
105 | # parcel-bundler cache (https://parceljs.org/)
106 |
107 | .cache
108 | .parcel-cache
109 |
110 | # Next.js build output
111 |
112 | .next
113 | out
114 |
115 | # Nuxt.js build / generate output
116 |
117 | .nuxt
118 | dist
119 |
120 | # Gatsby files
121 |
122 | .cache/
123 |
124 | # Comment in the public line in if your project uses Gatsby and not Next.js
125 |
126 | # https://nextjs.org/blog/next-9-1#public-directory-support
127 |
128 | # public
129 |
130 | # vuepress build output
131 |
132 | .vuepress/dist
133 |
134 | # vuepress v2.x temp and cache directory
135 |
136 | .temp
137 | .cache
138 |
139 | # Docusaurus cache and generated files
140 |
141 | .docusaurus
142 |
143 | # Serverless directories
144 |
145 | .serverless/
146 |
147 | # FuseBox cache
148 |
149 | .fusebox/
150 |
151 | # DynamoDB Local files
152 |
153 | .dynamodb/
154 |
155 | # TernJS port file
156 |
157 | .tern-port
158 |
159 | # Stores VSCode versions used for testing VSCode extensions
160 |
161 | .vscode-test
162 |
163 | # yarn v2
164 |
165 | .yarn/cache
166 | .yarn/unplugged
167 | .yarn/build-state.yml
168 | .yarn/install-state.gz
169 | .pnp.\*
170 |
171 | *.tsbuildinfo
--------------------------------------------------------------------------------
/bun-server/README.md:
--------------------------------------------------------------------------------
1 | # @mangobase/bun
2 |
3 | Bun server adapter for mangobase.
4 |
--------------------------------------------------------------------------------
/bun-server/build.js:
--------------------------------------------------------------------------------
1 | import esbuild from 'esbuild'
2 |
3 | esbuild.build({
4 | bundle: true,
5 | entryPoints: ['src/index.ts'],
6 | outdir: 'dist/',
7 | external: ['mangobase'],
8 | platform: 'node',
9 | })
10 |
--------------------------------------------------------------------------------
/bun-server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@mangobase/bun",
3 | "module": "src/index.ts",
4 | "version": "0.9.14",
5 | "repository": "https://github.com/blackmann/mangobase",
6 | "homepage": "https://degreat.co.uk/mangobase",
7 | "type": "module",
8 | "main": "dist/index.js",
9 | "files": [
10 | "dist/",
11 | "README.md"
12 | ],
13 | "scripts": {
14 | "build": "node ./build.js && tsc -p tsconfig.build.json",
15 | "clean": "rm -rf ./dist",
16 | "prepare": "yarn clean && yarn build"
17 | },
18 | "devDependencies": {
19 | "bun-types": "latest",
20 | "esbuild": "^0.19.3",
21 | "mangobase": "^0.9.14"
22 | },
23 | "peerDependencies": {
24 | "mangobase": "*",
25 | "typescript": "^5.0.0"
26 | },
27 | "dependencies": {
28 | "qs": "^6.11.2"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/bun-server/src/index.ts:
--------------------------------------------------------------------------------
1 | import type { App } from 'mangobase'
2 | import { context, methodFromHttp } from 'mangobase'
3 | import qs from 'qs'
4 |
5 | const API_PATH_REGEX = /^\/api(?:\/.*)?$/
6 | const ADMIN_PATH_REGEX = /^\/_(?:\/.*)?$/
7 |
8 | function bunServer(port = 5000) {
9 | return (app: App) =>
10 | Bun.serve({
11 | async fetch(req, server) {
12 | const url = new URL(req.url)
13 |
14 | if (API_PATH_REGEX.test(url.pathname)) {
15 | const ctx = context({
16 | // [ ] Handle bad json parse
17 | data: req.body ? await req.json() : null,
18 | headers: req.headers.toJSON(),
19 | method: methodFromHttp(req.method),
20 | path: url.pathname.replace(/^\/api\/?/, ''),
21 | query: qs.parse(url.search.replace(/^\?/, '')),
22 | url: req.url,
23 | })
24 |
25 | const res = await app.api(ctx)
26 | return new Response(JSON.stringify(res.result), {
27 | headers: { 'Content-Type': 'application/json' },
28 | status: res.statusCode,
29 | })
30 | }
31 |
32 | if (ADMIN_PATH_REGEX.test(url.pathname)) {
33 | const [path, queryParams] = url.pathname
34 | .replace(/^\/_/, '')
35 | .split('?')
36 | const file = await app.admin(path || 'index.html')
37 | return new Response(Bun.file(file))
38 | }
39 |
40 | return new Response(JSON.stringify({ detail: 'Not route found' }), {
41 | status: 404,
42 | })
43 | },
44 | port,
45 | })
46 | }
47 |
48 | export { bunServer }
49 |
--------------------------------------------------------------------------------
/bun-server/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": [
4 | "dist/**/**",
5 | "build.js"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/bun-server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["ESNext"],
4 | "module": "esnext",
5 | "target": "esnext",
6 | "moduleResolution": "bundler",
7 | "moduleDetection": "force",
8 | "allowImportingTsExtensions": true,
9 | "composite": true,
10 | "strict": true,
11 | "downlevelIteration": true,
12 | "skipLibCheck": true,
13 | "jsx": "preserve",
14 | "allowSyntheticDefaultImports": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "allowJs": true,
17 | "types": [
18 | "bun-types" // add Bun global
19 | ],
20 | "tsBuildInfoFile": "dist/.tsbuildinfo",
21 | "declaration": true,
22 | "emitDeclarationOnly": true,
23 | "outDir": "./dist",
24 | "rootDir": "./src",
25 | "resolveJsonModule": true
26 | },
27 | }
28 |
--------------------------------------------------------------------------------
/create-mango/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | end_of_line = lf
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
10 | [*.yml]
11 | indent_style = space
12 | indent_size = 2
13 |
--------------------------------------------------------------------------------
/create-mango/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/create-mango/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
--------------------------------------------------------------------------------
/create-mango/.prettierignore:
--------------------------------------------------------------------------------
1 | dist
2 |
--------------------------------------------------------------------------------
/create-mango/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "create-mango",
3 | "version": "0.9.14",
4 | "license": "MIT",
5 | "bin": "dist/cli.js",
6 | "repository": "https://github.com/blackmann/mangobase",
7 | "homepage": "https://degreat.co.uk/mangobase",
8 | "type": "module",
9 | "engines": {
10 | "node": ">=16"
11 | },
12 | "scripts": {
13 | "build": "tsc",
14 | "clean": "rm -rf dist",
15 | "copy-files": "cp -R templates ./dist/templates",
16 | "dev": "tsc --watch",
17 | "test": "prettier --check . && xo && ava",
18 | "prepare": "yarn clean && yarn build && yarn copy-files"
19 | },
20 | "files": [
21 | "dist",
22 | "readme.md"
23 | ],
24 | "dependencies": {
25 | "@types/listr": "^0.14.5",
26 | "execa": "^8.0.1",
27 | "ink": "^4.1.0",
28 | "ink-select-input": "^5.0.0",
29 | "ink-text-input": "^5.0.1",
30 | "listr": "^0.14.3",
31 | "make-dir": "^4.0.0",
32 | "meow": "^11.0.0",
33 | "react": "^18.2.0",
34 | "replace-string": "^4.0.0",
35 | "slugify": "^1.6.6"
36 | },
37 | "devDependencies": {
38 | "@next/env": "^13.4.19",
39 | "@sindresorhus/tsconfig": "^3.0.1",
40 | "@types/react": "^18.0.32",
41 | "ava": "^5.2.0",
42 | "chalk": "^5.2.0",
43 | "eslint-config-xo-react": "^0.27.0",
44 | "eslint-plugin-react": "^7.32.2",
45 | "eslint-plugin-react-hooks": "^4.6.0",
46 | "ink-testing-library": "^3.0.0",
47 | "prettier": "^2.8.7",
48 | "ts-node": "^10.9.1",
49 | "typescript": "^5.0.3",
50 | "xo": "^0.53.1"
51 | },
52 | "ava": {
53 | "extensions": {
54 | "ts": "module",
55 | "tsx": "module"
56 | },
57 | "nodeArguments": [
58 | "--loader=ts-node/esm"
59 | ]
60 | },
61 | "xo": {
62 | "extends": "xo-react",
63 | "prettier": true,
64 | "rules": {
65 | "react/prop-types": "off"
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/create-mango/readme.md:
--------------------------------------------------------------------------------
1 | # create-mango
2 |
3 | > This readme is automatically generated by [create-ink-app](https://github.com/vadimdemedes/create-ink-app)
4 |
5 | ## Install
6 |
7 | ```bash
8 | $ npm install --global create-mango
9 | ```
10 |
11 | ## CLI
12 |
13 | ```
14 | $ create-mango --help
15 |
16 | Usage
17 | $ create-mango
18 |
19 | Options
20 | --name Your name
21 |
22 | Examples
23 | $ create-mango --name=Jane
24 | Hello, Jane
25 | ```
26 |
--------------------------------------------------------------------------------
/create-mango/source/app.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Text, useApp, useInput } from 'ink'
3 | import SelectInput from 'ink-select-input'
4 | import TextInput from 'ink-text-input'
5 | import {
6 | CreateProjectOptions,
7 | Language,
8 | PackageManager,
9 | createProject,
10 | } from './create-project.js'
11 |
12 | const LANGUAGE_SELECT = 1
13 | const PACKAGE_MANAGER_SELECT = 2
14 | const DESTINATION_SELECT = 3
15 | const CONFIRM_OPTIONS = 4
16 | const CREATE_PROJECT = 5
17 |
18 | const languageOptions = [
19 | {
20 | label: 'Typescript',
21 | value: 'typescript',
22 | },
23 | {
24 | label: 'Javascript',
25 | value: 'javascript',
26 | },
27 | ]
28 |
29 | const packageManagerOptions = [
30 | {
31 | label: 'NPM',
32 | value: 'npm',
33 | },
34 | {
35 | label: 'Yarn',
36 | value: 'yarn',
37 | },
38 | ]
39 |
40 | function Wizard() {
41 | const [stage, setStage] = React.useState(LANGUAGE_SELECT)
42 | const languageSelection = React.useRef()
43 | const packageManagerSelection = React.useRef()
44 | const [projectName, setProjectName] = React.useState('')
45 |
46 | useInput((_, key) => {
47 | if (stage !== CONFIRM_OPTIONS) {
48 | return
49 | }
50 |
51 | if (key.return) {
52 | setStage(CREATE_PROJECT)
53 | }
54 | })
55 |
56 | function handleLanguageSelect({ value }: { value: string }) {
57 | languageSelection.current = value
58 | setStage(PACKAGE_MANAGER_SELECT)
59 | }
60 |
61 | function handlePackageManagerSelect({ value }: { value: string }) {
62 | packageManagerSelection.current = value
63 | setStage(DESTINATION_SELECT)
64 | }
65 |
66 | function handleDestinationSelect() {
67 | if (!projectName.trim().length) {
68 | return
69 | }
70 |
71 | setStage(CONFIRM_OPTIONS)
72 | }
73 |
74 | if (stage === LANGUAGE_SELECT) {
75 | return (
76 | <>
77 | [🈷️] What language do you prefer?
78 |
79 | >
80 | )
81 | }
82 |
83 | if (stage === PACKAGE_MANAGER_SELECT) {
84 | return (
85 | <>
86 | [🚌] Package Manager?
87 |
91 | >
92 | )
93 | }
94 |
95 | if (stage === DESTINATION_SELECT) {
96 | return (
97 | <>
98 | [💽] Enter name of this project
99 |
100 |
101 | App will be created in a folder with this name in this directory.{' '}
102 |
103 | setProjectName(value)}
105 | value={projectName}
106 | onSubmit={handleDestinationSelect}
107 | showCursor
108 | />
109 | >
110 | )
111 | }
112 |
113 | if (stage === CONFIRM_OPTIONS) {
114 | return (
115 | <>
116 | [🚪] Creating project with the following options
117 |
118 | {' '}
119 | Language: {languageSelection.current}
120 |
121 |
122 | {' '}
123 | Project name: {projectName}
124 |
125 | Press enter to confirm
126 | >
127 | )
128 | }
129 |
130 | return (
131 |
136 | )
137 | }
138 |
139 | function CreateProject(options: CreateProjectOptions) {
140 | const { exit } = useApp()
141 |
142 | React.useEffect(() => {
143 | createProject(options)
144 | .then(() => {
145 | console.log('')
146 | console.log('🌵 Done setting up project.')
147 | console.log('')
148 | console.log('Start project with `npm run dev`')
149 | exit()
150 | })
151 | .catch((error) => {
152 | console.error('')
153 | console.error(`❌ ${error.message}`)
154 | exit(error)
155 | })
156 | }, [options])
157 |
158 | return null
159 | }
160 |
161 | export default function App() {
162 | return
163 | }
164 |
--------------------------------------------------------------------------------
/create-mango/source/cli.tsx:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import React from 'react'
3 | import { render } from 'ink'
4 | import meow from 'meow'
5 | import App from './app.js'
6 |
7 | meow(
8 | `
9 | Usage
10 | $ create-mango
11 |
12 | Examples
13 | $ create-mango awesome-app
14 | `,
15 | {
16 | importMeta: import.meta,
17 | booleanDefault: undefined,
18 | }
19 | )
20 |
21 | render()
22 |
--------------------------------------------------------------------------------
/create-mango/templates/_common/.env:
--------------------------------------------------------------------------------
1 | DATABASE_URL=mongodb://127.0.0.1:27017/%NAME%-db
2 | SECRET_KEY=%SECRET_KEY%
3 |
--------------------------------------------------------------------------------
/create-mango/templates/_common/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:18-alpine as build
2 |
3 | COPY . .
4 | RUN yarn install --frozen-lockfile
5 | RUN yarn build
6 |
7 | FROM node:18-alpine as production
8 |
9 | WORKDIR /app
10 |
11 | ENV NODE_ENV=production
12 |
13 | COPY --from=build package.json .
14 | COPY --from=build dist/ dist/
15 |
16 | RUN yarn install --frozen-lockfile --production
17 |
18 | # copies admin static files
19 | # [ ] Use symlinks instead of copying files
20 | COPY --from=build node_modules/mangobase/dist/admin dist/admin
21 |
22 | EXPOSE 5000
23 | ENV PORT=5000
24 |
25 | CMD ["node", "dist/index.js"]
26 |
--------------------------------------------------------------------------------
/create-mango/templates/_common/_gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/create-mango/templates/_common/_gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
--------------------------------------------------------------------------------
/create-mango/templates/_common/_prettierignore:
--------------------------------------------------------------------------------
1 | dist
2 |
--------------------------------------------------------------------------------
/create-mango/templates/_common/readme.md:
--------------------------------------------------------------------------------
1 | # %NAME%
2 |
3 | This is a [Mangobase](https://degreat.co.uk/mangobase) project.
4 |
5 | Run in development with:
6 |
7 | ```sh
8 | npm run dev
9 | ```
10 |
11 | See [here](https://degreat.co.uk/mangobase/guide/dev-prod) for production guide.
12 |
--------------------------------------------------------------------------------
/create-mango/templates/js/_package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "%NAME%",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.mjs",
6 | "scripts": {
7 | "dev": "tsx watch src/index.mjs",
8 | "start": "node src/index.mjs"
9 | },
10 | "keywords": [],
11 | "author": "",
12 | "license": "ISC"
13 | }
14 |
--------------------------------------------------------------------------------
/create-mango/templates/js/src/index.mjs:
--------------------------------------------------------------------------------
1 | import { App } from 'mangobase'
2 | import { MongoDb } from '@mangobase/mongodb'
3 | import { expressServer } from '@mangobase/express'
4 | import env from '@next/env'
5 |
6 | env.loadEnvConfig('.', process.env.NODE_ENV !== 'production')
7 |
8 | const app = new App({
9 | db: new MongoDb(process.env.DATABASE_URL),
10 | })
11 |
12 | const PORT = process.env.PORT || 3000
13 |
14 | app.serve(expressServer).listen(PORT, () => {
15 | console.log(`App started`)
16 | console.log(`API base url: http://localhost:${PORT}/api/`)
17 | console.log(`Admin: http://localhost:${PORT}/_/`)
18 | })
19 |
--------------------------------------------------------------------------------
/create-mango/templates/ts/_package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "%NAME%",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "dist/index.js",
6 | "type": "module",
7 | "scripts": {
8 | "dev": "tsx watch src/index.ts",
9 | "build": "node build.js",
10 | "start": "node dist/index.js"
11 | },
12 | "keywords": [],
13 | "author": "",
14 | "license": "ISC"
15 | }
16 |
--------------------------------------------------------------------------------
/create-mango/templates/ts/build.js:
--------------------------------------------------------------------------------
1 | import esbuild from 'esbuild'
2 |
3 | esbuild.build({
4 | entryPoints: ['src/index.ts'],
5 | bundle: true,
6 | outfile: 'dist/index.js',
7 | format: 'esm',
8 | platform: 'node',
9 | target: 'node18',
10 | external: [
11 | '@mangobase/express',
12 | '@mangobase/mongodb',
13 | '@next/env',
14 | 'express',
15 | 'mangobase',
16 | 'mongodb',
17 | ],
18 | })
19 |
--------------------------------------------------------------------------------
/create-mango/templates/ts/src/index.ts:
--------------------------------------------------------------------------------
1 | import { App } from 'mangobase'
2 | import { MongoDb } from '@mangobase/mongodb'
3 | import { expressServer } from '@mangobase/express'
4 | import env from '@next/env'
5 |
6 | env.loadEnvConfig('.', process.env.NODE_ENV !== 'production')
7 |
8 | const app = new App({
9 | db: new MongoDb(process.env.DATABASE_URL!),
10 | })
11 |
12 | const PORT = process.env.PORT || 3000
13 |
14 | app.serve(expressServer).listen(PORT, () => {
15 | console.log(`App started`)
16 | console.log(`API base url: http://localhost:${PORT}/api/`)
17 | console.log(`Admin: http://localhost:${PORT}/_/`)
18 | })
19 |
--------------------------------------------------------------------------------
/create-mango/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@sindresorhus/tsconfig",
3 | "compilerOptions": {
4 | "outDir": "dist",
5 | "types": ["react"]
6 | },
7 | "include": ["source"]
8 | }
9 |
--------------------------------------------------------------------------------
/eslint-config-base/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | node: true,
6 | },
7 | extends: [
8 | 'eslint:recommended',
9 | 'plugin:@typescript-eslint/recommended',
10 | 'preact',
11 | 'prettier',
12 | ],
13 | overrides: [],
14 | parser: '@typescript-eslint/parser',
15 | parserOptions: {
16 | ecmaVersion: 'latest',
17 | sourceType: 'module',
18 | },
19 | plugins: ['@typescript-eslint', 'react', 'prettier'],
20 | rules: {
21 | 'no-unused-vars': 'off',
22 | 'no-dupe-class-members': 'off',
23 | 'no-duplicate-imports': 'off',
24 | 'prettier/prettier': 'warn',
25 | 'sort-keys': 'warn',
26 | 'sort-imports': 'warn',
27 | '@typescript-eslint/no-explicit-any': 'off',
28 | '@typescript-eslint/no-non-null-assertion': 'off',
29 | '@typescript-eslint/no-unused-vars': ['warn', { ignoreRestSiblings: true }],
30 | },
31 | }
32 |
--------------------------------------------------------------------------------
/eslint-config-base/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "eslint-config-base",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"✅ All good\""
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "ISC",
12 | "devDependencies": {
13 | "@typescript-eslint/eslint-plugin": "^5.59.5",
14 | "@typescript-eslint/parser": "^5.59.5",
15 | "eslint": "^8.0.1",
16 | "eslint-config-preact": "^1.3.0",
17 | "eslint-config-prettier": "^8.8.0",
18 | "eslint-plugin-import": "^2.25.2",
19 | "eslint-plugin-n": "^15.0.0",
20 | "eslint-plugin-prettier": "^4.2.1",
21 | "eslint-plugin-promise": "^6.0.0",
22 | "jest": "^29.6.0",
23 | "typescript": "*"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/examples/base-bun-mongo/.gitignore:
--------------------------------------------------------------------------------
1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
2 |
3 | # Logs
4 |
5 | logs
6 | _.log
7 | npm-debug.log_
8 | yarn-debug.log*
9 | yarn-error.log*
10 | lerna-debug.log*
11 | .pnpm-debug.log*
12 |
13 | # Diagnostic reports (https://nodejs.org/api/report.html)
14 |
15 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
16 |
17 | # Runtime data
18 |
19 | pids
20 | _.pid
21 | _.seed
22 | \*.pid.lock
23 |
24 | # Directory for instrumented libs generated by jscoverage/JSCover
25 |
26 | lib-cov
27 |
28 | # Coverage directory used by tools like istanbul
29 |
30 | coverage
31 | \*.lcov
32 |
33 | # nyc test coverage
34 |
35 | .nyc_output
36 |
37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
38 |
39 | .grunt
40 |
41 | # Bower dependency directory (https://bower.io/)
42 |
43 | bower_components
44 |
45 | # node-waf configuration
46 |
47 | .lock-wscript
48 |
49 | # Compiled binary addons (https://nodejs.org/api/addons.html)
50 |
51 | build/Release
52 |
53 | # Dependency directories
54 |
55 | node_modules/
56 | jspm_packages/
57 |
58 | # Snowpack dependency directory (https://snowpack.dev/)
59 |
60 | web_modules/
61 |
62 | # TypeScript cache
63 |
64 | \*.tsbuildinfo
65 |
66 | # Optional npm cache directory
67 |
68 | .npm
69 |
70 | # Optional eslint cache
71 |
72 | .eslintcache
73 |
74 | # Optional stylelint cache
75 |
76 | .stylelintcache
77 |
78 | # Microbundle cache
79 |
80 | .rpt2_cache/
81 | .rts2_cache_cjs/
82 | .rts2_cache_es/
83 | .rts2_cache_umd/
84 |
85 | # Optional REPL history
86 |
87 | .node_repl_history
88 |
89 | # Output of 'npm pack'
90 |
91 | \*.tgz
92 |
93 | # Yarn Integrity file
94 |
95 | .yarn-integrity
96 |
97 | # dotenv environment variable files
98 |
99 | .env
100 | .env.development.local
101 | .env.test.local
102 | .env.production.local
103 | .env.local
104 |
105 | # parcel-bundler cache (https://parceljs.org/)
106 |
107 | .cache
108 | .parcel-cache
109 |
110 | # Next.js build output
111 |
112 | .next
113 | out
114 |
115 | # Nuxt.js build / generate output
116 |
117 | .nuxt
118 | dist
119 |
120 | # Gatsby files
121 |
122 | .cache/
123 |
124 | # Comment in the public line in if your project uses Gatsby and not Next.js
125 |
126 | # https://nextjs.org/blog/next-9-1#public-directory-support
127 |
128 | # public
129 |
130 | # vuepress build output
131 |
132 | .vuepress/dist
133 |
134 | # vuepress v2.x temp and cache directory
135 |
136 | .temp
137 | .cache
138 |
139 | # Docusaurus cache and generated files
140 |
141 | .docusaurus
142 |
143 | # Serverless directories
144 |
145 | .serverless/
146 |
147 | # FuseBox cache
148 |
149 | .fusebox/
150 |
151 | # DynamoDB Local files
152 |
153 | .dynamodb/
154 |
155 | # TernJS port file
156 |
157 | .tern-port
158 |
159 | # Stores VSCode versions used for testing VSCode extensions
160 |
161 | .vscode-test
162 |
163 | # yarn v2
164 |
165 | .yarn/cache
166 | .yarn/unplugged
167 | .yarn/build-state.yml
168 | .yarn/install-state.gz
169 | .pnp.\*
170 |
--------------------------------------------------------------------------------
/examples/base-bun-mongo/README.md:
--------------------------------------------------------------------------------
1 | # base-bun-mongo
2 |
3 | To install dependencies:
4 |
5 | ```bash
6 | bun install
7 | ```
8 |
9 | To run:
10 |
11 | ```bash
12 | bun run src/index.ts
13 | ```
14 |
15 | This project was created using `bun init` in bun v0.8.1. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
16 |
--------------------------------------------------------------------------------
/examples/base-bun-mongo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "base-bun-mongo",
3 | "module": "src/index.ts",
4 | "version": "0.0.1",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "bun src/index.ts"
8 | },
9 | "dependencies": {
10 | "mangobase": "*",
11 | "@mangobase/mongodb": "*",
12 | "jose": "4.14.6"
13 | },
14 | "devDependencies": {
15 | "bun-types": "latest"
16 | },
17 | "peerDependencies": {
18 | "typescript": "^5.0.0"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/examples/base-bun-mongo/src/index.ts:
--------------------------------------------------------------------------------
1 | import { App } from 'mangobase'
2 | import { MongoDb } from '@mangobase/mongodb'
3 | import { bunServer } from '@mangobase/bun'
4 |
5 | process.env.SECRET_KEY = 'mango-bun'
6 | const app = new App({
7 | db: new MongoDb('mongodb://localhost:27017/bun-mango'),
8 | })
9 |
10 | const PORT = 4000
11 |
12 | app.serve(bunServer(PORT))
13 | console.log(`App started`)
14 | console.log(`API base url: http://localhost:${PORT}/api/`)
15 | console.log(`Admin: http://localhost:${PORT}/_/`)
16 |
--------------------------------------------------------------------------------
/examples/base-bun-mongo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["ESNext"],
4 | "module": "esnext",
5 | "target": "esnext",
6 | "moduleResolution": "bundler",
7 | "moduleDetection": "force",
8 | "allowImportingTsExtensions": true,
9 | "noEmit": true,
10 | "composite": true,
11 | "strict": true,
12 | "downlevelIteration": true,
13 | "skipLibCheck": true,
14 | "jsx": "preserve",
15 | "allowSyntheticDefaultImports": true,
16 | "forceConsistentCasingInFileNames": true,
17 | "allowJs": true,
18 | "types": [
19 | "bun-types" // add Bun global
20 | ]
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/examples/base-express-mongo/.gitignore:
--------------------------------------------------------------------------------
1 | .mangobase
2 | dist/
3 |
--------------------------------------------------------------------------------
/examples/base-express-mongo/index.ts:
--------------------------------------------------------------------------------
1 | import { App } from 'mangobase'
2 | import { MongoDb } from '@mangobase/mongodb'
3 | import { expressServer } from '@mangobase/express'
4 |
5 | const app = new App({
6 | db: new MongoDb('mongodb://127.0.0.1:27017/mangobase-demo'),
7 | })
8 |
9 | app.hooksRegistry.register({
10 | id: 'throttle',
11 | name: 'Throttle',
12 | description: 'Adds artificial latency to requests',
13 | configSchema: {
14 | delay: { type: 'number', required: true, defaultValue: 200 },
15 | prod_only: { type: 'boolean', defaultValue: true },
16 | except_email: { type: 'string' },
17 | },
18 | async run(ctx, config) {
19 | // add delay
20 | return ctx
21 | },
22 | })
23 |
24 | process.env.SECRET_KEY = 'test-key'
25 | const PORT = process.env.PORT || 3000
26 |
27 | app.serve(expressServer).listen(PORT, () => {
28 | console.log(`App started`)
29 | console.log(`API base url: http://localhost:${PORT}/api/`)
30 | console.log(`Admin: http://localhost:${PORT}/_/`)
31 | })
32 |
--------------------------------------------------------------------------------
/examples/base-express-mongo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "base-express-mongo",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "type": "module",
7 | "scripts": {
8 | "dev": "tsx watch index.ts",
9 | "build": "npx esbuild index.ts --platform=node --format=esm --target=node18 --outfile=dist/index.js"
10 | },
11 | "keywords": [],
12 | "author": "",
13 | "license": "ISC",
14 | "dependencies": {
15 | "@mangobase/express": "*",
16 | "@mangobase/mongodb": "*",
17 | "mangobase": "*"
18 | },
19 | "devDependencies": {
20 | "tsx": "^4.6.2"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/express-server/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
--------------------------------------------------------------------------------
/express-server/README.md:
--------------------------------------------------------------------------------
1 | # @mangobase/express
2 |
3 | Express server adapter for mangobase.
4 |
--------------------------------------------------------------------------------
/express-server/build.mjs:
--------------------------------------------------------------------------------
1 | import esbuild from 'esbuild'
2 |
3 | esbuild.build({
4 | bundle: true,
5 | entryPoints: ['src/index.ts'],
6 | external: ['express', 'mangobase', 'cors'],
7 | format: 'esm',
8 | outdir: 'dist/',
9 | platform: 'node',
10 | })
11 |
--------------------------------------------------------------------------------
/express-server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@mangobase/express",
3 | "version": "0.9.14",
4 | "description": "",
5 | "main": "dist/index.js",
6 | "repository": "https://github.com/blackmann/mangobase",
7 | "homepage": "https://degreat.co.uk/mangobase",
8 | "type": "module",
9 | "files": [
10 | "dist",
11 | "README.md"
12 | ],
13 | "scripts": {
14 | "clean": "rm -rf dist",
15 | "build": "node ./build.mjs && tsc -p tsconfig.build.json",
16 | "prepare": "yarn build"
17 | },
18 | "keywords": [],
19 | "author": "",
20 | "license": "ISC",
21 | "dependencies": {
22 | "cors": "^2.8.5"
23 | },
24 | "devDependencies": {
25 | "@types/cors": "^2.8.13",
26 | "@types/express": "^4.17.17",
27 | "esbuild": "^0.17.19",
28 | "express": "^4.18.2",
29 | "mangobase": "^0.9.14"
30 | },
31 | "peerDependencies": {
32 | "express": "^4.18.2",
33 | "mangobase": "*"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/express-server/src/index.ts:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import { type App, context, methodFromHttp } from 'mangobase'
3 | import cors from 'cors'
4 |
5 | /**
6 | * No-op server adapter for express
7 | */
8 | function expressServer(mangobaseApp: App): express.Express {
9 | const app = express()
10 |
11 | app.use(cors())
12 | app.use(express.json())
13 | app.use(express.urlencoded({ extended: true }))
14 |
15 | return withExpress(app, mangobaseApp)
16 | }
17 |
18 | /**
19 | * Use this function if you have a custom express instance. Remember to set `cors`, `json` and `urlencoded`.
20 | * These are critical to how to how mangobase works.
21 | *
22 | * @example
23 | * ```
24 | * import cors from 'cors'
25 | *
26 | * const app = express()
27 | * app.use(cors())
28 | * app.use(express.json())
29 | * app.use(express.urlencoded({ extended: true }))
30 | * ```
31 | */
32 | function withExpress(app: express.Express, mangobaseApp: App): express.Express {
33 | app.all(['/api', '/api/*'], (req, res) => {
34 | // [ ]: handle OPTIONS
35 | const ctx = context({
36 | data: req.body,
37 | headers: req.headers as Record,
38 | method: methodFromHttp(req.method),
39 | path: req.path.replace(/^\/api\/?/, ''),
40 | query: req.query,
41 | url: req.url,
42 | })
43 |
44 | mangobaseApp.api(ctx).then((context) => {
45 | res.status(context.statusCode || 200).json(context.result)
46 | })
47 | })
48 |
49 | app.get(['/_', '/_/*'], (req, res) => {
50 | const [path, queryParams] = req.url.replace(/^\/_/, '').split('?')
51 |
52 | mangobaseApp.admin(path || 'index.html').then((file) => {
53 | res.sendFile(file)
54 | })
55 | })
56 |
57 | return app
58 | }
59 |
60 | export { expressServer, withExpress }
61 |
--------------------------------------------------------------------------------
/express-server/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": [
4 | "dist/**/**"
5 | ]
6 | }
--------------------------------------------------------------------------------
/lerna.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "node_modules/lerna/schemas/lerna-schema.json",
3 | "version": "0.9.14",
4 | "packages": [
5 | "base",
6 | "create-mango",
7 | "express-server",
8 | "mongo-db",
9 | "bun-server"
10 | ],
11 | "npmClient": "yarn"
12 | }
13 |
--------------------------------------------------------------------------------
/mongo-db/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["base"],
3 | "parserOptions": {
4 | "project": "./tsconfig.json"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/mongo-db/.gitignore:
--------------------------------------------------------------------------------
1 | html
2 | dist/
3 |
--------------------------------------------------------------------------------
/mongo-db/README.md:
--------------------------------------------------------------------------------
1 | # @mangobase/mongodb
2 |
3 | MongoDb database adapter for mangobase.
4 |
--------------------------------------------------------------------------------
/mongo-db/build.mjs:
--------------------------------------------------------------------------------
1 | import esbuild from 'esbuild'
2 |
3 | esbuild.build({
4 | bundle: true,
5 | entryPoints: ['src/index.ts'],
6 | external: ['mongodb', 'mangobase'],
7 | format: 'esm',
8 | outdir: 'dist/',
9 | platform: 'node',
10 | })
11 |
--------------------------------------------------------------------------------
/mongo-db/global.setup.ts:
--------------------------------------------------------------------------------
1 | import { MongoMemoryServer } from 'mongodb-memory-server-core'
2 |
3 | let mongod: MongoMemoryServer
4 |
5 | export async function setup() {
6 | console.log('Starting up mongo server')
7 | mongod = await MongoMemoryServer.create()
8 | process.env.MONGO_URL = mongod.getUri('test')
9 |
10 | console.log('MONGO_URL', process.env.MONGO_URL)
11 | }
12 |
13 | export async function teardown() {
14 | mongod?.stop()
15 | }
16 |
--------------------------------------------------------------------------------
/mongo-db/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@mangobase/mongodb",
3 | "version": "0.9.14",
4 | "description": "",
5 | "main": "dist/index.js",
6 | "type": "module",
7 | "repository": "https://github.com/blackmann/mangobase",
8 | "homepage": "https://degreat.co.uk/mangobase",
9 | "files": [
10 | "dist",
11 | "README.md"
12 | ],
13 | "scripts": {
14 | "clean": "rm -rf dist",
15 | "build": "node ./build.mjs && tsc -p tsconfig.build.json",
16 | "prepare": "yarn build",
17 | "test": "vitest -w false"
18 | },
19 | "keywords": [],
20 | "author": "",
21 | "license": "ISC",
22 | "devDependencies": {
23 | "@vitest/coverage-c8": "^0.31.0",
24 | "@vitest/ui": "^0.31.0",
25 | "eslint-config-base": "*",
26 | "mangobase": "^0.9.14",
27 | "mongodb": "^6.3.0",
28 | "mongodb-memory-server-core": "^8.12.2",
29 | "typescript": "^5.0.4",
30 | "vitest": "^0.31.1"
31 | },
32 | "peerDependencies": {
33 | "mangobase": "*",
34 | "mongodb": "^6.3.0"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/mongo-db/src/index.ts:
--------------------------------------------------------------------------------
1 | export { MongoDb, MongoCursor } from './mongodb.js'
2 |
--------------------------------------------------------------------------------
/mongo-db/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": [
4 | "global.setup.ts",
5 | "vitest.config.ts",
6 | "src/**/*.test.ts",
7 | "dist/**/**"
8 | ]
9 | }
--------------------------------------------------------------------------------
/mongo-db/vitest.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import { defineConfig } from 'vite'
3 |
4 | export default defineConfig({
5 | test: {
6 | clearMocks: true,
7 | coverage: {
8 | enabled: true,
9 | },
10 | globalSetup: ['global.setup.ts'],
11 | reporters: ['default', 'html'],
12 | },
13 | })
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mangobase-monorepo",
3 | "private": true,
4 | "version": "1.0.0",
5 | "description": "",
6 | "main": "index.js",
7 | "scripts": {
8 | "boot": "yarn install && cd create-mango && yarn install",
9 | "build:admin": "yarn workspace @mangobase/admin run build",
10 | "build:base": "yarn workspace mangobase run build",
11 | "build:bun": "yarn workspace @mangobase/bun run build",
12 | "build:express-server": "yarn workspace @mangobase/express run build",
13 | "build:mongodb": "yarn workspace @mangobase/mongodb run build",
14 | "build:website": "yarn workspace website run build",
15 | "build": "yarn turbo build --filter=!website && yarn turbo build --filter=website && yarn turbo build --filter=@mangobase/admin",
16 | "clean": "yarn turbo clean",
17 | "dev:admin": "yarn workspace @mangobase/admin run dev",
18 | "dev:cli": "cd create-mango && yarn dev",
19 | "dev:express-mongo": "yarn workspace base-express-mongo run dev",
20 | "dev:bun-mongo": "yarn workspace base-bun-mongo run dev",
21 | "dev:website": "yarn workspace website run dev",
22 | "docs:generate": "yarn workspace website run docs",
23 | "publish:base": "yarn workspace mangobase publish",
24 | "publish:bun": "yarn workspace @mangobase/bun publish --access public",
25 | "publish:cli": "cd create-mango && yarn publish --access public",
26 | "publish:express": "yarn workspace @mangobase/express publish --access public",
27 | "publish:mongodb": "yarn workspace @mangobase/mongodb publish --access public",
28 | "test:base": "yarn workspace mangobase run test",
29 | "test:mongodb": "yarn workspace @mangobase/mongodb run test",
30 | "test": "yarn test:base && yarn test:mongodb"
31 | },
32 | "keywords": [],
33 | "author": "",
34 | "license": "MIT",
35 | "workspaces": {
36 | "packages": [
37 | "admin",
38 | "base",
39 | "bun-server",
40 | "eslint-config-base",
41 | "examples/*",
42 | "express-server",
43 | "mongo-db",
44 | "website"
45 | ]
46 | },
47 | "devDependencies": {
48 | "lerna": "^7.3.0",
49 | "prettier": "^2.8.8",
50 | "turbo": "^1.10.3"
51 | },
52 | "dependencies": {}
53 | }
54 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "pipeline": {
4 | "clean": {
5 | "cache": false
6 | },
7 | "build": {
8 | "outputs": ["dist/**", "api/**"],
9 | "dependsOn": ["^build"]
10 | },
11 | "copy-admin": {
12 | "dependsOn": ["build"],
13 | "outputs": ["base/dist/admin/**"]
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/website/.gitignore:
--------------------------------------------------------------------------------
1 | .vitepress/cache
2 | .vitepress/dist
3 | .vitepress/api-paths.json
4 |
5 | api/
6 | !api/index.md
--------------------------------------------------------------------------------
/website/.vitepress/config.mts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitepress'
2 | import apiPaths from './api-paths.json'
3 |
4 | // https://vitepress.dev/reference/site-config
5 | export default defineConfig({
6 | title: 'Mango 🥭',
7 | description: 'Mangobase: Low-code Javscript backend framework',
8 | base: '/mangobase/',
9 | sitemap: {
10 | hostname: 'https://degreat.co.uk/mangobase/',
11 | lastmodDateOnly: true,
12 | },
13 | themeConfig: {
14 | // https://vitepress.dev/reference/default-theme-config
15 | nav: [
16 | { text: 'Home', link: '/' },
17 | { text: 'Guide', link: '/guide/' },
18 | { text: 'API', link: '/api/' },
19 | ],
20 |
21 | sidebar: {
22 | '/guide/': [
23 | {
24 | text: 'Start here',
25 | items: [
26 | { text: 'Introduction', link: '/guide/' },
27 | { text: 'Getting started', link: '/guide/getting-started' },
28 | { text: 'Recap on REST', link: '/guide/rest' },
29 | { text: 'Dashboard', link: '/guide/dashboard' },
30 | { text: 'Deployment', link: '/guide/deployment' },
31 | ],
32 | },
33 | {
34 | text: 'Concepts',
35 | items: [
36 | { text: 'Context', link: '/guide/context' },
37 | { text: 'Hooks', link: '/guide/hooks' },
38 | { text: 'Paths & Queries', link: '/guide/query' },
39 | { text: 'Migrations', link: '/guide/migrations' },
40 | ],
41 | },
42 | {
43 | text: 'Extras',
44 | items: [
45 | { text: 'Authentication', link: '/guide/authentication' },
46 | { text: 'Server adapters', link: '/guide/server-adapters' },
47 | { text: 'Database adapters', link: '/guide/database-adapters' },
48 | { text: 'Version control', link: '/guide/version-control' },
49 | { text: 'Plugins', link: '/guide/plugins' },
50 | { text: 'FAQs', link: '/guide/faqs' },
51 | ],
52 | },
53 | ],
54 | '/api/': apiPaths,
55 | },
56 |
57 | socialLinks: [
58 | { icon: 'github', link: 'https://github.com/blackmann/mangobase' },
59 | ],
60 |
61 | search: {
62 | provider: 'local',
63 | },
64 | outline: [2, 4],
65 | },
66 | head: [
67 | [
68 | 'meta',
69 | {
70 | property: 'og:image',
71 | content: 'https://github.com/blackmann/mangobase/raw/master/assets/ss-dark.png'
72 | }
73 | ],
74 | [
75 | 'script',
76 | {
77 | async: '',
78 | src: 'https://analytics.umami.is/script.js',
79 | 'data-website-id': '6cc8bf19-147d-45a2-b7c9-75b7c1b607bf',
80 | },
81 | ],
82 | [
83 | 'script',
84 | {
85 | async: '',
86 | src: 'https://0.observe.so/script.js',
87 | 'data-app': 'clws46cwl00go8k13jz4apght',
88 | },
89 | ],
90 | ],
91 | })
92 |
--------------------------------------------------------------------------------
/website/.vitepress/theme/index.js:
--------------------------------------------------------------------------------
1 | import { h } from 'vue'
2 | import DefaultTheme from 'vitepress/theme'
3 | import MangoSpline from './mango-spline.vue'
4 | import './styles.css'
5 |
6 | export default {
7 | extends: DefaultTheme,
8 | Layout() {
9 | return h(DefaultTheme.Layout, null, {
10 | 'home-hero-image': () => h(MangoSpline)
11 | })
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/website/.vitepress/theme/mango-spline.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
15 |
--------------------------------------------------------------------------------
/website/.vitepress/theme/styles.css:
--------------------------------------------------------------------------------
1 | @media (max-width: 960px) {
2 | .VPHero .image {
3 | opacity: 0;
4 | margin-top: -260px;
5 | }
6 | }
7 |
8 | .hidden {
9 | display: none;
10 | }
11 |
--------------------------------------------------------------------------------
/website/api/index.md:
--------------------------------------------------------------------------------
1 | # Modules
--------------------------------------------------------------------------------
/website/generate-api.mjs:
--------------------------------------------------------------------------------
1 | import fs from 'fs/promises'
2 | import fsSync from 'fs'
3 | import typedoc, { Comment } from 'typedoc'
4 |
5 | const projects = ['base', 'express-server', 'bun-server', 'mongo-db']
6 |
7 | const apiDocPaths = []
8 |
9 | const SKIP_KINDS = [typedoc.ReflectionKind.TypeAlias]
10 |
11 | async function generateApis() {
12 | for (const projectDir of projects) {
13 | const app = await typedoc.Application.bootstrap({
14 | entryPoints: [`../${projectDir}/src/index.ts`],
15 | exclude: ["**/*+(.spec|.test).ts"],
16 | tsconfig: `../${projectDir}/tsconfig.build.json`,
17 | excludePrivate: true,
18 | })
19 |
20 | console.log('[-] Parsing ' + projectDir + '...')
21 |
22 | const pkg = JSON.parse(
23 | await fs.readFile(`../${projectDir}/package.json`, { encoding: 'utf-8' })
24 | )
25 |
26 | const group = {
27 | collapsed: true,
28 | text: pkg.name,
29 | items: [],
30 | }
31 |
32 | try {
33 | await fs.mkdir(`./api/${projectDir}/`)
34 | } catch (err) {
35 | //
36 | }
37 |
38 | const project = await app.convert()
39 | if (project) {
40 | project.traverse((node) => {
41 | if (SKIP_KINDS.includes(node.kind)) {
42 | return
43 | }
44 |
45 | console.log(' * ', `${node.name}.md`)
46 | let path = `/api/${projectDir}/${node.name}`
47 |
48 | if (node.name[0] === node.name[0].toLocaleLowerCase()) {
49 | // doing this because some function names clashed with interface/class
50 | // names, causing an override of content.
51 | path = `/api/${projectDir}/${node.name}_function`
52 | }
53 |
54 | group.items.push({
55 | text: node.name,
56 | link: path,
57 | })
58 |
59 | fsSync.writeFileSync(`.${path}.md`, composeDoc(node, projectDir), {
60 | encoding: 'utf-8',
61 | })
62 | })
63 | }
64 |
65 | group.items.sort((a, b) => (a.text > b.text ? 1 : -1))
66 |
67 | apiDocPaths.push(group)
68 | }
69 | }
70 |
71 | generateApis().then(() => {
72 | console.log('[x] Completed API generation')
73 | fsSync.writeFileSync(
74 | '.vitepress/api-paths.json',
75 | JSON.stringify(apiDocPaths, null, 2)
76 | )
77 | })
78 |
79 | function composeDoc(node, packagePath) {
80 | const urlGenerator = (ref) => urlTo(ref, packagePath)
81 |
82 | const content = [
83 | `# ${node.name}`,
84 | `${typedoc.ReflectionKind.singularString(node.kind)}`,
85 | ]
86 |
87 | if (node.comment) {
88 | content.push(Comment.displayPartsToMarkdown(node.comment.summary, urlGenerator))
89 | }
90 |
91 | node.traverse((innerNode) => {
92 | // [ ] Show signature if any
93 | // [ ] Show item type (string, number, etc.)
94 | if (SKIP_KINDS.includes(innerNode.kind)) {
95 | return
96 | }
97 |
98 | const innerContent = []
99 |
100 | innerContent.push(`## ${innerNode.name}`)
101 | if (innerNode.comment) {
102 | innerContent.push(
103 | Comment.displayPartsToMarkdown(innerNode.comment.summary, urlGenerator)
104 | )
105 | }
106 |
107 | switch (innerNode.kind) {
108 | case typedoc.ReflectionKind.Method: {
109 | innerNode.signatures.forEach((signature) => {
110 | const parameters = []
111 |
112 | for (const p of signature.parameters) {
113 | parameters.push(
114 | `${p.name}: ${typedoc.ReflectionKind.singularString(p.kind)}`
115 | )
116 | }
117 |
118 | const signatureCode = `
119 | \`\`\`typescript
120 | public ${signature.name}(${parameters.join(', ')}): Promise
121 | \`\`\`
122 | `
123 | innerContent.push(signatureCode)
124 |
125 | if (signature.hasComment()) {
126 | innerContent.push(
127 | Comment.displayPartsToMarkdown(signature.comment.summary, (ref) =>
128 | urlTo(ref, packagePath)
129 | )
130 | )
131 | }
132 | })
133 | }
134 | }
135 |
136 | content.push(innerContent.join('\n'))
137 | })
138 |
139 | return content.join('\n\n')
140 | }
141 |
142 | function urlTo(ref, base) {
143 | const parts = []
144 | parts.push(ref.name)
145 |
146 | let parent = ref.parent
147 | while (parent) {
148 | parts.push(parent.name)
149 | parent = parent.parent
150 | }
151 |
152 | parts.reverse()
153 | parts.shift()
154 |
155 | return `/api/${base}/` + parts.join('#')
156 | }
157 |
--------------------------------------------------------------------------------
/website/guide/authentication.md:
--------------------------------------------------------------------------------
1 | # Authentication
2 |
3 | Mangobase comes with a basic built-in authentication [plugin](/guide/plugins). It provides a simple way to register and authenticate users.
4 |
5 | To create a new user, you make a `POST` (aka `create`) request to the `/api/users` endpoint with body:
6 |
7 | ```json
8 | {
9 | "email": "carlson@gmail.com",
10 | "username": "carlson",
11 | "password": "gocarl"
12 | }
13 | ```
14 |
15 | You can then login by making a `POST` request to `/api/login` with body:
16 |
17 | ```json
18 | {
19 | "username": "carlson",
20 | // or "email": "carlson@gmail",
21 | "password": "gocarl"
22 | }
23 | ```
24 |
25 | You get a response in the format:
26 |
27 | ```json
28 | {
29 | "user": {
30 | "id": "5f1e5a8b9d6b2b0017b4e6b1",
31 | "username": "carlson",
32 | "email": "carlson@gmail.com",
33 | },
34 | "auth": {
35 | "type": "Bearer",
36 | "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
37 | }
38 | }
39 | ```
40 |
41 | When making authenticated requests using the built-in method, you add an `Authorization` header with the value `${auth.type} ${auth.token}`.
42 |
43 | ## Custom authentication
44 |
45 | You can choose to implement your own authentication process, like using an auth library or service. To do this, you can create a [plugin](/guide/plugins). See this [implementation](https://github.com/blackmann/mangobase/blob/de9281da1e840ed28acbf715f8417f29b3da56dc/base/src/authentication.ts#L115) as a reference.
46 |
--------------------------------------------------------------------------------
/website/guide/context.md:
--------------------------------------------------------------------------------
1 | # Context
2 |
3 | Context describes a single request to the app. After processing a request, the app returns a context containing a [`result`](/api/base/Context#result).
4 |
5 | How _processing_ works is described as a pipeline. A pipeline runs in the following sequence:
6 |
7 | 1. Runs all `before` hooks [in sequence] installed on the app
8 | 1. Runs all `before` hooks [in sequence] installed on the service
9 | 1. Passes `context` to the service to handle.
10 | 1. The result from the service is passed [in sequence] to all `after` hooks installed on the service
11 | 1. Runs all `after` hooks [in sequence] installed on the app
12 |
13 | The current state of the context is passed with every call to a hook in the pipeline or the service.
14 |
15 | ::: info
16 | Subsequent `before` hooks stop executing the moment `context.result` is set to a non-null value, then skips the service and proceeds with all `after` hooks.
17 | :::
18 |
19 | See the structure of a context [here](/api/base/Context).
20 |
21 | A context is normally created by the [server](/guide/server-adapters) and passed to the app with [`app.api(ctx)`](/api/base/App#api)
22 |
23 | ## Methods
24 |
25 | In Mangobase, the following methods are used. It exists in the context as `ctx.method`.
26 |
27 | ### create
28 |
29 | The equivalent HTTP method is `POST`. It is used to create a new resource. When you make a `POST` request to `/songs` for example, this is a `create` request.
30 |
31 | Using the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), this will look like:
32 |
33 | ```javascript{7}
34 | // The API endpoint is [conventionally] prefixed with `/api`
35 | const data = {
36 | title: 'Hello',
37 | artist: 'Adele',
38 | }
39 |
40 | fetch('/api/songs', {
41 | method: 'POST',
42 | body: JSON.stringify(data),
43 | })
44 | ```
45 |
46 | A successful call to `create` will return a `201` status code with the created resource in the response body.
47 |
48 | ### find
49 |
50 | The equivalent HTTP method is `GET`. It is used to retrieve a list of resources. A `GET` request to `/songs` for example is a `find` request. This method is intended to always return a paginated response in the following format:
51 |
52 | ```typescript
53 | interface PaginatedResponse {
54 | data: any[]
55 | total: number
56 | limit: number
57 | skip: number
58 | }
59 | ```
60 |
61 | In frontend code, this will look like:
62 |
63 | ```javascript
64 | fetch('/api/songs', {
65 | method: 'GET',
66 | })
67 | ```
68 |
69 | The response will look like:
70 |
71 | ```json
72 | {
73 | "data": [
74 | {
75 | "id": 1,
76 | "title": "Hello",
77 | "artist": "Adele"
78 | },
79 | {
80 | "id": 2,
81 | "title": "Someone Like You",
82 | "artist": "Adele"
83 | }
84 | ],
85 | "total": 2,
86 | "limit": 10,
87 | "skip": 0
88 | }
89 | ```
90 |
91 | If you wanted to limit the number of items returned, you can pass a `limit` query parameter. For example, `/songs?$limit=1` will return only one item.
92 |
93 | Pagination is done using the `skip` query parameter. For example, `/songs?$skip=5` will skip the first 5 items.
94 |
95 | :::info
96 | You may notice a `$` prefix on the query parameter. This is to prevent conflicts with the fields of a collection. For example, if you have a `limit` field on your collection, you can make a request to `/?limit=100km&$limit=3` _find_ items with a limit of 100km but only return 3 items.
97 | :::
98 |
99 | ### get
100 |
101 | Making a `GET` request to `/songs/1` for example is `get`. This method is intended to always return a single item.
102 |
103 | In frontend code, this will look like:
104 |
105 | ```javascript
106 | fetch('/api/songs/1', {
107 | method: 'GET',
108 | })
109 | ```
110 |
111 | A status code of `404` will be returned if the item is not found.
112 |
113 | ### update
114 |
115 | The equivalent HTTP method is `PATCH`. It is used to update a single resource. For example, when you make a `PATCH` request to `/songs/1`.
116 |
117 | ### remove
118 |
119 | The equivalent HTTP method is `DELETE`. A `DELETE` request to `/songs/1` for example, this is a `remove` request. Note that this requires the `id` of the resource to be passed as a parameter.
120 |
121 | :::tip
122 | All these methods can accept a query parameter. For example, with `find`, you can make a GET `/songs?title=hello` request to search for songs with the title `hello`.
123 | :::
124 |
125 | ## Invalid requests
126 |
127 | As far as Mangobase is concerned, some requests like the following are not supported:
128 |
129 | - POST `/songs/1` - You cannot create an item on a detail path.
130 | - PATCH `/songs` - You cannot patch a base path.
131 | - DELETE `/songs` - You cannot delete a base path.
132 | - PUT - Mangobase does not support `PUT` requests.
133 |
--------------------------------------------------------------------------------
/website/guide/dashboard.md:
--------------------------------------------------------------------------------
1 | # Dashboard
2 |
3 | The dashboard is central to how you use Mangobase. It offers a very intuitive experience.
4 |
5 | ## Collections
6 |
7 | This page is where you see all your collections, add new ones or edit existing ones. Selecting a collection allows you to see all the data that has been created (in a paginated format).
8 |
9 | It also provides a filter/query input to help you search for specific data.
10 |
11 | :::info
12 | The query filter, at the moment, is a work-in-progress
13 | :::
14 |
15 | ### Hooks editor
16 |
17 | The hooks editor allows you to compose before and after hooks for your collection's service. It is a very intuitive and creative way to extend the functionality of your service.
18 |
19 | You can select from a number of pre-installed plugin hooks or [write your own](/guide/hooks#registering-a-plugin-hook).
20 |
21 | See [hooks](/guide/hooks) for more information. Below is a video demonstrating how to use the hooks editor.
22 |
23 |
24 |
25 | ## Logs
26 |
27 | There is a page to show request logs. When a request is not _ok_, the error data is logged and you can see it here.
28 |
29 | ## Settings
30 |
31 | The settings page allows you to configure your app. At the moment, it only shows the developers added to the project and your profile.
32 |
--------------------------------------------------------------------------------
/website/guide/database-adapters.md:
--------------------------------------------------------------------------------
1 | # Database adapters
2 |
3 | Mangobase is designed to be database agnostic. This means you can implement any [Database](/api/base/Database) for your app. However, there's an official support for just Mongo at the moment.
4 |
5 | :::info
6 | You're welcome to author a database adapter for your favorite database and share with the community. [Here](https://github.com/blackmann/mangobase/blob/master/mongo-db/src/mongodb.ts) is an implementation of the MongoDb adapter.
7 | :::
8 |
--------------------------------------------------------------------------------
/website/guide/deployment.md:
--------------------------------------------------------------------------------
1 | # Deployment
2 |
3 | :::warning
4 | It's not advisable to make changes to schemas, hooks, or other configuration in production. If you need to make changes, it's best to do so in a development environment and then deploy the changes to production.
5 | :::
6 |
7 | ## Dokku
8 |
9 | If you boostrapped the app with the CLI, you can deploy to your Dokku instance/app with the following command:
10 |
11 | ```bash
12 | git push dokku master
13 | ```
14 |
15 | :::info
16 | Dokku is a self-hosted Heroku alternative. You can read more about it [here](https://dokku.com).
17 | :::
18 |
19 | Set the proxy port if your app is not receiving requests:
20 |
21 | ```bash
22 | dokku ports:set http:80:5000
23 | ```
24 |
25 | ## Docker-based deploys
26 |
27 | You can deploy your Mangobase app using Docker. When you use the [Mangobase CLI](/guide/getting-started#starting-a-new-project), it will generate a `Dockerfile` for you. You can then build and run the Docker image:
28 |
29 | ```bash
30 | $ docker build -t my-mangobase-app .
31 | $ docker run -p 8000:5000 my-mangobase-app --env=SECRET_KEY=xA90boi2 --env=DATABASE_URL=mongodb://host.docker.internal:27017/mangobase-app-db
32 | ```
33 |
34 | ## Deploying to Heroku
35 |
36 | Add a `heroku.yml` file with the following content:
37 |
38 | ```yaml
39 | build:
40 | docker:
41 | web: Dockerfile
42 | ```
43 |
44 | Then make a `git push` to your project.
45 |
46 | Remember to do set the `SECRET_KEY` and `DATABASE_URL` environment variables in your Heroku app.
47 |
48 | ```bash
49 | $ heroku config:set SECRET_KEY=xA90boi2 DATABASE_URL=mongodb://host.docker.internal:27017/mangobase-app-db
50 | ```
51 |
52 | Or you can use the [Heroku dashboard](https://devcenter.heroku.com/articles/config-vars#using-the-heroku-dashboard) to set the environment variables.
53 |
54 | Read more from [here](https://devcenter.heroku.com/articles/build-docker-images-heroku-yml) on how to deploy Dockerfile based apps to Heroku.
55 |
--------------------------------------------------------------------------------
/website/guide/faqs.md:
--------------------------------------------------------------------------------
1 | # FAQs
2 |
3 | ## Who is this for?
4 |
5 | - Beginners who want to ease into backend development.
6 | - Fronted [focused] developers who want to build a backend for their app.
7 | - Fullstack developers who want to quickly bootstrap a backend for their app without having to write boilerplate code.
8 |
9 | Though Mangobase is low-code and easy to use, it is also very flexible and can be used to build complex applications.
10 |
11 | ## How do I make requests to my API?
12 |
13 | You can use any HTTP client to make requests to your API. For example, you can use [HTTPie](https://httpie.io) or [Postman](https://www.postman.com).
14 |
15 | Inside your frontend app, you can use the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) or [Axios](https://axios-http.com), etc.
16 |
17 | If you added a collection with name `songs` for example, you can make a `GET` request to `http://localhost:3000/api/songs` to get all songs. You can also make a `POST` request to `http://localhost:3000/api/songs` to create a new song.
18 |
19 | Other ways to interact with your API can be found in [Context methods](/guide/context#methods).
20 |
--------------------------------------------------------------------------------
/website/guide/getting-started.md:
--------------------------------------------------------------------------------
1 | # Getting started
2 |
3 | Mangobase is fundamentally designed to be easy to use while providing a cohesion of developer experience; that said, it's easy to get started.
4 |
5 | :::info
6 | We currently provide a database adapter for Mongo only, so you may need to install [Mongo](https://mongodb.com) to be able to use Mango. You can however implement a custom database adapter for any database of choice. See [database adapters](/guide/database-adapters)
7 | :::
8 |
9 | ## Starting a new project
10 |
11 | You can use the command below to bootstrap a project
12 |
13 | ::: code-group
14 |
15 | ```sh [npm]
16 | $ npm create mango@latest
17 | ```
18 |
19 | ```sh [yarn]
20 | $ yarn create mango
21 | ```
22 |
23 | :::
24 |
25 | ## Adding to an existing project
26 |
27 | You can add Mango to any existing project. At the moment, [@mangobase/express](/guide/server-adapters#express) and [@mangobase/bun](/guide/server-adapters#bun) are the officially supported server adapters. You can add Mango to your project in a few easy steps if you're using any of those frameworks.
28 |
29 | Otherwise, you can implement a custom adapter for your project. See [implement a custom server adapter](/guide/server-adapters#other-servers)
30 |
31 | :::tip
32 | Reference the [examples](https://github.com/blackmann/mangobase/tree/master/examples) on how to set up your project.
33 | :::
34 |
--------------------------------------------------------------------------------
/website/guide/index.md:
--------------------------------------------------------------------------------
1 | # Introduction
2 |
3 | ::: warning
4 | Mango is still under heavy development; it's not advisable to use this for a production app as the API/design may change rapidly. You can however try it on side projects and proof-of-concepts.
5 | :::
6 |
7 | Mango (short for Mangobase) is a Javascript low-code backend framework for building [RESTful](rest.md) applications. Given that REST apps follow a convention on how to treat data, Mango provides the following benefits:
8 |
9 | - Reduces boilerplate for creating services for data models
10 | - Performs schema validation
11 | - Handles errors
12 | - Provides convenient querying mechanisms
13 |
14 | When building REST backends, it's ubiquitous that you implement all of these features and some. Mango takes care of all of those.
15 |
16 | ::: tip
17 | Being a low-code framework, you get to write code too, though a lot of things have been taken care of for you.
18 | :::
19 |
20 | ## How it works
21 |
22 | A Mangobase app works by accepting a [`context`](/guide/context) from a server (like express, bun, etc.), processing it and returning a `context` back.
23 |
24 | To demonstrate with code, this is how it looks like:
25 |
26 | :::warning
27 | Just so it's not misleading, you wouldn't write any of the code demonstrated below. It's only an illustration (with code) about how it works.
28 | :::
29 |
30 | ```javascript{5,12,15}
31 | const mangobaseApp = new App({})
32 | const app = express()
33 |
34 | app.get(['/songs', '/songs/:id'], async (req, res) => {
35 | // create context
36 | const context = {
37 | path: req.path,
38 | headers: req.headers,
39 | // ... other properties here
40 | }
41 |
42 | // pass context to Mangobase and get result and statusCode back
43 | const { result, statusCode} = await app.api(context)
44 |
45 | // send response with express
46 | res.status(statusCode).json(result)
47 | })
48 | ```
49 |
50 | But with Mangobase, you don't have to do any of those. That was just a demonstration of the process looks like.
51 |
52 | If it wasn't clear, Mangobase only processes a context and does not deal with how an http request comes in or how a response is sent.
53 | This allows Mangobase to be able to work with any server (express, bun, nestjs, etc. etc.)
54 |
55 | ### Mangobase Project
56 |
57 | A Mangobase projects combines a database adapter and a server adapter to handle RESTful requests. You can bootstrap a Mangobase project using the [CLI](/guide/getting-started). The code generated by the CLI may be all you may ever need.
58 |
59 | You will spend the rest of your time in the dashboard (which runs in the browser) preparing your resource schema and setting up hooks.
60 |
61 | ### Dashboard
62 |
63 | Mangobase ships with a dashboard to allow you view your data, create collections, configure hooks and more. See [dashboard](/guide/dashboard).
64 |
--------------------------------------------------------------------------------
/website/guide/plugins.md:
--------------------------------------------------------------------------------
1 | # Plugins
2 |
--------------------------------------------------------------------------------
/website/guide/query.md:
--------------------------------------------------------------------------------
1 | # Paths and Queries
2 |
3 | This document describes how paths and query parameters are treated in Mangobase.
4 |
5 | ## Paths
6 |
7 | When you add a collection with name `tasks` (from the dashboard), a service is registered to handle requests to `/tasks` and `/tasks/:id` paths.
8 |
9 | See [context methods](/guide/context#methods) for the different methods (HTTP method + paths) that can be used to interact with the service.
10 |
11 | ## Queries
12 |
13 | Queries are standardized to match the fields of a collection's schema. Therefore, if you have a schema for `users` collection with the following structure:
14 |
15 | ```json
16 | {
17 | "name": { "type": "string" },
18 | "age": { "type": "number" },
19 | "address": {
20 | "type": "object",
21 | "schema": {
22 | "city": { "type": "string" },
23 | "houseNumber": { "type": "string" },
24 | "state": { "type": "string" }
25 | }
26 | }
27 | }
28 | ```
29 |
30 | We can perform the following filter
31 |
32 | ``` javascript
33 | '/users?name=Not+Gr' // gets all users with name == 'Not Gr'
34 | '/users?age[$gt]=17&address.city=Accra' // get all users above 17 years of age and live Accra
35 | ```
36 |
37 | You may notice that there's a [pattern](/guide/rest) between the query parameters and the fields of the schema
38 |
39 | ### Query operators
40 |
41 | As you may have already noticed from the example above, there is an operator `$gt` used. These are special operators that allow us
42 | to perform advanced queries beside simple equality check.
43 |
44 | :::info
45 | These query operators are converted to the right filter and query by the database adapters.
46 | :::
47 |
48 | Lets talk about them:
49 |
50 | #### $gt/gte
51 |
52 | `/users?age[$gt]=17` - gets all users above 17 years of age
53 |
54 | `/users?age[$gte]=17` - gets all users above or equal to 17 years of age
55 |
56 | This query operator works for fields of number and date types.
57 |
58 | #### $lt/lte
59 |
60 | `/users?age[$lt]=17` - gets all users below 17 years of age
61 |
62 | `/users?age[$lte]=17` - gets all users below or equal to 17 years of age
63 |
64 | :::tip
65 | If you wanted to get all users between 17 and 20 years of age, you can combine the two operators like so:
66 |
67 | `/users?age[$gt]=17&age[$lt]=20`
68 | :::
69 |
--------------------------------------------------------------------------------
/website/guide/server-adapters.md:
--------------------------------------------------------------------------------
1 | # Server adapters
2 |
3 | You can use Mango with any server of choice. This is possible because the core tries to be agnostic of transport mechanisms or server implementations and uses [`contexts`](/guide/context) instead.
4 |
5 | This way, servers can form a context, pass it to [`app.api()`](/api/base/App#api) and then get a context back. With this result, the server can use it to form a response per its design.
6 |
7 | Mango, however, offers server implementations for Express and Bun. But you can be able to [implement a server](#other-servers) for any Javascript framework. _It's actually very easy._
8 |
9 | ## Express
10 |
11 | For express servers, install [@mangobase/express](https://www.npmjs.com/package/@mangobase/express)
12 |
13 | ```bash
14 | yarn add express @mangobase/express
15 | ```
16 |
17 | Usage example:
18 |
19 | ```javascript{2,5}
20 | import { App } from 'mangobase'
21 | import expressServer from '@mangobase/express'
22 |
23 | const app = new App({})
24 | app.serve(expressServer).listen(4000)
25 | ```
26 |
27 | ### Existing projects
28 |
29 | If you're adding Mangobase to an existing project, or need more control over the express app instance, you can do the following instead:
30 |
31 | ```javascript
32 | import { App } from 'mangobase'
33 | import express from 'express'
34 | import { withExpress } from '@mangobase/express'
35 |
36 | const app = new App({})
37 | let expressApp = express()
38 |
39 | // do stuff with `expressApp` as usual, like add middleware, etc.
40 |
41 | expressApp = app.serve(withExpress(expressApp, app))
42 |
43 | // do more stuff with `expressApp` ...
44 |
45 | expressApp.listen(4000)
46 | ```
47 |
48 | ## Bun
49 |
50 | Mango works with [bun](https://bun.sh) too.
51 |
52 | To use Mango with Bun, install [@mangobase/bun](https://www.npmjs.com/package/@mangobase/bun)
53 |
54 | Usage example:
55 |
56 | ```typescript
57 | import { App } from 'mangobase'
58 | import { bunServer } from '@mangobase/bun'
59 |
60 | const app = new App({})
61 |
62 | app.serve(bunServer(4000))
63 | ```
64 |
65 | ## Other servers
66 |
67 | If you're using other frameworks other than `express` or `Bun`, you can still use Mango but you'll have to implement an adapter to create and handle contexts for Mangobase [`App`](/api/base/App).
68 |
69 | This involves only a few lines of code. You can reference the implementation for express here: https://github.com/blackmann/mangobase/blob/master/express-server/src/index.ts
70 |
--------------------------------------------------------------------------------
/website/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | # https://vitepress.dev/reference/default-theme-home-page
3 | layout: home
4 |
5 | title: Home
6 |
7 | hero:
8 | name: "Mangobase"
9 | text: "Low-code Javascript backend framework"
10 | tagline: Build RESTful backend services quickly, with very few concepts to learn
11 | actions:
12 | - theme: brand
13 | text: Get started
14 | link: /guide/getting-started
15 | - theme: alt
16 | text: API Reference
17 | link: /api/
18 |
19 | features:
20 | - title: Amazing UI
21 | icon: ❇️
22 | details: You get a free dashboard to create your schema, view your data and configure parts of your project.
23 | - title: Few concepts
24 | icon: ✍🏽
25 | details: You have to know very few concepts to get started with Mangobase. Context, hook and queries.
26 | - title: Add to existing projects
27 | icon: 🚡
28 | details: You can add Mangobase to existing projects
29 | - title: Works with version control
30 | icon: 🏂
31 | details: Changes to your project can be tracked with version control. This allows for transparent collaboration with teams.
32 | ---
33 |
--------------------------------------------------------------------------------
/website/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "website",
3 | "version": "0.0.1",
4 | "scripts": {
5 | "dev": "vitepress dev",
6 | "build": "yarn docs && vitepress build",
7 | "preview": "vitepress preview",
8 | "docs": "node generate-api.mjs",
9 | "clean": "find api/ -mindepth 1 -not -name 'index.md' -exec rm -rf {} +"
10 | },
11 | "devDependencies": {
12 | "typedoc": "^0.25.1",
13 | "vitepress": "^1.0.0-rc.10"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------