use html-template-helper, validate html in tests
authorJustin Wind <justin.wind+git@gmail.com>
Sat, 14 Jun 2025 22:22:47 +0000 (15:22 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Sat, 14 Jun 2025 22:22:47 +0000 (15:22 -0700)
package-lock.json
package.json
src/template/info-html.js
src/template/report-html.js
src/template/root-html.js
test/src/template/info-html.js [new file with mode: 0644]
test/src/template/report-html.js [new file with mode: 0644]
test/src/template/root-html.js [new file with mode: 0644]

index 9c160571492ec95eeb409b7f5eb29651e05a8e24..c59083473690170ce6f4a5c21adf6781c7971cd3 100644 (file)
@@ -10,6 +10,7 @@
       "license": "ISC",
       "dependencies": {
         "@squeep/api-dingus": "^2",
+        "@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.6.1",
         "@squeep/logger-json-console": "^3",
         "argon2": "^0.43.0",
         "better-sqlite3": "^11",
@@ -21,6 +22,7 @@
         "@squeep/eslint-config": "^1",
         "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v3.0.1",
         "eslint": "^9",
+        "html-validate": "^9",
         "husky": "^9",
         "mocha": "^11",
         "nyc": "^17",
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
       }
     },
+    "node_modules/@html-validate/stylish": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/@html-validate/stylish/-/stylish-4.2.0.tgz",
+      "integrity": "sha512-Nl8HCv0hGRSLQ+n1OD4Hk3a+Urwk9HH0vQkAzzCarT4KlA7bRl+6xEiS5PZVwOmjtC7XiH/oNe3as9Fxcr2A1w==",
+      "dev": true,
+      "dependencies": {
+        "kleur": "^4.0.0"
+      },
+      "engines": {
+        "node": ">= 16"
+      }
+    },
     "node_modules/@humanfs/core": {
       "version": "0.19.1",
       "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
         "eslint": ">= 9"
       }
     },
+    "node_modules/@squeep/html-template-helper": {
+      "version": "1.6.1",
+      "resolved": "git+https://git.squeep.com/squeep-html-template-helper#93d1b030d6b3c6ea93c36a46f4940181a1acaca0",
+      "dependencies": {
+        "@squeep/lazy-property": "^1"
+      },
+      "engines": {
+        "node": ">=14.13.1"
+      }
+    },
+    "node_modules/@squeep/lazy-property": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@squeep/lazy-property/-/lazy-property-1.1.4.tgz",
+      "integrity": "sha512-UQ8Q+Znc4Ms0e+2K46DZH88S4f8xTwFIAwzM0rSSD2n6KCuAh38hDK2gyAE17sH9QekRYzUQdpLLdBwqM49Ihw==",
+      "engines": {
+        "node": ">=20"
+      }
+    },
     "node_modules/@squeep/log-helper": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/@squeep/log-helper/-/log-helper-1.0.1.tgz",
       "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
       "dev": true
     },
+    "node_modules/fast-uri": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
+      "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ]
+    },
     "node_modules/fastq": {
       "version": "1.19.1",
       "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
       "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
       "dev": true
     },
