ghtml 2.0.3 → 2.1.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/.eslintrc.json +1 -0
- package/README.md +1 -1
- package/bin/README.md +66 -0
- package/bin/example/assets/cat.jpeg +0 -0
- package/bin/example/assets/style.css +19 -0
- package/bin/example/package.json +13 -0
- package/bin/example/routes/index.js +28 -0
- package/bin/example/server.js +22 -0
- package/bin/src/index.js +48 -0
- package/bin/src/utils.js +129 -0
- package/package.json +7 -2
- package/src/html.js +42 -31
- package/test/index.js +7 -0
package/.eslintrc.json
CHANGED
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
ta**ghtml** lets you replace your template engine with fast JavaScript by leveraging the power of [tagged templates](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates).
|
|
2
2
|
|
|
3
3
|
Inspired by [html-template-tag](https://github.com/AntonioVdlC/html-template-tag).
|
|
4
4
|
|
package/bin/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Append unique hashes to assets referenced in your views to aggressively cache them while guaranteeing that clients receive the most recent versions.
|
|
2
|
+
|
|
3
|
+
## Usage
|
|
4
|
+
|
|
5
|
+
Running the following command will scan asset files found in the `roots` path(s) and replace their references with hashed versions in the `refs` path(s):
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx ghtml --roots="path/to/scan/assets1/,path/to/scan/assets2/" --refs="views/path/to/append/hashes1/,views/path/to/append/hashes2/"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Example (Fastify)
|
|
12
|
+
|
|
13
|
+
Register `@fastify/static`:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
await fastify.register(import("@fastify/static"), {
|
|
17
|
+
root: new URL("assets/", import.meta.url).pathname,
|
|
18
|
+
prefix: "/p/assets/",
|
|
19
|
+
wildcard: false,
|
|
20
|
+
index: false,
|
|
21
|
+
immutable: true,
|
|
22
|
+
maxAge: process.env.NODE_ENV === "production" ? 31536000 * 1000 : 0,
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Add the `ghtml` command to the build script:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "npx ghtml --roots=assets/ --refs=views/,routes/",
|
|
31
|
+
},
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Make sure to `npm run build` in `Dockerfile`:
|
|
35
|
+
|
|
36
|
+
```dockerfile
|
|
37
|
+
FROM node:latest
|
|
38
|
+
|
|
39
|
+
WORKDIR /app
|
|
40
|
+
|
|
41
|
+
COPY package*.json ./
|
|
42
|
+
|
|
43
|
+
RUN npm ci --include=dev
|
|
44
|
+
|
|
45
|
+
COPY . .
|
|
46
|
+
|
|
47
|
+
RUN npm run build
|
|
48
|
+
|
|
49
|
+
RUN npm prune --omit=dev
|
|
50
|
+
|
|
51
|
+
CMD ["npm", "start"]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Demo
|
|
55
|
+
|
|
56
|
+
A full project that uses the `ghtml` executable can be found in the `example` folder:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
cd example
|
|
60
|
+
|
|
61
|
+
npm i
|
|
62
|
+
|
|
63
|
+
npm run build
|
|
64
|
+
|
|
65
|
+
node .
|
|
66
|
+
```
|
|
Binary file
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
body {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
align-items: center;
|
|
5
|
+
justify-content: center;
|
|
6
|
+
height: 100vh;
|
|
7
|
+
margin: 0;
|
|
8
|
+
padding: 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
img {
|
|
12
|
+
max-width: 100%;
|
|
13
|
+
height: auto;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.caption {
|
|
17
|
+
text-align: center;
|
|
18
|
+
margin-top: 10px;
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"main": "./server.js",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"start": "node server.js",
|
|
6
|
+
"build": "node ../src/index.js --roots=assets/ --refs=routes/"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@fastify/static": "^7.0.1",
|
|
10
|
+
"fastify": "^4.26.1",
|
|
11
|
+
"fastify-html": "^0.3.3"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export default async (fastify) => {
|
|
2
|
+
const { html } = fastify;
|
|
3
|
+
|
|
4
|
+
fastify.addLayout((inner) => {
|
|
5
|
+
return html`<!doctype html>
|
|
6
|
+
<html lang="en">
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="UTF-8" />
|
|
9
|
+
<meta
|
|
10
|
+
name="viewport"
|
|
11
|
+
content="width=device-width, initial-scale=1.0"
|
|
12
|
+
/>
|
|
13
|
+
<title>Document</title>
|
|
14
|
+
<link rel="stylesheet" href="/p/assets/style.css" />
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
!${inner}
|
|
18
|
+
</body>
|
|
19
|
+
</html>`;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
fastify.get("/", async (request, reply) => {
|
|
23
|
+
return reply.html`
|
|
24
|
+
<h1 class="caption">Hello, world!</h1>
|
|
25
|
+
<img width="500" src="/p/assets/cat.jpeg" alt="Picture of a cat" />
|
|
26
|
+
`;
|
|
27
|
+
});
|
|
28
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* eslint n/no-missing-import: "off" */
|
|
2
|
+
|
|
3
|
+
import Fastify from "fastify";
|
|
4
|
+
|
|
5
|
+
const fastify = Fastify();
|
|
6
|
+
|
|
7
|
+
// Plugins
|
|
8
|
+
await fastify.register(import("@fastify/static"), {
|
|
9
|
+
root: new URL("assets/", import.meta.url).pathname,
|
|
10
|
+
prefix: "/p/assets/",
|
|
11
|
+
wildcard: false,
|
|
12
|
+
index: false,
|
|
13
|
+
immutable: true,
|
|
14
|
+
maxAge: 31536000 * 1000,
|
|
15
|
+
});
|
|
16
|
+
await fastify.register(import("fastify-html"));
|
|
17
|
+
|
|
18
|
+
// Routes
|
|
19
|
+
fastify.register(import("./routes/index.js"));
|
|
20
|
+
|
|
21
|
+
await fastify.listen({ port: 5050 });
|
|
22
|
+
console.warn("Server listening at http://localhost:5050");
|
package/bin/src/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { generateHashesAndReplace } from "./utils.js";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
|
|
6
|
+
const parseArguments = (args) => {
|
|
7
|
+
let roots = null;
|
|
8
|
+
let refs = null;
|
|
9
|
+
|
|
10
|
+
for (const arg of args) {
|
|
11
|
+
if (arg.startsWith("--roots=")) {
|
|
12
|
+
roots = arg.split("=", 2)[1].split(",");
|
|
13
|
+
} else if (arg.startsWith("--refs=")) {
|
|
14
|
+
refs = arg.split("=", 2)[1].split(",");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!roots || !refs) {
|
|
19
|
+
console.error(
|
|
20
|
+
'Usage: npx ghtml --roots="path/to/scan/assets1/,path/to/scan/assets2/" --refs="views/path/to/append/hashes1/,views/path/to/append/hashes2/"',
|
|
21
|
+
);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { roots, refs };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const main = async () => {
|
|
29
|
+
const { roots, refs } = parseArguments(process.argv.slice(2));
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
console.warn(`Generating hashes and updating file paths...`);
|
|
33
|
+
console.warn(`Scanning files in: ${roots}`);
|
|
34
|
+
console.warn(`Updating files in: ${refs}`);
|
|
35
|
+
|
|
36
|
+
await generateHashesAndReplace({
|
|
37
|
+
roots,
|
|
38
|
+
refs,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
console.warn("Hash generation and file updates completed successfully.");
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error(`Error occurred: ${error.message}`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
main();
|
package/bin/src/utils.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { win32, posix } from "node:path";
|
|
4
|
+
import { cpus } from "node:os";
|
|
5
|
+
import { Glob } from "glob";
|
|
6
|
+
import { promise as fastq } from "fastq";
|
|
7
|
+
const fastqConcurrency = Math.max(1, cpus().length - 1);
|
|
8
|
+
|
|
9
|
+
const generateFileHash = async (filePath) => {
|
|
10
|
+
try {
|
|
11
|
+
const fileBuffer = await readFile(filePath);
|
|
12
|
+
return createHash("md5").update(fileBuffer).digest("hex").slice(0, 16);
|
|
13
|
+
} catch (err) {
|
|
14
|
+
if (err.code !== "ENOENT") {
|
|
15
|
+
throw err;
|
|
16
|
+
}
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const updateFilePathsWithHashes = async (
|
|
22
|
+
fileHashes,
|
|
23
|
+
refs,
|
|
24
|
+
includeDotFiles,
|
|
25
|
+
skipPatterns,
|
|
26
|
+
) => {
|
|
27
|
+
for (let ref of refs) {
|
|
28
|
+
ref = ref.split(win32.sep).join(posix.sep);
|
|
29
|
+
if (!ref.endsWith("/")) {
|
|
30
|
+
ref += "/";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const filesIterable = new Glob("**/**", {
|
|
34
|
+
nodir: true,
|
|
35
|
+
follow: true,
|
|
36
|
+
absolute: true,
|
|
37
|
+
cwd: ref,
|
|
38
|
+
dot: includeDotFiles,
|
|
39
|
+
ignore: skipPatterns,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
for await (const file of filesIterable) {
|
|
43
|
+
let content = await readFile(file, "utf8");
|
|
44
|
+
let found = false;
|
|
45
|
+
|
|
46
|
+
for (const [originalPath, hash] of fileHashes) {
|
|
47
|
+
const escapedPath = originalPath.replace(
|
|
48
|
+
/[$()*+.?[\\\]^{|}]/gu,
|
|
49
|
+
"\\$&",
|
|
50
|
+
);
|
|
51
|
+
const regex = new RegExp(
|
|
52
|
+
`(?<path>${escapedPath})(\\?(?<queryString>[^#"'\`]*))?`,
|
|
53
|
+
"gu",
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
content = content.replace(
|
|
57
|
+
regex,
|
|
58
|
+
(match, p1, p2, p3, offset, string, groups) => {
|
|
59
|
+
found = true;
|
|
60
|
+
const { path, queryString } = groups;
|
|
61
|
+
|
|
62
|
+
return !queryString
|
|
63
|
+
? `${path}?hash=${hash}`
|
|
64
|
+
: queryString.includes("hash=")
|
|
65
|
+
? `${path}?${queryString.replace(/(?<hash>hash=)[\dA-Fa-f]*/u, `$1${hash}`)}`
|
|
66
|
+
: `${path}?hash=${hash}&${queryString}`;
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (found) {
|
|
72
|
+
await writeFile(file, content);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const generateHashesAndReplace = async ({
|
|
79
|
+
roots,
|
|
80
|
+
refs,
|
|
81
|
+
includeDotFiles = false,
|
|
82
|
+
skipPatterns = ["**/node_modules/**"],
|
|
83
|
+
}) => {
|
|
84
|
+
const fileHashes = new Map();
|
|
85
|
+
roots = Array.isArray(roots) ? roots : [roots];
|
|
86
|
+
refs = Array.isArray(refs) ? refs : [refs];
|
|
87
|
+
|
|
88
|
+
for (let rootPath of roots) {
|
|
89
|
+
rootPath = rootPath.split(win32.sep).join(posix.sep);
|
|
90
|
+
if (!rootPath.endsWith("/")) {
|
|
91
|
+
rootPath += "/";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const queue = fastq(generateFileHash, fastqConcurrency);
|
|
95
|
+
const queuePromises = [];
|
|
96
|
+
const files = [];
|
|
97
|
+
|
|
98
|
+
const filesIterable = new Glob("**/**", {
|
|
99
|
+
nodir: true,
|
|
100
|
+
follow: true,
|
|
101
|
+
absolute: true,
|
|
102
|
+
cwd: rootPath,
|
|
103
|
+
dot: includeDotFiles,
|
|
104
|
+
ignore: skipPatterns,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
for await (let file of filesIterable) {
|
|
108
|
+
file = file.split(win32.sep).join(posix.sep);
|
|
109
|
+
files.push(file);
|
|
110
|
+
queuePromises.push(queue.push(file));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const hashes = await Promise.all(queuePromises);
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < files.length; i++) {
|
|
116
|
+
const fileRelativePath = posix.relative(rootPath, files[i]);
|
|
117
|
+
fileHashes.set(fileRelativePath, hashes[i]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await updateFilePathsWithHashes(
|
|
122
|
+
fileHashes,
|
|
123
|
+
refs,
|
|
124
|
+
includeDotFiles,
|
|
125
|
+
skipPatterns,
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export { generateFileHash, generateHashesAndReplace };
|
package/package.json
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
"description": "Replace your template engine with fast JavaScript by leveraging the power of tagged templates.",
|
|
4
4
|
"author": "Gürgün Dayıoğlu",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"version": "2.0
|
|
6
|
+
"version": "2.1.0",
|
|
7
7
|
"type": "module",
|
|
8
|
+
"bin": "./bin/src/index.js",
|
|
8
9
|
"main": "./src/index.js",
|
|
9
10
|
"exports": {
|
|
10
11
|
".": "./src/index.js",
|
|
@@ -19,9 +20,13 @@
|
|
|
19
20
|
"lint": "eslint . && prettier --check .",
|
|
20
21
|
"lint:fix": "eslint --fix . && prettier --write ."
|
|
21
22
|
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"fastq": "^1.17.1",
|
|
25
|
+
"glob": "^10.4.2"
|
|
26
|
+
},
|
|
22
27
|
"devDependencies": {
|
|
23
28
|
"@fastify/pre-commit": "^2.1.0",
|
|
24
|
-
"c8": "^10.
|
|
29
|
+
"c8": "^10.1.2",
|
|
25
30
|
"grules": "^0.17.2",
|
|
26
31
|
"tinybench": "^2.8.0"
|
|
27
32
|
},
|
package/src/html.js
CHANGED
|
@@ -1,36 +1,42 @@
|
|
|
1
|
-
const
|
|
2
|
-
|
|
3
|
-
const symbolIterator = Symbol.iterator;
|
|
4
|
-
|
|
5
|
-
const symbolAsyncIterator = Symbol.asyncIterator;
|
|
6
|
-
|
|
7
|
-
const escapeDictionary = {
|
|
8
|
-
'"': """,
|
|
9
|
-
"&": "&",
|
|
10
|
-
"'": "'",
|
|
11
|
-
"<": "<",
|
|
12
|
-
">": ">",
|
|
13
|
-
"`": "`",
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const escapeRegExp = new RegExp(`[${Object.keys(escapeDictionary).join("")}]`);
|
|
17
|
-
|
|
1
|
+
const escapeRegExp = /["&'<>`]/;
|
|
18
2
|
const escapeFunction = (string) => {
|
|
19
3
|
const stringLength = string.length;
|
|
20
4
|
let start = 0;
|
|
21
5
|
let end = 0;
|
|
22
6
|
let escaped = "";
|
|
23
7
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
8
|
+
for (; end !== stringLength; ++end) {
|
|
9
|
+
switch (string.charCodeAt(end)) {
|
|
10
|
+
case 34: // "
|
|
11
|
+
escaped += string.slice(start, end) + """;
|
|
12
|
+
start = end + 1;
|
|
13
|
+
continue;
|
|
14
|
+
case 38: // &
|
|
15
|
+
escaped += string.slice(start, end) + "&";
|
|
16
|
+
start = end + 1;
|
|
17
|
+
continue;
|
|
18
|
+
case 39: // '
|
|
19
|
+
escaped += string.slice(start, end) + "'";
|
|
20
|
+
start = end + 1;
|
|
21
|
+
continue;
|
|
22
|
+
case 60: // <
|
|
23
|
+
escaped += string.slice(start, end) + "<";
|
|
24
|
+
start = end + 1;
|
|
25
|
+
continue;
|
|
26
|
+
case 62: // >
|
|
27
|
+
escaped += string.slice(start, end) + ">";
|
|
28
|
+
start = end + 1;
|
|
29
|
+
continue;
|
|
30
|
+
case 96: // `
|
|
31
|
+
escaped += string.slice(start, end) + "`";
|
|
32
|
+
start = end + 1;
|
|
33
|
+
continue;
|
|
30
34
|
}
|
|
31
|
-
}
|
|
35
|
+
}
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
escaped += string.slice(start, end);
|
|
38
|
+
|
|
39
|
+
return escaped;
|
|
34
40
|
};
|
|
35
41
|
|
|
36
42
|
/**
|
|
@@ -51,7 +57,7 @@ const html = ({ raw: literals }, ...expressions) => {
|
|
|
51
57
|
? expression
|
|
52
58
|
: expression == null
|
|
53
59
|
? ""
|
|
54
|
-
:
|
|
60
|
+
: Array.isArray(expression)
|
|
55
61
|
? expression.join("")
|
|
56
62
|
: `${expression}`;
|
|
57
63
|
|
|
@@ -64,7 +70,9 @@ const html = ({ raw: literals }, ...expressions) => {
|
|
|
64
70
|
accumulator += literal + string;
|
|
65
71
|
}
|
|
66
72
|
|
|
67
|
-
|
|
73
|
+
accumulator += literals[index];
|
|
74
|
+
|
|
75
|
+
return accumulator;
|
|
68
76
|
};
|
|
69
77
|
|
|
70
78
|
/**
|
|
@@ -86,7 +94,7 @@ const htmlGenerator = function* ({ raw: literals }, ...expressions) {
|
|
|
86
94
|
} else if (expression == null) {
|
|
87
95
|
string = "";
|
|
88
96
|
} else {
|
|
89
|
-
if (expression[
|
|
97
|
+
if (expression[Symbol.iterator]) {
|
|
90
98
|
const isRaw =
|
|
91
99
|
literal !== "" && literal.charCodeAt(literal.length - 1) === 33;
|
|
92
100
|
|
|
@@ -106,7 +114,7 @@ const htmlGenerator = function* ({ raw: literals }, ...expressions) {
|
|
|
106
114
|
continue;
|
|
107
115
|
}
|
|
108
116
|
|
|
109
|
-
if (expression[
|
|
117
|
+
if (expression[Symbol.iterator]) {
|
|
110
118
|
for (expression of expression) {
|
|
111
119
|
if (typeof expression === "string") {
|
|
112
120
|
string = expression;
|
|
@@ -183,7 +191,7 @@ const htmlAsyncGenerator = async function* ({ raw: literals }, ...expressions) {
|
|
|
183
191
|
} else if (expression == null) {
|
|
184
192
|
string = "";
|
|
185
193
|
} else {
|
|
186
|
-
if (expression[
|
|
194
|
+
if (expression[Symbol.iterator] || expression[Symbol.asyncIterator]) {
|
|
187
195
|
const isRaw =
|
|
188
196
|
literal !== "" && literal.charCodeAt(literal.length - 1) === 33;
|
|
189
197
|
|
|
@@ -203,7 +211,10 @@ const htmlAsyncGenerator = async function* ({ raw: literals }, ...expressions) {
|
|
|
203
211
|
continue;
|
|
204
212
|
}
|
|
205
213
|
|
|
206
|
-
if (
|
|
214
|
+
if (
|
|
215
|
+
expression[Symbol.iterator] ||
|
|
216
|
+
expression[Symbol.asyncIterator]
|
|
217
|
+
) {
|
|
207
218
|
for await (expression of expression) {
|
|
208
219
|
if (typeof expression === "string") {
|
|
209
220
|
string = expression;
|
package/test/index.js
CHANGED
|
@@ -63,6 +63,13 @@ test("renders unsafe content", () => {
|
|
|
63
63
|
);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
test("renders unsafe content /2", () => {
|
|
67
|
+
assert.strictEqual(
|
|
68
|
+
html`<p>${`${descriptionUnsafe}"&\``}</p>`,
|
|
69
|
+
`<p><script>alert('This is an unsafe description.')</script>"&`</p>`,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
66
73
|
test("renders arrays", () => {
|
|
67
74
|
assert.strictEqual(
|
|
68
75
|
html`<p>${[descriptionSafe, descriptionUnsafe]}</p>`,
|