├── cli ├── render.js ├── generate │ ├── templates │ │ ├── ui.css │ │ ├── template.ejs │ │ ├── server.js │ │ ├── model.ejs │ │ ├── env-config.js │ │ ├── mailer.ejs │ │ ├── controller.ejs │ │ ├── view.ejs │ │ ├── migration │ │ │ ├── drop.ejs │ │ │ ├── create.ejs │ │ │ └── update.ejs │ │ ├── layout.ejs │ │ ├── ui.js │ │ ├── test.ejs │ │ ├── component.ejs │ │ ├── webpack.js │ │ └── application.js │ ├── test.js │ ├── component.js │ ├── mailer.js │ ├── view.js │ ├── model.js │ ├── template.js │ ├── migration.js │ └── controller.js ├── help │ ├── upgrade.txt │ ├── generate │ │ ├── view.txt │ │ ├── controller.txt │ │ └── model.txt │ ├── server.txt │ ├── generate.txt │ ├── new.txt │ └── usage.ejs ├── run.js ├── upgrade.js ├── migrate.js ├── assets.js ├── server.js ├── help.js ├── generate.js └── new.js ├── docs ├── CNAME ├── _config.yml ├── _layouts │ ├── page.html │ ├── post.html │ └── default.html ├── blog.html ├── guides │ ├── index.md │ ├── cache.md │ ├── start.md │ ├── controllers.md │ ├── cli.md │ ├── models.md │ ├── architecture.md │ ├── components.md │ ├── views.md │ └── configuration.md ├── index.md └── _posts │ └── 2020-4-25-introducing-saur.md ├── example ├── public │ └── favicon.ico ├── .gitignore ├── src │ ├── index.css │ ├── components │ │ ├── title.css │ │ └── title.js │ └── index.js ├── config │ ├── server.js │ └── environments │ │ ├── test.js │ │ ├── development.js │ │ └── production.js ├── templates │ ├── home │ │ ├── index.html.ejs │ │ └── foo.html.jsx │ └── layouts │ │ └── default.html.ejs ├── models │ └── user.js ├── views │ ├── home │ │ └── index.js │ └── foo.jsx ├── docker-compose.yml ├── migrations │ └── 1587739886378_create_users.js ├── tests │ ├── models │ │ └── user.js │ ├── system │ │ └── home_page_test.js │ └── controllers │ │ └── home_test.js ├── controllers │ └── home.js ├── webpack.config.js ├── index.js └── package.json ├── .prettierignore ├── application ├── initializers │ ├── force-ssl.js │ ├── environment-config.js │ ├── setup-assets.js │ └── default-middleware.js ├── middleware │ ├── method-override.js │ ├── timing.js │ ├── ssl-redirect.js │ ├── missing-route.js │ ├── logger.js │ ├── content-security-policy.js │ ├── compile-assets.js │ ├── static-files.js │ ├── cors.js │ ├── authenticity-token.js │ └── http-cache.js ├── adapter.js ├── token.js ├── assets-compiler.js ├── database.js ├── defaults.js └── cache.js ├── .gitignore ├── loader ├── error.js ├── require.js ├── processor.js └── asset.js ├── ui ├── events.js ├── component.js └── decorators.js ├── testing.js ├── errors ├── missing-route.js └── action-missing.js ├── .github └── workflows │ ├── test.yml │ └── docs.yml ├── model ├── errors.js ├── validations.js ├── migration.js ├── decorators.js └── relation.js ├── .esdoc.json ├── view ├── elements │ ├── link.jsx │ └── form.jsx ├── react.js └── template.js ├── routes ├── helpers.js ├── route.js └── route-set.js ├── etc └── api-index.md ├── Makefile ├── README.md ├── package.json ├── loader.js ├── task.js ├── plugin.js ├── cli.js ├── routes.js ├── mailer.js ├── callbacks.js ├── ui.js ├── controller.js ├── application.js ├── view.js └── model.js /cli/render.js: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | denosaur.org -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | public/ 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | docs 3 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | -------------------------------------------------------------------------------- /application/initializers/force-ssl.js: -------------------------------------------------------------------------------- 1 | export default function ForceSSL(app) {} 2 | -------------------------------------------------------------------------------- /cli/generate/templates/ui.css: -------------------------------------------------------------------------------- 1 | :root { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | bin 3 | node_modules 4 | example/node_modules 5 | .vimrc 6 | docs/api 7 | docs/_site 8 | Gemfile* 9 | -------------------------------------------------------------------------------- /cli/generate/templates/template.ejs: -------------------------------------------------------------------------------- 1 |

<%= name %>

2 |

3 | Find me in <%= file %> 4 |

5 | -------------------------------------------------------------------------------- /example/config/server.js: -------------------------------------------------------------------------------- 1 | import App from "../index.js"; 2 | 3 | // Start the application server 4 | await App.start(); 5 | -------------------------------------------------------------------------------- /cli/generate/templates/server.js: -------------------------------------------------------------------------------- 1 | import App from "../index.js"; 2 | 3 | // Start the application server 4 | await App.start(); 5 | -------------------------------------------------------------------------------- /application/middleware/method-override.js: -------------------------------------------------------------------------------- 1 | export default async function MethodOverride(context, next, app) { 2 | await next(); 3 | } 4 | -------------------------------------------------------------------------------- /cli/help/upgrade.txt: -------------------------------------------------------------------------------- 1 | USAGE: 2 | saur upgrade 3 | 4 | DESCRIPTION: 5 | Upgrades the `saur` CLI by reinstalling it using `deno install`. 6 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | title: Deno Saur 2 | description: Full-Stack Web Framework 3 | theme: jekyll-theme-primer 4 | url: https://denosaur.org 5 | -------------------------------------------------------------------------------- /cli/help/generate/view.txt: -------------------------------------------------------------------------------- 1 | Usage: 2 | saur generate view NAME 3 | 4 | Description: 5 | Generate a view class and corresponding template. 6 | -------------------------------------------------------------------------------- /loader/error.js: -------------------------------------------------------------------------------- 1 | export default class LoadError extends Error { 2 | constructor(url) { 3 | super(`Error loading "${url}"`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ui/events.js: -------------------------------------------------------------------------------- 1 | export default class Events { 2 | constructor(config = {}) { 3 | this.config = config; 4 | } 5 | 6 | attach(element) {} 7 | } 8 | -------------------------------------------------------------------------------- /docs/_layouts/page.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 |
6 |
7 | {{ content }} 8 |
9 |
10 | -------------------------------------------------------------------------------- /cli/help/generate/controller.txt: -------------------------------------------------------------------------------- 1 | Usage: 2 | saur generate controller NAME [ACTION ACTION...] 3 | 4 | Description: 5 | Generate a new controller class for routing. 6 | -------------------------------------------------------------------------------- /example/templates/home/index.html.ejs: -------------------------------------------------------------------------------- 1 |
2 |

<%- view.linkTo("hello world", view.rootURL()) %>

3 |

a world of wonder

4 |
5 | -------------------------------------------------------------------------------- /loader/require.js: -------------------------------------------------------------------------------- 1 | import Loader from "./loader.js"; 2 | 3 | const loader = new Loader(); 4 | const require = loader.require.bind(loader); 5 | 6 | export default require; 7 | -------------------------------------------------------------------------------- /cli/generate/templates/model.ejs: -------------------------------------------------------------------------------- 1 | import App from "../index.js"; 2 | import Model from "https://deno.land/x/saur/model.js" 3 | 4 | export default class ${className} extends Model { 5 | } 6 | -------------------------------------------------------------------------------- /cli/help/server.txt: -------------------------------------------------------------------------------- 1 | Usage: 2 | saur server 3 | 4 | Description: 5 | Start a new server in the current directory. A `bin/server` script 6 | must be present in order for this command to succeed. 7 | -------------------------------------------------------------------------------- /example/models/user.js: -------------------------------------------------------------------------------- 1 | // import App from "../index.js"; 2 | import Model from "https://deno.land/x/saur/model.js"; 3 | 4 | export default class User extends Model { 5 | // static app = App; 6 | } 7 | -------------------------------------------------------------------------------- /cli/generate/templates/env-config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // Place your environment-specific configuration here. Settings 3 | // defined here will override those specified in the main `index.js`. 4 | }; 5 | -------------------------------------------------------------------------------- /example/config/environments/test.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // Place your environment-specific configuration here. Settings 3 | // defined here will override those specified in the main `index.js`. 4 | }; 5 | -------------------------------------------------------------------------------- /example/views/home/index.js: -------------------------------------------------------------------------------- 1 | import App from "../../index.js"; 2 | import View from "../../../view.js"; 3 | 4 | export default class HomeIndexView extends View { 5 | static template = "home/index"; 6 | } 7 | -------------------------------------------------------------------------------- /example/config/environments/development.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // Place your environment-specific configuration here. Settings 3 | // defined here will override those specified in the main `index.js`. 4 | }; 5 | -------------------------------------------------------------------------------- /example/config/environments/production.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // Place your environment-specific configuration here. Settings 3 | // defined here will override those specified in the main `index.js`. 4 | }; 5 | -------------------------------------------------------------------------------- /example/src/components/title.css: -------------------------------------------------------------------------------- 1 | .title { 2 | color: green; 3 | cursor: pointer; 4 | } 5 | 6 | .title--initialized { 7 | color: blue; 8 | } 9 | 10 | .title--clicked: { 11 | color: red; 12 | } 13 | -------------------------------------------------------------------------------- /cli/generate/templates/mailer.ejs: -------------------------------------------------------------------------------- 1 | import App from "../index.js"; 2 | import Mailer from "https://deno.land/x/saur/mailer.js" 3 | 4 | export default class <%= className %> extends Mailer { 5 | <%= methods %> 6 | } 7 | -------------------------------------------------------------------------------- /example/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | postgres: 4 | image: postgres 5 | ports: 6 | - '5432:5432' 7 | mysql: 8 | image: mysql 9 | ports: 10 | - '3306:3306' 11 | -------------------------------------------------------------------------------- /example/templates/home/foo.html.jsx: -------------------------------------------------------------------------------- 1 | import React from "https://deno.land/x/saur/view/react.js"; 2 | 3 | export default function (view) { 4 | const foo = view.constructor.name; 5 | 6 | return
{foo}
; 7 | } 8 | -------------------------------------------------------------------------------- /application/middleware/timing.js: -------------------------------------------------------------------------------- 1 | export default async function (ctx, next) { 2 | const start = Date.now(); 3 | await next(); 4 | const ms = Date.now() - start; 5 | ctx.response.headers.set("X-Response-Time", `${ms}ms`); 6 | } 7 | -------------------------------------------------------------------------------- /cli/generate/templates/controller.ejs: -------------------------------------------------------------------------------- 1 | import App from "../index.js"; 2 | import Controller from "https://deno.land/x/saur/controller.js"; 3 | 4 | export default class <%= className %> extends Controller { 5 | <%= methods %> 6 | } 7 | -------------------------------------------------------------------------------- /cli/generate/templates/view.ejs: -------------------------------------------------------------------------------- 1 | import App from "../index.js"; 2 | import View from "https://deno.land/x/saur/view.js"; 3 | 4 | export default class <%= className %> extends View { 5 | static template = App.template("<%= name %>"); 6 | } 7 | -------------------------------------------------------------------------------- /cli/generate/templates/migration/drop.ejs: -------------------------------------------------------------------------------- 1 | import Migration from "https://deno.land/x/saur/model/migration.js"; 2 | 3 | export default class <%= className %> extends Migration { 4 | up(db) { 5 | db.table("<%= tableName %>").drop(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /testing.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Wrap `Deno.test` methods in a group for easier organization. 3 | */ 4 | export function describe(group, callback) { 5 | const test = (name, fn) => Deno.test({ name: `${group}#${name}`, fn }); 6 | 7 | return callback({ test }); 8 | } 9 | -------------------------------------------------------------------------------- /example/views/foo.jsx: -------------------------------------------------------------------------------- 1 | import View from "https://deno.land/x/saur/view.js"; 2 | import React from "https://deno.land/x/saur/view/react.js"; 3 | 4 | export default class FooView extends View { 5 | render() { 6 | return
{this.rootURL()}
7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /loader/processor.js: -------------------------------------------------------------------------------- 1 | export default class Processor { 2 | constructor(source, loader) { 3 | this.source = source; 4 | this.loader = loader; 5 | } 6 | 7 | get processed() { 8 | return this.process(); 9 | } 10 | 11 | process() {} 12 | } 13 | -------------------------------------------------------------------------------- /ui/component.js: -------------------------------------------------------------------------------- 1 | class Component { 2 | constructor(element) { 3 | this.element = element; 4 | this.initialize(); 5 | } 6 | 7 | initialize() {} 8 | } 9 | 10 | Component.selector = null; 11 | Component.events = {}; 12 | 13 | export default Component; 14 | -------------------------------------------------------------------------------- /cli/generate/templates/layout.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= title %> 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /cli/generate/templates/migration/create.ejs: -------------------------------------------------------------------------------- 1 | import Migration from "https://deno.land/x/saur/model/migration.js"; 2 | 3 | export default class <%= className %> extends Migration { 4 | up(db) { 5 | db.table("<%= tableName %>").create({ 6 | <%- fields %> 7 | }); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /cli/help/generate/model.txt: -------------------------------------------------------------------------------- 1 | Usage: 2 | saur generate model NAME [FIELD:TYPE FIELD:TYPE...] 3 | 4 | Description: 5 | Generate a new model class in ./models as well as a migration for 6 | creating the table schema in ./migrations. Types are limited to those 7 | supported by your database. 8 | -------------------------------------------------------------------------------- /example/migrations/1587739886378_create_users.js: -------------------------------------------------------------------------------- 1 | import Migration from "../../model/migration.js"; 2 | 3 | export default class CreateUsers extends Migration { 4 | up(db) { 5 | db.table("users").create({ 6 | name: "string", 7 | password: "string", 8 | }); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /errors/missing-route.js: -------------------------------------------------------------------------------- 1 | export default class MissingRouteError extends Error { 2 | constructor(controller, action, params) { 3 | super( 4 | `No route matches for 5 | controller=${controller} 6 | action=${action} 7 | with params ${params}`, 8 | ); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /cli/run.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Execute some JS code in the context of your application. 3 | */ 4 | export default async function Run(options, code) { 5 | window.APP_ROOT = `${Deno.cwd()}/example`; 6 | window.APP_RUN = true; 7 | 8 | await import(`${window.APP_ROOT}/index.js`); 9 | eval(code); 10 | } 11 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import Saur from "saur/ui"; 2 | 3 | import "./index.css"; 4 | 5 | const context = require.context("./components", true, /\.js$/); 6 | const App = new Saur(context); 7 | const ready = "DOMContentLoaded"; 8 | 9 | document.addEventListener(ready, ({ target }) => App.start(target)); 10 | -------------------------------------------------------------------------------- /cli/generate/templates/ui.js: -------------------------------------------------------------------------------- 1 | import Saur from "saur/ui"; 2 | 3 | import "./index.css"; 4 | 5 | const context = require.context("./components", true, /\.js$/); 6 | const App = new Saur(context); 7 | const ready = "DOMContentLoaded"; 8 | 9 | document.addEventListener(ready, ({ target }) => App.start(target)); 10 | -------------------------------------------------------------------------------- /example/tests/models/user.js: -------------------------------------------------------------------------------- 1 | import App from "../../index.js"; 2 | import { describe } from "https://deno.land/x/saur/testing.js"; 3 | import { assert } from "https://deno.land/std/testing/mod.ts"; 4 | 5 | describe("User", ({ test }) => { 6 | test("the truth", () => { 7 | assert(true); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: push 3 | jobs: 4 | test: 5 | name: Run Automated Tests 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v1 9 | - uses: denolib/setup-deno@master 10 | with: 11 | deno-version: 0.36.0 12 | - run: make check 13 | -------------------------------------------------------------------------------- /example/controllers/home.js: -------------------------------------------------------------------------------- 1 | import Controller from "../../controller.js"; 2 | import HomeIndexView from "../views/home/index.js"; 3 | import User from "../models/user.js"; 4 | 5 | export default class HomeController extends Controller { 6 | async index() { 7 | return this.render(HomeIndexView); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /cli/generate/templates/test.ejs: -------------------------------------------------------------------------------- 1 | import App from "../../index.js"; 2 | import { describe } from "https://deno.land/x/saur/testing.js"; 3 | import { assert } from "https://deno.land/std/testing/mod.ts"; 4 | 5 | describe("<%= className %>", ({ test }) => { 6 | test("the truth", () => { 7 | assert(true); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /example/tests/system/home_page_test.js: -------------------------------------------------------------------------------- 1 | import App from "../../index.js"; 2 | import { describe } from "https://deno.land/x/saur/testing.js"; 3 | import { assert } from "https://deno.land/std/testing/asserts.ts"; 4 | 5 | test("Visit the home page", () => { 6 | visit("/"); 7 | 8 | assert.content("hello world!"); 9 | }); 10 | -------------------------------------------------------------------------------- /model/errors.js: -------------------------------------------------------------------------------- 1 | export default class Errors { 2 | constructor() { 3 | this.all = {}; 4 | } 5 | 6 | add(property, message) { 7 | this.all[property] = this.all[property] || []; 8 | this.all[property].push(message); 9 | } 10 | 11 | get any() { 12 | return Object.keys(this.all).length; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/_layouts/post.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 |
6 |
7 |

{{ title }}

8 | 11 |
12 |
13 | {{ content }} 14 |
15 |
16 | -------------------------------------------------------------------------------- /example/tests/controllers/home_test.js: -------------------------------------------------------------------------------- 1 | import App from "../../index.js"; 2 | import { describe } from "https://deno.land/x/saur/testing.js"; 3 | import { assert } from "https://deno.land/std/testing/asserts.ts"; 4 | 5 | describe("HomeController", ({ test }) => { 6 | test("the truth", () => { 7 | assert(true); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /cli/help/generate.txt: -------------------------------------------------------------------------------- 1 | Usage: 2 | saur generate GENERATOR NAME [OPTIONS] 3 | 4 | Description: 5 | Generate code in the current application. 6 | 7 | Run `saur help generate GENERATOR` for more info on each generator. 8 | 9 | Generators: 10 | - model 11 | - view 12 | - controller 13 | - test 14 | - template 15 | - mailer 16 | -------------------------------------------------------------------------------- /errors/action-missing.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Thrown when `Controller.perform` cannot run because the controller 3 | * class does not have the correct action method defined. 4 | */ 5 | export default class ActionMissingError extends Error { 6 | constructor(controller, action) { 7 | super(`${controller}#${action}() is not defined.`); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ui/decorators.js: -------------------------------------------------------------------------------- 1 | export function element(selector) { 2 | return (target) => (target.selector = selector); 3 | } 4 | 5 | export function on(event) { 6 | return (target, method, descriptor) => { 7 | target.events[event] = target.events[event] || []; 8 | target.events[event].push(method); 9 | 10 | return descriptor; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /cli/generate/templates/migration/update.ejs: -------------------------------------------------------------------------------- 1 | import Migration from "https://deno.land/x/saur/model/migration.js"; 2 | 3 | export default class <%= className %> extends Migration { 4 | up(db) { 5 | db.table("<%= tableName %>").alter({ 6 | <%- fields %> 7 | }); 8 | } 9 | 10 | down(db) { 11 | db.table("<%= tableName %>").drop(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.esdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": ".", 3 | "destination": "./docs/api", 4 | "excludes": ["node_modules"], 5 | "index": "etc/api-index.md", 6 | "plugins": [ 7 | { 8 | "name": "esdoc-standard-plugin" 9 | }, 10 | { 11 | "name": "esdoc-ecmascript-proposal-plugin", 12 | "option": { 13 | "all": true 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /application/initializers/environment-config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Load configuration from ./config/environment/$env-name.js 3 | */ 4 | export default async function EnvironmentConfig(app) { 5 | const { environment } = app.config; 6 | const file = `${app.root}/config/environments/${environment}.js`; 7 | const env = await import(file); 8 | app.config = { ...app.config, ...env.default }; 9 | } 10 | -------------------------------------------------------------------------------- /cli/generate/templates/component.ejs: -------------------------------------------------------------------------------- 1 | import Component from "saur/ui/component"; 2 | 3 | class <%= className %> extends Component { 4 | // someMethod(event) { 5 | // console.log("responds to the", event); 6 | // } 7 | } 8 | 9 | // <%= className %>.selector = ".your-element-selector"; 10 | // <%= className %>.events.click = ["someMethod"]; 11 | 12 | export default <%= className %>; 13 | -------------------------------------------------------------------------------- /cli/generate/templates/webpack.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 2 | 3 | module.exports = { 4 | output: { 5 | path: `${__dirname}/public`, 6 | }, 7 | plugins: [new MiniCssExtractPlugin()], 8 | module: { 9 | rules: [ 10 | { 11 | test: /\.css$/, 12 | use: ["style-loader", "css-loader"], 13 | }, 14 | ], 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /view/elements/link.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * An element you can include in your View JSX to link to a given URL. 3 | */ 4 | export default async function Link({ to, children, ...options }) { 5 | const { default: App } = await import(`${Deno.cwd()}/index.js`); 6 | const href = App.routes.resolve(to); 7 | 8 | return ( 9 | 10 | {children} 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /view/react.js: -------------------------------------------------------------------------------- 1 | const REACT_ELEMENT_TYPE = Symbol.for("react.element"); 2 | 3 | class Element { 4 | constructor(type, props, children) { 5 | this.$$typeof = REACT_ELEMENT_TYPE; 6 | this.props = props; 7 | this.props.children = children; 8 | } 9 | } 10 | 11 | export { 12 | createElement(type, props = {}, children) { 13 | return new Element(type, props, children) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /cli/generate/test.js: -------------------------------------------------------------------------------- 1 | import { ejs } from "../assets.js"; 2 | 3 | export default async function GenerateTest(name, className, options, encoder) { 4 | const context = { name, className }; 5 | const test = ejs(`cli/templates/test.ejs`, context); 6 | 7 | await Deno.writeFile(`tests/${name}.js`, encoder.encode(test.toString())); 8 | console.log(`Created test for ${className} in tests/${name}.js`); 9 | } 10 | -------------------------------------------------------------------------------- /example/templates/layouts/default.html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%- view.csrfMetaTag %> 6 | example 7 | 8 | 9 | 10 | <%- innerHTML %> 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /application/middleware/ssl-redirect.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Redirect all insecure requests to HTTPS 3 | */ 4 | export default function* SSLRedirect(next) { 5 | if (this.secure) { 6 | return yield next; 7 | } 8 | 9 | const host = this.request.header.host; 10 | const path = this.request.url; 11 | const url = `https://${host}/${path}`; 12 | 13 | this.response.status = 301; 14 | 15 | this.response.redirect(url); 16 | } 17 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 2 | 3 | module.exports = { 4 | mode: "development", 5 | output: { 6 | path: `${__dirname}/public`, 7 | }, 8 | plugins: [new MiniCssExtractPlugin()], 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.css$/, 13 | use: [MiniCssExtractPlugin.loader, "css-loader"], 14 | }, 15 | ], 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /application/adapter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Base class for database and caching adapters. 3 | */ 4 | export default class Adapter { 5 | static adapters = {}; 6 | static adapt(adapter) { 7 | if (!this.adapters[adapter]) { 8 | throw new Error(`Database adapter "${adapter}" not found`); 9 | } 10 | 11 | return this.adapters[adapter]; 12 | } 13 | 14 | constructor(config = {}) { 15 | this.config = config; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | import Application from "../application.js"; 2 | import HomeController from "./controllers/home.js"; 3 | 4 | // `import` your code here: 5 | 6 | const App = new Application({ 7 | root: import.meta.url, 8 | }); 9 | 10 | App.config.log.level = "DEBUG"; 11 | App.config.db.database = "example"; 12 | 13 | App.routes.draw(({ root }) => { 14 | root("home#index"); 15 | }); 16 | 17 | await App.initialize(); 18 | 19 | export default App; 20 | -------------------------------------------------------------------------------- /application/middleware/missing-route.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This occurs when the end of the middleware stack is reached and 3 | * nothing has rendered a response yet. 4 | */ 5 | export default function MissingRoute(context, next, app) { 6 | const message = `No route matches "${context.request.url}"`; 7 | 8 | if (!context.response.body) { 9 | context.response.body = message; 10 | context.response.status = 404; 11 | } 12 | 13 | app.log.warning(message); 14 | } 15 | -------------------------------------------------------------------------------- /cli/upgrade.js: -------------------------------------------------------------------------------- 1 | const { run } = Deno; 2 | 3 | /** 4 | * Reinstall the `saur` CLI. 5 | */ 6 | export default async function Upgrade() { 7 | const cmd = [ 8 | "deno", 9 | "install", 10 | "--force", 11 | "--allow-read=.", 12 | "--allow-write", 13 | "--allow-run", 14 | "saur", 15 | "https://deno.land/x/saur/cli.js", 16 | ]; 17 | const stdout = "piped"; 18 | const upgrade = run({ cmd, stdout }); 19 | 20 | await upgrade.status(); 21 | } 22 | -------------------------------------------------------------------------------- /routes/helpers.js: -------------------------------------------------------------------------------- 1 | export default class RouteHelpers { 2 | constructor(set, request) { 3 | this.set = set; 4 | this.request = request; 5 | } 6 | 7 | get host() { 8 | return `${request.protocol}://${request.host}`; 9 | } 10 | 11 | forEach(iterator) { 12 | this.set.forEach((route) => { 13 | const name = camelCase(route.path); 14 | const path = (params = {}) => `/${route.path}`; 15 | 16 | iterator({ name, path, url }); 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /application/token.js: -------------------------------------------------------------------------------- 1 | import { Hash } from "https://deno.land/x/checksum/mod.ts"; 2 | 3 | export default class Token { 4 | constructor(date, { token, hash }) { 5 | this.date = date.getTime(); 6 | this.secret = token; 7 | this.hash = new Hash(hash); 8 | } 9 | 10 | get source() { 11 | return `${this.date}|${this.secret}`; 12 | } 13 | 14 | get digest() { 15 | return this.hash.digest(this.source); 16 | } 17 | 18 | toString() { 19 | return this.digest.hex(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cli/help/new.txt: -------------------------------------------------------------------------------- 1 | Usage: 2 | saur new NAME [OPTIONS] 3 | 4 | Description: 5 | Create a new Deno Saur application with a minimal set of pre-defined 6 | files. This generates a new folder for your application and copies in 7 | some boilerplate code to get you started. 8 | 9 | Your application will start out with the following files: 10 | 11 | - bin/server 12 | - config/environments/development.js 13 | - config/environments/test.js 14 | - config/environments/production.js 15 | - index.js 16 | - README.md 17 | -------------------------------------------------------------------------------- /cli/generate/templates/application.js: -------------------------------------------------------------------------------- 1 | import Application from "https://deno.land/x/saur/application.js"; 2 | 3 | // `import` your code here: 4 | 5 | const App = new Application({ 6 | root: import.meta.url, 7 | // Place your default configuration here. Environments can override 8 | // this configuration in their respective `./config/environments/*.js` 9 | // file. 10 | }); 11 | 12 | App.routes.draw(({ root }) => { 13 | // root("index", HomeController) 14 | }); 15 | 16 | await App.initialize(); 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /example/src/components/title.js: -------------------------------------------------------------------------------- 1 | import Component from "saur/ui/component"; 2 | import "./title.css"; 3 | 4 | class Title extends Component { 5 | initialize() { 6 | this.element.classList.add("title--initialized"); 7 | } 8 | 9 | changeColor(event) { 10 | this.element.classList.add("title--clicked"); 11 | 12 | this.element.parentElement.insertAdjacentHTML( 13 | "beforeend", 14 | `

test

`, 15 | ); 16 | } 17 | } 18 | 19 | Title.selector = ".title"; 20 | Title.events = { click: ["changeColor"] }; 21 | 22 | export default Title; 23 | -------------------------------------------------------------------------------- /cli/migrate.js: -------------------------------------------------------------------------------- 1 | import { walkSync } from "https://deno.land/std/fs/walk.ts"; 2 | 3 | export default async function Migrate(options, direction = "up") { 4 | const root = Deno.cwd(); 5 | const { default: App } = await import(`${root}/index.js`); 6 | 7 | for (const { filename } of walkSync(`${root}/migrations`)) { 8 | if (filename.match(/\.js$/)) { 9 | const [version, name] = filename.split("_"); 10 | const { default: Migration } = await import(filename); 11 | const migration = new Migration(name, version, App); 12 | 13 | if (!migration.executed()) { 14 | migration.exec(direction); 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /cli/help/usage.ejs: -------------------------------------------------------------------------------- 1 | USAGE: 2 | saur COMMAND [ARGUMENTS] [options] 3 | 4 | DESCRIPTION: 5 | The \`saur\` command is used to manage and create Saur applications 6 | on your development workstation. 7 | 8 | Run the following command to view more information: 9 | 10 | saur help COMMAND 11 | 12 | SUBCOMMANDS: 13 | new Create a new application 14 | generate Generate code for your application 15 | server Run the application server 16 | migrate Run database migrations 17 | help Show this help 18 | <%= tasks %> 19 | 20 | OPTIONS: 21 | -h, --help Shows help for the command you've entered, or this usage 22 | info. 23 | -------------------------------------------------------------------------------- /application/middleware/logger.js: -------------------------------------------------------------------------------- 1 | import reduce from "https://deno.land/x/lodash/reduce.js"; 2 | 3 | export default async function logger(ctx, next, app) { 4 | const { 5 | method, 6 | url: { pathname }, 7 | } = ctx.request; 8 | const params = reduce( 9 | ctx.params || {}, 10 | (value, key, p) => `${p}, ${key}: "${value}"`, 11 | "", 12 | ); 13 | 14 | app.log.info(`Requesting ${method} "${pathname}" (Parameters: {${params}})`); 15 | 16 | await next(); 17 | 18 | const time = ctx.response.headers.get("X-Response-Time"); 19 | const status = ctx.response.status || 404; 20 | 21 | app.log.info(`Responded with ${status} in ${time}`); 22 | } 23 | -------------------------------------------------------------------------------- /application/middleware/content-security-policy.js: -------------------------------------------------------------------------------- 1 | import reduce from "https://deno.land/x/lodash/reduce.js"; 2 | 3 | export default async function ContentSecurityPolicy(context, next, app) { 4 | const { hosts, contentSecurityPolicy } = app.config; 5 | 6 | if (contentSecurityPolicy) { 7 | const domains = hosts.length ? hosts.join(" ") : ""; 8 | const policy = reduce( 9 | contentSecurityPolicy, 10 | (value, key, policy) => { 11 | `${policy}; ${key} ${value}`; 12 | }, 13 | `default-src 'self' ${domains}`, 14 | ); 15 | 16 | context.response.headers.set("Content-Security-Policy", policy); 17 | } 18 | 19 | await next(); 20 | } 21 | -------------------------------------------------------------------------------- /docs/blog.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | --- 4 | 5 |

Blog

6 | 7 | {% for post in site.posts %} 8 |
9 |
10 |

11 | {{ post.title }} 12 |

13 | 16 |
17 |
18 | {{ post.excerpt }} 19 |
20 | 23 |
24 | {% endfor %} 25 | -------------------------------------------------------------------------------- /cli/assets.js: -------------------------------------------------------------------------------- 1 | import { render } from "https://denopkg.com/tubbo/dejs@fuck-you-github/mod.ts"; 2 | import Loader from "../loader.js"; 3 | import Processor from "../loader/processor.js"; 4 | import { decode } from "https://deno.land/std/encoding/utf8.ts"; 5 | 6 | class EJSProcessor extends Processor { 7 | async process() { 8 | const compiled = await render(decode(this.source), this.params); 9 | 10 | return compiled; 11 | } 12 | } 13 | 14 | const EJS = new Loader({ 15 | processor: EJSProcessor, 16 | base: "https://deno.land/x/saur", 17 | }); 18 | 19 | export async function ejs(path, params = {}) { 20 | EJS.params = params; 21 | const asset = await EJS.require(path); 22 | 23 | return asset; 24 | } 25 | -------------------------------------------------------------------------------- /cli/generate/component.js: -------------------------------------------------------------------------------- 1 | import { ejs } from "../assets.js"; 2 | import { paramCase } from "https://deno.land/x/case/mod.ts"; 3 | 4 | const { cwd, writeFile } = Deno; 5 | 6 | export default async function GenerateComponent(name, className, encoder) { 7 | const component = ejs("cli/templates/component.ejs", { name, className }); 8 | const app = cwd(); 9 | const source = encoder.encode(component); 10 | const path = `src/components/${name}`; 11 | const cssClass = paramCase(name); 12 | const css = `.${cssClass} {}`; 13 | 14 | await writeFile(`${app}/${path}.js`, source); 15 | await writeFile(`${app}/${path}.css`, css); 16 | console.log("Created new Component", className, "in", `${path}.js`); 17 | } 18 | -------------------------------------------------------------------------------- /cli/generate/mailer.js: -------------------------------------------------------------------------------- 1 | import { ejs } from "../assets.js"; 2 | 3 | /** 4 | * `saur generate controller NAME` 5 | * 6 | * This generates a controller class and its test. 7 | */ 8 | export default async function (name, className, encoder, options, ...actions) { 9 | const methods = actions.map((action) => ` ${action}() {}`).join("\n"); 10 | const context = { name, className, methods }; 11 | const controller = ejs(`cli/templates/mailer.ejs`, context); 12 | const test = ejs(`cli/templates/test.ejs`, context); 13 | 14 | await Deno.writeFile(`mailers/${name}.js`, encoder.encode(controller)); 15 | await Deno.writeFile(`tests/mailers/${name}_test.js`, encoder.encode(test)); 16 | console.log(`Created ${className} in controllers/${name}.js`); 17 | } 18 | -------------------------------------------------------------------------------- /docs/guides/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | path: /guides 3 | layout: page 4 | --- 5 | 6 | ## Guides 7 | 8 | If you're already up to speed with what Deno Saur is and want to learn 9 | more, this site has a wealth of guides to help you get your job done. 10 | 11 | - [Quick Start](start.html) 12 | - [Architecture](architecture.html) 13 | - [Models](models.html) 14 | - [Controllers and Routing](controllers.html) 15 | - [Views and Templates](views.html) 16 | - [Mailers](mailers.html) 17 | - [Front-End Components](components.html) 18 | - [Configuration](configuration.html) 19 | - [CLI](cli.html) 20 | - [Cache](cache.html) 21 | 22 | Check out the [reference documentation][] if you want to learn more 23 | about each feature. 24 | 25 | [reference documentation]: https://api.denosaur.org 26 | -------------------------------------------------------------------------------- /cli/generate/view.js: -------------------------------------------------------------------------------- 1 | import GenerateTemplate from "./template.js"; 2 | import { existsSync } from "https://deno.land/std/fs/exists.ts"; 3 | import { dirname } from "https://deno.land/std/path/mod.ts"; 4 | import { ejs } from "../assets.js"; 5 | 6 | export default async function GenerateView(name, klass, encoder, options) { 7 | const className = `${klass}View`; 8 | const view = ejs(`cli/templates/view.ejs`, { name, className }); 9 | const file = `views/${name}.js`; 10 | const dir = dirname(file); 11 | 12 | if (!existsSync(dir)) { 13 | await Deno.mkdir(dir); 14 | } 15 | 16 | await Deno.writeFile(file, encoder.encode(view.toString())); 17 | console.log(`Created ${className} in views/${name}.js`); 18 | 19 | GenerateTemplate(name, null, encoder, options); 20 | } 21 | -------------------------------------------------------------------------------- /cli/server.js: -------------------------------------------------------------------------------- 1 | const { run, cwd } = Deno; 2 | 3 | export default async function Server(options) { 4 | const cmd = [`${cwd()}/bin/server`]; 5 | const stdout = "piped"; 6 | const stderr = "piped"; 7 | // const decoder = new TextDecoder(); 8 | const proc = run({ cmd, stdout, stderr }); 9 | // const buff = new Uint8Array(80); 10 | // let done = false; 11 | 12 | if (options.o || options.open) { 13 | const open = run({ cmd: ["open", "http://localhost:3000"] }); 14 | await open.status(); 15 | } 16 | 17 | /*while (!done) { 18 | await proc.stdout.read(buff); 19 | console.log(decoder.decode(buff).replace("\n", "")); 20 | done = buff.toString() === ""; 21 | }*/ 22 | 23 | const status = await proc.status(); 24 | 25 | Deno.exit(status); 26 | } 27 | -------------------------------------------------------------------------------- /application/middleware/compile-assets.js: -------------------------------------------------------------------------------- 1 | import { extname } from "https://deno.land/std/path/mod.ts"; 2 | import AssetsCompiler from "../assets-compiler.js"; 3 | 4 | /** 5 | * Run the `App.config.assets.webpack` command when an asset is 6 | * requested, then serve it. 7 | */ 8 | export default async function CompileAssets(context, next, app) { 9 | const { pathname } = context.request.url; 10 | const ext = extname(pathname).replace(".", ""); 11 | const type = app.config.assets.formats[ext]; 12 | 13 | if (!type) { 14 | await next(); 15 | return; 16 | } 17 | 18 | const { status, body } = await AssetsCompiler(app, pathname); 19 | 20 | context.response.status = status; 21 | context.response.body = body; 22 | context.response.headers.set("Content-Type", type); 23 | } 24 | -------------------------------------------------------------------------------- /cli/generate/model.js: -------------------------------------------------------------------------------- 1 | import GenerateMigration from "./migration.js"; 2 | import GenerateTest from "./test.js"; 3 | import { ejs } from "../assets.js"; 4 | 5 | export default async function (name, className, encoder, options, ...fields) { 6 | const context = { name, className }; 7 | const model = ejs(`cli/templates/model.ejs`, context); 8 | 9 | await Deno.writeFile(`models/${name}.js`, encoder.encode(model.toString())); 10 | 11 | console.log(`Created new model ${className} in models/${name}.js`); 12 | GenerateTest(`models/${name}`, className, options, encoder); 13 | 14 | if (fields.length) { 15 | const migration = `Create${className}s`; 16 | const table = `create_${name}s`; 17 | 18 | GenerateMigration(table, migration, options, encoder, ...fields); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /model/validations.js: -------------------------------------------------------------------------------- 1 | export class Validation { 2 | constructor({ property, ...options }) { 3 | this.property = property; 4 | this.options = options; 5 | } 6 | } 7 | 8 | export class Presence extends Validation { 9 | valid(model) { 10 | const { property } = this.options; 11 | const message = this.options.message || "must be present"; 12 | 13 | if (typeof model[property] === "undefined") { 14 | model.errors.add(property, message); 15 | } 16 | } 17 | } 18 | 19 | export class GenericValidation extends Validation { 20 | valid(model) { 21 | const { method } = this.options; 22 | const validation = model[method].bind(model); 23 | 24 | validation(); 25 | } 26 | } 27 | 28 | // Validations that can be used as part of the `validates` method. 29 | export default { 30 | presence: Presence, 31 | }; 32 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "private": true, 7 | "scripts": { 8 | "build": "webpack --silent" 9 | }, 10 | "dependencies": { 11 | "saur": "^0.0.1" 12 | }, 13 | "devDependencies": { 14 | "css-loader": "^3.5.2", 15 | "eslint-plugin-prettier": "^3.1.3", 16 | "mini-css-extract-plugin": "^0.9.0", 17 | "style-loader": "^1.1.4", 18 | "stylelint-config-recommended": "^3.0.0", 19 | "webpack": "^4.42.1", 20 | "webpack-cli": "^3.3.11" 21 | }, 22 | "eslintConfig": { 23 | "extends": [ 24 | "eslint:recommended", 25 | "prettier" 26 | ], 27 | "parser": "babel-eslint", 28 | "env": { 29 | "browser": true 30 | } 31 | }, 32 | "stylelint": { 33 | "extends": "stylelint-config-recommended" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /application/middleware/static-files.js: -------------------------------------------------------------------------------- 1 | import { send } from "https://deno.land/x/oak/mod.ts"; 2 | import { existsSync } from "https://deno.land/std/fs/exists.ts"; 3 | 4 | /** 5 | * Serve static files from the ./public directory. 6 | */ 7 | export default async function StaticFiles(context, next, app) { 8 | try { 9 | const root = `${Deno.cwd()}/public`; 10 | const index = "index.html"; 11 | const path = context.request.url.pathname; 12 | const dir = path.match(/\/$/); 13 | const exists = existsSync(`${root}/${context.request.path}`); 14 | 15 | if (dir || !exists) { 16 | await next(); 17 | return; 18 | } 19 | 20 | app.log.info(`Serving static file "${context.request.path}"`); 21 | 22 | await send(context, context.request.path, { root, index }); 23 | } catch (e) { 24 | console.log(e); 25 | await next(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /etc/api-index.md: -------------------------------------------------------------------------------- 1 | # Deno Saur API Reference 2 | 3 | Welcome to the reference documentation for [Deno Saur][guides]! Here you'll 4 | find detailed information about every object and its methods in the Saur 5 | framework. 6 | 7 | This documentation is [automatically generated][workflow] when code is 8 | pushed to the `master` branch, so you can be sure it's always up-to-date 9 | with the latest changes. 10 | 11 | See something missing? Create a [pull request][] and contribute some 12 | docs! All documentation is contained in the repository, with the 13 | [guides][] located in the `docs/` directory, and the reference 14 | documentation located within [ESDoc][] comments throughout the codebase. 15 | Help us out by editing or creating new guides and/or documentation for 16 | the code! 17 | 18 | [guides]: http://denosaur.org 19 | [workflow]: https://github.com/tubbo/saur/actions?query=workflow%3ADocumentation 20 | [pull request]: https://github.com/tubbo/saur/pulls/new 21 | -------------------------------------------------------------------------------- /cli/generate/template.js: -------------------------------------------------------------------------------- 1 | import { dirname, basename } from "https://deno.land/std/path/mod.ts"; 2 | import { existsSync } from "https://deno.land/std/fs/exists.ts"; 3 | import { ejs } from "../assets.js"; 4 | 5 | export default async function GenerateTemplate(name, cn, encoder, options) { 6 | const format = options.f || "html"; 7 | const language = "ejs"; 8 | const root = `${Deno.cwd()}/templates`; 9 | const dir = `${root}/${dirname(name)}`; 10 | const file = `${root}/${name}.${format}.${language}`; 11 | const base = basename(name); 12 | const template = await ejs("cli/templates/template.ejs", { name, file }); 13 | const source = encoder.encode(template.toString()); 14 | 15 | if (!existsSync(dir)) { 16 | await Deno.mkdir(dir); 17 | } 18 | 19 | if (!existsSync(file)) { 20 | await Deno.writeFile(file, source); 21 | } 22 | 23 | console.log( 24 | `Created Template for "${base}" in templates/${name}.${format}.${language}`, 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /docs/guides/cache.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | path: /guides/cache.html 4 | --- 5 | 6 | # Caching 7 | 8 | A caching layer is also built in, with support for "russian-doll" 9 | strategies in the view: 10 | 11 | ``` 12 |
13 | <%= cache(`users/${user.id}`, () => { %> 14 |
15 |

<%= user.name %>

16 |
17 |
18 | <%= cache(`users/${user.id}/posts`, () => { %> 19 | <% user.posts.forEach(post => { %> 20 |

<%= post.title %>

21 | <% }) %> 22 | <%= }) %> 23 |
24 | <%= }) %> 25 |
26 | ``` 27 | 28 | This `cache` method is provided in the `View` for your convenience, but 29 | you can access the app cache directly using `App.cache`. The method used 30 | in the `cache` helper is `App.cache.fetch`, and is notable because it 31 | first checks if a key is available and uses the cache if so, otherwise 32 | it will call the provided "fresh" method and save that value as the 33 | cache. 34 | 35 | 36 | -------------------------------------------------------------------------------- /application/middleware/cors.js: -------------------------------------------------------------------------------- 1 | export default async function CORS(context, next, app) { 2 | const { cors } = app.config; 3 | 4 | if (!cors.length) { 5 | await next(); 6 | return; 7 | } 8 | 9 | const defaultResource = cors.resources.find( 10 | (resource) => resource.path === "*", 11 | ); 12 | const matchingResource = cors.resources.find((resource) => 13 | context.request.url.match(resource.path) 14 | ); 15 | const resource = matchingResource || defaultResource; 16 | 17 | if (!resource) { 18 | await next(); 19 | return; 20 | } 21 | 22 | const { origins } = cors; 23 | const { headers, methods } = resource; 24 | const origin = origins.join(" "); 25 | 26 | context.response.headers.set("Access-Control-Allow-Origin", origin); 27 | 28 | if (headers) { 29 | context.response.headers.set("Access-Control-Expose-Headers", headers); 30 | } 31 | 32 | if (methods) { 33 | context.response.headers.set("Access-Control-Allow-Methods", methods); 34 | } 35 | 36 | await next(); 37 | } 38 | -------------------------------------------------------------------------------- /application/initializers/setup-assets.js: -------------------------------------------------------------------------------- 1 | import { existsSync } from "https://deno.land/std/fs/exists.ts"; 2 | import CompileAssets from "../middleware/compile-assets.js"; 3 | import AssetsCompiler from "../assets-compiler.js"; 4 | 5 | const { removeSync, watchFs } = Deno; 6 | 7 | function clean(root) { 8 | if (existsSync(`${root}/public/main.js`)) { 9 | removeSync(`${root}/public/main.js`); 10 | } 11 | 12 | if (existsSync(`${root}/public/main.css`)) { 13 | removeSync(`${root}/public/main.css`); 14 | } 15 | } 16 | 17 | export default async function SetupAssets(app) { 18 | if (app.config.assets.enabled) { 19 | const root = app.root.replace("file://", ""); 20 | const watcher = watchFs(`${root}/src`); 21 | 22 | app.use(CompileAssets); 23 | clean(root); 24 | AssetsCompiler(app, "/main.js"); 25 | 26 | for await (const event of watcher) { 27 | event.paths.forEach((path) => { 28 | app.log.debug(`Reloading ${path}`); 29 | }); 30 | 31 | clean(root); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /application/initializers/default-middleware.js: -------------------------------------------------------------------------------- 1 | import RequestLogger from "../middleware/logger.js"; 2 | import RequestTimer from "../middleware/timing.js"; 3 | import SSLRedirect from "../middleware/ssl-redirect.js"; 4 | import StaticFiles from "../middleware/static-files.js"; 5 | 6 | //import MethodOverride from "../middleware/method-override.js"; 7 | import CSP from "../middleware/content-security-policy.js"; 8 | import CORS from "../middleware/cors.js"; 9 | import AuthenticityToken from "../middleware/authenticity-token.js"; 10 | import HTTPCaching from "../middleware/http-cache.js"; 11 | 12 | export default async function DefaultMiddleware(app) { 13 | if (app.config.forceSSL) { 14 | app.use(SSLRedirect); 15 | } 16 | if (app.config.cache.http.enabled) { 17 | app.use(HTTPCaching); 18 | } 19 | if (app.config.serveStaticFiles) { 20 | app.use(StaticFiles); 21 | } 22 | 23 | //app.use(MethodOverride); 24 | app.use(AuthenticityToken); 25 | app.use(CSP); 26 | app.use(CORS); 27 | app.use(RequestLogger); 28 | app.use(RequestTimer); 29 | } 30 | -------------------------------------------------------------------------------- /loader/asset.js: -------------------------------------------------------------------------------- 1 | import { Hash } from "https://deno.land/x/checksum/mod.ts"; 2 | //import { dirname } from "https://deno.land/std/path/mod.ts"; 3 | import { existsSync } from "https://deno.land/std/fs/exists.ts"; 4 | 5 | const { dir, readFile } = Deno; 6 | const SHA1 = new Hash("sha1"); 7 | 8 | export default class Asset { 9 | constructor(url, base) { 10 | this.url = base ? `${base}/${url}` : url; 11 | this.id = SHA1.digest(url).hex(); 12 | } 13 | 14 | get cached() { 15 | return existsSync(this.path); 16 | } 17 | 18 | get path() { 19 | return `${dir()}/saur/${this.id}.json`; 20 | } 21 | 22 | get local() { 23 | return this.url.match(/$\./); 24 | } 25 | 26 | async cache(response) { 27 | // if (!existsSync(dirname(this.path))) { 28 | // mkdir(dirname(this.path)); 29 | // } 30 | // const json = JSON.stringify(response); 31 | // await writeFile(this.path, json); 32 | } 33 | 34 | async body() { 35 | const file = await readFile(this.path); 36 | const { body } = JSON.parse(file); 37 | 38 | return body; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /application/assets-compiler.js: -------------------------------------------------------------------------------- 1 | import { existsSync } from "https://deno.land/std/fs/exists.ts"; 2 | 3 | const { readFile } = Deno; 4 | 5 | /** 6 | * Responsible for running Webpack when an asset needs to be compiled. 7 | */ 8 | export default async function AssetsCompiler(app, url) { 9 | let status, body; 10 | const root = app.root.replace("file://", ""); 11 | const path = `${root}/public${url}`; 12 | 13 | try { 14 | if (!existsSync(path)) { 15 | app.log.info("Compiling assets..."); 16 | const command = Deno.run({ 17 | cwd: root, 18 | cmd: ["yarn", "--silent", "build"], 19 | }); 20 | const errors = await command.errors; 21 | 22 | if (errors) { 23 | throw new Error(errors); 24 | } 25 | 26 | await command.status(); 27 | app.log.info("Compilation succeeded!"); 28 | } 29 | 30 | body = await readFile(path); 31 | status = 200; 32 | } catch (e) { 33 | status = 500; 34 | body = e.message; 35 | 36 | app.log.error(`Compilation failed for ${path}: ${e.message}`); 37 | } 38 | 39 | return { status, body }; 40 | } 41 | -------------------------------------------------------------------------------- /application/middleware/authenticity-token.js: -------------------------------------------------------------------------------- 1 | import Token from "../token.js"; 2 | 3 | /** 4 | * Verify the authenticity token for each request, disallowing requests 5 | * that don't include the correct token in either a param or the 6 | * header. This prevents cross-site request forgery by ensuring any 7 | * request which can potentially change data is coming from the current 8 | * host. It can be disabled on a per-request basis by setting the 9 | * `app.config.authenticity.ignore` array. 10 | */ 11 | export default async function AuthenticityToken(context, next, app) { 12 | if (context.request.method === "GET") { 13 | await next(); 14 | return; 15 | } 16 | 17 | const date = new Date(context.request.headers.get("Date")); 18 | const token = new Token(date, app.config.secret); 19 | const param = context.request.searchParams.authenticity_token; 20 | const header = context.request.headers.get("X-Authenticity-Token"); 21 | const input = param || header; 22 | 23 | if (token != input) { 24 | app.log.error(`Invalid authenticity token: "${token}"`); 25 | return; 26 | } 27 | 28 | await next(); 29 | } 30 | -------------------------------------------------------------------------------- /application/middleware/http-cache.js: -------------------------------------------------------------------------------- 1 | import each from "https://deno.land/x/lodash/each.js"; 2 | 3 | const CACHEABLE_RESPONSE_CODES = [ 4 | 200, // OK 5 | 203, // Non-Authoritative Information 6 | 300, // Multiple Choices 7 | 301, // Moved Permanently 8 | 302, // Found 9 | 404, // Not Found 10 | 410, // Gone 11 | ]; 12 | 13 | /** 14 | * Read the response from the cache if it exists, otherwise write the 15 | * response to the cache. 16 | */ 17 | export default function HTTPCaching(ctx, next, app) { 18 | const shouldHTTPCache = 19 | app.cache.httpEnabled && 20 | CACHEABLE_RESPONSE_CODES.includes(ctx.response.status) && 21 | ctx.response.method === "GET" && 22 | ctx.response.headers.has("Cache-Control") && 23 | ctx.response.headers.has("ETag"); 24 | const hit = ({ status, headers, body }) => { 25 | app.log.info(`Serving "${ctx.request.url}" from cache`); 26 | each(headers, (v, h) => ctx.response.headers.set(h, v)); 27 | ctx.response.status = status; 28 | ctx.response.body = body; 29 | }; 30 | 31 | if (shouldHTTPCache) { 32 | app.cache.http(ctx.request.url, next, ctx, hit); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /docs/guides/start.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | path: /guides/start.html 4 | --- 5 | 6 | # Quick Start: Hello World 7 | 8 | Create a new application: 9 | 10 | saur new hello 11 | cd hello 12 | 13 | Generate the controller and action: 14 | 15 | saur generate home index 16 | 17 | Edit **templates/home/index.html.ejs**: 18 | 19 | ```html 20 |

hello world

21 | ``` 22 | 23 | Open **index.js** and add your route: 24 | 25 | ```javascript 26 | import Application from "../application.js"; 27 | import HomeController from "./controllers/home.js"; 28 | 29 | // `import` your code here: 30 | 31 | const App = new Application({ 32 | root: import.meta.url, 33 | // Place your default configuration here. Environments can override 34 | // this configuration in their respective `./config/environments/*.js` 35 | // file. 36 | }); 37 | 38 | App.routes.draw(({ root }) => { 39 | root("index", HomeController); 40 | }); 41 | 42 | await App.initialize(); 43 | 44 | export default App; 45 | ``` 46 | 47 | Start the server, and browse to 48 | 49 | saur server 50 | 51 | You should see a big ol' "Hello World!" 52 | -------------------------------------------------------------------------------- /docs/guides/controllers.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | path: /guides/controllers.html 4 | --- 5 | 6 | # Controllers and Routing 7 | 8 | The controller layer is used for parsing requests and rendering 9 | responses. It's the abstraction between the router/server and your 10 | application code. Controllers are primarily used to query the database 11 | and render Views to display that data in a certain way, depending on the 12 | requested format. 13 | 14 | Here's what a controller for the `User` model might look like: 15 | 16 | ```javascript 17 | import Controller from "https://deno.land/x/saur/controlller.js"; 18 | import UserView from "../views/user.js"; 19 | 20 | export default UsersController extends Controller { 21 | show({ id }) { 22 | const user = User.find(id); 23 | 24 | this.render(UserView, { user }); 25 | } 26 | } 27 | ``` 28 | 29 | And it could be routed in **index.js** like so: 30 | 31 | ```javascript 32 | App.routes.draw({ get }) => { 33 | get("users/:id", { controller: UsersController, action: "show" }); 34 | }); 35 | ``` 36 | 37 | This will render the `UsersController#show` action on the 38 | route. 39 | -------------------------------------------------------------------------------- /cli/help.js: -------------------------------------------------------------------------------- 1 | import { dirname } from "https://deno.land/std/path/mod.ts"; 2 | import { renderFile } from "https://denopkg.com/tubbo/dejs@fuck-you-github/mod.ts"; 3 | import { Task } from "../task.js"; 4 | 5 | const { readFile } = Deno; 6 | const root = dirname(import.meta.url).replace("file://", ""); 7 | const decoder = new TextDecoder("utf-8"); 8 | 9 | export default async function Help(options, cmd, ...argv) { 10 | const command = !cmd || cmd === "help" ? "usage" : [cmd, ...argv].join("/"); 11 | const path = 12 | command === "usage" 13 | ? `${root}/help/usage.ejs` 14 | : `${root}/help/${command}.txt`; 15 | let txt; 16 | 17 | try { 18 | if (command === "usage") { 19 | const Tasks = await Task.all(); 20 | const tasks = Tasks.map((t) => `${t.name} - ${t.description}`); 21 | const src = await renderFile(path, { tasks }); 22 | txt = src.toString(); 23 | } else { 24 | const src = await readFile(path); 25 | txt = decoder.decode(src); 26 | } 27 | 28 | console.log(txt); 29 | } catch (e) { 30 | console.error("No manual entry for", "saur", cmd, ...argv); 31 | Deno.exit(1); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Makefile for the `saur` command 3 | # 4 | 5 | all: bin/saur tags docs/api 6 | 7 | bin/saur: 8 | @mkdir -p bin 9 | @deno install --unstable --allow-net --allow-env --allow-run --allow-write --allow-read --root . --name saur cli.js 10 | 11 | dist: 12 | @mkdir -p dist 13 | @git archive -o dist/saur.tar.gz HEAD 14 | 15 | tags: 16 | @ctags -R . 17 | 18 | docs/api: 19 | @yarn run esdoc 20 | .PHONY: docs/api 21 | 22 | node_modules: 23 | @yarn install --check-files 24 | 25 | html: docs 26 | .PHONY: html 27 | 28 | clean: distclean mostlyclean 29 | .PHONY: clean 30 | 31 | mostlyclean: 32 | @rm -rf bin docs/api 33 | .PHONY: mostlyclean 34 | 35 | maintainer-clean: clean 36 | @rm -f tags 37 | .PHONY: maintainer-clean 38 | 39 | check: 40 | @echo "TODO: figure out how to test this" 41 | .PHONY: check 42 | 43 | # fmt: 44 | # @setopt extendedglob; deno fmt ^(node_modules|example)/**/*.js 45 | # .PHONY: fmt 46 | 47 | distclean: 48 | @rm -rf dist 49 | .PHONY: distclean 50 | 51 | install: 52 | @install bin/saur /usr/local/bin 53 | .PHONY: install 54 | 55 | uninstall: 56 | @rm -f /usr/local/bin/saur 57 | .PHONY: uninstall 58 | 59 | start: 60 | @cd example; bin/server 61 | -------------------------------------------------------------------------------- /cli/generate/migration.js: -------------------------------------------------------------------------------- 1 | import { ejs } from "../assets.js"; 2 | 3 | function statementize(field) { 4 | let [name, type, ...options] = field.split(":"); 5 | type = type || "string"; 6 | 7 | if (options.length) { 8 | options = options.map((option) => `${option}: true`).join(", "); 9 | 10 | return ` ${name}: { type: "${type}", ${options} },`; 11 | } 12 | 13 | return ` ${name}: "${type}",`; 14 | } 15 | 16 | const ACTIONS = ["create", "drop"]; 17 | 18 | export default async function (name, className, options, encoder, ...args) { 19 | const version = new Date().getTime(); 20 | const path = `migrations/${version}_${name}.js`; 21 | const fields = args.map(statementize).join("\n"); 22 | const splitName = name.split("_"); 23 | const action = ACTIONS.includes(splitName[0]) ? splitName[0] : "update"; 24 | const tableName = splitName[splitName.length - 1]; 25 | const context = { className, fields, tableName }; 26 | const template = `cli/templates/migration/${action}.ejs`; 27 | const source = ejs(template, context); 28 | 29 | await Deno.writeFile(path, encoder.encode(source.toString())); 30 | console.log("Created new migration", className, "in", path); 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deno Saur 2 | 3 | A rapid development web framework for [deno][]. This README is for 4 | developers of the project, for more information on the framework, check 5 | out the [main site][] or [reference docs][]. 6 | 7 | ## Building From Source 8 | 9 | To build Deno Saur locally, clone this repo and run `make`: 10 | 11 | git clone https://github.com/tubbo/saur.git 12 | cd saur 13 | make 14 | 15 | This will install a `bin/saur` command-line interface from the code in 16 | the repo. Use this to run CLI commands, generate apps/code, etc. 17 | 18 | ## Running Tests 19 | 20 | To run all tests: 21 | 22 | make check 23 | 24 | To run a single test: 25 | 26 | deno test tests/path/to/the/test.js 27 | 28 | ## Code Formatting 29 | 30 | This project uses `deno fmt` to format code. Make sure you run this 31 | command before committing: 32 | 33 | make fmt 34 | 35 | ## Contributing 36 | 37 | Please make contributions using a pull request, and follow our code of 38 | conduct. In order for your contributions to be accepted, all tests must 39 | pass. Thanks for contributing to open-source! 40 | 41 | [deno]: https://deno.land 42 | [main site]: https://denosaur.org 43 | [reference docs]: https://api.denosaur.org 44 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | on: 3 | push: 4 | branches: [master] 5 | jobs: 6 | reference: 7 | name: Reference 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: bobheadxi/deployments@master 11 | id: deployment 12 | with: 13 | step: start 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | env: api-reference 16 | - uses: actions/checkout@v1 17 | - uses: actions/setup-node@v1 18 | with: 19 | node_version: 13.x 20 | - run: yarn install --check-files 21 | - run: make docs/api 22 | - uses: jakejarvis/s3-sync-action@master 23 | with: 24 | args: --acl public-read --follow-symlinks --delete 25 | env: 26 | AWS_S3_BUCKET: ${{ secrets.bucket }} 27 | AWS_ACCESS_KEY_ID: ${{ secrets.aws_key }} 28 | AWS_SECRET_ACCESS_KEY: ${{ secrets.aws_secret }} 29 | SOURCE_DIR: docs/api 30 | - uses: bobheadxi/deployments@master 31 | if: always() 32 | with: 33 | step: finish 34 | token: ${{ secrets.GITHUB_TOKEN }} 35 | status: ${{ job.status }} 36 | deployment_id: ${{ steps.deployment.outputs.deployment_id }} 37 | env_url: https://api.denosaur.org 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saur", 3 | "description": "UI components for Deno's full-stack web application framework", 4 | "version": "0.0.1", 5 | "license": "MIT", 6 | "devDependencies": { 7 | "babel-eslint": "^10.1.0", 8 | "esdoc": "^1.1.0", 9 | "esdoc-ecmascript-proposal-plugin": "^1.0.0", 10 | "esdoc-standard-plugin": "^1.0.0", 11 | "eslint": "^6.8.0", 12 | "eslint-plugin-prettier": "^3.1.3", 13 | "prettier": "^2.0.4", 14 | "webpack": "^4.42.1" 15 | }, 16 | "eslintConfig": { 17 | "extends": "eslint:recommended", 18 | "parser": "babel-eslint", 19 | "plugins": [ 20 | "prettier" 21 | ], 22 | "rules": { 23 | "prettier/prettier": "error" 24 | }, 25 | "globals": { 26 | "Deno": true, 27 | "window": true, 28 | "globalThis": true, 29 | "console": true, 30 | "TextEncoder": true, 31 | "TextDecoder": true, 32 | "Uint8Array": true, 33 | "MutationObserver": true, 34 | "Set": true, 35 | "fetch": true 36 | } 37 | }, 38 | "prettier": { 39 | "proseWrap": "always", 40 | "trailingComma": "all" 41 | }, 42 | "scripts": { 43 | "lint": "eslint .", 44 | "pretty": "prettier" 45 | }, 46 | "files": [ 47 | "ui/*.js", 48 | "ui.js" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /cli/generate/controller.js: -------------------------------------------------------------------------------- 1 | import GenerateView from "./view.js"; 2 | import { ejs } from "../assets.js"; 3 | import pascalCase from "https://deno.land/x/case/pascalCase.ts"; 4 | 5 | const { cwd, writeFile } = Deno; 6 | 7 | /** 8 | * `saur generate controller NAME` 9 | * 10 | * This generates a controller class and its test. 11 | */ 12 | export default async function (name, klass, encoder, options, ...actions) { 13 | const className = `${klass}Controller`; 14 | const methods = actions.map((action) => ` ${action}() {}`).join("\n"); 15 | const context = { name, className, methods }; 16 | const controller = ejs("cli/templates/controller.ejs", context); 17 | const test = ejs(`cli/templates/test.ejs`, context); 18 | const needsView = (action) => !action.match(/(:bare)$/); 19 | const writeView = (action) => { 20 | const path = `${name}/${action}`; 21 | const className = pascalCase(path); 22 | 23 | GenerateView(path, className, options, encoder); 24 | }; 25 | 26 | await writeFile( 27 | `${cwd()}/controllers/${name}.js`, 28 | encoder.encode(controller.toString()), 29 | ); 30 | await writeFile( 31 | `${cwd()}/tests/controllers/${name}_test.js`, 32 | encoder.encode(test.toString()), 33 | ); 34 | console.log(`Created ${className} in controllers/${name}.js`); 35 | 36 | actions.filter(needsView).forEach(writeView); 37 | } 38 | -------------------------------------------------------------------------------- /loader.js: -------------------------------------------------------------------------------- 1 | import Asset from "./loader/asset.js"; 2 | // import LoadError from "./loader/error.js"; 3 | 4 | const { readFile } = Deno; 5 | 6 | /** 7 | * Loads files from arbitrary locations, typically a URL, and caches 8 | * them similarly to how Deno caches locally imported JavaScript files. 9 | * The Loader object attempts to extend this functionality to everything 10 | * in Saur, especially in the CLI, allowing template files and static 11 | * assets to be required into the project without needing to have a copy 12 | * of the source code locally. 13 | */ 14 | export default class Loader { 15 | constructor(options = {}) { 16 | this.Processor = options.processor; 17 | this.reader = options.reader || readFile; 18 | this.base = options.base; 19 | } 20 | 21 | process(body) { 22 | const { Processor } = this; 23 | 24 | if (!Processor) { 25 | return body; 26 | } 27 | 28 | const processor = new Processor(body); 29 | 30 | return processor.process(); 31 | } 32 | 33 | async require(path, caching = true) { 34 | const asset = new Asset(path, this.base); 35 | 36 | if (asset.local) { 37 | const file = await this.reader(path); 38 | 39 | return file; 40 | } 41 | 42 | if (caching && asset.cached) { 43 | return asset.body(); 44 | } 45 | 46 | const response = await fetch(asset.url); 47 | const body = await response.text(); 48 | 49 | asset.cache({ body }); 50 | 51 | return body; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /task.js: -------------------------------------------------------------------------------- 1 | import { walkSync } from "https://deno.land/std/fs/walk.ts"; 2 | 3 | /** 4 | * Tasks are user-defined subcommands of the `saur` CLI that are active 5 | * when the user is at the top level of an application directory. They 6 | * are typically created using the `task()` function which is the 7 | * default export of this module. Generally, `Task` classes do not need 8 | * to be instantiated or used direcrlty. 9 | */ 10 | export class Task { 11 | static async all() { 12 | const tasks = []; 13 | try { 14 | for (const { filename } of walkSync(`${Deno.cwd()}/tasks`)) { 15 | if (filename.match(/\.js$/)) { 16 | const exports = await import(filename); 17 | tasks.push(exports.default); 18 | } 19 | } 20 | return tasks; 21 | } catch (e) { 22 | return tasks; 23 | } 24 | } 25 | 26 | static find(command) { 27 | const task = this.all.find((task) => task.name === command); 28 | 29 | if (!task) { 30 | throw new Error(`Invalid command "${command}"`); 31 | } 32 | 33 | return task; 34 | } 35 | 36 | constructor({ name, description, perform }) { 37 | this.name = name; 38 | this.description = description; 39 | this.perform = perform; 40 | } 41 | } 42 | 43 | /** 44 | * Create a new task with the given `name` and `description`, calling 45 | * the function when the task is invoked. 46 | */ 47 | export default function task(name, description, perform) { 48 | return new Task({ name, description, perform }); 49 | } 50 | -------------------------------------------------------------------------------- /plugin.js: -------------------------------------------------------------------------------- 1 | import Application from "./application.js"; 2 | 3 | /** 4 | * Plugins are groupings of code that are imported into and included by 5 | * a host application. This is actually a subclass of `Application` with 6 | * some overrides that make it suitable for mounting within an app. 7 | * Other than that, it's basically the same thing as an Application, you 8 | * can add routes/initializers to it and even include other plugins. 9 | * Routes are not actually added into the application until the user 10 | * mounts the plugin with a call to `mount("some/path", YourPlugin)` in 11 | * the routing DSL. 12 | */ 13 | export default class Plugin extends Application { 14 | static setup = () => {}; 15 | 16 | constructor(name, config = {}) { 17 | super({ [name]: config }); 18 | } 19 | 20 | /** 21 | * Initialize all plugins and run this plugin's initializers in the 22 | * context of the host application. 23 | */ 24 | initialize(app) { 25 | this.log = app.log; 26 | this.plugins.forEach((plugin) => plugin.initialize(app)); 27 | this.initializers.forEach(async (initializer) => { 28 | await initializer(app); 29 | }); 30 | } 31 | 32 | /** 33 | * For plugins, the `setup` method does nothing out-of-box, as 34 | * default app initializers and middleware have already been loaded. 35 | */ 36 | setup() {} 37 | 38 | /** 39 | * Prevent this plugin from running a server on its own. 40 | */ 41 | start() { 42 | throw new Error("Plugins cannot be run on their own."); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /routes/route.js: -------------------------------------------------------------------------------- 1 | import camelCase from "https://deno.land/x/case/camelCase.ts"; 2 | 3 | export default class Route { 4 | constructor({ as, path, controller, action, app }) { 5 | this.app = app; 6 | this.name = camelCase(as); 7 | this.path = path; 8 | this.controller = controller; 9 | this.action = action; 10 | } 11 | 12 | get urlHelperName() { 13 | return `${this.name}URL`; 14 | } 15 | 16 | get pathHelperName() { 17 | return `${this.name}Path`; 18 | } 19 | 20 | /** 21 | * A helper method that resolves the route for this controller, 22 | * action, and given params, and returns an absolute path to the 23 | * resource. 24 | */ 25 | pathHelper(params = {}) { 26 | return this.app.routes.resolve(this.controller, this.action, params); 27 | } 28 | 29 | /** 30 | * A helper method that resolves the route for this controller, 31 | * action, and given params, and returns a fully-qualified URL to the 32 | * resource. 33 | */ 34 | 35 | urlHelper(params = {}, host) { 36 | return this.app.routes.resolve(this.controller, this.action, params, host); 37 | } 38 | 39 | /** 40 | * Define helper methods on another object instance. This makes routes easier 41 | * to access from within templates. 42 | */ 43 | hydrate(instance) { 44 | const host = "//" + instance.request.headers.get("Host"); 45 | 46 | instance[this.pathHelperName] = (params) => this.pathHelper(params); 47 | instance[this.urlHelperName] = (params) => this.urlHelper(params, host); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | const { args } = Deno; 2 | import { parse } from "https://deno.land/std/flags/mod.ts"; 3 | import { readJsonSync } from "https://deno.land/std/fs/read_json.ts"; 4 | 5 | import New from "./cli/new.js"; 6 | import Generate from "./cli/generate.js"; 7 | import Help from "./cli/help.js"; 8 | import Run from "./cli/run.js"; 9 | import Server from "./cli/server.js"; 10 | import Upgrade from "./cli/upgrade.js"; 11 | import Migrate from "./cli/migrate.js"; 12 | import Loader from "./loader.js"; 13 | 14 | let { 15 | _: [command, ...argv], 16 | help, 17 | v, 18 | version, 19 | ...options 20 | } = parse(args); 21 | help = help || options.h || options.help; 22 | 23 | if (help) { 24 | await Help(options, command, ...argv); 25 | Deno.exit(0); 26 | } 27 | 28 | const json = new Loader({ 29 | base: "https://deno.land/x/saur", 30 | reader: readJsonSync, 31 | }); 32 | const config = await json.require("./package.json"); 33 | 34 | if (v || version) { 35 | console.log(`Saur ${config.version}`); 36 | Deno.exit(0); 37 | } 38 | 39 | switch (command) { 40 | case "new": 41 | New(options, ...argv); 42 | break; 43 | case "server": 44 | Server(options); 45 | break; 46 | case "generate": 47 | Generate(options, ...argv); 48 | break; 49 | case "run": 50 | Run(options, ...argv); 51 | break; 52 | case "upgrade": 53 | Upgrade(); 54 | break; 55 | case "migrate": 56 | Migrate(); 57 | break; 58 | case "help": 59 | Help(options, ...argv); 60 | break; 61 | default: 62 | Help(options); 63 | break; 64 | } 65 | -------------------------------------------------------------------------------- /docs/guides/cli.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | path: /guides/cli.html 4 | --- 5 | 6 | # Command-Line Interface 7 | 8 | The `saur` CLI allows easy administration of your app from the Terminal. 9 | 10 | You can create a new app: 11 | 12 | saur new my-app 13 | 14 | Generate some code within it: 15 | 16 | saur model user name:citext password 17 | 18 | Start a web server: 19 | 20 | saur server 21 | 22 | Evaluate code within the context of your app: 23 | 24 | saur run "console.log(App.root)" 25 | /Users/tubbo/my-saur-app 26 | 27 | Or, run any number of custom tasks. For more information on what's 28 | available to you, run: 29 | 30 | saur help 31 | 32 | ## Custom Tasks 33 | 34 | Tasks can be defined in your application under `./tasks`, and mounted in 35 | the `saur` CLI when you're inside your application. For example, given 36 | the following code in **tasks/hello.js**: 37 | 38 | ```javascript 39 | import task from "https://deno.land/x/saur/task.js" 40 | import App from "../index.js" 41 | 42 | export default task("root", "Show the app root", console.log(App.root)) 43 | ``` 44 | 45 | Running `saur help` will show: 46 | 47 | saur [ARGUMENTS] [OPTIONS] 48 | 49 | ... 50 | 51 | Tasks: 52 | new - Generate a new app 53 | generate - Generate code for your app 54 | server - Start the server 55 | run - Evaluate code within your app environment 56 | help - Get help on any command 57 | root - Show the app root 58 | 59 | You can now run `saur root` to run the command within your app 60 | environment: 61 | 62 | $ saur root 63 | /Users/tubbo/my-saur-app 64 | -------------------------------------------------------------------------------- /docs/guides/models.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | path: /guides/models.html 4 | --- 5 | 6 | 7 | # Models 8 | 9 | Models are structured very similar to ActiveRecord. Here's an example of 10 | what a `User` model might look like: 11 | 12 | ```javascript 13 | import Model, { validates, validate } from "https://deno.land/x/saur/model.js" 14 | 15 | export default class User extends Model { 16 | static table = "users" 17 | static validations = [ 18 | validates("name", { presence: true }) 19 | validates("password", { presence: true }) 20 | validate("nameNotFoo") 21 | ] 22 | 23 | nameNotFoo() { 24 | if (this.name === "foo") { 25 | this.errors.add("name", "cannot be 'foo'") 26 | } 27 | } 28 | } 29 | ``` 30 | 31 | You can find a model by its ID like so; 32 | 33 | ```javascript 34 | const user = User.find(1) 35 | ``` 36 | 37 | Or, find it by any other property: 38 | 39 | ```javascript 40 | const userByName = User.findBy({ name: "bar" }) 41 | ``` 42 | 43 | Using [SQL Builder][] under the hood, you can chain query fragments 44 | together to construct a query based on the given data. Saur wraps the 45 | SQL Builder's `Query` object, lazy-loading the data you need when you 46 | ask for it. For example, the following query won't be run immediately: 47 | 48 | ```javascript 49 | const users = User.where({ name: "bar" }) 50 | 51 | users.limit(10) 52 | ``` 53 | 54 | Only when the data is actually needed, like iteration methods, will it be run: 55 | 56 | ```javascript 57 | const names = users.map(user => user.name) // => ["bar"] 58 | ``` 59 | 60 | This doesn't apply for `find` or `findBy`, since those need to return a 61 | full model object, and thus the query will run when called in order to 62 | give you immediate feedback. 63 | 64 | 65 | -------------------------------------------------------------------------------- /routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from "https://deno.land/x/oak/mod.ts"; 2 | import RouteSet from "./routes/route-set.js"; 3 | import MissingRouteError from "./errors/missing-route.js"; 4 | 5 | /** 6 | * Routes are used to collect all routes defined in `RouteSet`s and 7 | * connect them to the `Oak.Router` that actually does the work of 8 | * routing requests to their handlers. 9 | */ 10 | export default class Routes { 11 | constructor(app) { 12 | this.app = app; 13 | this.router = new Router(); 14 | this.set = new RouteSet(this.router, this.app); 15 | this.draw = this.set.draw.bind(this.set); 16 | } 17 | 18 | /* 19 | * Create the AllowedMethods middleware to insert a header based on 20 | * the given routes. 21 | */ 22 | get methods() { 23 | return this.router.allowedMethods(); 24 | } 25 | 26 | /** 27 | * Compile all routes into Oak middleware. 28 | */ 29 | get all() { 30 | return this.router.routes(); 31 | } 32 | 33 | /** 34 | * Find the first matching route given the controller, action, and 35 | * parameters. 36 | */ 37 | resolve(controller, action, params = {}, host = null) { 38 | if (typeof controller === "string") { 39 | return controller; 40 | } 41 | 42 | const keys = Object.keys(params); 43 | const route = this.set.routes.find( 44 | (route) => route.controller === controller && route.action === action, 45 | ); 46 | 47 | if (!route) { 48 | throw new MissingRouteError(controller, action, params); 49 | } 50 | 51 | const path = keys.reduce( 52 | (k, p) => p.replace(`:${k}`, params[k]), 53 | route.path, 54 | ); 55 | 56 | if (host) { 57 | return `${host}/${path}`; 58 | } 59 | 60 | return path; 61 | } 62 | 63 | forEach(iterator) { 64 | return this.set.routes.forEach(iterator); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /mailer.js: -------------------------------------------------------------------------------- 1 | import { SmtpClient } from "https://deno.land/x/smtp/mod.ts"; 2 | 3 | /** 4 | * Mailers send HTML-rendered emails to users based on predefined 5 | * information. They fill the same role as Controllers, except instead 6 | * of handling the request/response cycle of HTTP, they render their 7 | * results to an email message. Mailers use the SMTP configuration found 8 | * in `App.config.smtp` to configure the SMTP client when sending mails, 9 | * and are capable of rendering Views (and, subsequently, Templates) just 10 | * like controllers can. 11 | */ 12 | export default class Mailer { 13 | static layout = "mailer.ejs"; 14 | 15 | /** 16 | * Deliver a given message using the provided args. 17 | */ 18 | static async deliver(app, message, ...args) { 19 | const mailer = new this(app); 20 | const action = mailer[message].bind(mailer); 21 | 22 | await action(...args); 23 | } 24 | 25 | constructor(app) { 26 | this.app = app; 27 | this.config = app.config.mail; 28 | this.smtp = new SmtpClient(); 29 | } 30 | 31 | get request() { 32 | const { hostname, protocol } = this.config; 33 | 34 | return { hostname, protocol }; 35 | } 36 | 37 | /** 38 | * Compile the given view's template using an instance as context, 39 | * then email the rendered HTML given the configuration. 40 | */ 41 | async mail(message = {}, View = null, context = {}) { 42 | const to = message.to || message.bcc; 43 | 44 | if (View) { 45 | const view = new View(this, context); 46 | const result = await view.render(); 47 | this.app.log.info(`Rendered ${View.name}`); 48 | message.content = result.toString(); 49 | } 50 | 51 | await this.smtp.connect(this.config.smtp); 52 | await this.smtp.send(message); 53 | await this.smtp.close(); 54 | 55 | this.app.log.info(`Sent mail to "${to}"`); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /application/database.js: -------------------------------------------------------------------------------- 1 | import Adapter from "./adapter.js"; 2 | import * as PostgreSQL from "https://deno.land/x/postgres/mod.ts"; 3 | import * as MySQL from "https://deno.land/x/mysql/mod.ts"; 4 | import * as SQLite from "https://deno.land/x/sqlite/mod.ts"; 5 | 6 | class Database extends Adapter { 7 | constructor(config = {}, logger) { 8 | super(); 9 | this.config = config; 10 | this.logger = logger; 11 | this.initialize(); 12 | } 13 | 14 | /** 15 | * Run when the adapter is instantiated. 16 | */ 17 | initialize() {} 18 | 19 | /** 20 | * Execute the passed-in SQL query. 21 | */ 22 | exec() {} 23 | } 24 | 25 | export class PostgresAdapter extends Database { 26 | initialize() { 27 | this.client = new PostgreSQL.Client(this.config); 28 | } 29 | 30 | async exec(sql) { 31 | this.logger.debug(`Running Query "${sql}"`); 32 | 33 | await this.client.connect(); 34 | 35 | const result = await this.client.query(sql); 36 | 37 | await this.client.end(); 38 | 39 | return result.rowsOfObjects(); 40 | } 41 | } 42 | 43 | export class MysqlAdapter extends Database { 44 | initialize() { 45 | this.client = new MySQL.Client(this.config); 46 | } 47 | 48 | async exec(sql) { 49 | this.logger.debug(`Running Query "${sql}"`); 50 | 51 | await this.client.connect(); 52 | 53 | const result = await this.client.query(sql); 54 | 55 | await this.client.end(); 56 | 57 | return result.rowsOfObjects(); 58 | } 59 | } 60 | 61 | export class SqliteAdapter extends Database { 62 | async exec(sql) { 63 | const db = await SQLite.open(this.config.database); 64 | const results = db.query(sql); 65 | 66 | await SQLite.save(db); 67 | 68 | return results; 69 | } 70 | } 71 | 72 | Database.adapters = { 73 | postgres: PostgresAdapter, 74 | mysql: MysqlAdapter, 75 | sqlite: SqliteAdapter, 76 | }; 77 | 78 | export default Database; 79 | -------------------------------------------------------------------------------- /callbacks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @example 3 | * @before("valid"); 4 | * generateSlug() { 5 | * this.slug = paramCase(this.name); 6 | * } 7 | * @param method 8 | */ 9 | export function before(...methods) { 10 | return (target, key, descriptor) => { 11 | methods.forEach((method) => { 12 | const original = target[method]; 13 | const callback = descriptor.value; 14 | const value = () => { 15 | callback(); 16 | return original(); 17 | }; 18 | 19 | target.defineProperty(method, { value }); 20 | }); 21 | }; 22 | } 23 | 24 | /** 25 | * @example 26 | * @after("save"); 27 | * deliverConfirmationEmail() { 28 | * UserMailer.deliver("confirmation", { user: this }) 29 | * } 30 | * @param method 31 | */ 32 | export function after(...methods) { 33 | return (target, key, descriptor) => { 34 | methods.forEach((method) => { 35 | const original = target[method]; 36 | const callback = descriptor.value; 37 | const value = () => { 38 | const rv = original(); 39 | 40 | callback(rv); 41 | 42 | return rv; 43 | }; 44 | 45 | target.defineProperty(method, { value }); 46 | }); 47 | }; 48 | } 49 | 50 | /** 51 | * @example 52 | * @around("update"); 53 | * lock(update) { 54 | * this.locked = true; 55 | * const rv = update(); 56 | * this.locked = false; 57 | * 58 | * return rv; 59 | * } 60 | * @param method 61 | */ 62 | export function around(...methods) { 63 | return (target, key, descriptor) => { 64 | methods.forEach((method) => { 65 | const original = target[method]; 66 | const callback = descriptor.value; 67 | const value = () => callback(original); 68 | 69 | target.defineProperty(method, { value }); 70 | }); 71 | }; 72 | } 73 | 74 | /** 75 | * Callbacks can be used on any object 76 | */ 77 | export default { before, after, around }; 78 | -------------------------------------------------------------------------------- /model/migration.js: -------------------------------------------------------------------------------- 1 | import { Query } from "https://deno.land/x/sql_builder/mod.ts"; 2 | 3 | /** 4 | * Migration represents a single migration used when defining the 5 | * database schema. Migrations occur sequentially and the last known 6 | * version is stored in the DB so they won't be re-run. 7 | */ 8 | export default class Migration { 9 | constructor(name, version, app) { 10 | this.name = name; 11 | this.version = parseInt(version); 12 | this.query = new Query(); 13 | this.execute = app.db.exec.bind(app.db); 14 | } 15 | 16 | /** 17 | * Build the query into SQL so it can be executed. 18 | */ 19 | get sql() { 20 | return this.query.build(); 21 | } 22 | 23 | get latestVersion() { 24 | const query = new Query(); 25 | 26 | query 27 | .table("schema_migrations") 28 | .select("version") 29 | .limit(1, 1) 30 | .order("version", "desc") 31 | .build(); 32 | 33 | return query; 34 | } 35 | 36 | get appendVersions() { 37 | const query = new Query(); 38 | 39 | query.table("schema_migrations").insert("version", this.version).build(); 40 | 41 | return query; 42 | } 43 | 44 | /** 45 | * Query for the latest version from the database, and test whether 46 | * this version is higher than the one specified by the migration. If 47 | * so, this migration was already executed. 48 | */ 49 | async executed() { 50 | const rows = await this.execute(this.latestVersion); 51 | const current = parseInt(rows[0].version); 52 | 53 | return current <= this.version; 54 | } 55 | 56 | /** 57 | * Run the specified action and execute the built SQL query 58 | * afterwards. 59 | */ 60 | exec(direction) { 61 | if (this.executed) { 62 | return; 63 | } 64 | 65 | const action = this[direction]; 66 | 67 | action(this.query); 68 | 69 | const value = this.execute(this.sql); 70 | 71 | this.execute(this.appendVersions); 72 | 73 | return value; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /ui.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Front-end application for Saur. 3 | */ 4 | export default class UI { 5 | constructor(context) { 6 | this.require = context; 7 | this.observer = new MutationObserver((records) => this.update(records)); 8 | this.changed = []; 9 | } 10 | 11 | /** 12 | * All filenames in the `./components` dir. 13 | */ 14 | get files() { 15 | return this.require.keys(); 16 | } 17 | 18 | /** 19 | * Require all components in `./components`. 20 | */ 21 | get components() { 22 | return this.files.map((file) => this.require(file).default); 23 | } 24 | 25 | /** 26 | * Run the application's `initialize()` call for the first time, and 27 | * set up the observer for the root element. 28 | */ 29 | start(target) { 30 | this.initialize(target); 31 | this.observer.observe(target, { childList: true, subtree: true }); 32 | } 33 | 34 | /** 35 | * Initializes all components by finding their elements, instantiating 36 | * them, and binding their events. 37 | */ 38 | initialize(target) { 39 | this.components.forEach((Component) => { 40 | const elements = target.querySelectorAll(Component.selector); 41 | 42 | elements.forEach((element) => { 43 | const component = new Component(element); 44 | 45 | Object.entries(Component.events).forEach(([event, methods]) => { 46 | methods.forEach((property) => { 47 | const method = component[property]; 48 | const handler = method.bind(component); 49 | 50 | element.addEventListener(event, handler); 51 | }); 52 | }); 53 | }); 54 | }); 55 | 56 | this.changed = [...this.changed, ...this.observer.takeRecords()]; 57 | } 58 | 59 | /** 60 | * Run when the DOM changes at any point, and re-initializes 61 | * components within the scope of the change. 62 | */ 63 | update(records) { 64 | records.forEach(({ addedNodes }) => { 65 | [...addedNodes].forEach((target) => this.initialize(target)); 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /model/decorators.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Decorator for adding pre-defined validators to a model. 3 | * 4 | * @example 5 | * import Model, { validates } from "https://deno.land/x/saur/model.js"; 6 | * import { titleCase } from "https://deno.land/x/case/mod.ts"; 7 | * 8 | * @validates("name", { presence: true }) 9 | * export default class YourModel extends Model { 10 | * get title() { 11 | * return titleCase(this.name) 12 | * } 13 | * } 14 | * @param string name - Name of the property 15 | * @param Object validations - Validations to add 16 | */ 17 | export function validates(name, validations = {}) { 18 | return (target) => { 19 | console.log(target); 20 | target.validates(name, validations); 21 | }; 22 | } 23 | 24 | /** 25 | * Decorator for adding a custom validator to a model. 26 | * 27 | * @example 28 | * import Model, { validate } from "https://deno.land/x/saur/model.js"; 29 | * 30 | * @validate("nameNotFoo"); 31 | * export default class YourModel extends Model { 32 | * nameNotFoo() { 33 | * if (this.name === "foo") { 34 | * this.errors.add("name", "cannot be foo"); 35 | * } 36 | * } 37 | * } 38 | * @param string method - Method name to run in validations. 39 | */ 40 | export function validate(method) { 41 | return (target) => { 42 | target.validate(method); 43 | }; 44 | } 45 | 46 | export function model(table) { 47 | return (target) => { 48 | target.table = table; 49 | }; 50 | } 51 | 52 | export function association(type, name, Model) { 53 | return (target) => { 54 | target[type][name] = Model; 55 | }; 56 | } 57 | 58 | export function belongsTo(Model, options) { 59 | const name = options.name || Model.paramName; 60 | 61 | return association("belongsTo", name, Model); 62 | } 63 | 64 | export function hasMany(Model, options) { 65 | const name = options.name || Model.tableName; 66 | 67 | return association("hasMany", name, Model); 68 | } 69 | 70 | export function hasOne(Model, options) { 71 | const name = options.name || Model.tableName; 72 | 73 | return association("hasOne", name, Model); 74 | } 75 | -------------------------------------------------------------------------------- /docs/guides/architecture.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | path: /guides/architecture.html 4 | --- 5 | 6 | # Architecture 7 | 8 | Saur is strucutured similarly to other "Web MVC" frameworks like 9 | [Django][] and [Ruby on Rails][]. Each object in your application 10 | represents a different role that's played: 11 | 12 | - **Controllers** are used to respond to requests from the main 13 | application router. After parsing the URL, a new instance of the 14 | Controller is created to fulfill the request. Controllers come 15 | pre-packed with abstractions meant to help you in this effort. 16 | - **Models** encapsulate the database logic with an Object-Relational 17 | Mapper, pre-populating your model objects with the data from the 18 | database and validating input before it's persisted. 19 | - **Views** are objects used to render templates within a given context. 20 | Similar to helpers and presenters in Rails, you can define View 21 | methods and have them automatically appear in the rendered template. 22 | - **Templates** are [EJS][] files that live in `./templates`, and 23 | populated with a `View` object as context when they are rendered from 24 | file. Templates can be rendered as the result of a controller action, 25 | or within another view as a "partial", in which they won't be compiled 26 | with their wrapping layout of choice. 27 | - **Mailers** are similar to controllers, in that they render responses 28 | from Views, but have the responsibility of emailing those responses 29 | over SMTP rather than responding to an HTTP request. A mailer can be 30 | called from anywhere in your application, and you can re-use views 31 | that were created as part of your controllers with mailers. 32 | - **Tasks** are sub-commands of `saur` that pertain to a specific 33 | application. You can write these tasks yourself in the `tasks/` 34 | folder, all tasks 35 | 36 | All of the aforementioned objects have corresponding generators, so to 37 | generate any of them, you can run: 38 | 39 | saur generate [controller|model|view|template|mailer|task] NAME 40 | 41 | Some of these generators come with additional options, which you can 42 | view by adding an `-h` to the command, or by running: 43 | 44 | saur help generate [controller|model|view|template|mailer|task] 45 | -------------------------------------------------------------------------------- /cli/generate.js: -------------------------------------------------------------------------------- 1 | import GenerateModel from "./generate/model.js"; 2 | import GenerateController from "./generate/controller.js"; 3 | import GenerateView from "./generate/view.js"; 4 | import GenerateTemplate from "./generate/template.js"; 5 | import GenerateComponent from "./generate/component.js"; 6 | import pascalCase from "https://deno.land/x/case/pascalCase.ts"; 7 | import { existsSync } from "https://deno.land/std/fs/exists.ts"; 8 | 9 | const { cwd, exit } = Deno; 10 | 11 | /** 12 | * The `saur generate` command is used to generate boilerplate code for 13 | * you to edit later. 14 | */ 15 | export default function Generate(options, type, name, ...args) { 16 | const className = pascalCase(name); 17 | const USAGE = 18 | "Usage: saur generate [model|view|controller|template|component|help] NAME [OPTIONS]"; 19 | const encoder = new TextEncoder(); 20 | const config = "config/server.js"; 21 | const app = existsSync(`${cwd()}/${config}`); 22 | 23 | if (!app) { 24 | console.error(`Error: ${config} not found. Are you in a Saur application?`); 25 | exit(1); 26 | return; 27 | } 28 | 29 | switch (type) { 30 | case "model": 31 | GenerateModel(name, className, encoder, options, ...args); 32 | break; 33 | case "controller": 34 | GenerateController(name, className, encoder, options, ...args); 35 | break; 36 | case "view": 37 | GenerateView(name, className, encoder, options, ...args); 38 | break; 39 | case "template": 40 | GenerateTemplate(name, className, encoder, options, ...args); 41 | break; 42 | case "component": 43 | GenerateComponent(name, className, encoder, options, ...args); 44 | break; 45 | case "resource": 46 | GenerateModel(name, className, encoder, options, ...args); 47 | GenerateController(name, className, encoder, options); 48 | console.log("Add the following to index.js in `App.routes.draw`:"); 49 | console.log(" App.routes.draw(({ resources }) => { "); 50 | console.log(` resources("${name}", ${className}Controller); `); 51 | console.log(" }); "); 52 | 53 | break; 54 | default: 55 | console.log("Invalid generator", type); 56 | console.log(USAGE); 57 | exit(1); 58 | break; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /view/template.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Template reads and compiles a template to render the response for a 3 | * View, using the View as context. Templates have a conventional 4 | * filename, `${path_to_template}.${response_format}.${templating_language}`. 5 | * A "users/show" page for HTML would therefore have a template of 6 | * `users/show.html.ejs`, while the same route for XML would use the 7 | * `users/show.xml.ejs` template. 8 | */ 9 | export default class Template { 10 | constructor(path, format, view) { 11 | const { 12 | app: { 13 | config: { template }, 14 | root, 15 | }, 16 | } = view; 17 | 18 | this.view = view; 19 | this.app = view.app; 20 | this.name = path; 21 | this.format = format; 22 | this.language = "ejs"; 23 | this.ext = `${this.format}.${this.language}`; 24 | this.root = root.replace("file://", ""); 25 | this.path = `${this.root}/templates/${this.name}.${this.ext}`; 26 | this.handler = template.handlers[this.language] || template.handlers.txt; 27 | } 28 | 29 | get layoutName() { 30 | return this.view.layout 31 | ? this.view.layout 32 | : this.app.config.template.layout; 33 | } 34 | 35 | get layout() { 36 | return `${this.root}/templates/layouts/${this.layoutName}.${this.ext}`; 37 | } 38 | 39 | /** 40 | * Compile this template using the template handler. 41 | */ 42 | async compile(path, context) { 43 | try { 44 | const source = await this.handler(path, context); 45 | 46 | return source; 47 | } catch (e) { 48 | throw new Error(`Template "${path}" not compiled: ${e.message}`); 49 | } 50 | } 51 | 52 | /** 53 | * Render this template without its outer layout. 54 | */ 55 | async partial(view) { 56 | const source = await this.compile(this.path, { view }); 57 | 58 | return source; 59 | } 60 | 61 | /** 62 | * Render this template and wrap it in a layout. 63 | */ 64 | async render(view) { 65 | try { 66 | const innerHTML = await this.partial(view); 67 | const outerHTML = await this.compile(this.layout, { innerHTML, view }); 68 | 69 | this.app.log.info( 70 | `Compiled template "${this.name}" with layout "${this.layoutName}".`, 71 | ); 72 | 73 | return outerHTML; 74 | } catch (e) { 75 | this.app.log.error(e.message); 76 | 77 | return e.message; 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /application/defaults.js: -------------------------------------------------------------------------------- 1 | import { renderFileToString } from "https://denopkg.com/tubbo/dejs@fuck-you-github/mod.ts"; 2 | import ReactDOMServer from "https://dev.jspm.io/react-dom/server"; 3 | 4 | const { readFile } = Deno; 5 | const renderJSX = async (path, view) => { 6 | const exports = await import(path); 7 | const jsx = exports.default; 8 | 9 | return ReactDOMServer.renderToStaticMarkup(jsx(view)); 10 | }; 11 | 12 | export default { 13 | // Whether to force SSL connectivity. This just installs another 14 | // middleware into your stack at init time. 15 | forceSSL: false, 16 | 17 | // Deno.ListenOptions passed to the OAK server 18 | server: { 19 | port: 3000, 20 | }, 21 | 22 | // Serve static files 23 | serveStaticFiles: true, 24 | 25 | log: { 26 | level: "INFO", 27 | formatter: "{datetime} [{levelName}] {msg}", 28 | }, 29 | 30 | // Database configuration passed to the client, except the `adapter` 31 | // which is used to find the database adapter to instantiate. 32 | db: { 33 | adapter: "sqlite", 34 | database: "db/development.sqlite", 35 | }, 36 | 37 | hosts: ["localhost"], 38 | 39 | contentSecurityPolicy: null, 40 | cors: {}, 41 | 42 | // Cache configuration passed to the client, except the `enabled` and 43 | // `adapter` options which are used to bypass or instantiate the 44 | // cache, respectively. 45 | cache: { 46 | enabled: false, 47 | adapter: "memory", 48 | url: "redis://localhost:6379", 49 | http: { 50 | expires: 900, 51 | enabled: false, 52 | }, 53 | }, 54 | 55 | // Default environment to "development" 56 | environment: "development", 57 | 58 | mail: { 59 | smtp: { 60 | hostname: null, 61 | username: null, 62 | password: null, 63 | port: 25, 64 | }, 65 | request: { 66 | protocol: "http", 67 | hostname: "localhost:3000", 68 | }, 69 | }, 70 | 71 | template: { 72 | // Default layout that will be passed into all controllers. This can 73 | // be changed on a controller-by-controller basis by setting the 74 | // static property `layout`. 75 | layout: "default", 76 | handlers: { 77 | txt: readFile, 78 | ejs: renderFileToString, 79 | jsx: renderJSX, 80 | }, 81 | }, 82 | 83 | assets: { 84 | enabled: true, 85 | formats: { 86 | js: "text/javascript", 87 | css: "text/css", 88 | }, 89 | }, 90 | 91 | authenticity: { 92 | token: "something-secret", 93 | ignore: [], 94 | hash: "sha1", 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /view/elements/form.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @example 3 | * 4 | * 5 | * 6 | * 7 | * 8 | * 9 | * Submit 10 | * 11 | * @param model 12 | */ 13 | export async function For({ model, children, ...options }) { 14 | const { default: App } = await import(`${Deno.cwd()}/index.js`); 15 | const method = model.persisted ? "PATCH" : "POST"; 16 | const action = App.routes.urlFor(model); 17 | 18 | return ( 19 |
20 | {children} 21 |
22 | ); 23 | } 24 | 25 | export function Label({ name, base, children, ...options }) { 26 | if (base) { 27 | name = `${base}[${name}]`; 28 | } 29 | 30 | return ( 31 | 34 | ); 35 | } 36 | 37 | export function Input({ type, name, base, children, ...options }) { 38 | if (base) { 39 | name = `${base}[${name}]`; 40 | } 41 | 42 | return ( 43 | 44 | {children} 45 | 46 | ); 47 | } 48 | 49 | export function Submit({ value = "commit", children, ...options }) { 50 | return ( 51 | 54 | ); 55 | } 56 | 57 | /** 58 | * High-level component for wrapping groups of fields with a base name. 59 | */ 60 | export function Fields({ name, children }) { 61 | return children.map((child) => child({ base: name })); 62 | } 63 | 64 | /** 65 | * HTML Form helper in JSX. 66 | */ 67 | export default async function Form({ action, method, children, ...options }) { 68 | const { default: App } = await import(`${Deno.cwd()}/index.js`); 69 | const token = App.authenticityToken; 70 | const methodOverride = !method.match(/GET|POST/) ? ( 71 | 72 | ) : ( 73 | "" 74 | ); 75 | 76 | return ( 77 |
78 | {methodOverride} 79 | 80 | {children} 81 |
82 | ); 83 | } 84 | 85 | export async function Button({ to, ...options }) { 86 | return ( 87 |
88 | {children} 89 |
90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /docs/guides/components.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | path: /guides/components.html 4 | --- 5 | 6 | # Front-End Components 7 | 8 | In Saur, Components are objects coupled to HTML elements, which respond 9 | to their events. For the most part, the assumption is that components 10 | are JS code for your existing HTML that is rendered on the server using 11 | the Saur framework. 12 | 13 | A component is instantiated when an element matching its `selector` 14 | appears in the DOM, whether on a page load or asynchronously, and can be 15 | defined by setting the `.selector` attribute on your Component class: 16 | 17 | ```javascript 18 | import Component from "saur/ui/component"; 19 | 20 | class AlertLink extends Component { 21 | show() { 22 | alert(this.element.href); 23 | } 24 | } 25 | 26 | AlertLink.selector = "[data-alert-link]"; 27 | 28 | export default AlertLink 29 | ``` 30 | 31 | To bind events to this component, you can set them on the `.events` 32 | configuration on the class level: 33 | 34 | ```javascript 35 | import Component from "saur/ui/component"; 36 | 37 | class AlertLink extends Component { 38 | show(event) { 39 | event.preventDefault(); 40 | alert(this.element.href); 41 | } 42 | } 43 | 44 | AlertLink.selector = "[data-alert-link]"; 45 | AlertLink.events.click = ["show"]; 46 | 47 | export default AlertLink 48 | ``` 49 | 50 | Now, you can write HTML like the following, and expect the href to be 51 | shown in an alert dialog: 52 | 53 | ```html 54 | Red Alert! 55 | ``` 56 | 57 | ## Decorator Support 58 | 59 | Decorator functions are also supported for those environments which can 60 | handle them. Since this isn't an official part of ECMAScript yet, 61 | decorators are only an alternative syntax sugar to the described methods 62 | above, but can result in much more expressive code. Here's the 63 | aforementioned component rewritten to use decorators: 64 | 65 | ```javascript 66 | import Component from "saur/ui/component"; 67 | import { element, on } from "saur/ui/decorators"; 68 | 69 | @element("[data-alert-link]"); 70 | export default class AlertLink extends Component { 71 | @on("click"); 72 | show(event) { 73 | event.preventDefault(); 74 | alert(this.element.href); 75 | } 76 | } 77 | ``` 78 | 79 | These decorators only serve to manipulate the static `.events` and 80 | `.selector` properties of your Component class, so you don't have to 81 | manipulate them directly. They don't add any additional functionality 82 | that you can't get from a standard Webpack configuration. 83 | -------------------------------------------------------------------------------- /docs/guides/views.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | path: /guides/views.html 4 | --- 5 | 6 | 7 | # Views and Templates 8 | 9 | View objects are used to encapsulate template rendering and data 10 | presentation. Separating the concerns of the "presentation" and "data" 11 | layers, Views take the form of a "presenter" from other applications, 12 | but are coupled to a particular template that it is responsible for 13 | rendering. As such, a view will need to be made alongside a 14 | corresponding template for each response you wish to create. Here's an 15 | example of what the `UserView` might look like from before: 16 | 17 | ```javascript 18 | import View from "https://deno.land/x/saur/view.js"; 19 | 20 | export default UserView extends View { 21 | static template = "user.ejs"; 22 | 23 | get title() { 24 | const { user: { name } } = this.context 25 | 26 | return `@${name}'s Profile` 27 | } 28 | } 29 | ``` 30 | 31 | And your `user.ejs` template: 32 | 33 | ```html 34 |
35 |
36 |

<%= title %>

37 |
38 |
39 | ``` 40 | 41 | Although views need a template in order to render, templates can be 42 | reused between views given the right kind of context. The markup in 43 | `user.ejs`, for example, can be reused by another template like 44 | `users.ejs` when it comes time to render a partial: 45 | 46 | ```html 47 | 52 | ``` 53 | 54 | The partial is rendered without the `UserView` context. To supply a 55 | different view context, you can also render the partial from the view 56 | class itself: 57 | 58 | ```javascript 59 | import View from "https://deno.land/x/saur/view.js"; 60 | 61 | export default UsersView extends View { 62 | static template = "users.ejs"; 63 | 64 | get users() { 65 | return this.context.users.map(user => this.render(UserView, { user }) 66 | } 67 | 68 | get title() { 69 | const { user: { name } } = this.context 70 | 71 | return `@${name}'s Profile` 72 | } 73 | } 74 | ``` 75 | 76 | The template can be cleaned up like so: 77 | 78 | ```html 79 | 82 | ``` 83 | 84 | Additionally, views are not coupled to controllers. They can be used in 85 | mailers as well: 86 | 87 | ```javascript 88 | import Mailer from "https://deno.land/x/saur/mailer.js" 89 | 90 | export default class UserMailer extends Mailer { 91 | confirmation(user) { 92 | const title = "Click here to confirm your account" 93 | this.render(UserView, { user }) 94 | } 95 | } 96 | ``` 97 | 98 | 99 | -------------------------------------------------------------------------------- /docs/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% seo %} 8 | 9 | 48 | 49 | 50 |
51 |
52 | {% if site.title and site.title != page.title %} 53 |

54 | {{ site.title }} 55 |

56 | {% endif %} 57 | 63 |
64 |
65 | {{ content }} 66 |
67 |
68 | This site is open source. {% github_edit_link "Improve this page" %}. 69 |
70 |
71 | 72 | 73 | {% if site.google_analytics %} 74 | 82 | {% endif %} 83 | 84 | 85 | -------------------------------------------------------------------------------- /docs/guides/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | path: /guides/configuration.html 4 | --- 5 | 6 | 7 | # Configuration 8 | 9 | A Saur app is configured in the **index.js** file at the root of your 10 | application, and in the environment-specific files found in 11 | **config/environments**. When initialized, the app will apply 12 | its main configuration that it was instantiated with first, and move on 13 | to environment-specific configuration to apply overrides. Initializers 14 | that you define with `App.initialize` will be run after the 15 | environment-specific configuration is applied. 16 | 17 | ## Settings 18 | 19 | - **forceSSL:** Set this to `true` to redirect non-SSL requests to the 20 | SSL domain. 21 | - **server:** Settings passed into Deno's `http.Server` through Oak. 22 | Defaults to `{ port: 3000 }`. 23 | - **serveStaticFiles:** Whether to serve files from `./public` in the 24 | app root. Defaults to `true`. 25 | - **log:** Settings for the application logger available at `App.log` 26 | - **log.level:** Level of logs to render. Defaults to `"INFO"`. 27 | - **log.formatter:** Log formatter string compatible with Deno's 28 | logger syntax. 29 | - **db:** Configure the database adapter. Additional settings depend on the 30 | adapter you're using. 31 | - **db.adapter:** Adapter name. Defaults to `"postgres"`. 32 | - **hosts:** A list of hosts that the app will respond to. Defaults to 33 | `["localhost"]`. 34 | - **contentSecurityPolicy:** Settings for the `Content-Security-Policy` 35 | header. Defaults to only allowing connections from the local domain. 36 | - **cors:** CORS settings for the `Allow-Access-Control-*` headers. 37 | - **cache:** Configure the cache adapter. Additional settings depend on 38 | the adapter you're using. 39 | - **cache.enabled:** Whether to cache responses. Defaults to `false`. 40 | - **cache.adapter:** Adapter name. Defaults to `"memory"`. 41 | - **cache.http.enabled:** Whether to cache full page responses. 42 | Defaults to `false`. 43 | - **cache.http.expires:** How long before HTTP cache expires in 44 | seconds. Defaults to `900`, which is 15 minutes. 45 | - **mail:** Configure email delivery 46 | - **mail.smtp.hostname:** SMTP server 47 | - **mail.smtp.username:** SMTP username 48 | - **mail.smtp.password:** SMTP password 49 | - **mail.smtp.port:** SMTP port. Defaults to `25` 50 | - **mail.request.protocol:** The protocol name (http or https) that 51 | is used when generating paths for email. Defaults to `"http"`. 52 | - **mail.request.hostname:** Default host for emails. Defaults to 53 | `"localhost"`. 54 | - **template:** Configure template handling 55 | - **template.layout:** Default layout for templates. Default: `"default"` 56 | - **template.handlers:** Map of template handlers that the 57 | Template class will load, with the keys as their extensions and 58 | the values being the function used to render the template. 59 | - **assets:** Asset compilation 60 | - **assets.enabled:** Enable per-request Webpack compilation 61 | - **assets.matcher:** Regex for finding assets to load from 62 | Webpack. Defaults to `.js` and `.css` files only. 63 | -------------------------------------------------------------------------------- /controller.js: -------------------------------------------------------------------------------- 1 | import each from "https://deno.land/x/lodash/each.js"; 2 | import ActionMissingError from "./errors/action-missing.js"; 3 | 4 | export default class Controller { 5 | static get name() { 6 | return `${this}`.split(" ")[1]; 7 | } 8 | 9 | /** 10 | * Perform a request using an action method on this controller. 11 | */ 12 | static perform(action, app) { 13 | return async (context) => { 14 | try { 15 | const controller = new this(context, app); 16 | const method = controller[action]; 17 | 18 | if (!method) { 19 | throw new ActionMissingError(this.name, action); 20 | } 21 | 22 | const handler = method.bind(controller); 23 | const params = context.request.params; 24 | 25 | app.log.info(`Performing ${this.name}#${action}`); 26 | await handler(params); 27 | } catch (e) { 28 | app.log.error(e); 29 | context.response.body = e.message; 30 | context.response.status = 500; 31 | context.response.headers.set("Content-Type", "text/html"); 32 | } 33 | }; 34 | } 35 | 36 | constructor(context, app) { 37 | this.request = context.request; 38 | this.response = context.response; 39 | this.status = 200; 40 | this.headers = { 41 | "Content-Type": "text/html; charset=utf-8", 42 | }; 43 | this.app = app; 44 | this.routes = app.routes; 45 | this.initialize(); 46 | } 47 | 48 | /** 49 | * Executed when the controller is instantiated, prior to the request 50 | * being fulfilled. 51 | */ 52 | initialize() {} 53 | 54 | /** 55 | * All methods on this controller that aren't defined on the 56 | * superclass are considered actions. 57 | */ 58 | get actions() { 59 | return Object.keys(this).filter( 60 | (key) => 61 | typeof this[key] === "function" && typeof super[key] === "undefined", 62 | ); 63 | } 64 | 65 | get format() { 66 | return this.request.accepts()[0].replace("text/", ""); 67 | } 68 | 69 | /** 70 | * Prepare the response for rendering by setting its status and 71 | * headers based on the information in the controller. 72 | */ 73 | prepare() { 74 | this.response.status = this.status; 75 | const bytes = encodeURIComponent(this.response.body).match(/%[89ABab]/g); 76 | const length = this.response.body.length + (bytes ? bytes.length : 0); 77 | this.headers["Content-Length"] = length; 78 | 79 | each(this.headers, (value, header) => 80 | this.response.headers.set(header, value), 81 | ); 82 | } 83 | 84 | /** 85 | * Render the given view's template using an instance as context. 86 | */ 87 | async render(View, context = {}) { 88 | const view = new View(this, context); 89 | const result = await view.render(); 90 | const html = result.toString(); 91 | 92 | this.response.body = html; 93 | this.headers["Content-Type"] = this.request.accepts()[0]; 94 | 95 | this.prepare(); 96 | this.app.log.info(`Rendered ${View.name}`); 97 | } 98 | 99 | /** 100 | * Redirect to an entirely new location 101 | */ 102 | redirect(action, options) { 103 | const controller = options.controller || this; 104 | const params = options.params || {}; 105 | const url = this.app.routes.resolve(controller, action, params); 106 | this.status = 301; 107 | this.headers["Location"] = url; 108 | this.response.body = `You are being redirected`; 109 | 110 | this.app.log.info(`Redirecting to ${url} as ${this.status}`); 111 | this.prepare(); 112 | } 113 | 114 | /** 115 | * Return an empty response with a status code. 116 | * 117 | * @param number status - HTTP status code 118 | */ 119 | head(status) { 120 | this.status = status; 121 | this.prepare(); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /application.js: -------------------------------------------------------------------------------- 1 | import * as path from "https://deno.land/std/path/mod.ts"; 2 | import * as log from "https://deno.land/std/log/mod.ts"; 3 | import { Application as Oak } from "https://deno.land/x/oak/mod.ts"; 4 | import Routes from "./routes.js"; 5 | import Database from "./application/database.js"; 6 | import Cache from "./application/cache.js"; 7 | import DEFAULTS from "./application/defaults.js"; 8 | import Token from "./application/token.js"; 9 | 10 | import EnvironmentConfig from "./application/initializers/environment-config.js"; 11 | import DefaultMiddleware from "./application/initializers/default-middleware.js"; 12 | import SetupAssets from "./application/initializers/setup-assets.js"; 13 | 14 | import MissingRoute from "./application/middleware/missing-route.js"; 15 | 16 | export default class Application { 17 | constructor(config = {}) { 18 | this.config = { ...DEFAULTS, ...config }; 19 | this.oak = new Oak(); 20 | this.routes = new Routes(this); 21 | this.root = path.dirname(this.config.root || Deno.cwd()); 22 | this.initializers = []; 23 | this.plugins = []; 24 | this.setup(); 25 | } 26 | 27 | /** 28 | * Run the code given in the callback when the app initializes. 29 | */ 30 | initializer(Initializer) { 31 | this.initializers.push(Initializer); 32 | } 33 | 34 | /** 35 | * Add routes, initializers, and configuration from a plugin. 36 | */ 37 | include(plugin) { 38 | this.plugins.push(plugin); 39 | } 40 | 41 | /** 42 | * Append an application middleware function to the Oak stack. 43 | * Application middleware functions apply an additional argument, the 44 | * current instance of the application. 45 | */ 46 | use(middleware) { 47 | const appified = (context, next) => middleware(context, next, this); 48 | 49 | this.oak.use(appified); 50 | } 51 | 52 | /** 53 | * Run immediately after instantiation, this is responsible for 54 | * setting up the list of default initializers prior to any other 55 | * initializers getting loaded. 56 | */ 57 | setup() { 58 | this.initializer(EnvironmentConfig); 59 | this.initializer(DefaultMiddleware); 60 | this.initializer(SetupAssets); 61 | } 62 | 63 | /** 64 | * Run all initializers for the application. 65 | */ 66 | async initialize() { 67 | this.log = await this._setupLogging(); 68 | 69 | this.log.info("Initializing Saur application"); 70 | this.plugins.forEach((plugin) => plugin.initialize(this)); 71 | this.initializers.forEach(async (init) => { 72 | await init(this); 73 | }); 74 | } 75 | 76 | deliver(Mailer, action, ...options) { 77 | return Mailer.deliver(this, action, ...options); 78 | } 79 | 80 | /** 81 | * Apply routing and start the application server. 82 | */ 83 | async start() { 84 | this.oak.use(this.routes.all); 85 | this.oak.use(this.routes.methods); 86 | this.use(MissingRoute); 87 | 88 | this.log.info( 89 | `Starting application server on port ${this.config.server.port}`, 90 | ); 91 | 92 | await this.oak.listen(this.config.server); 93 | } 94 | 95 | /** 96 | * Authenticity token for the current time and secret key base. 97 | */ 98 | get authenticityToken() { 99 | return new Token(new Date(), this.config.authenticity); 100 | } 101 | 102 | /** 103 | * Database connection for the application. 104 | */ 105 | get db() { 106 | const Adapter = Database.adapt(this.config.db.adapter); 107 | 108 | return new Adapter(this.config.db, this.log); 109 | } 110 | 111 | /** 112 | * Cache database connection for the application. 113 | */ 114 | get cache() { 115 | const Adapter = Cache.adapt(this.config.cache.adapter); 116 | 117 | return new Adapter(this.config.cache, this.log); 118 | } 119 | 120 | async _setupLogging() { 121 | const { 122 | log: { level, formatter }, 123 | } = this.config; 124 | 125 | await log.setup({ 126 | handlers: { 127 | default: new log.handlers.ConsoleHandler(level, { formatter }), 128 | }, 129 | loggers: { 130 | default: { 131 | level: level, 132 | handlers: ["default"], 133 | }, 134 | }, 135 | }); 136 | 137 | return log.getLogger(); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /model/relation.js: -------------------------------------------------------------------------------- 1 | import { Query } from "https://deno.land/x/sql_builder/mod.ts"; 2 | import each from "https://deno.land/x/lodash/each.js"; 3 | 4 | /** 5 | * An extension of `SQLBuilder.Query`, Relations are used to both build 6 | * new queries from fragments and to return the results of that query 7 | * when it is needed. 8 | */ 9 | export default class Relation extends Query { 10 | constructor(model) { 11 | super(); 12 | this.model = model; 13 | this.db = model.app.db; 14 | this.table(this.model.table); 15 | this.filters = {}; 16 | } 17 | 18 | /** 19 | * Compile this query to SQL. 20 | */ 21 | get sql() { 22 | if (!this._fields) { 23 | this.select("*"); 24 | } 25 | 26 | return this.build(); 27 | } 28 | 29 | /** 30 | * Perform the compiled query specified by this object. 31 | */ 32 | run() { 33 | const result = this.db.exec(this.sql); 34 | 35 | if (!Array.isArray(result)) { 36 | return result; 37 | } 38 | 39 | result.map((row) => new this.model(row)); 40 | } 41 | 42 | /** 43 | * Return the first record in the result set. 44 | */ 45 | get first() { 46 | const records = this.run(); 47 | 48 | return records[0]; 49 | } 50 | 51 | /** 52 | * Return the last record in the result set. 53 | */ 54 | get last() { 55 | const records = this.run(); 56 | 57 | return records[records.length]; 58 | } 59 | 60 | /** 61 | * Find the length of all returned records in the result. 62 | */ 63 | get length() { 64 | return this.run().length; 65 | } 66 | 67 | /** 68 | * Perform a SQL COUNT() query with the given parameters and return 69 | * the result. 70 | */ 71 | get count() { 72 | this.select("count(*)"); 73 | 74 | return this.run(); 75 | } 76 | 77 | /** 78 | * Override where() from `SQLBuilder.Query` to save off all filters 79 | * specified the object. 80 | */ 81 | where(query = {}, ...context) { 82 | if (typeof query === "string") { 83 | return super.where(query, ...context); 84 | } 85 | 86 | this.filters = { ...this.filters, ...query }; 87 | 88 | each(query, (value, param) => { 89 | if (typeof value === "function") { 90 | super.where(param, ...value()); 91 | } else { 92 | super.where(param, "=", value()); 93 | } 94 | }); 95 | 96 | return this; 97 | } 98 | 99 | /** 100 | * Perform the query and iterate over all of its results. 101 | */ 102 | forEach(iterator) { 103 | const records = this.run(); 104 | 105 | return records.forEach(iterator); 106 | } 107 | 108 | /** 109 | * Perform the query and iterate over all of its results, returning a 110 | * new Array of each iteration's return value. 111 | */ 112 | map(iterator) { 113 | const records = this.run(); 114 | 115 | return records.map(iterator); 116 | } 117 | 118 | /** 119 | * Perform the query and iterate over all of its results, returning a 120 | * new memoized object based on the logic performed in each iteration. 121 | */ 122 | reduce(iterator, memo) { 123 | const records = this.run(); 124 | 125 | return records.reduce(iterator, memo); 126 | } 127 | 128 | /** 129 | * Perform the query and iterate over all of its results, returning a 130 | * new Array containing the records for which the iteration's return 131 | * value was truthy. 132 | */ 133 | filter(iterator) { 134 | const records = this.run(); 135 | 136 | return records.filter(iterator); 137 | } 138 | 139 | /** 140 | * Perform the query and iterate over all of its results, returning 141 | * `true` if the given record exists within it. The record's ID 142 | * property is used to evaluate this. 143 | */ 144 | contains(record) { 145 | return this.map((record) => record.id).contains(record.id); 146 | } 147 | 148 | /** 149 | * Create a new model that would appear in this query. 150 | */ 151 | create(attributes = {}) { 152 | return this.model.create({ ...attributes, ...this.filters }); 153 | } 154 | 155 | /** 156 | * Instantiate a new model that would appear in this query. 157 | */ 158 | build(attributes = {}) { 159 | return new this.model({ ...attributes, ...this.filters }); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /cli/new.js: -------------------------------------------------------------------------------- 1 | import { ejs } from "./assets.js"; 2 | import Loader from "../loader.js"; 3 | import { titleCase } from "https://deno.land/x/case/mod.ts"; 4 | import { encode, decode } from "https://deno.land/std/encoding/utf8.ts"; 5 | import { readJson } from "https://deno.land/std/fs/read_json.ts"; 6 | 7 | const { mkdir, writeFile, run } = Deno; 8 | const packages = [ 9 | "mini-css-extract-plugin", 10 | "css-loader", 11 | "eslint-plugin-prettier", 12 | "eslint", 13 | "babel-eslint", 14 | "stylelint-config-recommended", 15 | "webpack-cli", 16 | ]; 17 | 18 | const loader = new Loader({ base: "https://deno.land/x/saur" }); 19 | const require = loader.require.bind(loader); 20 | 21 | export default async function New(options, name) { 22 | let errors; 23 | let command; 24 | 25 | try { 26 | const title = titleCase(name); 27 | const app = await require(`cli/generate/templates/application.js`); 28 | const server = await require(`cli/generate/templates/server.js`); 29 | const webpack = await require(`cli/generate/templates/webpack.js`); 30 | const layout = await ejs(`cli/generate/templates/layout.ejs`, { 31 | title, 32 | }); 33 | const env = await require(`cli/generate/templates/env-config.js`); 34 | const ui = await require(`cli/generate/templates/ui.js`); 35 | const css = await require(`cli/generate/templates/ui.css`); 36 | 37 | console.log(`Creating new application '${name}'...`); 38 | 39 | //throw new Error(app); 40 | 41 | await mkdir(name); 42 | await mkdir(`${name}/bin`); 43 | await mkdir(`${name}/controllers`); 44 | await mkdir(`${name}/config`); 45 | await mkdir(`${name}/config/environments`); 46 | await mkdir(`${name}/models`); 47 | await mkdir(`${name}/mailers`); 48 | await mkdir(`${name}/templates`); 49 | await mkdir(`${name}/templates/layouts`); 50 | await mkdir(`${name}/tests`); 51 | await mkdir(`${name}/tests/controllers`); 52 | await mkdir(`${name}/tests/models`); 53 | await mkdir(`${name}/tests/mailers`); 54 | await mkdir(`${name}/tests/views`); 55 | await mkdir(`${name}/views`); 56 | await mkdir(`${name}/src`); 57 | await writeFile(`${name}/index.js`, encode(app)); 58 | await writeFile(`${name}/webpack.config.js`, encode(webpack)); 59 | await writeFile(`${name}/config/server.js`, encode(server)); 60 | await writeFile( 61 | `${name}/templates/layouts/default.html.ejs`, 62 | encode(layout.toString()), 63 | ); 64 | await writeFile( 65 | `${name}/config/environments/development.js`, 66 | encode(decode(env)), 67 | ); 68 | await writeFile(`${name}/config/environments/test.js`, encode(env)); 69 | await writeFile( 70 | `${name}/config/environments/production.js`, 71 | encode(decode(env)), 72 | ); 73 | await writeFile(`${name}/index.js`, encode(app)); 74 | await writeFile(`${name}/src/index.js`, encode(ui)); 75 | await writeFile(`${name}/src/index.css`, encode(css)); 76 | 77 | console.log("Installing dependencies..."); 78 | 79 | command = run({ 80 | cmd: [ 81 | "deno", 82 | "install", 83 | "--unstable", 84 | `--allow-read=./${name}`, 85 | `--allow-write=./${name}`, 86 | "--allow-net", 87 | "--root", 88 | `./${name}`, 89 | "--name", 90 | "server", 91 | `${name}/config/server.js`, 92 | ], 93 | }); 94 | errors = await command.errors; 95 | await command.status(); 96 | 97 | if (!errors) { 98 | console.log("Installing frontend dependencies..."); 99 | command = run({ cmd: ["yarn", "init", "-yps"], cwd: name }); 100 | errors = await command.errors; 101 | await command.status(); 102 | } 103 | 104 | if (!errors) { 105 | command = run({ 106 | cmd: ["yarn", "add", "webpack", "-D", "-s", ...packages], 107 | cwd: name, 108 | }); 109 | errors = await command.errors; 110 | await command.status(); 111 | } 112 | 113 | if (errors) { 114 | throw new Error(`Error installing dependencies: ${errors}`); 115 | } 116 | 117 | const original = await readJson(`${Deno.cwd()}/${name}/package.json`); 118 | const config = { ...original }; 119 | 120 | config.scripts = { 121 | build: "webpack", 122 | start: "saur server", 123 | }; 124 | config.eslintConfig = { 125 | extends: ["eslint:recommended", "prettier"], 126 | parser: "babel-eslint", 127 | env: { browser: true }, 128 | }; 129 | config.stylelint = { 130 | extends: "stylelint-config-recommended", 131 | }; 132 | 133 | writeFile(`${name}/package.json`, encode(JSON.stringify(config, null, 2))); 134 | 135 | console.log(`Application '${name}' has been created!`); 136 | Deno.exit(0); 137 | } catch (e) { 138 | console.error(`Application '${name}' failed to create:`); 139 | console.error(e); 140 | Deno.exit(1); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /view.js: -------------------------------------------------------------------------------- 1 | import reduce from "https://deno.land/x/lodash/reduce.js"; 2 | import Template from "./view/template.js"; 3 | 4 | /** 5 | * A decorator for defining the `template` of a given view. 6 | */ 7 | export function template(name) { 8 | return (target) => { 9 | target.template = name; 10 | }; 11 | } 12 | 13 | /** 14 | * View encapsulates the presentation code and template rendering for a 15 | * given UI. 16 | */ 17 | export default class View { 18 | /** 19 | * Optional configuration for this View's template. This is the 20 | * template file that will be rendered when the `render()` method is 21 | * called on this View, out-of-box. 22 | * 23 | * @return string 24 | */ 25 | static template = null; 26 | 27 | static get name() { 28 | return `${this}`.split(" ")[1]; 29 | } 30 | 31 | constructor(controller, context = {}) { 32 | this.context = context; 33 | this.controller = controller; 34 | this.app = controller.app; 35 | this.request = controller.request; 36 | this.template = new Template( 37 | this.constructor.template, 38 | controller.format, 39 | this, 40 | ); 41 | this.urlFor = this.app.routes.resolve.bind(this.app.routes); 42 | 43 | Object.entries(this.context).forEach((value, key) => (this[key] = value)); 44 | this.app.routes.forEach((route) => route.hydrate(this)); 45 | this.initialize(); 46 | } 47 | 48 | /** 49 | * Called when the `View` is instantiated, this method can be 50 | * overridden in your class to provide additional setup functionality 51 | * for the view. Views are instantiated when they are rendered. 52 | */ 53 | initialize() {} 54 | 55 | cache(key, options = {}, fresh) { 56 | return this.app.cache.fetch(key, options, fresh); 57 | } 58 | 59 | /** 60 | * Render the given View's template as a partial within this View, 61 | * using this View as context. You can also pass in other context as 62 | * the last argument to this method. 63 | */ 64 | async partial(View, context = {}) { 65 | const view = new View(this, { ...this.context, ...context }); 66 | const result = await view.toHTML(); 67 | 68 | this.app.log.info(`Rendering partial ${view.template.path}`); 69 | 70 | return result.toString(); 71 | } 72 | 73 | /** 74 | * Render this View's template as a String of HTML. 75 | * 76 | * @return string 77 | */ 78 | toHTML() { 79 | return this.template.partial(this); 80 | } 81 | 82 | /** 83 | * Render this View's template as the response to a Controller 84 | * request. This method can be overridden to return a String of HTML 85 | * or JSX, rather than use the configured template. 86 | * 87 | * @return string 88 | */ 89 | render() { 90 | return this.template.render(this); 91 | } 92 | 93 | /** 94 | * Render a hash of options as HTML attributes. 95 | */ 96 | htmlAttributes(options = {}, prefix = null) { 97 | return reduce( 98 | options, 99 | (value, option, memo) => { 100 | if (typeof value === "object") { 101 | value = this.htmlAttributes(value, option); 102 | } 103 | 104 | if (prefix) { 105 | option = `${prefix}-${option}`; 106 | } 107 | 108 | return `${memo} ${option}="${value}"`; 109 | }, 110 | "", 111 | ); 112 | } 113 | 114 | /** 115 | * Render a `
` tag and hidden fields to verify authenticity 116 | * token and make PATCH/PUT/DELETE requests. 117 | */ 118 | formTag({ action, method, ...options }) { 119 | const attributes = this.htmlAttributes(options); 120 | const token = this.app.authenticityToken; 121 | 122 | if (method === "GET" && method === "POST") { 123 | return ``; 124 | } 125 | 126 | return ` 127 | 128 | 129 | `; 130 | } 131 | 132 | formFor({ model = null, action = null, method = null, ...options }) { 133 | action = model ? this.urlFor(model) : action; 134 | method = method || (model && model.persisted ? "PATCH" : "POST"); 135 | 136 | return this.formTag({ action, method, ...options }); 137 | } 138 | 139 | /** 140 | * Render an `` tag pointing to a specific route. You can use 141 | * `urlFor` syntax or a route helper in the `href` argument. 142 | */ 143 | linkTo(text, href, options) { 144 | const attributes = this.htmlAttributes(options); 145 | const url = this.urlFor(href); 146 | 147 | return `${text}`; 148 | } 149 | 150 | get csrfMetaTag() { 151 | const token = this.app.authenticityToken; 152 | 153 | return ``; 154 | } 155 | 156 | get endFormTag() { 157 | return "
"; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /application/cache.js: -------------------------------------------------------------------------------- 1 | import Adapter from "./adapter.js"; 2 | import { connect } from "https://denopkg.com/keroxp/deno-redis/mod.ts"; 3 | 4 | /** 5 | * Cache is the base class for app/http cache adapters. It provides 6 | * methods for accessing the cache both for external users and 7 | * subclasses, as well as the typical `Adapter` functionality: A 8 | * standard API for configuring and connecting to the backend cache 9 | * store. 10 | */ 11 | class Cache extends Adapter { 12 | constructor(config = {}, log) { 13 | super(config); 14 | this.log = log; 15 | this.config = config; 16 | this.keys = new Set(); 17 | this.initialize(); 18 | } 19 | 20 | /** 21 | * Executed after instantiation for subclassing adapters which need to 22 | * connect to a client or set up configuration. 23 | */ 24 | initialize() {} 25 | 26 | /** 27 | * Check the set for the given key. This can be overridden in the 28 | * cache store to actually make a DB query if that's more accurate. 29 | * 30 | * @return boolean 31 | */ 32 | includes(key) { 33 | return this.keys.has(key); 34 | } 35 | 36 | /** 37 | * Determine whether HTTP caching is enabled. 38 | * 39 | * @return boolean 40 | */ 41 | get httpEnabled() { 42 | return this.config.enabled && this.config.http.enabled; 43 | } 44 | 45 | /** 46 | * Cache Upsertion. This method first checks if the given `key` is 47 | * available in the cache (since it's the cache store's 48 | * responsibility to expire keys in the cache when needed), and if so, 49 | * reads the item from the cache and returns it. Otherwise, it calls 50 | * the provided `fresh()` function to write data to the cache. 51 | * 52 | * @return string 53 | */ 54 | fetch(key, options = {}, fresh) { 55 | if (this.includes(key)) { 56 | this.log.info(`Reading "${key}" from cache`); 57 | return this.read(key, (options = {})); 58 | } else { 59 | this.log.info(`Writing new cache entry for "${key}"`); 60 | this.keys.add(key); 61 | return this.write(key, options, fresh()); 62 | } 63 | } 64 | 65 | /** 66 | * HTTP Caching. Runs `this.fetch()` on a key as determined by the URL 67 | * and ETag of the given request. The fetch method will determine if 68 | * this cache needs to be freshened. It calls a function with 3 data 69 | * points: `status`, `headers`, and `body`, and that function is used 70 | * in the middleware to determine the response. This allows the 71 | * middleware to handle the actual HTTP logic, while this method is 72 | * entirely devoted to manipulating the cache storage. 73 | */ 74 | http(url, freshen, context, send) { 75 | const { 76 | http: { expires }, 77 | } = this.config; 78 | const etag = context.response.headers.get("ETag"); 79 | const json = this.fetch(`${url}|${etag}`, { expires }, () => { 80 | freshen(); 81 | 82 | const status = context.response.status; 83 | const body = context.response.body; 84 | let headers = {}; 85 | 86 | context.response.headers.forEach((v, h) => (headers[h] = v)); 87 | 88 | return JSON.stringify({ status, headers, body }); 89 | }); 90 | 91 | send(JSON.parse(json)); 92 | } 93 | 94 | /** 95 | * This method should be overridden in the adapter to pull data out of 96 | * the cache. It takes a `key` and an `options` hash. 97 | * 98 | * @return string 99 | */ 100 | read() {} 101 | 102 | /** 103 | * This method should be overridden in the adapter to write data into 104 | * the cache. It takes a `key`, `value`, and an `options` hash which 105 | * typically includes the `expires` property. The value is then 106 | * returned back to the user to keep a consistent API with `read()`. 107 | * 108 | * @return string 109 | */ 110 | write() {} 111 | } 112 | 113 | /** 114 | * Cache store for Redis, connnecting to the server specified by the 115 | * `hostname` and `port` options in your app configuration. 116 | */ 117 | export class RedisCache extends Cache { 118 | async initialize() { 119 | const { hostname, port } = this.config; 120 | this.client = await connect({ hostname, port }); 121 | } 122 | 123 | /** 124 | * Read a key from the Redis cache. 125 | * 126 | * @return string 127 | */ 128 | async read(key) { 129 | const value = await this.client.get(key); 130 | 131 | return value; 132 | } 133 | 134 | /** 135 | * Use setex() when an expire key is given, otherwise store it 136 | * permanently with set(). 137 | * 138 | * @return string 139 | */ 140 | async write(key, value, { expire = null }) { 141 | if (expire) { 142 | await this.client.setex(key, expire, value); 143 | } else { 144 | await this.client.set(key, value); 145 | } 146 | 147 | return value; 148 | } 149 | } 150 | 151 | /** 152 | * An in-memory cache store used for testing and development. Data is 153 | * stored in the heap of the program in Deno, and flushed after the 154 | * server shuts down. 155 | */ 156 | export class MemoryCache extends Cache { 157 | initialize() { 158 | this.data = {}; 159 | } 160 | 161 | read(key) { 162 | return this.data[key].value; 163 | } 164 | 165 | write(key, value, { expire = null }) { 166 | this.data[key] = { value, expire }; 167 | 168 | return value; 169 | } 170 | 171 | /** 172 | * Check if a given key exists in the `data` hash. If so, also check 173 | * whether the current time is past the `expire` time set on the 174 | * entry, if the key was set to expire at any point. 175 | */ 176 | includes(key) { 177 | const entry = this.data[key]; 178 | const now = new Date(); 179 | 180 | if (!entry) { 181 | return false; 182 | } 183 | 184 | if (entry.expire === null) { 185 | return true; 186 | } 187 | 188 | return now <= entry.expire; 189 | } 190 | } 191 | 192 | Cache.adapters = { 193 | redis: RedisCache, 194 | memory: MemoryCache, 195 | }; 196 | 197 | export default Cache; 198 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | path: / 4 | --- 5 | 6 | [Deno Saur][] is a web application framework optimized for rapid 7 | development. Its goal is to provide a full set of features for creating 8 | monolithic, server-side applications with [Deno][]. 9 | 10 | ## Features 11 | 12 | - **Security:** Deno Saur uses the latest advancements to make sure your 13 | apps stay safe. It's also running on Deno and Rust's extremely stable 14 | runtime, meaning you're protected from lower-level issues. 15 | - **Immediacy:** When you need to build something *now*, Saur includes a 16 | complete set of command-line generators to quickly scaffold an 17 | application. 18 | - **Persistence:** Saur makes dealing with a database easier by making 19 | use of the [SQL Builder][] query DSL, as well as pluggable adapters 20 | and migrations for schema updates. 21 | - **Modularity:** Saur was designed from the ground up to be a 22 | decoupled, modular architecture. This is to promote reusability of 23 | your objects within the framework and outside of it. Almost anything 24 | from Saur's core codebase can be `export`-ed and used without 25 | depending on the full framework. 26 | - **Modernity:** Use the latest features of JavaScript without pulling 27 | your hair out. Deno, and Saur, have first-class support for the latest 28 | features in the ECMAScript standard, as well as TypeScript. Saur 29 | includes decorators for TypeScript users that make writing models, 30 | views, and components a breeze. 31 | - **Familiarity:** Saur looks and feels like the technology you already 32 | know, without the overhead of learning an additional language. Learn 33 | how writing your backend application in JavaScript can be just as 34 | expressive, if not more so, than in PHP, Ruby, or Python. 35 | 36 | ## Installation 37 | 38 | To install Deno Saur, make sure you have [Deno][] installed, then run: 39 | 40 | ```bash 41 | deno install --unstable --allow-net --allow-env --allow-read=. --allow-write --allow-run --name saur https://deno.land/x/saur/cli.js 42 | ``` 43 | 44 | This will install the CLI to **~/.deno/bin/**, and give it permissions 45 | to read and write to the filesystem, as well as run commands. You can 46 | define which directory it has write access to by passing that 47 | directory into the command: 48 | 49 | ```bash 50 | deno install --allow-read=. --allow-write=~/Code --allow-run saur https://deno.land/x/saur/cli.js 51 | ``` 52 | 53 | Once everything is installed and compiled, you'll want to have the 54 | `saur` command in your `$PATH` if you haven't done so already: 55 | 56 | ```bash 57 | export PATH=~/.deno/bin:$PATH 58 | ``` 59 | 60 | You can now create your first application by running: 61 | 62 | ```bash 63 | saur new my-first-app 64 | ``` 65 | 66 | This command will create a new directory and generate some boilerplate 67 | files you'll need for Saur to work. It will also install the 68 | `bin/server` script for easily starting the application server, which is 69 | used by `saur server` to run your application. This is due to Deno's 70 | security model. The `saur` CLI has no network access, meaning it can't 71 | make outbound calls to the Internet, however, it has read/write access 72 | to your entire filesystem and the ability to run commands. The `bin/server` 73 | script does have network access, but only the ability to write to the 74 | filesystem within its own directory, and no ability to run arbitrary 75 | commands. This means that even if you download a malicious plugin or 76 | module, it won't be able to change any information outside of your app, 77 | so you can isolate and contain its impact. The most it can do is affect 78 | your actual application code and make outbound calls to the Internet, 79 | which is still bad, but not as bad as losing your identity. 80 | 81 | ## Why? 82 | 83 | In JavaScript backend development, there aren't very many resources for 84 | a fully-featured web application framework. While smaller libraries can 85 | be cobbled together to make an application, there's nothing that can get 86 | you started as quickly as something like Rails. For many developers, 87 | running `rails new` is a lot easier than setting up a project with 88 | `yarn` and including all the dependencies you'll need to get started. In 89 | addition, once you get a sufficient framework of sorts going, you still 90 | don't have any generators or a common way of running tasks that can be 91 | exported and modularized. The result is a lot of projects out there are 92 | difficult to navigate, which makes developing on them much harder. 93 | 94 | Saur attempts to solve these problems by taking a lot of what we've 95 | learned from Rails, Django, and other "web MVC" frameworks and applying 96 | them in the world of server-side JavaScript. By imitating their 97 | successes, and addressing some of their faults, we're ensuring that 98 | backend applications can be made quickly, easily, and securely in 99 | JavaScript without having to reach for additional tools. 100 | 101 | Much like how the beauty of Ruby spurred the pragmatic development of 102 | Rails, Saur probably wouldn't have been as straightforward to use 103 | without the accomplishments of the [Deno][] JavaScript runtime. Deno's 104 | built-in TypeScript, code formatting, external module importing (without 105 | the need for a centralized package manager!) and Promise-based 106 | asynchronous I/O is a major positive boon to the Saur framework, 107 | bringing with it the speed and correctness you've come to expect from 108 | your backend, without the weird hacks and questionable runtimes that 109 | plague Node.js applications. No need to set `--use-experimental` here, 110 | ECMAScript modules and top-level async/await are fully supported in 111 | Deno, and the runtime itself is written in Rust to ensure maximum 112 | compatibility and minimize nasty segfault bugs that are rampant 113 | throughout the C-based interpreted languages that most of the Web runs 114 | on. 115 | 116 | [Deno Saur]: https://denosaur.org 117 | [Deno]: https://deno.land 118 | [reference documentation]: https://api.denosaur.org 119 | [Django]: https://djangoproject.com 120 | [Ruby on Rails]: https://rubyonrails.org 121 | [SQL Builder]: https://github.com/manyuanrong/sql-builder 122 | -------------------------------------------------------------------------------- /routes/route-set.js: -------------------------------------------------------------------------------- 1 | import Route from "./route.js"; 2 | 3 | /** 4 | * RouteSet defines the routing DSL used by the top-level application 5 | * router. It uses `Oak.Router` under the hood, but pre-fills 6 | * information based on the current context and whether you've selected 7 | * a controller. Controllers are selected by providing a `resources()` 8 | * route, and base paths can be selected by themselves by providing a 9 | * `namespace()`. 10 | */ 11 | export default class RouteSet { 12 | constructor(router, app, options = {}) { 13 | this.router = router; 14 | this.app = app; 15 | this.controller = options.controller; 16 | this.base = options.base; 17 | this.routes = []; 18 | this.namespaces = []; 19 | } 20 | 21 | /** 22 | * Call the function provided and pass in all of the routing methods 23 | * contextualized to this particular set. This enables "relative" 24 | * routing where calling e.g. `get()` within a `namespace()` 25 | * will nest the path of the GET request into the namespace itself. 26 | */ 27 | draw(routing) { 28 | const get = this.get.bind(this); 29 | const post = this.post.bind(this); 30 | const put = this.put.bind(this); 31 | const patch = this.patch.bind(this); 32 | const del = this.delete.bind(this); 33 | const resources = this.resources.bind(this); 34 | const namespace = this.namespace.bind(this); 35 | const root = this.root.bind(this); 36 | const use = this.use.bind(this); 37 | 38 | if (this.base) { 39 | routing({ 40 | use, 41 | get, 42 | post, 43 | put, 44 | patch, 45 | delete: del, 46 | resources, 47 | namespace, 48 | }); 49 | } else { 50 | routing({ 51 | use, 52 | get, 53 | post, 54 | put, 55 | patch, 56 | delete: del, 57 | resources, 58 | namespace, 59 | root, 60 | }); 61 | } 62 | } 63 | 64 | async add(method, name, options = {}, config = {}) { 65 | let action, controller, controllerName; 66 | 67 | const { app } = this; 68 | const as = options.as || config.as || name; 69 | const path = this.base ? `${this.base}/${name}` : name; 70 | 71 | if (typeof options === "string") { 72 | [controllerName, action] = options.split("#"); 73 | const controllerPath = `${app.root}/controllers/${controllerName}.js`; 74 | const exports = await import(controllerPath); 75 | controller = exports.default; 76 | } else { 77 | action = options.action || name; 78 | controller = options.controller || this.controller; 79 | } 80 | 81 | this.routes.push(new Route({ as, path, controller, action, app })); 82 | this.router[method](path, controller.perform(action, this.app)); 83 | } 84 | 85 | /** 86 | * Route a GET request to the given path. You can also specify a 87 | * `controller` and `action`, but these options will default to the 88 | * top-level controller (if you're in a `resources()` block) and the 89 | * name of the path, respectively. 90 | */ 91 | get(path, options = {}) { 92 | this.add("get", path, options); 93 | } 94 | 95 | use(middleware) { 96 | this.router.use(this.base, middleware); 97 | } 98 | 99 | /** 100 | * Route a POST request to the given path. You can also specify a 101 | * `controller` and `action`, but these options will default to the 102 | * top-level controller (if you're in a `resources()` block) and the 103 | * name of the path, respectively. 104 | */ 105 | post(path, options = {}) { 106 | this.add("post", path, options); 107 | } 108 | 109 | /** 110 | * Route a PUT request to the given path. You can also specify a 111 | * `controller` and `action`, but these options will default to the 112 | * top-level controller (if you're in a `resources()` block) and the 113 | * name of the path, respectively. 114 | */ 115 | put(path, options = {}) { 116 | this.add("put", path, options); 117 | } 118 | 119 | /** 120 | * Route a PATCH request to the given path. You can also specify a 121 | * `controller` and `action`, but these options will default to the 122 | * top-level controller (if you're in a `resources()` block) and the 123 | * name of the path, respectively. 124 | */ 125 | patch(path, options = {}) { 126 | this.add("patch", path, options); 127 | } 128 | 129 | /** 130 | * Route a del request to the given path. You can also specify a 131 | * `controller` and `action`, but these options will default to the 132 | * top-level controller (if you're in a `resources()` block) and the 133 | * name of the path, respectively. 134 | */ 135 | delete(path, options = {}) { 136 | this.add("delete", path, options); 137 | } 138 | 139 | /** 140 | * Define a RouteSet that is nested within this one. 141 | */ 142 | namespace(path, routes) { 143 | const controller = this.controller; 144 | const base = `${this.base}/${path}`; 145 | const set = new RouteSet(this.router, this.app, { controller, base }); 146 | path = this.base ? `${this.base}/${path}` : path; 147 | 148 | this.namespaces.push({ path, controller, base }); 149 | set.draw(routes); 150 | } 151 | 152 | /** 153 | * Define the index route to the application. This is always a GET 154 | * request. 155 | */ 156 | root(action, controller = null) { 157 | const as = "root"; 158 | 159 | if (!controller) { 160 | return this.add("get", "/", action, { as }); 161 | } 162 | 163 | this.add("get", "/", { as, controller, action }); 164 | } 165 | 166 | /** 167 | * Define a RESTful resource, which contains all 168 | * create/read/update/destroy actions as well as new/edit pages. You 169 | * can optionally also pass a function in which provides two route 170 | * sets of nested resources for the "collection" and "member" routes. 171 | */ 172 | resources(path, controller, nested) { 173 | this.get(path, { controller, action: "index" }); 174 | this.post(path, { controller, action: "create" }); 175 | this.get(`${path}/new`, { controller, action: "new" }); 176 | this.get(`${path}/:id`, { controller, action: "show" }); 177 | this.get(`${path}/:id/edit`, { controller, action: "edit" }); 178 | this.put(`${path}/:id`, { controller, action: "update" }); 179 | this.patch(`${path}/:id`, { controller, action: "update" }); 180 | this.delete(`${path}/:id`, { controller, action: "destroy" }); 181 | 182 | if (nested) { 183 | const cs = new RouteSet(this.router, this.app, { 184 | controller, 185 | base: path, 186 | }); 187 | const ms = new RouteSet(this.router, this.app, { 188 | controller, 189 | base: `${path}/:id`, 190 | }); 191 | const collection = cs.draw.bind(cs); 192 | const member = ms.draw.bind(ms); 193 | 194 | nested({ collection, member }); 195 | } 196 | } 197 | 198 | /** 199 | * Mount an application or Oak middleware at the given path. 200 | */ 201 | mount(path, app) { 202 | path = this.base ? `${this.base}/${path}` : path; 203 | 204 | if (app.routes) { 205 | this.namespace(path, ({ use }) => use(app.routes.all)); 206 | } else { 207 | this.router.use(path, app); 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /model.js: -------------------------------------------------------------------------------- 1 | import reduce from "https://deno.land/x/lodash/reduce.js"; 2 | import merge from "https://deno.land/x/lodash/merge.js"; 3 | import each from "https://deno.land/x/lodash/forEach.js"; 4 | import flatten from "https://deno.land/x/lodash/flatten.js"; 5 | import Validations, { GenericValidation } from "./model/validations.js"; 6 | import Errors from "./model/errors.js"; 7 | import Relation from "./model/relation.js"; 8 | import { camelCase, snakeCase } from "https://deno.land/x/case/mod.ts"; 9 | // import "https://deno.land/x/humanizer.ts/vocabularies.ts"; 10 | 11 | /** 12 | * Models are classes that construct the data model for your 13 | * application, as well as perform any database-related business logic 14 | * such as validations and data massaging or aggregation. They follow 15 | * the active record pattern, and therefore encapsulate all logic 16 | * related to the querying and persistence of a table in your database. 17 | */ 18 | export default class Model { 19 | /** 20 | * The app this model is a part of. 21 | */ 22 | static app = null 23 | 24 | /** 25 | * Name of the class, used in logging. 26 | */ 27 | static get name() { 28 | return `${this}`.split(" ")[1]; 29 | } 30 | 31 | /** 32 | * Name used in parameters. 33 | */ 34 | static get paramName() { 35 | return camelCase(this.name); 36 | } 37 | 38 | static get collectionName() { 39 | return snakeCase(this.name) + "s"; 40 | } 41 | 42 | /** 43 | * All validations on this model. 44 | */ 45 | static validations = []; 46 | 47 | /** 48 | * All associations to other models. 49 | */ 50 | static associations = { 51 | belongsTo: {}, 52 | hasMany: {}, 53 | hasOne: {}, 54 | }; 55 | 56 | /** 57 | * Table name for this model. 58 | */ 59 | static table = this.tableName; 60 | 61 | /** 62 | * A macro for creating a new `Validation` object in the list of 63 | * validations a model may run through. Call it with 64 | * `YourModelName.validates`. 65 | * 66 | * @param string name - Name of the property 67 | * @param Object validations - Validations to add 68 | */ 69 | static validates(name, validations = {}) { 70 | each(validations, (options, name) => { 71 | const Validation = Validations[name]; 72 | options = options === true ? {} : options; 73 | 74 | this.validations.push(new Validation(options)); 75 | }); 76 | } 77 | 78 | /** 79 | * A macro for creating a new `GenericValidation` object, allowing the 80 | * validation to consist of just running a method which may or may not 81 | * add errors. Call it with `YourModelName.validate`. 82 | * 83 | * @param string method - Name of the method to call 84 | */ 85 | static validate(method) { 86 | this.validations.push(new GenericValidation({ method })); 87 | } 88 | 89 | /** 90 | * Create a new model record and save it to the database. 91 | */ 92 | static create(attributes = {}) { 93 | const model = new this(attributes); 94 | model.save(); 95 | return model; 96 | } 97 | 98 | /** 99 | * Return a relation representing all records in the database. 100 | */ 101 | static get all() { 102 | return new Relation(this); 103 | } 104 | 105 | /** 106 | * Perform a query for matching models in the database. 107 | */ 108 | static where(query) { 109 | return this.all.where(query); 110 | } 111 | 112 | /** 113 | * Find an existing model record in the database by the given 114 | * parameters. 115 | */ 116 | static findBy(query) { 117 | return this.where(query).first; 118 | } 119 | 120 | /** 121 | * Find an existing model record in the database by its ID. 122 | */ 123 | static find(id) { 124 | return this.findBy({ id }); 125 | } 126 | 127 | constructor(attributes = {}) { 128 | this.attributes = attributes; 129 | this.errors = new Errors(); 130 | this.associated = {}; 131 | 132 | this._buildAssociations(); 133 | this.initialize(); 134 | } 135 | 136 | initialize() {} 137 | 138 | /** 139 | * All non-function properties of this object. 140 | */ 141 | get attributes() { 142 | return reduce( 143 | this, 144 | (attrs, value, prop) => { 145 | if (typeof value !== "function") { 146 | attrs[prop] = value; 147 | } 148 | 149 | return attrs; 150 | }, 151 | {}, 152 | ); 153 | } 154 | 155 | /** 156 | * Set attributes on this object by assigning properties directly to 157 | * it. 158 | */ 159 | set attributes(attrs = {}) { 160 | merge(this, attrs); 161 | } 162 | 163 | /** 164 | * Flatten validators from their method calls. 165 | */ 166 | get validations() { 167 | return flatten(this.constructor.validations); 168 | } 169 | 170 | /** 171 | * Run all configured validators. 172 | */ 173 | get valid() { 174 | this.validations.forEach((validation) => validation.valid(this)); 175 | 176 | return this.errors.any; 177 | } 178 | 179 | /** 180 | * Persist the current information in this model to the database. 181 | */ 182 | save() { 183 | if (!this.valid) { 184 | return false; 185 | } 186 | 187 | const query = new Relation(this); 188 | 189 | if (this.id) { 190 | query.where("id", this.id).update(this.attributes); 191 | } else { 192 | query.insert(this.attributes); 193 | } 194 | 195 | query.run(); 196 | 197 | return true; 198 | } 199 | 200 | /** 201 | * Set the given attributes on this model and persist. 202 | */ 203 | update(attributes = {}) { 204 | this.attributes = attributes; 205 | 206 | return this.save(); 207 | } 208 | 209 | /** 210 | * Remove this model from the database. 211 | */ 212 | destroy() { 213 | const query = new Relation(this); 214 | 215 | query.where("id", this.id).delete(); 216 | query.run(); 217 | 218 | return true; 219 | } 220 | 221 | /** 222 | * Reload this model's information from the database. 223 | */ 224 | reload() { 225 | const model = this.constructor.find(this.id); 226 | 227 | merge(this, model.attributes); 228 | 229 | return this; 230 | } 231 | 232 | /** 233 | * Build model associations from the `.associations` static property 234 | * when constructed. 235 | * 236 | * @private 237 | */ 238 | _buildAssociations() { 239 | Object.entries(this.constructor.associations, (type, associations) => { 240 | Object.entries(associations, (name, Model) => { 241 | Object.defineProperty(this, name, { 242 | get() { 243 | if (typeof this.associated[name] !== "undefined") { 244 | return this.associated[name]; 245 | } 246 | 247 | const param = this.constructor.paramName; 248 | const fk = `${param}ID`; 249 | const id = this[`${name}ID`]; 250 | let value; 251 | 252 | if (type === "hasMany") { 253 | value = Model.where({ [fk]: this.id }); 254 | } else if (type === "hasOne") { 255 | value = Model.where({ [fk]: this.id }); 256 | } else if (type === "belongsTo") { 257 | value = Model.find(id); 258 | } else { 259 | throw new Error(`Invalid association type: "${type}"`); 260 | } 261 | 262 | this.associated[name] = value; 263 | 264 | return value; 265 | }, 266 | 267 | set(value) { 268 | this.associated[name] = value; 269 | this[`${name}ID`] = value.id; 270 | }, 271 | }); 272 | }); 273 | }); 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /docs/_posts/2020-4-25-introducing-saur.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: post 3 | --- 4 | 5 | # Introducing Saur! 6 | 7 | This is **Deno Saur**, a web application framework for JavaScript 8 | on the [Deno][] runtime. Saur was created for web developers who want a 9 | full-stack application framework in a language they are comfortable in. 10 | It was designed for getting things done, and getting them done fast. 11 | 12 | ## Fully-Featured Tooling 13 | 14 | Saur's tooling is made for rapid development. To generate the 15 | boilerplate code for a new app, run: 16 | 17 | saur new YOUR-APP-NAME 18 | 19 | To generate code in your app, run: 20 | 21 | saur generate TYPE NAME 22 | 23 | The `TYPE` parameter names one of the set of generators that are 24 | available to you (such as `model` or `controller`). You can run the 25 | following command to view more information for them all: 26 | 27 | saur help generate 28 | 29 | Or, for a specific generator: 30 | 31 | saur help generate model 32 | 33 | ## RESTful Routing 34 | 35 | Saur's main app file is also where you define your routes. A routing DSL 36 | is provided that makes it easy to define the API for your application: 37 | 38 | ```javascript 39 | App.routes.draw({ root, resources }) => { 40 | resources("posts"); 41 | root("posts#index"); 42 | }) 43 | ``` 44 | 45 | ## Web MVC 46 | 47 | One of the many concepts brought over from other frameworks is the 48 | **Model-View-Controller** pattern. Since this is not the same "MVC" as 49 | is used in UI development, it's been known as "Web MVC" when similar 50 | concepts are used to model the backend. Saur is similar to Rails in that 51 | it uses **Controllers** for handling requests, **Models** to express the 52 | database logic, and **Views** for rendering the HTML for responses. 53 | 54 | ### Controllers 55 | 56 | Controllers look very similar to their Rails counterparts: 57 | 58 | ```javascript 59 | import Controller from "https://deno.land/x/saur/controller.js"; 60 | import IndexView from "../views/posts/index.jsx"; 61 | import ShowView from "../views/posts/show.jsx"; 62 | 63 | export default class PostsController extends Controller { 64 | // Controller actions typically render 65 | index() { 66 | const posts = Post.all; 67 | 68 | return this.render(IndexView, { posts }); 69 | } 70 | 71 | // When params are given in the URL, they are passed down into the 72 | // controller's action method. 73 | show({ id }) { 74 | const posts = Post.find(id); 75 | 76 | return this.render(ShowView, { post }); 77 | } 78 | } 79 | ``` 80 | 81 | You can also use TypeScript decorators to establish callbacks for 82 | actions in `.ts` files: 83 | 84 | ```typescript 85 | import Controller from "https://deno.land/x/saur/controller.js"; 86 | import IndexView from "../views/posts/index.jsx"; 87 | import { before } from "https://deno.land/x/saur/callbacks.js" 88 | 89 | export default class PostsController extends Controller { 90 | @before("index") 91 | authenticateUser() { 92 | try { 93 | this.user = User.find(this.request.cookies.userID); 94 | } catch() { 95 | return this.redirect("sessions#new"); 96 | } 97 | } 98 | 99 | index() { 100 | const posts = Post.all; 101 | 102 | return this.render(IndexView, { posts }); 103 | } 104 | } 105 | ``` 106 | 107 | ### Models 108 | 109 | Models are also derived from Rails' ActiveRecord, and the active record 110 | pattern in general. The built-in `Database` object follows the adapter 111 | pattern and can be used to interact with [MySQL][], [PostgreSQL][], and 112 | [SQLite][] databases out of the box. 113 | 114 | A model also looks very similar to an ActiveRecord class: 115 | 116 | ```javascript 117 | import Model from "https://deno.land/x/saur/model.js"; 118 | 119 | export default class Post extends Model { 120 | static validations = [ 121 | validates("title", { presence: true, uniqueness: true }), 122 | validates("body", { presence: true }) 123 | ]; 124 | } 125 | ``` 126 | 127 | If you use TypeScript, you can also use decorators to define validations 128 | and business logic: 129 | 130 | ```typescript 131 | import Model, { validates } from "https://deno.land/x/saur/model.js"; 132 | import { validates } from "https://deno.land/x/saur/model/decorators.js"; 133 | import { before, after, around } from "https://deno.land/x/saur/callbacks.js"; 134 | 135 | @validates("title", { presence: true, uniqueness: true }) 136 | @validates("body", { presence: true }) 137 | export default class Post extends Model { 138 | @before("valid") 139 | generateSlug() { 140 | this.slug = paramCase(this.name); 141 | } 142 | 143 | @after("save") 144 | generateSlug() { 145 | UserMailer.deliver("notify"); 146 | } 147 | 148 | @around("update") 149 | lock(update) { 150 | this.locked = true; 151 | const value = update(); 152 | this.locked = false; 153 | 154 | return value; 155 | } 156 | } 157 | ``` 158 | 159 | ### Views 160 | 161 | Here's where things start to diverge. In Saur, a view object is used to 162 | either wrap a pre-defined EJS template or just render straight HTML to a 163 | String with JSX. View files can either be `.js` or `.jsx`, depending on 164 | how you want to use them. Since there are no helper methods in Saur, 165 | view objects are used to encapsulate presentation-level logic for the 166 | various controller actions and means of rendering content in your 167 | application. 168 | 169 | Here's an example of a view object used to render a post using JSX: 170 | 171 | ```javascript 172 | import View from "https://deno.land/x/saur/view.js"; 173 | import React from "https://deno.land/x/saur/view/react.js"; 174 | import { moment } from "https://deno.land/x/moment/moment.ts"; 175 | 176 | export default class PostView extends View { 177 | get date() { 178 | return moment(this.post.created_at).format(); 179 | } 180 | 181 | render() { 182 | const __html = this.post.body; 183 | 184 | return( 185 |
186 |
187 |

{this.post.title}

188 |
189 |
190 |
191 |

Posted on {this.date}

192 |
193 |
194 | ); 195 | } 196 | } 197 | ``` 198 | 199 | And here's what that same view would look like written using an EJS 200 | template: 201 | 202 | ```javascript 203 | import View from "https://deno.land/x/saur/view.js"; 204 | import { moment } from "https://deno.land/x/moment/moment.ts"; 205 | 206 | export default class PostsShowView extends View { 207 | static template = "posts/show"; 208 | 209 | get date() { 210 | return moment(this.post.created_at).format(); 211 | } 212 | } 213 | ``` 214 | 215 | With a template in **templates/posts/index.html.ejs**: 216 | 217 | ```html 218 |
219 |
220 |

<%= post.title %>

221 |
222 |
223 | <%- post.body %> 224 | 227 |
228 | ``` 229 | 230 | Views also get their own decorators for configuring the `View.template`: 231 | 232 | ```typescript 233 | import View from "https://deno.land/x/saur/view.js"; 234 | import { template } from "https://deno.land/x/saur/view/decorators.js"; 235 | import { moment } from "https://deno.land/x/moment/moment.ts"; 236 | 237 | @template("posts/show") 238 | export default class PostsShowView extends View { 239 | get date() { 240 | return moment(this.post.created_at).format(); 241 | } 242 | } 243 | ``` 244 | 245 | ## Security 246 | 247 | Saur takes security seriously, just like Deno. By leveraging Deno's 248 | security model, Saur applications are inherently protected against 249 | malicious libraries and other problems because their actions are 250 | isolated to the app code directory. A Saur application, out-of-box, 251 | cannot access other parts of the filesystem other than its own folder. 252 | The main `saur` CLI must actually delegate to the application's own 253 | `bin/server` executable in order to run the server, because `saur` does 254 | not have `--allow-net` privileges. This is by design, as the CLI does 255 | not need to make outbound network calls, and the application server 256 | never needs to access files outside the application itself. As a result, 257 | `bin/server` has read/write and network permissions, but 258 | 259 | ## The Front-End 260 | 261 | It wouldn't be a "full-stack" framework without some front-end 262 | enhancements! Saur comes complete with its own (optional) framework for 263 | organizing your client-side JavaScript. The UI aspect of Saur takes 264 | inspiration from other JS frameworks for server-rendered HTML, like 265 | [Stimulus][]. What Stimulus calls "Controllers", Saur calls 266 | "Components", but they serve relatively the same purpose. Here's an 267 | example of a component used to open a dialog box: 268 | 269 | ```typescript 270 | import Component from "saur/ui/component"; 271 | import { element, event } from "saur/ui/decorators"; 272 | import Dialog from "../templates/dialog.ejs"; 273 | 274 | @element("[data-dialog-link]"); 275 | export default class OpenDialog extends Component { 276 | @event("click") 277 | async open(event) { 278 | const title = this.element.getAttribute("title"); 279 | const href = this.element.getAttribute("href"); 280 | const response = await fetch(href); 281 | const content = await response.text(); 282 | const dialog = Dialog({ title, content }); 283 | 284 | document.insertAdjacentHTML("beforeend", dialog); 285 | } 286 | } 287 | ``` 288 | 289 | You'll notice that this is also ES6 modular JavaScript. For now, we're 290 | using [Webpack][] to transpile and bundle your JS/CSS assets for the 291 | browser. One benefit of this is that decorators are supported here in 292 | `.js` files since out-of-box the babel plugin for transpiling them is 293 | included and configured. They are _not_ required, however, as you can 294 | bind events and set the element selector manually with static attributes 295 | as well: 296 | 297 | ```javascript 298 | import Component from "saur/ui/component"; 299 | import Dialog from "../templates/dialog.ejs"; 300 | 301 | class OpenDialog extends Component { 302 | async open(event) { 303 | const title = this.element.getAttribute("title"); 304 | const href = this.element.getAttribute("href"); 305 | const response = await fetch(href); 306 | const content = await response.text(); 307 | const dialog = Dialog({ title, content }); 308 | 309 | document.insertAdjacentHTML("beforeend", dialog); 310 | } 311 | } 312 | 313 | OpenDialog.selector = "[data-dialog-link]"; 314 | OpenDialog.events.click = ["open"]; 315 | 316 | export default OpenDialog; 317 | ``` 318 | 319 | EJS templating is supported on the front-end out of the box, but not 320 | JSX. You should just use [React][] for that. 321 | --------------------------------------------------------------------------------