+    "node_modules/html-validate": {
+      "version": "9.6.1",
+      "resolved": "https://registry.npmjs.org/html-validate/-/html-validate-9.6.1.tgz",
+      "integrity": "sha512-BmHr0dac/HKNxP3Iks7Nvos50ohl45ZeZ071OqDMsvF2Hi+didPsuveM3Wjdlx78TMkqA+S87syiER9TmKPrpw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/html-validate"
+        }
+      ],
+      "dependencies": {
+        "@html-validate/stylish": "^4.1.0",
+        "@sidvind/better-ajv-errors": "4.0.0",
+        "ajv": "^8.0.0",
+        "glob": "^10.0.0",
+        "kleur": "^4.1.0",
+        "minimist": "^1.2.0",
+        "prompts": "^2.0.0",
+        "semver": "^7.0.0"
+      },
+      "bin": {
+        "html-validate": "bin/html-validate.mjs"
+      },
+      "engines": {
+        "node": "^18.19.0 || >= 20.6.0"
+      },
+      "peerDependencies": {
+        "jest": "^27.1 || ^28.1.3 || ^29.0.3 || ^30.0.0",
+        "jest-diff": "^27.1 || ^28.1.3 || ^29.0.3 || ^30.0.0",
+        "jest-snapshot": "^27.1 || ^28.1.3 || ^29.0.3 || ^30.0.0",
+        "vitest": "^0.34.0 || ^1.0.0 || ^2.0.0 || ^3.0.0"
+      },
+      "peerDependenciesMeta": {
+        "jest": {
+          "optional": true
+        },
+        "jest-diff": {
+          "optional": true
+        },
+        "jest-snapshot": {
+          "optional": true
+        },
+        "vitest": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/html-validate/node_modules/@sidvind/better-ajv-errors": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/@sidvind/better-ajv-errors/-/better-ajv-errors-4.0.0.tgz",
+      "integrity": "sha512-rLZQkN6IfNwG6iqZZwqFMcs7DvQX3ZrLVhsHmSO1LUA4EZAz+VZLpTBCIOFsC5Qu3xuwzVfRMZ+1rtk/mCRRZw==",
+      "dev": true,
+      "dependencies": {
+        "kleur": "^4.1.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "peerDependencies": {
+        "ajv": "^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/html-validate/node_modules/ajv": {
+      "version": "8.17.1",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
+      "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "fast-uri": "^3.0.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/html-validate/node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "dev": true
+    },
     "node_modules/http-proxy-agent": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
         "json-buffer": "3.0.1"
       }
     },
+    "node_modules/kleur": {
+      "version": "4.1.5",
+      "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+      "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/levn": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
         "read": "^1.0.4"
       }
     },
+    "node_modules/prompts": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
+      "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
+      "dev": true,
+      "dependencies": {
+        "kleur": "^3.0.3",
+        "sisteransi": "^1.0.5"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/prompts/node_modules/kleur": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
+      "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/proxy-agent": {
       "version": "6.4.0",
       "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz",
         "node": ">=0.10.0"
       }
     },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/require-in-the-middle": {
       "version": "5.2.0",
       "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-5.2.0.tgz",
         "url": "https://opencollective.com/sinon"
       }
     },
+    "node_modules/sisteransi": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+      "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+      "dev": true
+    },
     "node_modules/smart-buffer": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
