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", "Error",
"EventEmitter", "EventEmitter",
"HttpErrorOptions", "HttpErrorOptions",
"Template",
"ValuePreferencesArg" "ValuePreferencesArg"
] ]

View File

@ -86,6 +86,10 @@ module.exports = {
'unicorn/prefer-ternary': 'off', 'unicorn/prefer-ternary': 'off',
'yield-star-spacing': [ 'error', 'after' ], '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 // Naming conventions
'@typescript-eslint/naming-convention': [ '@typescript-eslint/naming-convention': [
'error', 'error',

View File

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

View File

@ -26,17 +26,24 @@
{ "@id": "urn:solid-server:default:QuadToRdfConverter" }, { "@id": "urn:solid-server:default:QuadToRdfConverter" },
{ "@type": "ErrorToQuadConverter" }, { "@type": "ErrorToQuadConverter" },
{ {
"comment": "Converts an error into a Markdown description of its details.",
"@type": "ErrorToTemplateConverter", "@type": "ErrorToTemplateConverter",
"engine": { "@type": "HandlebarsTemplateEngine" }, "templateEngine": {
"templatePath": "$PACKAGE_ROOT/templates/error/main.md", "@type": "HandlebarsTemplateEngine",
"descriptions": "$PACKAGE_ROOT/templates/error/descriptions/", "template": { "templateFile": "$PACKAGE_ROOT/templates/error/main.md" }
"contentType": "text/markdown", },
"extension": ".md" "templatePath": "$PACKAGE_ROOT/templates/error/descriptions/",
"extension": ".md",
"contentType": "text/markdown"
}, },
{ {
"comment": "Renders Markdown snippets into the main page template.",
"@type": "MarkdownToHtmlConverter", "@type": "MarkdownToHtmlConverter",
"engine": { "@type": "HandlebarsTemplateEngine" }, "templateEngine": {
"templatePath": "$PACKAGE_ROOT/templates/main.html" "@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/BaseComponentsJsFactory';
export * from './pods/generate/ComponentsJsFactory'; export * from './pods/generate/ComponentsJsFactory';
export * from './pods/generate/GenerateUtil'; export * from './pods/generate/GenerateUtil';
export * from './pods/generate/HandlebarsTemplateEngine';
export * from './pods/generate/IdentifierGenerator'; export * from './pods/generate/IdentifierGenerator';
export * from './pods/generate/PodGenerator'; export * from './pods/generate/PodGenerator';
export * from './pods/generate/ResourcesGenerator'; export * from './pods/generate/ResourcesGenerator';
export * from './pods/generate/SubdomainIdentifierGenerator'; export * from './pods/generate/SubdomainIdentifierGenerator';
export * from './pods/generate/SuffixIdentifierGenerator'; export * from './pods/generate/SuffixIdentifierGenerator';
export * from './pods/generate/TemplatedPodGenerator'; export * from './pods/generate/TemplatedPodGenerator';
export * from './pods/generate/TemplateEngine';
export * from './pods/generate/TemplatedResourcesGenerator'; export * from './pods/generate/TemplatedResourcesGenerator';
// Pods/Settings // Pods/Settings
@ -223,8 +221,8 @@ export * from './storage/conversion/ChainedConverter';
export * from './storage/conversion/ConstantConverter'; export * from './storage/conversion/ConstantConverter';
export * from './storage/conversion/ContentTypeReplacer'; export * from './storage/conversion/ContentTypeReplacer';
export * from './storage/conversion/ConversionUtil'; export * from './storage/conversion/ConversionUtil';
export * from './storage/conversion/ErrorToTemplateConverter';
export * from './storage/conversion/ErrorToQuadConverter'; export * from './storage/conversion/ErrorToQuadConverter';
export * from './storage/conversion/ErrorToTemplateConverter';
export * from './storage/conversion/IfNeededConverter'; export * from './storage/conversion/IfNeededConverter';
export * from './storage/conversion/MarkdownToHtmlConverter'; export * from './storage/conversion/MarkdownToHtmlConverter';
export * from './storage/conversion/PassthroughConverter'; export * from './storage/conversion/PassthroughConverter';
@ -311,6 +309,10 @@ export * from './util/locking/ResourceLocker';
export * from './util/locking/SingleThreadedResourceLocker'; export * from './util/locking/SingleThreadedResourceLocker';
export * from './util/locking/WrappedExpiringReadWriteLocker'; export * from './util/locking/WrappedExpiringReadWriteLocker';
// Util/Templates
export * from './util/templates/HandlebarsTemplateEngine';
export * from './util/templates/TemplateEngine';
// Util // Util
export * from './util/ContentTypes'; export * from './util/ContentTypes';
export * from './util/GuardedStream'; 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 type { Guarded } from '../../util/GuardedStream';
import { joinFilePath, isContainerIdentifier, resolveAssetPath } from '../../util/PathUtil'; import { joinFilePath, isContainerIdentifier, resolveAssetPath } from '../../util/PathUtil';
import { guardedStreamFrom, readableToString } from '../../util/StreamUtil'; import { guardedStreamFrom, readableToString } from '../../util/StreamUtil';
import type { TemplateEngine } from '../../util/templates/TemplateEngine';
import type { Resource, ResourcesGenerator } from './ResourcesGenerator'; import type { Resource, ResourcesGenerator } from './ResourcesGenerator';
import type { TemplateEngine } from './TemplateEngine';
import Dict = NodeJS.Dict; import Dict = NodeJS.Dict;
interface TemplateResourceLink extends ResourceLink { interface TemplateResourceLink extends ResourceLink {
@ -33,7 +33,7 @@ interface TemplateResourceLink extends ResourceLink {
export class TemplatedResourcesGenerator implements ResourcesGenerator { export class TemplatedResourcesGenerator implements ResourcesGenerator {
private readonly templateFolder: string; private readonly templateFolder: string;
private readonly factory: FileIdentifierMapperFactory; private readonly factory: FileIdentifierMapperFactory;
private readonly engine: TemplateEngine; private readonly templateEngine: TemplateEngine;
private readonly templateExtension: string; private readonly templateExtension: string;
/** /**
@ -41,28 +41,28 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator {
* *
* @param templateFolder - Folder where the templates are located. * @param templateFolder - Folder where the templates are located.
* @param factory - Factory used to generate mapper relative to the base identifier. * @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. * @param templateExtension - The extension of files that need to be interpreted as templates.
* Will be removed to generate the identifier. * 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') { templateExtension = '.hbs') {
this.templateFolder = resolveAssetPath(templateFolder); this.templateFolder = resolveAssetPath(templateFolder);
this.factory = factory; this.factory = factory;
this.engine = engine; this.templateEngine = templateEngine;
this.templateExtension = templateExtension; this.templateExtension = templateExtension;
} }
public async* generate(location: ResourceIdentifier, options: Dict<string>): AsyncIterable<Resource> { public async* generate(location: ResourceIdentifier, options: Dict<string>): AsyncIterable<Resource> {
const mapper = await this.factory.create(location.path, this.templateFolder); const mapper = await this.factory.create(location.path, this.templateFolder);
const folderLink = await this.toTemplateLink(this.templateFolder, mapper); 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. * 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> { AsyncIterable<Resource> {
// Group resource links with their corresponding metadata links // Group resource links with their corresponding metadata links
const links = await this.groupLinks(folderLink.filePath, mapper); 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)) { for (const { link, meta } of Object.values(links)) {
if (isContainerIdentifier(link.identifier)) { if (isContainerIdentifier(link.identifier)) {
yield* this.parseFolder(link, mapper, options); yield* this.processFolder(link, mapper, options);
} else { } else {
yield this.generateResource(link, options, meta); yield this.generateResource(link, options, meta);
} }
@ -131,7 +131,7 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator {
// Read file if it is not a container // Read file if it is not a container
if (!isContainerIdentifier(link.identifier)) { if (!isContainerIdentifier(link.identifier)) {
data = await this.parseFile(link, options); data = await this.processFile(link, options);
metadata.contentType = link.contentType; metadata.contentType = link.contentType;
} }
@ -154,7 +154,7 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator {
Promise<RepresentationMetadata> { Promise<RepresentationMetadata> {
const metadata = new RepresentationMetadata(metaLink.identifier); 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 parser = new Parser({ format: metaLink.contentType, baseIRI: metaLink.identifier.path });
const quads = parser.parse(await readableToString(data)); const quads = parser.parse(await readableToString(data));
metadata.addQuads(quads); 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. * 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) { if (link.isTemplate) {
const raw = await fsPromises.readFile(link.filePath, 'utf8'); const rendered = await this.templateEngine.render(options, { templateFile: link.filePath });
const result = this.engine.apply(raw, options); return guardedStreamFrom(rendered);
return guardedStreamFrom(result);
} }
return guardStream(createReadStream(link.filePath)); return guardStream(createReadStream(link.filePath));
} }

View File

@ -1,13 +1,11 @@
import assert from 'assert'; import assert from 'assert';
import { promises as fsPromises } from 'fs';
import arrayifyStream from 'arrayify-stream'; import arrayifyStream from 'arrayify-stream';
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
import type { Representation } from '../../ldp/representation/Representation'; import type { Representation } from '../../ldp/representation/Representation';
import type { TemplateEngine } from '../../pods/generate/TemplateEngine';
import { INTERNAL_ERROR } from '../../util/ContentTypes'; import { INTERNAL_ERROR } from '../../util/ContentTypes';
import { HttpError } from '../../util/errors/HttpError'; import { HttpError } from '../../util/errors/HttpError';
import { InternalServerError } from '../../util/errors/InternalServerError'; 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 type { RepresentationConverterArgs } from './RepresentationConverter';
import { TypedRepresentationConverter } from './TypedRepresentationConverter'; import { TypedRepresentationConverter } from './TypedRepresentationConverter';
@ -23,52 +21,45 @@ import { TypedRepresentationConverter } from './TypedRepresentationConverter';
* using the variable `codeMessage`. * using the variable `codeMessage`.
*/ */
export class ErrorToTemplateConverter extends TypedRepresentationConverter { export class ErrorToTemplateConverter extends TypedRepresentationConverter {
private readonly engine: TemplateEngine; private readonly templateEngine: TemplateEngine;
private readonly templatePath: string; private readonly templatePath: string;
private readonly descriptions: string;
private readonly contentType: string;
private readonly extension: string; private readonly extension: string;
private readonly contentType: string;
public constructor(engine: TemplateEngine, templatePath: string, descriptions: string, contentType: string, public constructor(templateEngine: TemplateEngine, templatePath: string, extension: string, contentType: string) {
extension: string) {
super(INTERNAL_ERROR, contentType); super(INTERNAL_ERROR, contentType);
this.engine = engine; this.templateEngine = templateEngine;
this.templatePath = resolveAssetPath(templatePath); this.templatePath = templatePath;
this.descriptions = resolveAssetPath(descriptions);
this.contentType = contentType;
this.extension = extension; this.extension = extension;
this.contentType = contentType;
} }
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> { public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
// Obtain the error from the representation stream
const errors = await arrayifyStream(representation.data); const errors = await arrayifyStream(representation.data);
if (errors.length !== 1) { if (errors.length !== 1) {
throw new InternalServerError('Only single errors are supported.'); throw new InternalServerError('Only single errors are supported.');
} }
const error = errors[0] as Error; 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 { name, message, stack } = error;
const description = await this.getErrorCodeMessage(error);
const variables = { name, message, stack, description }; const variables = { name, message, stack, description };
const template = await fsPromises.readFile(this.templatePath, 'utf8'); const rendered = await this.templateEngine.render(variables);
const rendered = this.engine.apply(template, variables);
return new BasicRepresentation(rendered, representation.metadata, this.contentType); 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 marked from 'marked';
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
import type { Representation } from '../../ldp/representation/Representation'; import type { Representation } from '../../ldp/representation/Representation';
import type { TemplateEngine } from '../../pods/generate/TemplateEngine';
import { TEXT_HTML, TEXT_MARKDOWN } from '../../util/ContentTypes'; import { TEXT_HTML, TEXT_MARKDOWN } from '../../util/ContentTypes';
import { resolveAssetPath } from '../../util/PathUtil';
import { readableToString } from '../../util/StreamUtil'; import { readableToString } from '../../util/StreamUtil';
import type { TemplateEngine } from '../../util/templates/TemplateEngine';
import type { RepresentationConverterArgs } from './RepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter';
import { TypedRepresentationConverter } from './TypedRepresentationConverter'; 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`. * 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. * 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. * In case the Markdown body starts with a header (#), that value will also be used as `title` parameter.
*/ */
export class MarkdownToHtmlConverter extends TypedRepresentationConverter { export class MarkdownToHtmlConverter extends TypedRepresentationConverter {
private readonly engine: TemplateEngine; private readonly templateEngine: TemplateEngine;
private readonly templatePath: string;
public constructor(engine: TemplateEngine, templatePath: string) { public constructor(templateEngine: TemplateEngine) {
super(TEXT_MARKDOWN, TEXT_HTML); super(TEXT_MARKDOWN, TEXT_HTML);
this.engine = engine; this.templateEngine = templateEngine;
this.templatePath = resolveAssetPath(templatePath);
} }
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> { public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
const markdown = await readableToString(representation.data); 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 // Place the rendered Markdown into the HTML template
const match = /^\s*#+\s*([^\n]+)\n/u.exec(markdown);
const title = match?.[1];
const htmlBody = marked(markdown); const htmlBody = marked(markdown);
const html = await this.templateEngine.render({ htmlBody, title });
const template = await fsPromises.readFile(this.templatePath, 'utf8');
const html = this.engine.apply(template, { htmlBody, title });
return new BasicRepresentation(html, representation.metadata, TEXT_HTML); 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 type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
import { HandlebarsTemplateEngine } from '../../../../src/pods/generate/HandlebarsTemplateEngine';
import { TemplatedResourcesGenerator } from '../../../../src/pods/generate/TemplatedResourcesGenerator'; import { TemplatedResourcesGenerator } from '../../../../src/pods/generate/TemplatedResourcesGenerator';
import type { import type {
FileIdentifierMapper, FileIdentifierMapper,
@ -8,6 +7,7 @@ import type {
} from '../../../../src/storage/mapping/FileIdentifierMapper'; } from '../../../../src/storage/mapping/FileIdentifierMapper';
import { ensureTrailingSlash, trimTrailingSlashes } from '../../../../src/util/PathUtil'; import { ensureTrailingSlash, trimTrailingSlashes } from '../../../../src/util/PathUtil';
import { readableToString } from '../../../../src/util/StreamUtil'; import { readableToString } from '../../../../src/util/StreamUtil';
import { HandlebarsTemplateEngine } from '../../../../src/util/templates/HandlebarsTemplateEngine';
import { mockFs } from '../../../util/Util'; import { mockFs } from '../../../util/Util';
jest.mock('fs'); jest.mock('fs');

View File

@ -1,32 +1,23 @@
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { TemplateEngine } from '../../../../src/pods/generate/TemplateEngine';
import { ErrorToTemplateConverter } from '../../../../src/storage/conversion/ErrorToTemplateConverter'; import { ErrorToTemplateConverter } from '../../../../src/storage/conversion/ErrorToTemplateConverter';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
import { readableToString } from '../../../../src/util/StreamUtil'; import { readableToString } from '../../../../src/util/StreamUtil';
import { mockFs } from '../../../util/Util'; import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine';
jest.mock('fs');
describe('An ErrorToTemplateConverter', (): void => { describe('An ErrorToTemplateConverter', (): void => {
let cache: { data: any };
const identifier = { path: 'http://test.com/error' }; const identifier = { path: 'http://test.com/error' };
const templatePath = '/templates/error.template'; const templatePath = '/templates/codes';
const descriptions = '/templates/codes';
const errorCode = 'E0001'; const errorCode = 'E0001';
let engine: TemplateEngine; let templateEngine: jest.Mocked<TemplateEngine>;
let converter: ErrorToTemplateConverter; let converter: ErrorToTemplateConverter;
const preferences = {}; const preferences = {};
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
cache = mockFs('/templates'); templateEngine = {
cache.data['error.template'] = '{{ template }}'; render: jest.fn().mockReturnValue(Promise.resolve('<html>')),
cache.data.codes = { [`${errorCode}.html`]: '{{{ errorText }}}' };
engine = {
apply: jest.fn().mockReturnValue('<html>'),
}; };
converter = new ErrorToTemplateConverter(templateEngine, templatePath, '.html', 'text/html');
converter = new ErrorToTemplateConverter(engine, templatePath, descriptions, 'text/html', '.html');
}); });
it('supports going from errors to the given content type.', async(): Promise<void> => { 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 representation = new BasicRepresentation([ error ], 'internal/error', false);
const prom = converter.handle({ identifier, representation, preferences }); const prom = converter.handle({ identifier, representation, preferences });
await expect(prom).resolves.toBeDefined(); await expect(prom).resolves.toBeDefined();
const result = await prom; const result = await prom;
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(engine.apply).toHaveBeenCalledTimes(1); expect(templateEngine.render).toHaveBeenCalledTimes(1);
expect(engine.apply).toHaveBeenLastCalledWith( expect(templateEngine.render).toHaveBeenLastCalledWith(
'{{ template }}', { name: 'Error', message: 'error text', stack: error.stack }, { name: 'Error', message: 'error text', stack: error.stack },
); );
}); });
@ -60,15 +52,19 @@ describe('An ErrorToTemplateConverter', (): void => {
const error = new BadRequestHttpError('error text'); const error = new BadRequestHttpError('error text');
const representation = new BasicRepresentation([ error ], 'internal/error', false); const representation = new BasicRepresentation([ error ], 'internal/error', false);
const prom = converter.handle({ identifier, representation, preferences }); const prom = converter.handle({ identifier, representation, preferences });
templateEngine.render.mockRejectedValueOnce(new Error('error-specific template not found'));
await expect(prom).resolves.toBeDefined(); await expect(prom).resolves.toBeDefined();
const result = await prom; const result = await prom;
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(engine.apply).toHaveBeenCalledTimes(1); expect(templateEngine.render).toHaveBeenCalledTimes(2);
expect(engine.apply).toHaveBeenLastCalledWith( expect(templateEngine.render).toHaveBeenNthCalledWith(1,
'{{ template }}', { name: 'BadRequestHttpError', message: 'error text', stack: error.stack }, {},
); { 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> => { it('only adds stack if it is defined.', async(): Promise<void> => {
@ -76,18 +72,22 @@ describe('An ErrorToTemplateConverter', (): void => {
delete error.stack; delete error.stack;
const representation = new BasicRepresentation([ error ], 'internal/error', false); const representation = new BasicRepresentation([ error ], 'internal/error', false);
const prom = converter.handle({ identifier, representation, preferences }); const prom = converter.handle({ identifier, representation, preferences });
templateEngine.render.mockRejectedValueOnce(new Error('error-specific template not found'));
await expect(prom).resolves.toBeDefined(); await expect(prom).resolves.toBeDefined();
const result = await prom; const result = await prom;
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(engine.apply).toHaveBeenCalledTimes(1); expect(templateEngine.render).toHaveBeenCalledTimes(2);
expect(engine.apply).toHaveBeenLastCalledWith( expect(templateEngine.render).toHaveBeenNthCalledWith(1,
'{{ template }}', { name: 'BadRequestHttpError', message: 'error text' }, {},
); { 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 error = new BadRequestHttpError('error text', { errorCode, details: { key: 'val' }});
const representation = new BasicRepresentation([ error ], 'internal/error', false); const representation = new BasicRepresentation([ error ], 'internal/error', false);
const prom = converter.handle({ identifier, representation, preferences }); const prom = converter.handle({ identifier, representation, preferences });
@ -96,14 +96,12 @@ describe('An ErrorToTemplateConverter', (): void => {
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(engine.apply).toHaveBeenCalledTimes(2); expect(templateEngine.render).toHaveBeenCalledTimes(2);
expect(engine.apply).toHaveBeenCalledWith( expect(templateEngine.render).toHaveBeenNthCalledWith(1,
'{{{ errorText }}}', { key: 'val' }, { key: 'val' },
); { templatePath: '/templates/codes', templateFile: 'E0001.html' });
expect(engine.apply).toHaveBeenLastCalledWith( expect(templateEngine.render).toHaveBeenNthCalledWith(2,
'{{ template }}', { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' });
{ 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> => { 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 representation = new BasicRepresentation([ error ], 'internal/error', false);
const prom = converter.handle({ identifier, representation, preferences }); const prom = converter.handle({ identifier, representation, preferences });
await expect(prom).resolves.toBeDefined(); await expect(prom).resolves.toBeDefined();
const result = await prom; const result = await prom;
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(engine.apply).toHaveBeenCalledTimes(2); expect(templateEngine.render).toHaveBeenCalledTimes(2);
expect(engine.apply).toHaveBeenCalledWith( expect(templateEngine.render).toHaveBeenNthCalledWith(1,
'{{{ errorText }}}', { }, {},
); { templatePath: '/templates/codes', templateFile: 'E0001.html' });
expect(engine.apply).toHaveBeenLastCalledWith( expect(templateEngine.render).toHaveBeenNthCalledWith(2,
'{{ template }}', { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' });
{ 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> => { 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 error = new BadRequestHttpError('error text', { errorCode: 'invalid' });
const representation = new BasicRepresentation([ error ], 'internal/error', false); const representation = new BasicRepresentation([ error ], 'internal/error', false);
const prom = converter.handle({ identifier, representation, preferences }); const prom = converter.handle({ identifier, representation, preferences });
templateEngine.render.mockRejectedValueOnce(new Error('error-specific template not found'));
await expect(prom).resolves.toBeDefined(); await expect(prom).resolves.toBeDefined();
const result = await prom; const result = await prom;
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(engine.apply).toHaveBeenCalledTimes(1); expect(templateEngine.render).toHaveBeenCalledTimes(2);
expect(engine.apply).toHaveBeenLastCalledWith( expect(templateEngine.render).toHaveBeenNthCalledWith(1,
'{{ template }}', {},
{ name: 'BadRequestHttpError', message: 'error text', stack: error.stack }, { 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 { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { TemplateEngine } from '../../../../src/pods/generate/TemplateEngine';
import { MarkdownToHtmlConverter } from '../../../../src/storage/conversion/MarkdownToHtmlConverter'; import { MarkdownToHtmlConverter } from '../../../../src/storage/conversion/MarkdownToHtmlConverter';
import { readableToString } from '../../../../src/util/StreamUtil'; import { readableToString } from '../../../../src/util/StreamUtil';
import { mockFs } from '../../../util/Util'; import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine';
jest.mock('fs');
describe('A MarkdownToHtmlConverter', (): void => { describe('A MarkdownToHtmlConverter', (): void => {
let cache: { data: any };
const identifier = { path: 'http://test.com/text' }; const identifier = { path: 'http://test.com/text' };
const templatePath = '/templates/error.template';
const preferences = {}; const preferences = {};
let engine: TemplateEngine; let templateEngine: TemplateEngine;
let converter: MarkdownToHtmlConverter; let converter: MarkdownToHtmlConverter;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
cache = mockFs('/templates'); templateEngine = {
cache.data['error.template'] = '{{ template }}'; render: jest.fn().mockReturnValue(Promise.resolve('<html>')),
engine = {
apply: jest.fn().mockReturnValue('<html>'),
}; };
converter = new MarkdownToHtmlConverter(templateEngine);
converter = new MarkdownToHtmlConverter(engine, templatePath);
}); });
it('supports going from markdown to html.', async(): Promise<void> => { 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.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(engine.apply).toHaveBeenCalledTimes(1); expect(templateEngine.render).toHaveBeenCalledTimes(1);
expect(engine.apply).toHaveBeenLastCalledWith( expect(templateEngine.render).toHaveBeenLastCalledWith(
'{{ template }}', { htmlBody: '<p>Text <code>code</code> more text.</p>\n' }, { 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.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(engine.apply).toHaveBeenCalledTimes(1); expect(templateEngine.render).toHaveBeenCalledTimes(1);
expect(engine.apply).toHaveBeenLastCalledWith( expect(templateEngine.render).toHaveBeenLastCalledWith(
'{{ template }}', { htmlBody: '<h1 id="title-text">title text</h1>\n<p>more text</p>\n', title: 'title text' }, { 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}}');
});
});