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 |
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 |
82 | );
83 | }
84 |
85 | export async function Button({ to, ...options }) {
86 | return (
87 |
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 |
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 `";
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 |
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 |
--------------------------------------------------------------------------------