index 34fe62e899ae97f2dd9efbadc53979527a50c652..918cf344f462e5ba4942399a9c1d68d3867b3163 100644 (file)
@@ -22,6 +22,7 @@
   "license": "ISC",
   "dependencies": {
     "@squeep/api-dingus": "^2",
+    "@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.6.1",
     "@squeep/logger-json-console": "^3",
     "argon2": "^0.43.0",
     "better-sqlite3": "^11",
@@ -34,6 +35,7 @@
     "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v3.0.1",
     "eslint": "^9",
     "husky": "^9",
+    "html-validate": "^9",
     "mocha": "^11",
     "nyc": "^17",
     "sinon": "^21"
index 3fd62f99feae4e2a8b3089719c761d4209bdaf7e..d18a2c51b9d7460e193625a00c6ce4a606731538 100644 (file)
@@ -1,32 +1,48 @@
 'use strict';
 
-const toDateString = (ts) => (ts && isFinite(ts)) ? new Date(ts * 1000).toISOString() : 'never';
+const { TemplateHelper: th } = require('@squeep/html-template-helper');
 
-module.exports = (ctx, details, pageTitle) => {
+const toDateString = (ts) => {
+  try {
+    return (ts && isFinite(ts)) ? (new Date(ts * 1000).toISOString()) : 'never';
+  } catch (e) { // eslint-disable-line no-unused-vars
+    return `(whatever '${ts.toString()}' is)`;
+  }
+};
+
+
+/**
+ * 
+ * @param {object} details link details
+ * @returns {string} HTML details section
+ */
+function renderDetailsSection(details) {
   const created = toDateString(details.created);
   const lastAccess = toDateString(details.lastAccess);
   const expires = details.expires ? toDateString(details.expires) : '';
 
-  return `<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="utf-8">
-    <title>${pageTitle} - ${details.id} Details</title>
-    <link rel="stylesheet" href="../../static/theme.css">
-  </head>
-  <body>
-    <header>
-      <h1>Link Details</h1>
-    </header>
-    <main>
-      <div class="identifier">id: <span><a href="/${details.id}">${details.id}</a></span></div>
-      <div class="url">url: <span>${details.url}</span></div>
-      <div class="created">created: <time datetime="${created}">${created}</time></div>
-      <div class="accesses">accesses: <span>${details.accesses}</span></div>
-      <div class="lastaccess">last access: <time datetime="${lastAccess}">${lastAccess}</time></div>` +
-      (!details.expires ? '' : `
-      <div class="expires">expires: <time datetime="${expires}">${expires}</time></div>`) + `
-    </main>
-  </body>
-</html>`;
+  return `<section>
+\t<div class="identifier">id: <span><a href="/${details.id}">${details.id}</a></span></div>
+\t<div class="url">url: <span>${details.url}</span></div>
+\t<div class="created">created: <time datetime="${created}">${created}</time></div>
+\t<div class="accesses">accesses: <span>${details.accesses}</span></div>
+\t<div class="lastaccess">last access: <time datetime="${lastAccess}">${lastAccess}</time></div>` +
+(!details.expires ? '' : `
+\t<div class="expires">expires: <time datetime="${expires}">${expires}</time></div>`) + `
+</section>`;
+}
+
+module.exports = (ctx, details, pageTitle) => {
+  const pagePathLevel = 1;
+  const htmlOptions = {
+    pageIdentifier: 'details',
+    pageTitle,
+    footerEntries: [],
+    headElements: [],
+  };
+
+  const main = [
+    renderDetailsSection(details),
+  ];
+  return th.htmlPage(pagePathLevel, ctx, htmlOptions, main);
 };
\ No newline at end of file
index 62a823fe036d8982c1003477e400b6b0408e0dce..9c995d92f7c200b0681e71625167764a0efb977d 100644 (file)
@@ -1,5 +1,7 @@
 'use strict';
 
+const { TemplateHelper: th } = require('@squeep/html-template-helper');
+
 const toDateString = (ts) => {
   try {
     return (ts && isFinite(ts)) ? (new Date(ts * 1000).toISOString()) : 'never';
@@ -8,43 +10,66 @@ const toDateString = (ts) => {
   }
 };
 
+
+/**
+ * 
+ * @returns {string} HTML table header row
+ */
+function renderLinkHeaderRow() {
+  const headers = ['id', 'url', 'accesses', 'created', 'expires', 'last access'];
+  return '<tr>' +
+    (headers.map((h) => `\t<th scope="col">${h}</th>`).join('\n')) +
+    '</tr>';
+}
+
+
+/**
+ * 
+ * @param {object} link link object
+ * @returns {string} HTML table row
+ */
+function renderLinkRow(link) {
+  return `<tr>
+\t<th scope="row"><a href="/${link.id}/info">${link.id}</a></th>
+\t<td>${link.url}</td>
+\t<td>${link.accesses}</td>
+\t<td>${toDateString(link.created)}</td>
+\t<td>${toDateString(link.expires)}</td>
+\t<td>${toDateString(link.lastAccess)}</td>
+</tr>`;
+}
+
+
+/**
+ * 
+ * @param {object[]} links array of link objects
+ * @returns {string} link report section
+ */
+function linkReportSection(links) {
+  return `<section>
+\t<table>
+\t\t<thead>
+${renderLinkHeaderRow()}
+\t\t</thead>
+\t\t<tbody>
+${links.map((l) => renderLinkRow(l)).join('\n')}
+\t\t</tbody>
+\t</table>
+</section>`;
+}
+
+
 module.exports = (ctx, links, pageTitle) => {
-  return `<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="utf-8">
-    <title>${pageTitle} - Link Report</title>
-    <link rel="stylesheet" href="../../static/theme.css">
-  </head>
-  <body>
-    <header>
-      <h1>Link Report</h1>
-    </header>
-    <main>
-      <table>
-        <thead>
-          <tr>
-            <th scope="col">id</th>
-            <th scope="col">url</th>
-            <th scope="col">accesses</th>
-            <th scope="col">created</th>
-            <th scope="col">expires</th>
-            <th scope="col">last access</th>
-          </tr>
-        </thead>
-        <tbody>` +
-  links.map((l) => `
-          <tr>
-            <th scope="row"><a href="/${l.id}/info">${l.id}</a></th>
-            <td>${l.url}</td>
-            <td>${l.accesses}</td>
-            <td>${toDateString(l.created)}</td>
-            <td>${toDateString(l.expires)}</td>
-            <td>${toDateString(l.lastAccess)}</td>
-          </tr>`).join('\n') + `
-        </tbody>
-      <table>
-    </main>
-  </body>
-</html>`;
+  const pagePathLevel = 1;
+  const htmlOptions = {
+    pageIdentifier: 'report',
+    pageTitle,
+    footerEntries: [],
+    headElements: [],
+  };
+
+  const main = [
+    linkReportSection(links),
+  ];
+  return th.htmlPage(pagePathLevel, ctx, htmlOptions, main);
 };
index c7550c5e37251cdcc072acd5680c95da6d104946..eaae2ad0bf96847ae3c84f91771ba0aa39b62d4d 100644 (file)
 'use strict';
 
+const { TemplateHelper: th } = require('@squeep/html-template-helper');
+
+/**
+ * 
+ * @param {object} ctx context
+ * @returns {string} copy script or not
+ */
+function copyScript(ctx) {
+  return '<script>' + (!ctx.createdLink ? '0;' : `
+document.addEventListener('DOMContentLoaded', function () {
+\tlet timeout;
+\tdocument.querySelector('.copy-button').addEventListener('click', function (event) {
+\t\tconst linkHref = document.querySelector('.link').href;
+\t\tnavigator.clipboard.writeText(linkHref);
+\t\tconst copyButton = this;
+\t\tcopyButton.style.backgroundColor = 'lightgreen';
+\t\tif (timeout) {
+\t\t\tclearTimeout(timeout);
+\t\t}
+\t\ttimeout = setTimeout(function () {
+\t\t\tcopyButton.style.backgroundColor = 'initial';
+\t\t\ttimeout = undefined;
+\t\t}, 3000);
+\t});
+});`) + '</script>';
+}
+
+
+/**
+ * 
+ * @param {object} ctx context
+ * @returns {string} link created section or not
+ */
+function linkCreatedSection(ctx) {
+  return (!ctx.createdLink ? '' : `<section class="created-link">
+\t<a class="src-link" href="${ctx.sourceLink}">${ctx.sourceLink}</a> is now liked to by <a class="link" href="${ctx.createdLink}">${ctx.createdLink}</a>
+\t<span class="copy">
+\t\t<button type="button" class="copy-button">Copy to Clipboard</button>
+\t</span>
+\t<div class="meta-link">
+\t\tcontrol token <span class="auth-token">${ctx.authToken}</span>
+\t</div>
+</section>`);
+}
+
+
+/**
+ * 
+ * @returns {string} create link form section
+ */
+function createLinkSection() {
+  return `<section class="create-link">
+\t<form method="post" class="submit-url">
+\t\t<div class="submit-url">
+\t\t\t<label for="url">URL: </label>
+\t\t\t<input type="text" name="url" id="url" required>
+\t\t</div>
+\t\t<div class="submit-url">
+\t\t\t<button type="submit" value="Create Link">Create Link</button>
+\t\t</div>
+\t</form>
+</section>
+`;
+}
+
+
+/**
+ * 
+ * @param {object} ctx context
+ * @returns {string} message section or not
+ */
+function messageSection(ctx) {
+  return (!ctx.message ? '' : `<section class="message">
+\t${ctx.message}
+</section>`);
+}
+
+/**
+ * @param {object} ctx context
+ * @param {string} ctx.createdLink created link url
+ * @param {string} ctx.sourceLink source link url
+ * @param {string} ctx.authToken auth token
+ * @param {string} ctx.message message
+ * @param {string} pageTitle page title
+ * @param {string} logoUrl logo url
+ * @returns {string} html content
+ */
 module.exports = (ctx, pageTitle, logoUrl) => {
-  const logoImg = logoUrl ? `<img src="${logoUrl}" class="logo">` : '';
-  return `<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="utf-8">
-    <meta name="robots" content="noindex">
-    <title>${pageTitle}</title>
-    <link rel="stylesheet" href="static/theme.css">
-  </head>
-  <body>
-  <script type="text/javascript">` + (!ctx.createdLink ? '0;' : `
-    document.addEventListener('DOMContentLoaded', function () {
-      let timeout;
-      document.querySelector('.copy-button').addEventListener('click', function (event) {
-        const linkHref = document.querySelector('.link').href;
-        navigator.clipboard.writeText(linkHref);
-        const copyButton = this;
-        copyButton.style.backgroundColor = 'lightgreen';
-        if (timeout) {
-          clearTimeout(timeout);
-        }
-        timeout = setTimeout(function () {
-          copyButton.style.backgroundColor = 'initial';
-          timeout = undefined;
-        }, 3000);
-      });
-    });`) + `
-    </script>
-    <header>
-      <h1>${logoImg}${pageTitle}</h1>
-    </header>` +
-    (!ctx.createdLink ? '' : `
-    <section class="created-link">
-      <a class="src-link" href="${ctx.sourceLink}">${ctx.sourceLink}</a>
-      is now liked to by
-      <a class="link" href="${ctx.createdLink}">${ctx.createdLink}</a>
-      <span class="copy">
-        <button class="copy-button">Copy to Clipboard</button>
-      </span>
-      <div class="meta-link">
-        control token <span class="auth-token">${ctx.authToken}</span>
-      </div>
-    </section>`) +
-    (!ctx.message ? '' : `
-    <section class="message">
-      ${ctx.message}
-    </section>`) + `
-    <section class="create-link">
-      <form action="" method="post" class="submit-url">
-        <div class="submit-url">
-          <label for="url">URL: </label>
-          <input type="text" name="url" id="url" required>
-        </div>
-        <div class="submit-url">
-          <input type="submit" value="Create Link">
-        </div>
-      </form>
-    </section>
-  </body>
-</html>`;
+  const pagePathLevel = 0;
+  const htmlOptions = {
+    pageIdentifier: 'root',
+    pageTitle,
+    logoUrl,
+    footerEntries: [],
+    headElements: [
+      '<meta name="robots" content="noindex">',
+    ],
+  };
+  const main = [
+    copyScript(ctx),
+    linkCreatedSection(ctx),
+    messageSection(ctx),
+    createLinkSection(),
+  ];
+
+  return th.htmlPage(pagePathLevel, ctx, htmlOptions, main);
 };
\ No newline at end of file
diff --git a/test/src/template/info-html.js b/test/src/template/info-html.js
new file mode 100644 (file)
index 0000000..46bc2d8
--- /dev/null
@@ -0,0 +1,41 @@
+'use strict';
+
+const assert = require('node:assert');
+const sinon = require('sinon');
+const template = require('../../../src/template/info-html');
+const { StubLogger } = require('@squeep/test-helper');
+const { makeHtmlLint } = require('@squeep/html-template-helper');
+const { HtmlValidate } = require('html-validate');
+
+const stubLogger = new StubLogger(sinon);
+const htmlValidate = new HtmlValidate();
+const lintHtml = makeHtmlLint(stubLogger, htmlValidate);
+
+describe('Info HTML Template', function () {
+  let ctx, details, pageTitle;
+  beforeEach(function () {
+    pageTitle = 'info page';
+    ctx = {};
+    details = {
+      id: 'abc123',
+      url: 'https://example.com',
+      accesses: 42,
+      created: 1672531200, // 2023-01-01T00:00:00.000Z
+      expires: 1704067200, // 2024-01-01T00:00:00.000Z
+      lastAccess: 1696118400, // 2023-10-01T00:00:00.000Z
+    };
+  });
+  it('renders details', async function () {
+    const result = template(ctx, details, pageTitle);
+    await lintHtml(result);
+    assert(result);
+  });
+  it('renders odd details', async function () {
+    details.expires = Infinity;
+    details.lastAccess = 1696118400n;
+    const result = template(ctx, details, pageTitle);
+    await lintHtml(result);
+    assert(result);
+  });
+
+}); // Report HTML Template
\ No newline at end of file
diff --git a/test/src/template/report-html.js b/test/src/template/report-html.js
new file mode 100644 (file)
index 0000000..c159190
--- /dev/null
@@ -0,0 +1,51 @@
+'use strict';
+
+const assert = require('node:assert');
+const sinon = require('sinon');
+const template = require('../../../src/template/report-html');
+const { StubLogger } = require('@squeep/test-helper');
+const { makeHtmlLint } = require('@squeep/html-template-helper');
+const { HtmlValidate } = require('html-validate');
+
+const stubLogger = new StubLogger(sinon);
+const htmlValidate = new HtmlValidate();
+const lintHtml = makeHtmlLint(stubLogger, htmlValidate);
+
+describe('Report HTML Template', function () {
+  let ctx, pageTitle, links;
+  beforeEach(function () {
+    pageTitle = 'report page';
+    ctx = {};
+    links = [
+      {
+        id: 'abc123',
+        url: 'https://example.com',
+        accesses: 42,
+        created: 1672531200, // 2023-01-01T00:00:00.000Z
+        expires: 1704067200, // 2024-01-01T00:00:00.000Z
+        lastAccess: 1696118400, // 2023-10-01T00:00:00.000Z
+      },
+      {
+        id: 'def456',
+        url: 'https://example.org',
+        accesses: 0,
+        created: null,
+        expires: Infinity,
+        lastAccess: null,
+      },
+      {
+        id: 'ghi789',
+        url: 'https://example.net',
+        accesses: 100,
+        created: 1672531200, // 2023-01-01T00:00:00.000Z
+        expires: null,
+        lastAccess: 1672531200n,
+      },
+    ];
+  });
+  it('renders links', async function () {
+    const result = template(ctx, links, pageTitle);
+    await lintHtml(result);
+    assert(result);
+  });
+}); // Report HTML Template
\ No newline at end of file
diff --git a/test/src/template/root-html.js b/test/src/template/root-html.js
new file mode 100644 (file)
index 0000000..f82ad5c
--- /dev/null
@@ -0,0 +1,34 @@
+'use strict';
+
+const assert = require('node:assert');
+const sinon = require('sinon');
+const template = require('../../../src/template/root-html');
+const { StubLogger } = require('@squeep/test-helper');
+const { makeHtmlLint } = require('@squeep/html-template-helper');
+const { HtmlValidate } = require('html-validate');
+
+const stubLogger = new StubLogger(sinon);
+const htmlValidate = new HtmlValidate();
+const lintHtml = makeHtmlLint(stubLogger, htmlValidate);
+
+describe('Root HTML Template', function () {
+  let ctx, pageTitle, logoUrl;
+  beforeEach(function () {
+    pageTitle = 'root page';
+    ctx = {};
+  });
+  it('renders plain', async function () {
+    const result = template(ctx, pageTitle, logoUrl);
+    await lintHtml(result);
+    assert(result);
+  });
+  it('covers created link', async function () {
+    ctx.createdLink = 'https://example.com/created';
+    ctx.sourceLink = 'https://example.com/source';
+    ctx.authToken = 'auth-token';
+    ctx.message = 'message';
+    const result = template(ctx, pageTitle, logoUrl);
+    await lintHtml(result);
+    assert(result);
+  });
+}); // Root HTML Template
\ No newline at end of file