html-express-js 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,8 @@
7
7
  ## Features
8
8
 
9
9
  - Serves HTML documents using template literals
10
- - Supports includes in served HTML documents
10
+ - Supports includes in HTML documents
11
+ - Allows shared global state throughout templates
11
12
 
12
13
  ## Installation
13
14
 
@@ -17,28 +18,32 @@ npm install html-express-js
17
18
 
18
19
  ## Basic Usage
19
20
 
20
- The following shows at a high level how the package can be used as an Express template engine. See [example](/example) directory for all details of a working implementation.
21
+ The following is a high level example of how the package can be used as an Express template engine. See [example](/example) directory for all details of a working implementation.
21
22
 
22
23
  Set up your Express app to use this engine:
23
24
 
24
25
  ```js
25
- import htmlExpress, { staticIndexHandler } from 'html-express-js';
26
+ import htmlExpress from 'html-express-js';
26
27
 
27
28
  const app = express();
28
29
  const __dirname = resolve();
29
30
 
31
+ const viewsDir = `${__dirname}/public`;
32
+
33
+ const { engine, staticIndexHandler } = htmlExpress({
34
+ viewsDir, // root views directory to serve all index.js files
35
+ includesDir: `${viewsDir}/includes`, // OPTIONAL: where all includes reside
36
+ notFoundView: '404/index', // OPTIONAL: relative to viewsDir above
37
+ });
38
+
30
39
  // set up engine
31
- app.engine(
32
- 'js',
33
- htmlExpress({
34
- includesDir: 'includes', // where all includes reside
35
- }),
36
- );
40
+ app.engine('js', engine);
41
+
37
42
  // use engine
38
43
  app.set('view engine', 'js');
39
44
 
40
45
  // set directory where all index.js pages are served
41
- app.set('views', `${__dirname}/public`);
46
+ app.set('views', viewsDir);
42
47
 
43
48
  // render HTML in public/homepage.js with data
44
49
  app.get('/', function (req, res, next) {
@@ -51,12 +56,7 @@ app.get('/', function (req, res, next) {
51
56
  // OPTIONALLY: route all GET requests to directories
52
57
  // to their associated static index.js views in the public directory
53
58
  // and, if not found, route to the 404/index.js view
54
- app.use(
55
- staticIndexHandler({
56
- viewsDir: `${__dirname}/public`, // root views directory to serve all index.js files
57
- notFoundView: '404/index', // relative to viewsDir above
58
- }),
59
- );
59
+ app.use(staticIndexHandler());
60
60
  ```
61
61
 
62
62
  Then you can create the associated files:
@@ -93,3 +93,76 @@ export const view = (data, state) => html`
93
93
  </html>
94
94
  `;
