From 4b758f9fffc72f135866fbfd198242dbf404eb3d Mon Sep 17 00:00:00 2001 From: Justin Wind Date: Sat, 14 Jun 2025 15:22:47 -0700 Subject: [PATCH] use html-template-helper, validate html in tests --- package-lock.json | 179 +++++++++++++++++++++++++++++++ package.json | 2 + src/template/info-html.js | 64 ++++++----- src/template/report-html.js | 101 ++++++++++------- src/template/root-html.js | 165 +++++++++++++++++----------- test/src/template/info-html.js | 41 +++++++ test/src/template/report-html.js | 51 +++++++++ test/src/template/root-html.js | 34 ++++++ 8 files changed, 515 insertions(+), 122 deletions(-) create mode 100644 test/src/template/info-html.js create mode 100644 test/src/template/report-html.js create mode 100644 test/src/template/root-html.js diff --git a/package-lock.json b/package-lock.json index 9c16057..c590834 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -462,6 +464,18 @@ "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", @@ -1043,6 +1057,24 @@ "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", @@ -2477,6 +2509,22 @@ "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", @@ -2858,6 +2906,91 @@ "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", @@ -3356,6 +3489,15 @@ "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", @@ -4677,6 +4819,28 @@ "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", @@ -4844,6 +5008,15 @@ "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", @@ -5151,6 +5324,12 @@ "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", diff --git a/package.json b/package.json index 34fe62e..918cf34 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/template/info-html.js b/src/template/info-html.js index 3fd62f9..d18a2c5 100644 --- a/src/template/info-html.js +++ b/src/template/info-html.js @@ -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 ` - - - - ${pageTitle} - ${details.id} Details - - - -
-

Link Details

-
-
- -
url: ${details.url}
-
created:
-
accesses: ${details.accesses}
-
last access:
` + - (!details.expires ? '' : ` -
expires:
`) + ` -
- -`; + return `
+\t +\t
url: ${details.url}
+\t
created:
+\t
accesses: ${details.accesses}
+\t
last access:
` + +(!details.expires ? '' : ` +\t
expires:
`) + ` +
`; +} + +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 diff --git a/src/template/report-html.js b/src/template/report-html.js index 62a823f..9c995d9 100644 --- a/src/template/report-html.js +++ b/src/template/report-html.js @@ -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 '' + + (headers.map((h) => `\t${h}`).join('\n')) + + ''; +} + + +/** + * + * @param {object} link link object + * @returns {string} HTML table row + */ +function renderLinkRow(link) { + return ` +\t${link.id} +\t${link.url} +\t${link.accesses} +\t${toDateString(link.created)} +\t${toDateString(link.expires)} +\t${toDateString(link.lastAccess)} +`; +} + + +/** + * + * @param {object[]} links array of link objects + * @returns {string} link report section + */ +function linkReportSection(links) { + return `
+\t +\t\t +${renderLinkHeaderRow()} +\t\t +\t\t +${links.map((l) => renderLinkRow(l)).join('\n')} +\t\t +\t
+
`; +} + + module.exports = (ctx, links, pageTitle) => { - return ` - - - - ${pageTitle} - Link Report - - - -
-

Link Report

-
-
- - - - - - - - - - - - ` + - links.map((l) => ` - - - - - - - - `).join('\n') + ` - -
idurlaccessescreatedexpireslast access
${l.id}${l.url}${l.accesses}${toDateString(l.created)}${toDateString(l.expires)}${toDateString(l.lastAccess)}
- - -`; + const pagePathLevel = 1; + const htmlOptions = { + pageIdentifier: 'report', + pageTitle, + footerEntries: [], + headElements: [], + }; + + const main = [ + linkReportSection(links), + ]; + return th.htmlPage(pagePathLevel, ctx, htmlOptions, main); }; diff --git a/src/template/root-html.js b/src/template/root-html.js index c7550c5..eaae2ad 100644 --- a/src/template/root-html.js +++ b/src/template/root-html.js @@ -1,64 +1,109 @@ 'use strict'; +const { TemplateHelper: th } = require('@squeep/html-template-helper'); + +/** + * + * @param {object} ctx context + * @returns {string} copy script or not + */ +function copyScript(ctx) { + return ''; +} + + +/** + * + * @param {object} ctx context + * @returns {string} link created section or not + */ +function linkCreatedSection(ctx) { + return (!ctx.createdLink ? '' : ``); +} + + +/** + * + * @returns {string} create link form section + */ +function createLinkSection() { + return ` +`; +} + + +/** + * + * @param {object} ctx context + * @returns {string} message section or not + */ +function messageSection(ctx) { + return (!ctx.message ? '' : `
+\t${ctx.message} +
`); +} + +/** + * @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 ? `` : ''; - return ` - - - - - ${pageTitle} - - - - -
-

${logoImg}${pageTitle}

-
` + - (!ctx.createdLink ? '' : ` - `) + - (!ctx.message ? '' : ` -
- ${ctx.message} -
`) + ` - - -`; + const pagePathLevel = 0; + const htmlOptions = { + pageIdentifier: 'root', + pageTitle, + logoUrl, + footerEntries: [], + headElements: [ + '', + ], + }; + 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 index 0000000..46bc2d8 --- /dev/null +++ b/test/src/template/info-html.js @@ -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 index 0000000..c159190 --- /dev/null +++ b/test/src/template/report-html.js @@ -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 index 0000000..f82ad5c --- /dev/null +++ b/test/src/template/root-html.js @@ -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 -- 2.49.0