├── .env-sample ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── client ├── .eslintrc.js ├── App.vue └── index.js ├── package-lock.json ├── package.json └── src ├── config.js ├── data ├── events │ ├── addEvent.sql │ ├── deleteEvent.sql │ ├── getEvents.sql │ ├── index.js │ └── updateEvent.sql ├── index.js └── utils.js ├── index.js ├── plugins ├── auth.js ├── index.js └── sql.js ├── routes ├── api │ ├── events.js │ └── index.js ├── auth.js └── index.js ├── server.js └── templates ├── index.ejs ├── layout.ejs └── partials └── navigation.ejs /.env-sample: -------------------------------------------------------------------------------- 1 | # If deploying to production, set NODE_ENV=production 2 | NODE_ENV=development 3 | 4 | # hapi server config 5 | PORT=8080 6 | HOST=localhost 7 | HOST_URL=http://localhost:8080 8 | COOKIE_ENCRYPT_PWD=superAwesomePasswordStringThatIsAtLeast32CharactersLong! 9 | 10 | #sql server config 11 | SQL_USER=yourDBUser 12 | SQL_PASSWORD=yourPassword 13 | SQL_DATABASE=calendar 14 | SQL_SERVER=yourSqlServer 15 | # If deploying to Azure, set SQL_ENCRYPT to true 16 | SQL_ENCRYPT=false 17 | 18 | #okta config 19 | OKTA_ORG_URL=https://{yourOrgUrl} 20 | OKTA_CLIENT_ID={yourClientId} 21 | OKTA_CLIENT_SECRET={yourClientSecret} 22 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ "reverentgeek/node" ] 3 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .DS_Store 4 | .cache 5 | /dist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Okta Developer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build a Node.js App using SQL Server Step-By-Step 2 | 3 | This is the source code for a video tutorial. 4 | 5 | ## Requirements 6 | 7 | * [Node.js](https://nodejs.org) 10.x or higher 8 | * SQL Server 2017 or higher 9 | * [Free Okta developer](https://developer.okta.com) account 10 | 11 | ## Setup 12 | 13 | 1. Clone or download this project 14 | 1. Copy `.env-sample` to `.env` 15 | 1. Run `npm install` to install dependencies 16 | 1. Create a new Web Application in Okta for this project, accepting defaults 17 | 1. Copy your Okta application Client ID and Client Secret 18 | 1. Change the values in `.env` to match your environment 19 | 20 | ## Run 21 | 22 | ```sh 23 | npm run dev 24 | ``` 25 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ "reverentgeek" ], 3 | parserOptions: { 4 | ecmaVersion: 2019, 5 | sourceType: "module" 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /client/App.vue: -------------------------------------------------------------------------------- 1 | 85 | 86 | 196 | 197 | -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | import Datetime from "vue-datetime"; 2 | import Vue from "vue"; 3 | import "materialize-css"; 4 | import "materialize-css/dist/css/materialize.min.css"; 5 | import "vue-datetime/dist/vue-datetime.css"; 6 | 7 | import App from "./App"; 8 | 9 | Vue.use( Datetime ); 10 | 11 | new Vue( { // eslint-disable-line no-new 12 | el: "#app", 13 | render: h => h( App ) 14 | } ); 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-sql-tutorial", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "build": "parcel build client/index.js", 8 | "dev": "nodemon --watch client --watch src -e js,ejs,sql,vue,css --exec npm run dev:start", 9 | "dev:start": "npm-run-all build start", 10 | "start": "node ." 11 | }, 12 | "keywords": [], 13 | "author": "David Neal (https://reverentgeek.com)", 14 | "license": "MIT", 15 | "dependencies": { 16 | "@hapi/bell": "^12.0.0", 17 | "@hapi/boom": "^9.1.0", 18 | "@hapi/cookie": "^11.0.1", 19 | "@hapi/hapi": "^19.1.1", 20 | "@hapi/inert": "^6.0.1", 21 | "@hapi/vision": "^6.0.0", 22 | "axios": "^0.21.2", 23 | "dotenv": "^8.2.0", 24 | "ejs": "^3.1.7", 25 | "fs-extra": "^9.0.0", 26 | "luxon": "^1.22.2", 27 | "materialize-css": "^1.0.0", 28 | "moment": "^2.29.4", 29 | "mssql": "^6.2.0", 30 | "vue": "^2.6.11", 31 | "vue-datetime": "^1.0.0-beta.11", 32 | "weekstart": "^1.0.1" 33 | }, 34 | "devDependencies": { 35 | "@vue/component-compiler-utils": "^2.6.0", 36 | "eslint": "^6.8.0", 37 | "eslint-config-reverentgeek": "^2.0.2", 38 | "nodemon": "^2.0.20", 39 | "npm-run-all": "^4.1.5", 40 | "parcel-bundler": "^1.12.4", 41 | "vue-template-compiler": "^2.6.11" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const dotenv = require( "dotenv" ); 4 | const assert = require( "assert" ); 5 | 6 | dotenv.config(); 7 | 8 | const { 9 | PORT, 10 | HOST, 11 | HOST_URL, 12 | COOKIE_ENCRYPT_PWD, 13 | SQL_SERVER, 14 | SQL_DATABASE, 15 | SQL_USER, 16 | SQL_PASSWORD, 17 | OKTA_ORG_URL, 18 | OKTA_CLIENT_ID, 19 | OKTA_CLIENT_SECRET 20 | } = process.env; 21 | 22 | const sqlEncrypt = process.env.SQL_ENCRYPT === "true"; 23 | 24 | assert( PORT, "PORT is required" ); 25 | assert( HOST, "HOST is required" ); 26 | 27 | module.exports = { 28 | port: PORT, 29 | host: HOST, 30 | url: HOST_URL, 31 | cookiePwd: COOKIE_ENCRYPT_PWD, 32 | sql: { 33 | server: SQL_SERVER, 34 | database: SQL_DATABASE, 35 | user: SQL_USER, 36 | password: SQL_PASSWORD, 37 | options: { 38 | encrypt: sqlEncrypt, 39 | enableArithAbort: true 40 | } 41 | }, 42 | okta: { 43 | url: OKTA_ORG_URL, 44 | clientId: OKTA_CLIENT_ID, 45 | clientSecret: OKTA_CLIENT_SECRET 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/data/events/addEvent.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO [dbo].[events] 2 | ( 3 | [userId] 4 | , [title] 5 | , [description] 6 | , [startDate] 7 | , [startTime] 8 | , [endDate] 9 | , [endTime] 10 | ) 11 | VALUES 12 | ( 13 | @userId 14 | , @title 15 | , @description 16 | , @startDate 17 | , @startTime 18 | , @endDate 19 | , @endTime 20 | ); 21 | 22 | SELECT SCOPE_IDENTITY() AS id; -------------------------------------------------------------------------------- /src/data/events/deleteEvent.sql: -------------------------------------------------------------------------------- 1 | DELETE [dbo].[events] 2 | WHERE [id] = @id 3 | AND [userId] = @userId; -------------------------------------------------------------------------------- /src/data/events/getEvents.sql: -------------------------------------------------------------------------------- 1 | SELECT [id] 2 | , [title] 3 | , [description] 4 | , [startDate] 5 | , [startTime] 6 | , [endDate] 7 | , [endTime] 8 | FROM [dbo].[events] 9 | WHERE [userId] = @userId 10 | ORDER BY 11 | [startDate], [startTime] 12 | -------------------------------------------------------------------------------- /src/data/events/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const utils = require( "../utils" ); 4 | 5 | const register = async ( { sql, getConnection } ) => { 6 | const sqlQueries = await utils.loadSqlQueries( "events" ); 7 | 8 | const getEvents = async userId => { 9 | const cnx = await getConnection(); 10 | const request = await cnx.request(); 11 | request.input( "userId", sql.VarChar( 50 ), userId ); 12 | return await request.query( sqlQueries.getEvents ); 13 | }; 14 | 15 | const addEvent = async ( { userId, title, description, startDate, startTime, endDate, endTime } ) => { 16 | const cnx = await getConnection(); 17 | const request = await cnx.request(); 18 | request.input( "userId", sql.VarChar( 50 ), userId ); 19 | request.input( "title", sql.NVarChar( 200 ), title ); 20 | request.input( "description", sql.NVarChar( 1000 ), description ); 21 | request.input( "startDate", sql.Date, startDate ); 22 | request.input( "startTime", sql.Time, startTime ); 23 | request.input( "endDate", sql.Date, endDate ); 24 | request.input( "endTime", sql.Time, endTime ); 25 | return await request.query( sqlQueries.addEvent ); 26 | }; 27 | 28 | const updateEvent = async ( { id, userId, title, description, startDate, startTime, endDate, endTime } ) => { 29 | const cnx = await getConnection(); 30 | const request = await cnx.request(); 31 | request.input( "id", sql.Int, id ); 32 | request.input( "userId", sql.VarChar( 50 ), userId ); 33 | request.input( "title", sql.NVarChar( 200 ), title ); 34 | request.input( "description", sql.NVarChar( 1000 ), description ); 35 | request.input( "startDate", sql.Date, startDate ); 36 | request.input( "startTime", sql.Time, startTime ); 37 | request.input( "endDate", sql.Date, endDate ); 38 | request.input( "endTime", sql.Time, endTime ); 39 | return request.query( sqlQueries.updateEvent ); 40 | }; 41 | 42 | const deleteEvent = async ( { id, userId } ) => { 43 | const cnx = await getConnection(); 44 | const request = await cnx.request(); 45 | request.input( "id", sql.Int, id ); 46 | request.input( "userId", sql.VarChar( 50 ), userId ); 47 | return request.query( sqlQueries.deleteEvent ); 48 | }; 49 | 50 | return { 51 | addEvent, 52 | deleteEvent, 53 | getEvents, 54 | updateEvent 55 | }; 56 | }; 57 | 58 | module.exports = { register }; 59 | 60 | -------------------------------------------------------------------------------- /src/data/events/updateEvent.sql: -------------------------------------------------------------------------------- 1 | UPDATE [dbo].[events] 2 | SET [title] = @title 3 | , [description] = @description 4 | , [startDate] = @startDate 5 | , [startTime] = @startTime 6 | , [endDate] = @endDate 7 | , [endTime] = @endTime 8 | WHERE [id] = @id 9 | AND [userId] = @userId; 10 | 11 | SELECT [id] 12 | , [title] 13 | , [description] 14 | , [startDate] 15 | , [startTime] 16 | , [endDate] 17 | , [endTime] 18 | FROM [dbo].[events] 19 | WHERE [id] = @id 20 | AND [userId] = @userId; -------------------------------------------------------------------------------- /src/data/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const events = require( "./events" ); 4 | const sql = require( "mssql" ); 5 | 6 | const client = async ( server, config ) => { 7 | let pool = null; 8 | 9 | const closePool = async () => { 10 | try { 11 | await pool.close(); 12 | pool = null; 13 | } catch ( err ) { 14 | pool = null; 15 | console.log( err ); 16 | } 17 | }; 18 | 19 | const getConnection = async () => { 20 | try { 21 | if ( pool ) { 22 | return pool; 23 | } 24 | pool = await sql.connect( config ); 25 | pool.on( "error", async err => { 26 | console.log( err ); 27 | await closePool(); 28 | } ); 29 | return pool; 30 | } catch( err ) { 31 | console.log( err ); 32 | pool=null; 33 | } 34 | }; 35 | 36 | return { 37 | events: await events.register( { sql, getConnection } ) 38 | }; 39 | }; 40 | 41 | module.exports = client; 42 | -------------------------------------------------------------------------------- /src/data/utils.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require( "fs-extra" ); 4 | const { join } = require( "path" ); 5 | 6 | const loadSqlQueries = async folderName => { 7 | const filePath = join( process.cwd(), "src", "data", folderName ); 8 | const files = await fs.readdir( filePath ); 9 | const sqlFiles = files.filter( f => f.endsWith( ".sql" ) ); 10 | const queries = {}; 11 | for( const sqlFile of sqlFiles ) { 12 | const query = fs.readFileSync( join( filePath, sqlFile ), { encoding: "UTF-8" } ); 13 | queries[ sqlFile.replace( ".sql", "" ) ] = query; 14 | } 15 | return queries; 16 | }; 17 | 18 | module.exports = { 19 | loadSqlQueries 20 | }; 21 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const config = require( "./config" ); 4 | const server = require( "./server" ); 5 | 6 | const startServer = async () => { 7 | try { 8 | const app = await server( config ); 9 | await app.start(); 10 | 11 | console.log( `Server running at http://${ config.host }:${ config.port }` ); 12 | } 13 | catch( err ) { 14 | console.log( "startup error", err ); 15 | } 16 | }; 17 | 18 | startServer(); 19 | -------------------------------------------------------------------------------- /src/plugins/auth.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const bell = require( "@hapi/bell" ); 4 | const cookie = require( "@hapi/cookie" ); 5 | 6 | const isSecure = process.env.NODE_ENV === "production"; 7 | 8 | module.exports = { 9 | name: "auth", 10 | version: "1.0.0", 11 | register: async server => { 12 | await server.register( [ bell, cookie ] ); 13 | const config = server.app.config; 14 | 15 | server.auth.strategy( "session", "cookie", { 16 | cookie: { 17 | name: "okta-oauth", 18 | path: "/", 19 | password: config.cookiePwd, 20 | isSecure 21 | }, 22 | redirectTo: "/authorization-code/callback" 23 | } ); 24 | 25 | server.auth.strategy( "okta", "bell", { 26 | provider: "okta", 27 | config: { uri: config.okta.url }, 28 | password: config.cookiePwd, 29 | isSecure, 30 | location: config.url, 31 | clientId: config.okta.clientId, 32 | clientSecret: config.okta.clientSecret 33 | } ); 34 | 35 | server.auth.default( "session" ); 36 | 37 | server.ext( "onPreResponse", ( request, h ) => { 38 | if ( request.response.variety === "view" ) { 39 | const auth = request.auth.isAuthenticated ? { 40 | isAuthenticated: true, 41 | isAnonymous: false, 42 | email: request.auth.artifacts.profile.email, 43 | firstName: request.auth.artifacts.profile.firstName, 44 | lastName: request.auth.artifacts.profile.lastName 45 | } : { 46 | isAuthenticated: false, 47 | isAnonymous: true, 48 | email: "", 49 | firstName: "", 50 | lastName: "" 51 | }; 52 | request.response.source.context.auth = auth; 53 | } 54 | return h.continue; 55 | } ); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /src/plugins/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ejs = require( "ejs" ); 4 | const inert = require( "@hapi/inert" ); 5 | const vision = require( "@hapi/vision" ); 6 | 7 | const auth = require( "./auth" ); 8 | const sql = require( "./sql" ); 9 | 10 | module.exports.register = async server => { 11 | await server.register( [ auth, inert, vision, sql ] ); 12 | 13 | server.views( { 14 | engines: { ejs }, 15 | relativeTo: __dirname, 16 | path: "../templates", 17 | layout: true 18 | } ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/plugins/sql.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const dataClient = require( "../data" ); 4 | 5 | module.exports = { 6 | name: "sql", 7 | version: "1.0.0", 8 | register: async server => { 9 | const config = server.app.config.sql; 10 | const client = await dataClient( server, config ); 11 | server.expose( "client", client ); 12 | } 13 | } 14 | ; 15 | -------------------------------------------------------------------------------- /src/routes/api/events.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const boom = require( "@hapi/boom" ); 4 | 5 | module.exports.register = async server => { 6 | server.route( { 7 | method: "GET", 8 | path: "/api/events", 9 | options: { 10 | auth: { mode: "try" } 11 | }, 12 | handler: async request => { 13 | try { 14 | if ( !request.auth.isAuthenticated ) { 15 | return boom.unauthorized(); 16 | } 17 | const db = request.server.plugins.sql.client; 18 | 19 | const userId = request.auth.credentials.profile.id; 20 | const res = await db.events.getEvents( userId ); 21 | 22 | return res.recordset; 23 | } catch( err ) { 24 | console.log( err ); 25 | } 26 | } 27 | } ); 28 | 29 | server.route( { 30 | method: "POST", 31 | path: "/api/events", 32 | options: { 33 | auth: { mode: "try" } 34 | }, 35 | handler: async request => { 36 | try { 37 | if ( !request.auth.isAuthenticated ) { 38 | return boom.unauthorized(); 39 | } 40 | const db = request.server.plugins.sql.client; 41 | const userId = request.auth.credentials.profile.id; 42 | const { startDate, startTime, endDate, endTime, title, description } = request.payload; 43 | const res = await db.events.addEvent( { userId, startDate, startTime, endDate, endTime, title, description } ); 44 | return res.recordset[ 0 ]; 45 | } catch ( err ) { 46 | console.log( err ); 47 | return boom.boomify( err ); 48 | } 49 | } 50 | } ); 51 | 52 | server.route( { 53 | method: "DELETE", 54 | path: "/api/events/{id}", 55 | options: { 56 | auth: { mode: "try" } 57 | }, 58 | handler: async ( request, h ) => { 59 | try { 60 | if ( !request.auth.isAuthenticated ) { 61 | return boom.unauthorized(); 62 | } 63 | const id = request.params.id; 64 | const userId = request.auth.credentials.profile.id; 65 | const db = request.server.plugins.sql.client; 66 | const res = await db.events.deleteEvent( { id, userId } ); 67 | 68 | return res.rowsAffected[ 0 ] === 1 ? h.response().code( 204 ) : boom.notFound(); 69 | } catch ( err ) { 70 | console.log( err ); 71 | return boom.boomify( err ); 72 | } 73 | } 74 | } ); 75 | 76 | }; 77 | -------------------------------------------------------------------------------- /src/routes/api/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const events = require( "./events" ); 4 | 5 | module.exports.register = async server => { 6 | await events.register( server ); 7 | }; 8 | -------------------------------------------------------------------------------- /src/routes/auth.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const boom = require( "@hapi/boom" ); 4 | 5 | const login = { 6 | method: "GET", 7 | path: "/login", 8 | handler: request => { 9 | if ( !request.auth.isAuthenticated ) { 10 | return `Authentication failed due to ${ request.auth.error.message }`; 11 | } 12 | } 13 | }; 14 | 15 | const oAuthCallback = { 16 | method: "GET", 17 | path: "/authorization-code/callback", 18 | handler: ( request, h ) => { 19 | if ( !request.auth.isAuthenticated ) { 20 | throw boom.unauthorized( `Authentication failed: ${ request.auth.error.message }` ); 21 | } 22 | request.cookieAuth.set( request.auth.credentials ); 23 | return h.redirect( "/" ); 24 | }, 25 | options: { 26 | auth: "okta" 27 | } 28 | }; 29 | 30 | const logout = { 31 | method: "GET", 32 | path: "/logout", 33 | handler: ( request, h ) => { 34 | try { 35 | if ( request.auth.isAuthenticated ) { 36 | request.cookieAuth.clear(); 37 | } 38 | return h.redirect( "/" ); 39 | } catch( err ) { 40 | console.log( err ); 41 | } 42 | }, 43 | options: { 44 | auth: { 45 | mode: "try" 46 | } 47 | } 48 | }; 49 | 50 | module.exports.register = async server => { 51 | server.route( [ login, oAuthCallback, logout ] ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/routes/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const api = require( "./api" ); 4 | const auth = require( "./auth" ); 5 | 6 | module.exports.register = async server => { 7 | 8 | await api.register( server ); 9 | await auth.register( server ); 10 | 11 | server.route( { 12 | method: "GET", 13 | path: "/", 14 | handler: async ( request, h ) => { 15 | return h.view( "index", { title: "Home", message: "Welcome!" } ); 16 | }, 17 | options: { 18 | auth: { 19 | mode: "try" 20 | } 21 | } 22 | } ); 23 | 24 | server.route( { 25 | method: "GET", 26 | path: "/{param*}", 27 | handler: { 28 | directory: { 29 | path: "dist" 30 | } 31 | }, 32 | options: { 33 | auth: false 34 | } 35 | } ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Hapi = require( "@hapi/hapi" ); 4 | const plugins = require( "./plugins" ); 5 | const routes = require( "./routes" ); 6 | 7 | const app = async config => { 8 | const { host, port } = config; 9 | 10 | const server = Hapi.server( { host, port } ); 11 | server.app.config = config; 12 | 13 | await plugins.register( server ); 14 | await routes.register( server ); 15 | return server; 16 | }; 17 | 18 | module.exports = app; 19 | -------------------------------------------------------------------------------- /src/templates/index.ejs: -------------------------------------------------------------------------------- 1 |
2 | <% if ( auth.isAuthenticated ) { %> 3 |
4 | <% } else { %> 5 |

<%= title %>

6 |

<%= message %>

7 | Login 8 | <% } %> 9 |
10 | -------------------------------------------------------------------------------- /src/templates/layout.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= title %> 7 | 8 | 9 | 10 | 11 | 12 | <%- include( "partials/navigation" ) %> 13 | <%- content %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/templates/partials/navigation.ejs: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------