refactor: Allow default template in template engines.

This commit is contained in:
Ruben Verborgh 2021-07-19 21:19:49 +02:00 committed by Joachim Van Herwegen
parent 1488c7e221
commit 19624dc729
18 changed files with 269 additions and 174 deletions

View File

@ -4,5 +4,6 @@
"Error",
"EventEmitter",
"HttpErrorOptions",
"Template",
"ValuePreferencesArg"
]

View File

@ -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',

View File

@ -9,7 +9,7 @@
"factory": {
"@type": "ExtensionBasedMapperFactory"
},
"engine": {
"templateEngine": {
"@type": "HandlebarsTemplateEngine"
}
}

View File

@ -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" }
}
}
]
}

View File

@ -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';

View File

@ -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>): string {
const compiled = compile(template);
return compiled(options);
}
}

View File

@ -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>) => string;
}

View File

@ -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<string>): AsyncIterable<Resource> {
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<string>):
private async* processFolder(folderLink: TemplateResourceLink, mapper: FileIdentifierMapper, options: Dict<string>):
AsyncIterable<Resource> {
// 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<RepresentationMetadata> {
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<string>): Promise<Guarded<Readable>> {
private async processFile(link: TemplateResourceLink, options: Dict<string>): Promise<Guarded<Readable>> {
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));
}

View File

@ -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<Representation> {
// 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<string | undefined> {
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<string>);
}
}
}

View File

@ -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 <p> 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 <p> 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<Representation> {
const markdown = await readableToString(representation.data);
// Try to extract the main title for use in the <title> 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);
}

View File

@ -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);
}
}

View File

@ -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');
}

View File

@ -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>.');
});
});

View File

@ -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');

View File

@ -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 });
});
});

View File

@ -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' },
);
});
});

View File

@ -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&amp;b');
});
it('uses the passed template.', async(): Promise<void> => {
await expect(templateEngine.render(contents, { templateString: 'my' })).resolves.toBe('my: a&amp;b');
});
});

View File

@ -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}}');
});
});