95
95
  ```
96
+
97
+ ## Advanced usage
98
+
99
+ ### Injecting and using state based on a request
100
+
101
+ The following shows an example of showing a logged out state based on the cookie on a request.
102
+
103
+ ```js
104
+ import htmlExpress from 'html-express-js';
105
+
106
+ const app = express();
107
+ const __dirname = resolve();
108
+
109
+ const viewsDir = `${__dirname}/public`;
110
+
111
+ const { engine, staticIndexHandler } = htmlExpress({
112
+ viewsDir,
113
+ /**
114
+ * Inject global state into all views based on cookie
115
+ */
116
+ buildRequestState: (req) => {
117
+ if (req.cookies['authed']) {
118
+ return {
119
+ loggedIn: true,
120
+ };
121
+ }
122
+ },
123
+ });
124
+
125
+ app.engine('js', engine);
126
+ app.set('view engine', 'js');
127
+ app.set('views', viewsDir);
128
+
129
+ app.get('/', function (req, res, next) {
130
+ res.render('homepage');
131
+ });
132
+ ```
133
+
134
+ ```js
135
+ // public/homepage.js
136
+ import { html } from 'html-express-js';
137
+
138
+ export const view = (data, state) => {
139
+ const { loggedIn } = state;
140
+
141
+ return html`
142
+ <!doctype html>
143
+ <html lang="en">
144
+ <head>
145
+ <title>${data.title}</title>
146
+ </head>
147
+
148
+ <body>
149
+ ${loggedIn ? `<a href="/logout">Logout</a>` : 'Not logged in'}
150
+ </body>
151
+ </html>
152
+ `;
153
+ };
154
+ ```
155
+
156
+ ## Development
157
+
158
+ Run site in examples directory
159
+
160
+ ```bash
161
+ npm start
162
+ ```
163
+
164
+ Run tests
165
+
166
+ ```bash
167
+ npm test
168
+ ```
package/package.json CHANGED
@@ -1,17 +1,25 @@
1
1
  {
2
2
  "name": "html-express-js",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "An Express template engine to render HTML views using native JavaScript",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
7
7
  "engines": {
8
8
  "node": ">=16"
9
9
  },
10
+ "files": [
11
+ "src/index.js",
12
+ "src/index.d.ts"
13
+ ],
10
14
  "scripts": {
11
- "format": "prettier --write '**/*'",
12
- "test": "prettier --check '**/*'",
15
+ "format": "prettier --write '**/*' --log-level=warn",
16
+ "format-check": "prettier --check '**/*' --ignore-unknown",
17
+ "test": "npm run test:src && npm run type-check && npm run format-check",
18
+ "test:src": "mocha src/**/*.tests.js",
13
19
  "start": "node ./example/server.js",
14
- "prepare": "husky install"
20
+ "type-check": "tsc -p tsconfig.json",
21
+ "prepare": "husky install",
22
+ "build": "tsc -p tsconfig.build.json"
15
23
  },
16
24
  "repository": {
17
25
  "type": "git",
@@ -34,12 +42,19 @@
34
42
  "glob": "^10.2.2"
35
43
  },
36
44
  "devDependencies": {
37
- "@types/glob": "^8.1.0",
45
+ "@types/chai": "^4.3.13",
46
+ "@types/express": "^4.17.21",
47
+ "@types/mocha": "^10.0.6",
48
+ "@types/sinon": "^17.0.3",
49
+ "chai": "^5.1.0",
38
50
  "chokidar": "^3.5.3",
39
51
  "express": "^4.18.1",
40
52
  "husky": "^9.0.7",
53
+ "mocha": "^10.3.0",
41
54
  "prettier": "^3.0.3",
42
- "release-it": "^17.0.0",
43
- "reload": "^3.2.0"
55
+ "release-it": "^17.1.1",
56
+ "reload": "^3.2.0",
57
+ "sinon": "^17.0.1",
58
+ "typescript": "^5.4.3"
44
59
  }
45
60
  }
package/src/index.js CHANGED
@@ -2,6 +2,23 @@ import { basename, extname } from 'path';
2
2
  import { glob } from 'glob';
3
3
  import { stat } from 'fs/promises';
4
4
 
5
+ /**
6
+ * @callback HTMLExpressBuildStateHandler
7
+ * @param {import('express').Request} req
8
+ * @returns {Record<string, any>}
9
+ */
10
+
11
+ /**
12
+ * @typedef {object} HTMLExpressOptions
13
+ * @property {string} viewsDir - The directory that houses any potential index files
14
+ * @property {string} [includesDir] - The directory that houses all of the includes
15
+ * that will be available on the includes property of each static page.
16
+ * @property {string} [notFoundView] - The path of a file relative to the views
17
+ * directory that should be served as 404 when no matching index page exists. Defaults to `404/index`.
18
+ * @property {HTMLExpressBuildStateHandler} [buildRequestState] - A callback function that allows for
19
+ * building a state object from request information, that will be merged with default state and made available to all views
20
+ */
21
+
5
22
  /**
6
23
  * Renders an HTML template in a file.
7
24
  *
@@ -26,14 +43,15 @@ async function renderHtmlFileTemplate(path, data, state) {
26
43
  *
27
44
  * @param {string} filePath - The path to html file
28
45
  * @param {object} data - Data to be made available in view
29
- * @param {object} instanceOptions - Options passed to original instantiation
46
+ * @param {object} options - Options passed to original instantiation
47
+ * @param {HTMLExpressOptions['includesDir']} options.includesDir
48
+ * @param {Record<string, any>} [options.state]
30
49
  * @returns {Promise<string>} HTML with includes available (appended to state)
31
50
  */
32
- async function renderHtmlFile(filePath, data = {}, instanceOptions = {}) {
33
- const state = {
34
- includes: {},
35
- };
36
- const { includesDir } = instanceOptions;
51
+ async function renderHtmlFile(filePath, data = {}, options) {
52
+ const { includesDir } = options || {};
53
+ const state = options.state || {};
54
+ state.includes = {};
37
55
 
38
56
  const includeFilePaths = await glob(`${includesDir}/*.js`);
39
57
  for await (const includePath of includeFilePaths) {
@@ -48,7 +66,9 @@ async function renderHtmlFile(filePath, data = {}, instanceOptions = {}) {
48
66
  }
49
67
 
50
68
  /**
51
- * Template literal that supports string interpolating in passed HTML.
69
+ * Template literal that supports string
70
+ * interpolating in passed HTML.
71
+ *
52
72
  * @param {*} strings
53
73
  * @param {...any} data
54
74
  * @returns {string} - HTML string
@@ -59,22 +79,23 @@ export function html(strings, ...data) {
59
79
  const exp = data[i] || '';
60
80
  rawHtml += str + exp;
61
81
  }
62
- const html = rawHtml.replace(/[\n\r]/g, '');
63
- return html;
82
+ return rawHtml;
64
83
  }
65
84
 
85
+ /**
86
+ * @callback HTMLExpressStaticIndexHandler
87
+ * @param {HTMLExpressOptions} [options]
88
+ * @returns {import('express').RequestHandler}
89
+ */
90
+
66
91
  /**
67
92
  * Attempts to render index.js pages when requesting to
68
93
  * directories and fallback to 404/index.js if doesnt exist.
69
94
  *
70
- * @param {object} [options]
71
- * @param {object} options.viewsDir - The directory that houses any potential index files
72
- * @param {string} [options.notFoundView] - The path of a file relative to the views
73
- * directory that should be served as 404 when no matching index page exists. Defaults to `404/index`.
74
- * @returns {import('express').RequestHandler} - Middleware function
95
+ * @type {HTMLExpressStaticIndexHandler}
75
96
  */
