From 19624dc729c7e03c06a9cf1be1589ab425ddfcf3 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Mon, 19 Jul 2021 21:19:49 +0200 Subject: [PATCH] refactor: Allow default template in template engines. --- .componentsignore | 1 + .eslintrc.js | 4 + .../pod/resource-generators/templated.json | 2 +- .../representation-conversion/default.json | 21 ++-- src/index.ts | 8 +- src/pods/generate/HandlebarsTemplateEngine.ts | 12 --- src/pods/generate/TemplateEngine.ts | 8 -- .../generate/TemplatedResourcesGenerator.ts | 27 +++--- .../conversion/ErrorToTemplateConverter.ts | 55 +++++------ .../conversion/MarkdownToHtmlConverter.ts | 29 +++--- .../templates/HandlebarsTemplateEngine.ts | 29 ++++++ src/util/templates/TemplateEngine.ts | 49 ++++++++++ .../generate/HandlebarsTemplateEngine.test.ts | 11 --- .../TemplatedResourcesGenerator.test.ts | 2 +- .../ErrorToTemplateConverter.test.ts | 96 +++++++++---------- .../MarkdownToHtmlConverter.test.ts | 30 +++--- .../HandlebarsTemplateEngine.test.ts | 23 +++++ .../util/templates/TemplateEngine.test.ts | 36 +++++++ 18 files changed, 269 insertions(+), 174 deletions(-) delete mode 100644 src/pods/generate/HandlebarsTemplateEngine.ts delete mode 100644 src/pods/generate/TemplateEngine.ts create mode 100644 src/util/templates/HandlebarsTemplateEngine.ts create mode 100644 src/util/templates/TemplateEngine.ts delete mode 100644 test/unit/pods/generate/HandlebarsTemplateEngine.test.ts create mode 100644 test/unit/util/templates/HandlebarsTemplateEngine.test.ts create mode 100644 test/unit/util/templates/TemplateEngine.test.ts diff --git a/.componentsignore b/.componentsignore index a482e25d5..1659e95c6 100644 --- a/.componentsignore +++ b/.componentsignore @@ -4,5 +4,6 @@ "Error", "EventEmitter", "HttpErrorOptions", + "Template", "ValuePreferencesArg" ] diff --git a/.eslintrc.js b/.eslintrc.js index 15c7e1eda..ed1509d3f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -86,6 +86,10 @@ module.exports = { 'unicorn/prefer-ternary': 'off', 'yield-star-spacing': [ 'error', 'after' ], + // Need to use the typescript version of this rule to support overloading + "no-dupe-class-members": "off", + "@typescript-eslint/no-dupe-class-members": ["error"], + // Naming conventions '@typescript-eslint/naming-convention': [ 'error', diff --git a/config/identity/pod/resource-generators/templated.json b/config/identity/pod/resource-generators/templated.json index bf7152f14..75e94add7 100644 --- a/config/identity/pod/resource-generators/templated.json +++ b/config/identity/pod/resource-generators/templated.json @@ -9,7 +9,7 @@ "factory": { "@type": "ExtensionBasedMapperFactory" }, - "engine": { + "templateEngine": { "@type": "HandlebarsTemplateEngine" } } diff --git a/config/util/representation-conversion/default.json b/config/util/representation-conversion/default.json index 271827531..0d7401b04 100644 --- a/config/util/representation-conversion/default.json +++ b/config/util/representation-conversion/default.json @@ -26,17 +26,24 @@ { "@id": "urn:solid-server:default:QuadToRdfConverter" }, { "@type": "ErrorToQuadConverter" }, { + "comment": "Converts an error into a Markdown description of its details.", "@type": "ErrorToTemplateConverter", - "engine": { "@type": "HandlebarsTemplateEngine" }, - "templatePath": "$PACKAGE_ROOT/templates/error/main.md", - "descriptions": "$PACKAGE_ROOT/templates/error/descriptions/", - "contentType": "text/markdown", - "extension": ".md" + "templateEngine": { + "@type": "HandlebarsTemplateEngine", + "template": { "templateFile": "$PACKAGE_ROOT/templates/error/main.md" } + }, + "templatePath": "$PACKAGE_ROOT/templates/error/descriptions/", + "extension": ".md", + "contentType": "text/markdown" }, { + "comment": "Renders Markdown snippets into the main page template.", "@type": "MarkdownToHtmlConverter", - "engine": { "@type": "HandlebarsTemplateEngine" }, - "templatePath": "$PACKAGE_ROOT/templates/main.html" + "templateEngine": { + "@id": "urn:solid-server:default:MainTemplateEngine", + "@type": "HandlebarsTemplateEngine", + "template": { "templateFile": "$PACKAGE_ROOT/templates/main.html" } + } } ] } diff --git a/src/index.ts b/src/index.ts index daaf77a53..f87bac283 100644 --- a/src/index.ts +++ b/src/index.ts @@ -174,14 +174,12 @@ export * from './pods/generate/variables/VariableSetter'; export * from './pods/generate/BaseComponentsJsFactory'; export * from './pods/generate/ComponentsJsFactory'; export * from './pods/generate/GenerateUtil'; -export * from './pods/generate/HandlebarsTemplateEngine'; export * from './pods/generate/IdentifierGenerator'; export * from './pods/generate/PodGenerator'; export * from './pods/generate/ResourcesGenerator'; export * from './pods/generate/SubdomainIdentifierGenerator'; export * from './pods/generate/SuffixIdentifierGenerator'; export * from './pods/generate/TemplatedPodGenerator'; -export * from './pods/generate/TemplateEngine'; export * from './pods/generate/TemplatedResourcesGenerator'; // Pods/Settings @@ -223,8 +221,8 @@ export * from './storage/conversion/ChainedConverter'; export * from './storage/conversion/ConstantConverter'; export * from './storage/conversion/ContentTypeReplacer'; export * from './storage/conversion/ConversionUtil'; -export * from './storage/conversion/ErrorToTemplateConverter'; export * from './storage/conversion/ErrorToQuadConverter'; +export * from './storage/conversion/ErrorToTemplateConverter'; export * from './storage/conversion/IfNeededConverter'; export * from './storage/conversion/MarkdownToHtmlConverter'; export * from './storage/conversion/PassthroughConverter'; @@ -311,6 +309,10 @@ export * from './util/locking/ResourceLocker'; export * from './util/locking/SingleThreadedResourceLocker'; export * from './util/locking/WrappedExpiringReadWriteLocker'; +// Util/Templates +export * from './util/templates/HandlebarsTemplateEngine'; +export * from './util/templates/TemplateEngine'; + // Util export * from './util/ContentTypes'; export * from './util/GuardedStream'; diff --git a/src/pods/generate/HandlebarsTemplateEngine.ts b/src/pods/generate/HandlebarsTemplateEngine.ts deleted file mode 100644 index fcf474b96..000000000 --- a/src/pods/generate/HandlebarsTemplateEngine.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { compile } from 'handlebars'; -import type { TemplateEngine } from './TemplateEngine'; - -/** - * Fills in Handlebars templates. - */ -export class HandlebarsTemplateEngine implements TemplateEngine { - public apply(template: string, options: NodeJS.Dict): string { - const compiled = compile(template); - return compiled(options); - } -} diff --git a/src/pods/generate/TemplateEngine.ts b/src/pods/generate/TemplateEngine.ts deleted file mode 100644 index 0f4224aa7..000000000 --- a/src/pods/generate/TemplateEngine.ts +++ /dev/null @@ -1,8 +0,0 @@ -import Dict = NodeJS.Dict; - -/** - * A template engine takes as input a template and applies the given options to it. - */ -export interface TemplateEngine { - apply: (template: string, options: Dict) => string; -} diff --git a/src/pods/generate/TemplatedResourcesGenerator.ts b/src/pods/generate/TemplatedResourcesGenerator.ts index 9c4bdca40..2865497f5 100644 --- a/src/pods/generate/TemplatedResourcesGenerator.ts +++ b/src/pods/generate/TemplatedResourcesGenerator.ts @@ -13,8 +13,8 @@ import { guardStream } from '../../util/GuardedStream'; import type { Guarded } from '../../util/GuardedStream'; import { joinFilePath, isContainerIdentifier, resolveAssetPath } from '../../util/PathUtil'; import { guardedStreamFrom, readableToString } from '../../util/StreamUtil'; +import type { TemplateEngine } from '../../util/templates/TemplateEngine'; import type { Resource, ResourcesGenerator } from './ResourcesGenerator'; -import type { TemplateEngine } from './TemplateEngine'; import Dict = NodeJS.Dict; interface TemplateResourceLink extends ResourceLink { @@ -33,7 +33,7 @@ interface TemplateResourceLink extends ResourceLink { export class TemplatedResourcesGenerator implements ResourcesGenerator { private readonly templateFolder: string; private readonly factory: FileIdentifierMapperFactory; - private readonly engine: TemplateEngine; + private readonly templateEngine: TemplateEngine; private readonly templateExtension: string; /** @@ -41,28 +41,28 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator { * * @param templateFolder - Folder where the templates are located. * @param factory - Factory used to generate mapper relative to the base identifier. - * @param engine - Template engine for generating the resources. + * @param templateEngine - Template engine for generating the resources. * @param templateExtension - The extension of files that need to be interpreted as templates. * Will be removed to generate the identifier. */ - public constructor(templateFolder: string, factory: FileIdentifierMapperFactory, engine: TemplateEngine, + public constructor(templateFolder: string, factory: FileIdentifierMapperFactory, templateEngine: TemplateEngine, templateExtension = '.hbs') { this.templateFolder = resolveAssetPath(templateFolder); this.factory = factory; - this.engine = engine; + this.templateEngine = templateEngine; this.templateExtension = templateExtension; } public async* generate(location: ResourceIdentifier, options: Dict): AsyncIterable { const mapper = await this.factory.create(location.path, this.templateFolder); const folderLink = await this.toTemplateLink(this.templateFolder, mapper); - yield* this.parseFolder(folderLink, mapper, options); + yield* this.processFolder(folderLink, mapper, options); } /** * Generates results for all entries in the given folder, including the folder itself. */ - private async* parseFolder(folderLink: TemplateResourceLink, mapper: FileIdentifierMapper, options: Dict): + private async* processFolder(folderLink: TemplateResourceLink, mapper: FileIdentifierMapper, options: Dict): AsyncIterable { // Group resource links with their corresponding metadata links const links = await this.groupLinks(folderLink.filePath, mapper); @@ -76,7 +76,7 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator { for (const { link, meta } of Object.values(links)) { if (isContainerIdentifier(link.identifier)) { - yield* this.parseFolder(link, mapper, options); + yield* this.processFolder(link, mapper, options); } else { yield this.generateResource(link, options, meta); } @@ -131,7 +131,7 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator { // Read file if it is not a container if (!isContainerIdentifier(link.identifier)) { - data = await this.parseFile(link, options); + data = await this.processFile(link, options); metadata.contentType = link.contentType; } @@ -154,7 +154,7 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator { Promise { const metadata = new RepresentationMetadata(metaLink.identifier); - const data = await this.parseFile(metaLink, options); + const data = await this.processFile(metaLink, options); const parser = new Parser({ format: metaLink.contentType, baseIRI: metaLink.identifier.path }); const quads = parser.parse(await readableToString(data)); metadata.addQuads(quads); @@ -165,11 +165,10 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator { /** * Creates a read stream from the file and applies the template if necessary. */ - private async parseFile(link: TemplateResourceLink, options: Dict): Promise> { + private async processFile(link: TemplateResourceLink, options: Dict): Promise> { if (link.isTemplate) { - const raw = await fsPromises.readFile(link.filePath, 'utf8'); - const result = this.engine.apply(raw, options); - return guardedStreamFrom(result); + const rendered = await this.templateEngine.render(options, { templateFile: link.filePath }); + return guardedStreamFrom(rendered); } return guardStream(createReadStream(link.filePath)); } diff --git a/src/storage/conversion/ErrorToTemplateConverter.ts b/src/storage/conversion/ErrorToTemplateConverter.ts index 083678756..89a70ef48 100644 --- a/src/storage/conversion/ErrorToTemplateConverter.ts +++ b/src/storage/conversion/ErrorToTemplateConverter.ts @@ -1,13 +1,11 @@ import assert from 'assert'; -import { promises as fsPromises } from 'fs'; import arrayifyStream from 'arrayify-stream'; import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; import type { Representation } from '../../ldp/representation/Representation'; -import type { TemplateEngine } from '../../pods/generate/TemplateEngine'; import { INTERNAL_ERROR } from '../../util/ContentTypes'; import { HttpError } from '../../util/errors/HttpError'; import { InternalServerError } from '../../util/errors/InternalServerError'; -import { joinFilePath, resolveAssetPath } from '../../util/PathUtil'; +import type { TemplateEngine } from '../../util/templates/TemplateEngine'; import type { RepresentationConverterArgs } from './RepresentationConverter'; import { TypedRepresentationConverter } from './TypedRepresentationConverter'; @@ -23,52 +21,45 @@ import { TypedRepresentationConverter } from './TypedRepresentationConverter'; * using the variable `codeMessage`. */ export class ErrorToTemplateConverter extends TypedRepresentationConverter { - private readonly engine: TemplateEngine; + private readonly templateEngine: TemplateEngine; private readonly templatePath: string; - private readonly descriptions: string; - private readonly contentType: string; private readonly extension: string; + private readonly contentType: string; - public constructor(engine: TemplateEngine, templatePath: string, descriptions: string, contentType: string, - extension: string) { + public constructor(templateEngine: TemplateEngine, templatePath: string, extension: string, contentType: string) { super(INTERNAL_ERROR, contentType); - this.engine = engine; - this.templatePath = resolveAssetPath(templatePath); - this.descriptions = resolveAssetPath(descriptions); - this.contentType = contentType; + this.templateEngine = templateEngine; + this.templatePath = templatePath; this.extension = extension; + this.contentType = contentType; } public async handle({ representation }: RepresentationConverterArgs): Promise { + // Obtain the error from the representation stream const errors = await arrayifyStream(representation.data); if (errors.length !== 1) { throw new InternalServerError('Only single errors are supported.'); } const error = errors[0] as Error; - // Render the template + // Render the error description using an error-specific template + let description: string | undefined; + if (HttpError.isInstance(error)) { + try { + const templateFile = `${error.errorCode}${this.extension}`; + assert(/^[\w.-]+$/u.test(templateFile), 'Invalid error template name'); + description = await this.templateEngine.render(error.details ?? {}, + { templateFile, templatePath: this.templatePath }); + } catch { + // In case no template is found, or rendering errors, we still want to convert + } + } + + // Render the main template, embedding the rendered error description const { name, message, stack } = error; - const description = await this.getErrorCodeMessage(error); const variables = { name, message, stack, description }; - const template = await fsPromises.readFile(this.templatePath, 'utf8'); - const rendered = this.engine.apply(template, variables); + const rendered = await this.templateEngine.render(variables); return new BasicRepresentation(rendered, representation.metadata, this.contentType); } - - private async getErrorCodeMessage(error: Error): Promise { - if (HttpError.isInstance(error)) { - let template: string; - try { - const fileName = `${error.errorCode}${this.extension}`; - assert(/^[\w.-]+$/u.test(fileName), 'Invalid error template name'); - template = await fsPromises.readFile(joinFilePath(this.descriptions, fileName), 'utf8'); - } catch { - // In case no template is found we still want to convert - return; - } - - return this.engine.apply(template, (error.details ?? {}) as NodeJS.Dict); - } - } } diff --git a/src/storage/conversion/MarkdownToHtmlConverter.ts b/src/storage/conversion/MarkdownToHtmlConverter.ts index e17466fff..1bf8c5ac9 100644 --- a/src/storage/conversion/MarkdownToHtmlConverter.ts +++ b/src/storage/conversion/MarkdownToHtmlConverter.ts @@ -1,41 +1,34 @@ -import { promises as fsPromises } from 'fs'; import marked from 'marked'; import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; import type { Representation } from '../../ldp/representation/Representation'; -import type { TemplateEngine } from '../../pods/generate/TemplateEngine'; import { TEXT_HTML, TEXT_MARKDOWN } from '../../util/ContentTypes'; -import { resolveAssetPath } from '../../util/PathUtil'; import { readableToString } from '../../util/StreamUtil'; +import type { TemplateEngine } from '../../util/templates/TemplateEngine'; import type { RepresentationConverterArgs } from './RepresentationConverter'; import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** - * Converts markdown data to HTML. + * Converts Markdown data to HTML. * The generated HTML will be injected into the given template using the parameter `htmlBody`. - * A standard markdown string will be converted to a

tag, so html and body tags should be part of the template. - * In case the markdown body starts with a header (#), that value will also be used as `title` parameter. + * A standard Markdown string will be converted to a

tag, so html and body tags should be part of the template. + * In case the Markdown body starts with a header (#), that value will also be used as `title` parameter. */ export class MarkdownToHtmlConverter extends TypedRepresentationConverter { - private readonly engine: TemplateEngine; - private readonly templatePath: string; + private readonly templateEngine: TemplateEngine; - public constructor(engine: TemplateEngine, templatePath: string) { + public constructor(templateEngine: TemplateEngine) { super(TEXT_MARKDOWN, TEXT_HTML); - this.engine = engine; - this.templatePath = resolveAssetPath(templatePath); + this.templateEngine = templateEngine; } public async handle({ representation }: RepresentationConverterArgs): Promise { const markdown = await readableToString(representation.data); + // Try to extract the main title for use in the tag + const title = /^#+\s*([^\n]+)\n/u.exec(markdown)?.[1]; - // See if there is a title we can use - const match = /^\s*#+\s*([^\n]+)\n/u.exec(markdown); - const title = match?.[1]; - + // Place the rendered Markdown into the HTML template const htmlBody = marked(markdown); - - const template = await fsPromises.readFile(this.templatePath, 'utf8'); - const html = this.engine.apply(template, { htmlBody, title }); + const html = await this.templateEngine.render({ htmlBody, title }); return new BasicRepresentation(html, representation.metadata, TEXT_HTML); } diff --git a/src/util/templates/HandlebarsTemplateEngine.ts b/src/util/templates/HandlebarsTemplateEngine.ts new file mode 100644 index 000000000..dc9d513fb --- /dev/null +++ b/src/util/templates/HandlebarsTemplateEngine.ts @@ -0,0 +1,29 @@ +/* eslint-disable tsdoc/syntax */ +// tsdoc/syntax can't handle {json} parameter +import type { TemplateDelegate } from 'handlebars'; +import { compile } from 'handlebars'; +import type { TemplateEngine, Template } from './TemplateEngine'; +import { readTemplate } from './TemplateEngine'; +import Dict = NodeJS.Dict; + +/** + * Fills in Handlebars templates. + */ +export class HandlebarsTemplateEngine<T extends Dict<any> = Dict<any>> implements TemplateEngine<T> { + private readonly applyTemplate: Promise<TemplateDelegate>; + + /** + * @param template - The default template @range {json} + */ + public constructor(template?: Template) { + this.applyTemplate = readTemplate(template) + .then((templateString: string): TemplateDelegate => compile(templateString)); + } + + public async render(contents: T): Promise<string>; + public async render<TCustom = T>(contents: TCustom, template: Template): Promise<string>; + public async render<TCustom = T>(contents: TCustom, template?: Template): Promise<string> { + const applyTemplate = template ? compile(await readTemplate(template)) : await this.applyTemplate; + return applyTemplate(contents); + } +} diff --git a/src/util/templates/TemplateEngine.ts b/src/util/templates/TemplateEngine.ts new file mode 100644 index 000000000..9de294dea --- /dev/null +++ b/src/util/templates/TemplateEngine.ts @@ -0,0 +1,49 @@ +import { promises as fsPromises } from 'fs'; +import { joinFilePath, resolveAssetPath } from '../PathUtil'; +import Dict = NodeJS.Dict; + +export type Template = TemplateString | TemplatePath; + +export interface TemplateString { + // String contents of the template + templateString: string; +} + +export interface TemplatePath { + // Name of the template file + templateFile: string; + // Path of the template file + templatePath?: string; +} + +/* eslint-disable @typescript-eslint/method-signature-style */ +/** + * A template engine renders content into a template. + */ +export interface TemplateEngine<T extends Dict<any> = Dict<any>> { + /** + * Renders the given contents into the template. + * + * @param contents - The contents to render. + * @param template - The template to use for rendering; + * if omitted, a default template is used. + * @returns The rendered contents. + */ + render(contents: T): Promise<string>; + render<TCustom = T>(contents: TCustom, template: Template): Promise<string>; +} +/* eslint-enable @typescript-eslint/method-signature-style */ + +/** + * Reads the template and returns it as a string. + */ +export async function readTemplate(template: Template = { templateString: '' }): Promise<string> { + // The template has already been given as a string + if ('templateString' in template) { + return template.templateString; + } + // The template needs to be read from disk + const { templateFile, templatePath } = template; + const fullTemplatePath = templatePath ? joinFilePath(templatePath, templateFile) : templateFile; + return fsPromises.readFile(resolveAssetPath(fullTemplatePath), 'utf8'); +} diff --git a/test/unit/pods/generate/HandlebarsTemplateEngine.test.ts b/test/unit/pods/generate/HandlebarsTemplateEngine.test.ts deleted file mode 100644 index ba5d220bb..000000000 --- a/test/unit/pods/generate/HandlebarsTemplateEngine.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { HandlebarsTemplateEngine } from '../../../../src/pods/generate/HandlebarsTemplateEngine'; - -describe('A HandlebarsTemplateEngine', (): void => { - const engine = new HandlebarsTemplateEngine(); - - it('fills in Handlebars templates.', async(): Promise<void> => { - const template = '<{{webId}}> a <http://xmlns.com/foaf/0.1/Person>.'; - const options = { webId: 'http://alice/#profile' }; - expect(engine.apply(template, options)).toBe('<http://alice/#profile> a <http://xmlns.com/foaf/0.1/Person>.'); - }); -}); diff --git a/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts b/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts index c5cdb0544..b68a1133e 100644 --- a/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts +++ b/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts @@ -1,5 +1,4 @@ import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; -import { HandlebarsTemplateEngine } from '../../../../src/pods/generate/HandlebarsTemplateEngine'; import { TemplatedResourcesGenerator } from '../../../../src/pods/generate/TemplatedResourcesGenerator'; import type { FileIdentifierMapper, @@ -8,6 +7,7 @@ import type { } from '../../../../src/storage/mapping/FileIdentifierMapper'; import { ensureTrailingSlash, trimTrailingSlashes } from '../../../../src/util/PathUtil'; import { readableToString } from '../../../../src/util/StreamUtil'; +import { HandlebarsTemplateEngine } from '../../../../src/util/templates/HandlebarsTemplateEngine'; import { mockFs } from '../../../util/Util'; jest.mock('fs'); diff --git a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts index d1502d360..3e6b0354f 100644 --- a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts +++ b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts @@ -1,32 +1,23 @@ import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; -import type { TemplateEngine } from '../../../../src/pods/generate/TemplateEngine'; import { ErrorToTemplateConverter } from '../../../../src/storage/conversion/ErrorToTemplateConverter'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; import { readableToString } from '../../../../src/util/StreamUtil'; -import { mockFs } from '../../../util/Util'; - -jest.mock('fs'); +import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; describe('An ErrorToTemplateConverter', (): void => { - let cache: { data: any }; const identifier = { path: 'http://test.com/error' }; - const templatePath = '/templates/error.template'; - const descriptions = '/templates/codes'; + const templatePath = '/templates/codes'; const errorCode = 'E0001'; - let engine: TemplateEngine; + let templateEngine: jest.Mocked<TemplateEngine>; let converter: ErrorToTemplateConverter; const preferences = {}; beforeEach(async(): Promise<void> => { - cache = mockFs('/templates'); - cache.data['error.template'] = '{{ template }}'; - cache.data.codes = { [`${errorCode}.html`]: '{{{ errorText }}}' }; - engine = { - apply: jest.fn().mockReturnValue('<html>'), + templateEngine = { + render: jest.fn().mockReturnValue(Promise.resolve('<html>')), }; - - converter = new ErrorToTemplateConverter(engine, templatePath, descriptions, 'text/html', '.html'); + converter = new ErrorToTemplateConverter(templateEngine, templatePath, '.html', 'text/html'); }); it('supports going from errors to the given content type.', async(): Promise<void> => { @@ -46,13 +37,14 @@ describe('An ErrorToTemplateConverter', (): void => { const representation = new BasicRepresentation([ error ], 'internal/error', false); const prom = converter.handle({ identifier, representation, preferences }); await expect(prom).resolves.toBeDefined(); + const result = await prom; expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe('<html>'); - expect(engine.apply).toHaveBeenCalledTimes(1); - expect(engine.apply).toHaveBeenLastCalledWith( - '{{ template }}', { name: 'Error', message: 'error text', stack: error.stack }, + expect(templateEngine.render).toHaveBeenCalledTimes(1); + expect(templateEngine.render).toHaveBeenLastCalledWith( + { name: 'Error', message: 'error text', stack: error.stack }, ); }); @@ -60,15 +52,19 @@ describe('An ErrorToTemplateConverter', (): void => { const error = new BadRequestHttpError('error text'); const representation = new BasicRepresentation([ error ], 'internal/error', false); const prom = converter.handle({ identifier, representation, preferences }); + templateEngine.render.mockRejectedValueOnce(new Error('error-specific template not found')); await expect(prom).resolves.toBeDefined(); + const result = await prom; expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe('<html>'); - expect(engine.apply).toHaveBeenCalledTimes(1); - expect(engine.apply).toHaveBeenLastCalledWith( - '{{ template }}', { name: 'BadRequestHttpError', message: 'error text', stack: error.stack }, - ); + expect(templateEngine.render).toHaveBeenCalledTimes(2); + expect(templateEngine.render).toHaveBeenNthCalledWith(1, + {}, + { templatePath: '/templates/codes', templateFile: 'H400.html' }); + expect(templateEngine.render).toHaveBeenNthCalledWith(2, + { name: 'BadRequestHttpError', message: 'error text', stack: error.stack }); }); it('only adds stack if it is defined.', async(): Promise<void> => { @@ -76,18 +72,22 @@ describe('An ErrorToTemplateConverter', (): void => { delete error.stack; const representation = new BasicRepresentation([ error ], 'internal/error', false); const prom = converter.handle({ identifier, representation, preferences }); + templateEngine.render.mockRejectedValueOnce(new Error('error-specific template not found')); await expect(prom).resolves.toBeDefined(); + const result = await prom; expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe('<html>'); - expect(engine.apply).toHaveBeenCalledTimes(1); - expect(engine.apply).toHaveBeenLastCalledWith( - '{{ template }}', { name: 'BadRequestHttpError', message: 'error text' }, - ); + expect(templateEngine.render).toHaveBeenCalledTimes(2); + expect(templateEngine.render).toHaveBeenNthCalledWith(1, + {}, + { templatePath: '/templates/codes', templateFile: 'H400.html' }); + expect(templateEngine.render).toHaveBeenNthCalledWith(2, + { name: 'BadRequestHttpError', message: 'error text' }); }); - it('adds additional information if an error code is found.', async(): Promise<void> => { + it('adds additional information if an error code description is found.', async(): Promise<void> => { const error = new BadRequestHttpError('error text', { errorCode, details: { key: 'val' }}); const representation = new BasicRepresentation([ error ], 'internal/error', false); const prom = converter.handle({ identifier, representation, preferences }); @@ -96,14 +96,12 @@ describe('An ErrorToTemplateConverter', (): void => { expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe('<html>'); - expect(engine.apply).toHaveBeenCalledTimes(2); - expect(engine.apply).toHaveBeenCalledWith( - '{{{ errorText }}}', { key: 'val' }, - ); - expect(engine.apply).toHaveBeenLastCalledWith( - '{{ template }}', - { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' }, - ); + expect(templateEngine.render).toHaveBeenCalledTimes(2); + expect(templateEngine.render).toHaveBeenNthCalledWith(1, + { key: 'val' }, + { templatePath: '/templates/codes', templateFile: 'E0001.html' }); + expect(templateEngine.render).toHaveBeenNthCalledWith(2, + { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' }); }); it('sends an empty object for additional error code parameters if none are defined.', async(): Promise<void> => { @@ -111,33 +109,35 @@ describe('An ErrorToTemplateConverter', (): void => { const representation = new BasicRepresentation([ error ], 'internal/error', false); const prom = converter.handle({ identifier, representation, preferences }); await expect(prom).resolves.toBeDefined(); + const result = await prom; expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe('<html>'); - expect(engine.apply).toHaveBeenCalledTimes(2); - expect(engine.apply).toHaveBeenCalledWith( - '{{{ errorText }}}', { }, - ); - expect(engine.apply).toHaveBeenLastCalledWith( - '{{ template }}', - { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' }, - ); + expect(templateEngine.render).toHaveBeenCalledTimes(2); + expect(templateEngine.render).toHaveBeenNthCalledWith(1, + {}, + { templatePath: '/templates/codes', templateFile: 'E0001.html' }); + expect(templateEngine.render).toHaveBeenNthCalledWith(2, + { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' }); }); it('converts errors with a code as usual if no corresponding template is found.', async(): Promise<void> => { const error = new BadRequestHttpError('error text', { errorCode: 'invalid' }); const representation = new BasicRepresentation([ error ], 'internal/error', false); const prom = converter.handle({ identifier, representation, preferences }); + templateEngine.render.mockRejectedValueOnce(new Error('error-specific template not found')); await expect(prom).resolves.toBeDefined(); + const result = await prom; expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe('<html>'); - expect(engine.apply).toHaveBeenCalledTimes(1); - expect(engine.apply).toHaveBeenLastCalledWith( - '{{ template }}', - { name: 'BadRequestHttpError', message: 'error text', stack: error.stack }, - ); + expect(templateEngine.render).toHaveBeenCalledTimes(2); + expect(templateEngine.render).toHaveBeenNthCalledWith(1, + {}, + { templatePath: '/templates/codes', templateFile: 'invalid.html' }); + expect(templateEngine.render).toHaveBeenNthCalledWith(2, + { name: 'BadRequestHttpError', message: 'error text', stack: error.stack }); }); }); diff --git a/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts b/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts index 6eea42c3f..ee140fdc8 100644 --- a/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts +++ b/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts @@ -1,27 +1,19 @@ import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; -import type { TemplateEngine } from '../../../../src/pods/generate/TemplateEngine'; import { MarkdownToHtmlConverter } from '../../../../src/storage/conversion/MarkdownToHtmlConverter'; import { readableToString } from '../../../../src/util/StreamUtil'; -import { mockFs } from '../../../util/Util'; - -jest.mock('fs'); +import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; describe('A MarkdownToHtmlConverter', (): void => { - let cache: { data: any }; const identifier = { path: 'http://test.com/text' }; - const templatePath = '/templates/error.template'; const preferences = {}; - let engine: TemplateEngine; + let templateEngine: TemplateEngine; let converter: MarkdownToHtmlConverter; beforeEach(async(): Promise<void> => { - cache = mockFs('/templates'); - cache.data['error.template'] = '{{ template }}'; - engine = { - apply: jest.fn().mockReturnValue('<html>'), + templateEngine = { + render: jest.fn().mockReturnValue(Promise.resolve('<html>')), }; - - converter = new MarkdownToHtmlConverter(engine, templatePath); + converter = new MarkdownToHtmlConverter(templateEngine); }); it('supports going from markdown to html.', async(): Promise<void> => { @@ -38,9 +30,9 @@ describe('A MarkdownToHtmlConverter', (): void => { expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe('<html>'); - expect(engine.apply).toHaveBeenCalledTimes(1); - expect(engine.apply).toHaveBeenLastCalledWith( - '{{ template }}', { htmlBody: '<p>Text <code>code</code> more text.</p>\n' }, + expect(templateEngine.render).toHaveBeenCalledTimes(1); + expect(templateEngine.render).toHaveBeenLastCalledWith( + { htmlBody: '<p>Text <code>code</code> more text.</p>\n' }, ); }); @@ -53,9 +45,9 @@ describe('A MarkdownToHtmlConverter', (): void => { expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe('<html>'); - expect(engine.apply).toHaveBeenCalledTimes(1); - expect(engine.apply).toHaveBeenLastCalledWith( - '{{ template }}', { htmlBody: '<h1 id="title-text">title text</h1>\n<p>more text</p>\n', title: 'title text' }, + expect(templateEngine.render).toHaveBeenCalledTimes(1); + expect(templateEngine.render).toHaveBeenLastCalledWith( + { htmlBody: '<h1 id="title-text">title text</h1>\n<p>more text</p>\n', title: 'title text' }, ); }); }); diff --git a/test/unit/util/templates/HandlebarsTemplateEngine.test.ts b/test/unit/util/templates/HandlebarsTemplateEngine.test.ts new file mode 100644 index 000000000..273a057cf --- /dev/null +++ b/test/unit/util/templates/HandlebarsTemplateEngine.test.ts @@ -0,0 +1,23 @@ +import { HandlebarsTemplateEngine } from '../../../../src/util/templates/HandlebarsTemplateEngine'; + +jest.mock('../../../../src/util/templates/TemplateEngine', (): any => ({ + readTemplate: jest.fn(async({ templateString }): Promise<string> => `${templateString}: {{detail}}`), +})); + +describe('A HandlebarsTemplateEngine', (): void => { + const template = { templateString: 'xyz' }; + const contents = { detail: 'a&b' }; + let templateEngine: HandlebarsTemplateEngine; + + beforeEach((): void => { + templateEngine = new HandlebarsTemplateEngine(template); + }); + + it('uses the default template when no template was passed.', async(): Promise<void> => { + await expect(templateEngine.render(contents)).resolves.toBe('xyz: a&b'); + }); + + it('uses the passed template.', async(): Promise<void> => { + await expect(templateEngine.render(contents, { templateString: 'my' })).resolves.toBe('my: a&b'); + }); +}); diff --git a/test/unit/util/templates/TemplateEngine.test.ts b/test/unit/util/templates/TemplateEngine.test.ts new file mode 100644 index 000000000..8a517c0ee --- /dev/null +++ b/test/unit/util/templates/TemplateEngine.test.ts @@ -0,0 +1,36 @@ +import { resolveAssetPath } from '../../../../src/util/PathUtil'; +import { readTemplate } from '../../../../src/util/templates/TemplateEngine'; +import { mockFs } from '../../../util/Util'; + +jest.mock('fs'); + +describe('readTemplate', (): void => { + const templateFile = 'template.xyz'; + const templatePath = 'other'; + + beforeEach(async(): Promise<void> => { + const { data } = mockFs(resolveAssetPath('')); + Object.assign(data, { + 'template.xyz': '{{template}}', + other: { + 'template.xyz': '{{other}}', + }, + }); + }); + + it('returns the empty string when no template is provided.', async(): Promise<void> => { + await expect(readTemplate()).resolves.toBe(''); + }); + + it('accepts string templates.', async(): Promise<void> => { + await expect(readTemplate({ templateString: 'abc' })).resolves.toBe('abc'); + }); + + it('accepts a filename.', async(): Promise<void> => { + await expect(readTemplate({ templateFile })).resolves.toBe('{{template}}'); + }); + + it('accepts a filename and path.', async(): Promise<void> => { + await expect(readTemplate({ templateFile, templatePath })).resolves.toBe('{{other}}'); + }); +});