76
- export function staticIndexHandler(options) {
77
- const notFoundView = options.notFoundView || `404/index`;
97
+ function staticIndexHandler(options) {
98
+ const { viewsDir, notFoundView, includesDir, buildRequestState } = options;
78
99
 
79
100
  return async function (req, res, next) {
80
101
  const { path: rawPath } = req;
@@ -82,39 +103,74 @@ export function staticIndexHandler(options) {
82
103
  if (fileExtension) {
83
104
  return next();
84
105
  }
85
- const sanitizedPath = rawPath.replace('/', ''); // remove beginning slash
86
- const path = sanitizedPath ? `${sanitizedPath}/index` : 'index';
106
+ const pathWithoutPrecedingSlash = rawPath.replace('/', ''); // remove beginning slash
107
+ const path = pathWithoutPrecedingSlash
108
+ ? `${pathWithoutPrecedingSlash}/index`
109
+ : 'index';
110
+
111
+ const requestState = buildRequestState ? buildRequestState(req) : {};
112
+
113
+ const renderOptions = {
114
+ includesDir,
115
+ state: requestState,
116
+ };
117
+ res.setHeader('Content-Type', 'text/html');
87
118
  try {
88
- await stat(`${options.viewsDir}/${path}.js`); // check if file exists
89
- res.render(path);
119
+ const absoluteFilePath = `${viewsDir}/${path}.js`;
120
+ await stat(absoluteFilePath); // check if file exists
121
+ const html = await renderHtmlFile(absoluteFilePath, {}, renderOptions);
122
+ res.send(html);
90
123
  } catch (e) {
91
124
  if (e.code !== 'ENOENT') {
92
125
  throw e;
93
126
  }
127
+ const notFoundViewPath = notFoundView || `404/index`;
128
+ const notFoundAbsoluteFilePath = `${viewsDir}/${notFoundViewPath}.js`;
129
+ const html = await renderHtmlFile(
130
+ notFoundAbsoluteFilePath,
131
+ {},
132
+ renderOptions,
133
+ );
94
134
  res.status(404);
95
- res.render(notFoundView);
135
+ res.send(html);
96
136
  }
97
137
  };
98
138
  }
99
139
 
100
140
  /**
101
- * Returns a template engine view function.
141
+ * Returns an object containing both static
142
+ * index handler and the template engine callback.
102
143
  *
103
- * @param {object} [opts]
104
- * @param {object} [opts.includesDir]
105
- * @returns {(path: string, options: object, callback: (e: any, rendered?: string) => void) => void}
144
+ * @param {HTMLExpressOptions} [opts]
145
+ * @returns {{
146
+ * staticIndexHandler: HTMLExpressStaticIndexHandler,
147
+ * engine: Parameters<import('express').Application['engine']>[1],
148
+ * }}
106
149
  */
107
- export default function (opts = {}) {
108
- return async (filePath, data, callback) => {
109
- const viewsDir = data.settings.views;
110
- const includePath = opts.includesDir || 'includes';
111
-
112
- const sanitizedOptions = {
113
- viewsDir,
114
- includesDir: `${viewsDir}/${includePath}`,
115
- };
116
-
117
- const html = await renderHtmlFile(filePath, data, sanitizedOptions);
118
- return callback(null, html);
150
+ export default function (opts) {
151
+ const { buildRequestState, notFoundView, viewsDir } = opts;
152
+ const includesDir = opts.includesDir
153
+ ? opts.includesDir
154
+ : `${viewsDir}/includes`;
155
+ return {
156
+ staticIndexHandler: (options) => {
157
+ return staticIndexHandler({
158
+ includesDir,
159
+ viewsDir,
160
+ notFoundView,
161
+ buildRequestState,
162
+ ...options,
163
+ });
164
+ },
165
+ engine: async (filePath, data, callback) => {
166
+ const html = await renderHtmlFile(
167
+ filePath,
168
+ {},
169
+ {
170
+ includesDir,
171
+ },
172
+ );
173
+ return callback(null, html);
174
+ },
119
175
  };
120
176
  }
@@ -1,6 +0,0 @@
1
- version: 2
2
- updates:
3
- - package-ecosystem: npm
4
- directory: '/'
5
- schedule:
6
- interval: monthly
@@ -1,17 +0,0 @@
1
- name: Dependabot auto-merge
2
- on: pull_request
3
-
4
- permissions:
5
- pull-requests: write
6
- contents: write
7
-
8
- jobs:
9
- dependabot:
10
- runs-on: ubuntu-latest
11
- if: ${{ github.actor == 'dependabot[bot]' }}
12
- steps:
13
- - name: Enable auto-merge for Dependabot PRs
14
- run: gh pr merge --auto --squash "$PR_URL"
15
- env:
16
- PR_URL: ${{github.event.pull_request.html_url}}
17
- GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
package/.husky/pre-commit DELETED
@@ -1,4 +0,0 @@
1
- #!/usr/bin/env sh
2
- . "$(dirname -- "$0")/_/husky.sh"
3
-
4
- npm run format
package/.nvmrc DELETED
@@ -1 +0,0 @@
1
- 16
package/.prettierignore DELETED
@@ -1,8 +0,0 @@
1
- package*.json
2
- node_modules
3
- .husky
4
- LICENSE
5
- .gitignore
6
- .npmrc
7
- .prettierignore
8
- .nvmrc
package/.prettierrc.cjs DELETED
@@ -1,3 +0,0 @@
1
- module.exports = {
2
- singleQuote: true,
3
- };
package/.release-it.cjs DELETED
@@ -1,13 +0,0 @@
1
- module.exports = {
2
- git: {
3
- commitMessage: '${version}',
4
- tagName: 'v${version}',
5
- },
6
- github: {
7
- release: true,
8
- releaseName: '${version}',
9
- },
10
- hooks: {
11
- 'before:init': ['npm test'],
12
- },
13
- };
package/.travis.yml DELETED
@@ -1,8 +0,0 @@
1
- language: node_js
2
- node_js:
3
- - '16'
4
- branches:
5
- only:
6
- - master
7
- script:
8
- - npm test
package/example/app.js DELETED
@@ -1,36 +0,0 @@
1
- import express from 'express';
2
- import { resolve } from 'path';
3
- import htmlExpress, { staticIndexHandler } from '../src/index.js';
4
-
5
- const __dirname = resolve();
6
-
7
- const app = express();
8
-
9
- app.engine(
10
- 'js',
11
- htmlExpress({
12
- includesDir: 'includes',
13
- }),
14
- );
15
-
16
- app.set('view engine', 'js');
17
- app.set('views', `${__dirname}/example/public`);
18
-
19
- // serve all other static files like CSS, images, etc
20
- app.use(express.static(`${__dirname}/example/public`));
21
-
22
- app.get('/hello', async function (req, res) {
23
- res.render('hello', {
24
- name: 'world',
25
- });
26
- });
27
-
28
- // Automatically serve any index.js file as HTML in the public directory
29
- app.use(
30
- staticIndexHandler({
31
- viewsDir: `${__dirname}/example/public`,
32
- notFoundView: 'not-found', // OPTIONAL: defaults to `404/index`
33
- }),
34
- );
35
-
36
- export default app;
@@ -1,17 +0,0 @@
1
- import { html } from '../../../src/index.js';
2
-
3
- export const view = (data, state) => html`
4
- <!doctype html>
5
- <html lang="en">
6
- <head>
7
- ${state.includes.head}
8
- <title>Hello!</title>
9
- </head>
10
-
11
- <body>
12
- <h1>Hello, ${data.name}!</h1>
13
-
14
- <p>Click <a href="/">here</a> to go back to the dashboard.</p>
15
- </body>
16
- </html>
17
- `;
@@ -1,15 +0,0 @@
1
- import { html } from '../../../src/index.js';
2
-
3
- export const view = (/*data, state*/) => html`
4
- <meta charset="utf-8" />
5
- <meta http-equiv="X-UA-Compatible" content="IE=edge" />
6
- <meta
7
- name="viewport"
8
- content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=0"
9
- />
10
-
11
- <link rel="stylesheet" href="/main.css" />
12
-
13
- <!-- For HMR! -->
14
- <script src="/reload/reload.js"></script>
15
- `;
@@ -1,21 +0,0 @@
1
- import { html } from '../../src/index.js';
2
-
3
- export const view = (data, state) => html`
4
- <!doctype html>
5
- <html lang="en">
6
- <head>
7
- ${state.includes.head}
8
- <title>Dashboard</title>
9
- </head>
10
-
11
- <body>
12
- <h1>This is the dashboard!</h1>
13
-
14
- <p>
15
- This file is served by the <code>staticIndexHandler</code> in app.js
16
- </p>
17
-
18
- <p>Click <a href="/hello">here</a> to go hello route.</p>
19
- </body>
20
- </html>
21
- `;
@@ -1,3 +0,0 @@
1
- body {
2
- background-color: lightgray;
3
- }
@@ -1,15 +0,0 @@
1
- import { html } from '../../src/index.js';
2
-
3
- export const view = (data, state) => html`
4
- <!doctype html>
5
- <html lang="en">
6
- <head>
7
- ${state.includes.head}
8
- <title>404</title>
9
- </head>
10
-
11
- <body>
12
- Not found!
13
- </body>
14
- </html>
15
- `;
package/example/server.js DELETED
@@ -1,20 +0,0 @@
1
- import app from './app.js';
2
- import reload from 'reload';
3
- import chokidar from 'chokidar';
4
-
5
- const port = 2222;
6
-
7
- // reload browser on file changes
8
- reload(app, { verbose: true })
9
- .then(function (reloadReturned) {
10
- chokidar.watch(['./src', './example']).on('all', (/*event, path*/) => {
11
- reloadReturned.reload();
12
- });
13
-
14
- app.listen(port, function () {
15
- console.log(`Server started at http://localhost:${port}`);
16
- });
17
- })
18
- .catch(function (err) {
19
- console.error('Reload could not start, could not start example app', err);
20
- });
package/jsconfig.json DELETED
@@ -1,9 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "checkJs": true,
4
- "maxNodeModuleJsDepth": 1,
5
- "moduleResolution": "nodenext",
6
- "module": "nodenext",
7
- "target": "es2022"
8
- }
9
- }