feat: Add HTML container listing.

This commit is contained in:
Ruben Verborgh 2021-07-21 19:39:46 +02:00 committed by Joachim Van Herwegen
parent c0dac12111
commit 1394b9cb56
14 changed files with 277 additions and 24 deletions

View File

@ -0,0 +1,27 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"import": [
"files-scs:config/util/representation-conversion/converters/content-type-replacer.json",
"files-scs:config/util/representation-conversion/converters/quad-to-rdf.json",
"files-scs:config/util/representation-conversion/converters/rdf-to-quad.json",
"files-scs:config/util/representation-conversion/converters/markdown.json"
],
"@graph": [
{
"@id": "urn:solid-server:default:ErrorToQuadConverter",
"@type": "ErrorToQuadConverter",
},
{
"comment": "Converts an error into a Markdown description of its details.",
"@id": "urn:solid-server:default:ErrorToTemplateConverter",
"@type": "ErrorToTemplateConverter",
"templateEngine": {
"@type": "HandlebarsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/error/main.md.hbs"
},
"templatePath": "$PACKAGE_ROOT/templates/error/descriptions/",
"extension": ".md.hbs",
"contentType": "text/markdown"
}
]
}

View File

@ -14,6 +14,16 @@
"@type": "HandlebarsTemplateEngine", "@type": "HandlebarsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/main.html.hbs" "template": "$PACKAGE_ROOT/templates/main.html.hbs"
} }
},
{
"comment": "Converts a container into a Markdown listing of its contents.",
"@id": "urn:solid-server:default:ContainerToTemplateConverter",
"@type": "ContainerToTemplateConverter",
"templateEngine": {
"@type": "HandlebarsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/container.md.hbs"
},
"contentType": "text/markdown"
} }
] ]
} }

View File

@ -2,9 +2,10 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"import": [ "import": [
"files-scs:config/util/representation-conversion/converters/content-type-replacer.json", "files-scs:config/util/representation-conversion/converters/content-type-replacer.json",
"files-scs:config/util/representation-conversion/converters/errors.json",
"files-scs:config/util/representation-conversion/converters/markdown.json",
"files-scs:config/util/representation-conversion/converters/quad-to-rdf.json", "files-scs:config/util/representation-conversion/converters/quad-to-rdf.json",
"files-scs:config/util/representation-conversion/converters/rdf-to-quad.json", "files-scs:config/util/representation-conversion/converters/rdf-to-quad.json"
"files-scs:config/util/representation-conversion/converters/markdown.json"
], ],
"@graph": [ "@graph": [
{ {
@ -26,18 +27,9 @@
"converters": [ "converters": [
{ "@id": "urn:solid-server:default:RdfToQuadConverter" }, { "@id": "urn:solid-server:default:RdfToQuadConverter" },
{ "@id": "urn:solid-server:default:QuadToRdfConverter" }, { "@id": "urn:solid-server:default:QuadToRdfConverter" },
{ "@type": "ErrorToQuadConverter" }, { "@id": "urn:solid-server:default:ContainerToTemplateConverter" },
{ { "@id": "urn:solid-server:default:ErrorToQuadConverter" },
"comment": "Converts an error into a Markdown description of its details.", { "@id": "urn:solid-server:default:ErrorToTemplateConverter" },
"@type": "ErrorToTemplateConverter",
"templateEngine": {
"@type": "HandlebarsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/error/main.md.hbs"
},
"templatePath": "$PACKAGE_ROOT/templates/error/descriptions/",
"extension": ".md.hbs",
"contentType": "text/markdown"
},
{ "@id": "urn:solid-server:default:MarkdownToHtmlConverter" } { "@id": "urn:solid-server:default:MarkdownToHtmlConverter" }
] ]
} }

34
package-lock.json generated
View File

@ -18,6 +18,7 @@
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/cors": "^2.8.10", "@types/cors": "^2.8.10",
"@types/end-of-stream": "^1.4.0", "@types/end-of-stream": "^1.4.0",
"@types/lodash.orderby": "^4.6.6",
"@types/marked": "^2.0.3", "@types/marked": "^2.0.3",
"@types/mime-types": "^2.1.0", "@types/mime-types": "^2.1.0",
"@types/n3": "^1.10.0", "@types/n3": "^1.10.0",
@ -47,6 +48,7 @@
"fetch-sparql-endpoint": "^2.0.1", "fetch-sparql-endpoint": "^2.0.1",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"jose": "^3.11.6", "jose": "^3.11.6",
"lodash.orderby": "^4.6.0",
"marked": "^2.1.3", "marked": "^2.1.3",
"mime-types": "^2.1.31", "mime-types": "^2.1.31",
"n3": "^1.10.0", "n3": "^1.10.0",
@ -6004,8 +6006,7 @@
"node_modules/@types/lodash": { "node_modules/@types/lodash": {
"version": "4.14.170", "version": "4.14.170",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.170.tgz", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.170.tgz",
"integrity": "sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q==", "integrity": "sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q=="
"dev": true
}, },
"node_modules/@types/lodash.clonedeep": { "node_modules/@types/lodash.clonedeep": {
"version": "4.5.6", "version": "4.5.6",
@ -6016,6 +6017,14 @@
"@types/lodash": "*" "@types/lodash": "*"
} }
}, },
"node_modules/@types/lodash.orderby": {
"version": "4.6.6",
"resolved": "https://registry.npmjs.org/@types/lodash.orderby/-/lodash.orderby-4.6.6.tgz",
"integrity": "sha512-wQzu6xK+bSwhu45OeMI7fjywiIZiiaBzJB8W3fwnF1SJXHoOXRLutrSnVmq4yHPOM036qsy8lx9wHQcAbXNjJw==",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/lru-cache": { "node_modules/@types/lru-cache": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz",
@ -14304,6 +14313,11 @@
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true "dev": true
}, },
"node_modules/lodash.orderby": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.orderby/-/lodash.orderby-4.6.0.tgz",
"integrity": "sha1-5pfwTOXXhSL1TZM4syuBozk+TrM="
},
"node_modules/lodash.template": { "node_modules/lodash.template": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz", "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz",
@ -23673,8 +23687,7 @@
"@types/lodash": { "@types/lodash": {
"version": "4.14.170", "version": "4.14.170",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.170.tgz", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.170.tgz",
"integrity": "sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q==", "integrity": "sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q=="
"dev": true
}, },
"@types/lodash.clonedeep": { "@types/lodash.clonedeep": {
"version": "4.5.6", "version": "4.5.6",
@ -23685,6 +23698,14 @@
"@types/lodash": "*" "@types/lodash": "*"
} }
}, },
"@types/lodash.orderby": {
"version": "4.6.6",
"resolved": "https://registry.npmjs.org/@types/lodash.orderby/-/lodash.orderby-4.6.6.tgz",
"integrity": "sha512-wQzu6xK+bSwhu45OeMI7fjywiIZiiaBzJB8W3fwnF1SJXHoOXRLutrSnVmq4yHPOM036qsy8lx9wHQcAbXNjJw==",
"requires": {
"@types/lodash": "*"
}
},
"@types/lru-cache": { "@types/lru-cache": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz",
@ -30166,6 +30187,11 @@
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true "dev": true
}, },
"lodash.orderby": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.orderby/-/lodash.orderby-4.6.0.tgz",
"integrity": "sha1-5pfwTOXXhSL1TZM4syuBozk+TrM="
},
"lodash.template": { "lodash.template": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz", "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz",

View File

@ -84,6 +84,7 @@
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/cors": "^2.8.10", "@types/cors": "^2.8.10",
"@types/end-of-stream": "^1.4.0", "@types/end-of-stream": "^1.4.0",
"@types/lodash.orderby": "^4.6.6",
"@types/marked": "^2.0.3", "@types/marked": "^2.0.3",
"@types/mime-types": "^2.1.0", "@types/mime-types": "^2.1.0",
"@types/n3": "^1.10.0", "@types/n3": "^1.10.0",
@ -113,6 +114,7 @@
"fetch-sparql-endpoint": "^2.0.1", "fetch-sparql-endpoint": "^2.0.1",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"jose": "^3.11.6", "jose": "^3.11.6",
"lodash.orderby": "^4.6.0",
"marked": "^2.1.3", "marked": "^2.1.3",
"mime-types": "^2.1.31", "mime-types": "^2.1.31",
"n3": "^1.10.0", "n3": "^1.10.0",

View File

@ -216,6 +216,7 @@ export * from './storage/accessors/SparqlDataAccessor';
// Storage/Conversion // Storage/Conversion
export * from './storage/conversion/ChainedConverter'; export * from './storage/conversion/ChainedConverter';
export * from './storage/conversion/ConstantConverter'; export * from './storage/conversion/ConstantConverter';
export * from './storage/conversion/ContainerToTemplateConverter';
export * from './storage/conversion/ContentTypeReplacer'; export * from './storage/conversion/ContentTypeReplacer';
export * from './storage/conversion/ConversionUtil'; export * from './storage/conversion/ConversionUtil';
export * from './storage/conversion/ErrorToQuadConverter'; export * from './storage/conversion/ErrorToQuadConverter';

View File

@ -1,6 +1,4 @@
import type { Readable } from 'stream'; import type { Readable } from 'stream';
import { promisify } from 'util';
import eos from 'end-of-stream';
import type { AuxiliaryIdentifierStrategy } from '../ldp/auxiliary/AuxiliaryIdentifierStrategy'; import type { AuxiliaryIdentifierStrategy } from '../ldp/auxiliary/AuxiliaryIdentifierStrategy';
import type { Patch } from '../ldp/http/Patch'; import type { Patch } from '../ldp/http/Patch';
import { BasicRepresentation } from '../ldp/representation/BasicRepresentation'; import { BasicRepresentation } from '../ldp/representation/BasicRepresentation';
@ -9,10 +7,10 @@ import type { RepresentationPreferences } from '../ldp/representation/Representa
import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
import { getLoggerFor } from '../logging/LogUtil'; import { getLoggerFor } from '../logging/LogUtil';
import type { ExpiringReadWriteLocker } from '../util/locking/ExpiringReadWriteLocker'; import type { ExpiringReadWriteLocker } from '../util/locking/ExpiringReadWriteLocker';
import { endOfStream } from '../util/StreamUtil';
import type { AtomicResourceStore } from './AtomicResourceStore'; import type { AtomicResourceStore } from './AtomicResourceStore';
import type { Conditions } from './Conditions'; import type { Conditions } from './Conditions';
import type { ResourceStore } from './ResourceStore'; import type { ResourceStore } from './ResourceStore';
const endOfStream = promisify(eos);
/** /**
* Store that for every call acquires a lock before executing it on the requested resource, * Store that for every call acquires a lock before executing it on the requested resource,

View File

@ -0,0 +1,80 @@
import type { Readable } from 'stream';
import orderBy from 'lodash.orderby';
import type { Quad } from 'rdf-js';
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
import type { Representation } from '../../ldp/representation/Representation';
import { INTERNAL_QUADS } from '../../util/ContentTypes';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { isContainerIdentifier, isContainerPath } from '../../util/PathUtil';
import { endOfStream } from '../../util/StreamUtil';
import type { TemplateEngine } from '../../util/templates/TemplateEngine';
import { LDP } from '../../util/Vocabularies';
import type { RepresentationConverterArgs } from './RepresentationConverter';
import { TypedRepresentationConverter } from './TypedRepresentationConverter';
interface ResourceDetails {
name: string;
identifier: string;
container: boolean;
}
/**
* A {@link RepresentationConverter} that creates a templated representation of a container.
*/
export class ContainerToTemplateConverter extends TypedRepresentationConverter {
private readonly templateEngine: TemplateEngine;
private readonly contentType: string;
public constructor(templateEngine: TemplateEngine, contentType: string) {
super(INTERNAL_QUADS, contentType);
this.templateEngine = templateEngine;
this.contentType = contentType;
}
public async canHandle(args: RepresentationConverterArgs): Promise<void> {
if (!isContainerIdentifier(args.identifier)) {
throw new NotImplementedHttpError('Can only convert containers.');
}
await super.canHandle(args);
}
public async handle({ identifier, representation }: RepresentationConverterArgs): Promise<Representation> {
const rendered = await this.templateEngine.render({
container: this.getLocalName(identifier.path),
children: await this.getChildResources(identifier.path, representation.data),
});
return new BasicRepresentation(rendered, representation.metadata, this.contentType);
}
/**
* Collects the children of the container as simple objects.
*/
private async getChildResources(container: string, quads: Readable): Promise<ResourceDetails[]> {
// Collect the needed bits of information from the containment triples
const resources = new Set<string>();
quads.on('data', ({ subject, predicate, object }: Quad): void => {
if (subject.value === container && predicate.equals(LDP.terms.contains)) {
resources.add(object.value);
}
});
await endOfStream(quads);
// Create a simplified object for every resource
const children = [ ...resources ].map((resource: string): ResourceDetails => ({
identifier: resource,
name: this.getLocalName(resource),
container: isContainerPath(resource),
}));
// Sort the resulting list
return orderBy(children, [ 'container', 'identifier' ], [ 'desc', 'asc' ]);
}
/**
* Derives a short name for the given resource.
*/
private getLocalName(iri: string, keepTrailingSlash = false): string {
const match = /:\/+[^/]+.*\/(([^/]+)\/?)$/u.exec(iri);
return match ? decodeURIComponent(match[keepTrailingSlash ? 1 : 2]) : '/';
}
}

View File

@ -1,12 +1,16 @@
import type { Writable, ReadableOptions, DuplexOptions } from 'stream'; import type { Writable, ReadableOptions, DuplexOptions } from 'stream';
import { Readable, Transform } from 'stream'; import { Readable, Transform } from 'stream';
import { promisify } from 'util';
import arrayifyStream from 'arrayify-stream'; import arrayifyStream from 'arrayify-stream';
import eos from 'end-of-stream';
import pump from 'pump'; import pump from 'pump';
import { getLoggerFor } from '../logging/LogUtil'; import { getLoggerFor } from '../logging/LogUtil';
import { isHttpRequest } from '../server/HttpRequest'; import { isHttpRequest } from '../server/HttpRequest';
import type { Guarded } from './GuardedStream'; import type { Guarded } from './GuardedStream';
import { guardStream } from './GuardedStream'; import { guardStream } from './GuardedStream';
export const endOfStream = promisify(eos);
const logger = getLoggerFor('StreamUtil'); const logger = getLoggerFor('StreamUtil');
/** /**

View File

@ -11,7 +11,8 @@
}, },
{ {
"@id": "urn:solid-server:template:IdentifierStrategy", "comment": "Custom pods always use the suffix strategy with their pod URL as base.",
"@id": "urn:solid-server:default:IdentifierStrategy",
"@type": "SingleRootIdentifierStrategy", "@type": "SingleRootIdentifierStrategy",
"baseUrl": { "baseUrl": {
"@id": "urn:solid-server:template:variable:baseUrl" "@id": "urn:solid-server:template:variable:baseUrl"
@ -25,7 +26,7 @@
"@id": "urn:solid-server:template:DataAccessor" "@id": "urn:solid-server:template:DataAccessor"
}, },
"identifierStrategy": { "identifierStrategy": {
"@id": "urn:solid-server:template:IdentifierStrategy" "@id": "urn:solid-server:default:IdentifierStrategy"
}, },
"auxiliaryStrategy": { "auxiliaryStrategy": {
"@id": "urn:solid-server:default:AuxiliaryStrategy" "@id": "urn:solid-server:default:AuxiliaryStrategy"

View File

@ -12,7 +12,7 @@
"@id": "urn:solid-server:template:DataAccessor", "@id": "urn:solid-server:template:DataAccessor",
"@type": "InMemoryDataAccessor", "@type": "InMemoryDataAccessor",
"identifierStrategy": { "identifierStrategy": {
"@id": "urn:solid-server:template:IdentifierStrategy" "@id": "urn:solid-server:default:IdentifierStrategy"
} }
} }
] ]

View File

@ -0,0 +1,8 @@
# Contents of {{container}}
<ul class="container">
{{#each children}}
<li class="{{#if container}}container{{else}}document{{/if}}">
<a href="{{identifier}}">{{name}}</a>
</li>
{{/each}}
</ul>

View File

@ -174,3 +174,12 @@ form ul.actions > li {
display: inline; display: inline;
margin-right: 1em; margin-right: 1em;
} }
ul.container > li {
margin: 0.25em 0;
list-style-type: none;
}
ul.container > li.container > a {
font-weight: 800;
}

View File

@ -0,0 +1,95 @@
import { namedNode as nn, quad } from '@rdfjs/data-model';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import { ContainerToTemplateConverter } from '../../../../src/storage/conversion/ContainerToTemplateConverter';
import { readableToString } from '../../../../src/util/StreamUtil';
import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine';
import { LDP, RDF } from '../../../../src/util/Vocabularies';
describe('A ContainerToTemplateConverter', (): void => {
const preferences = {};
let templateEngine: jest.Mocked<TemplateEngine>;
let converter: ContainerToTemplateConverter;
beforeEach(async(): Promise<void> => {
templateEngine = {
render: jest.fn().mockReturnValue(Promise.resolve('<html>')),
};
converter = new ContainerToTemplateConverter(templateEngine, 'text/html');
});
it('supports containers.', async(): Promise<void> => {
const container = { path: 'http://test.com/foo/bar/container/' };
const representation = new BasicRepresentation([], 'internal/quads', false);
await expect(converter.canHandle({ identifier: container, representation, preferences }))
.resolves.toBeUndefined();
});
it('does not support documents.', async(): Promise<void> => {
const document = { path: 'http://test.com/foo/bar/document' };
const representation = new BasicRepresentation([], 'internal/quads', false);
await expect(converter.canHandle({ identifier: document, representation, preferences }))
.rejects.toThrow('Can only convert containers');
});
it('calls the template with the contained resources.', async(): Promise<void> => {
const container = { path: 'http://test.com/foo/bar/my-container/' };
const representation = new BasicRepresentation([
quad(nn(container.path), RDF.terms.type, LDP.terms.BasicContainer),
quad(nn(container.path), LDP.terms.contains, nn(`${container.path}b`)),
quad(nn(container.path), LDP.terms.contains, nn(`${container.path}a`)),
quad(nn(container.path), LDP.terms.contains, nn(`${container.path}a`)),
quad(nn(container.path), LDP.terms.contains, nn(`${container.path}ccc`)),
quad(nn(container.path), LDP.terms.contains, nn(`${container.path}d/`)),
quad(nn(`${container.path}d/`), LDP.terms.contains, nn(`${container.path}d`)),
], 'internal/quads', false);
const converted = await converter.handle({ identifier: container, representation, preferences });
expect(converted.binary).toBe(true);
expect(converted.metadata.contentType).toBe('text/html');
await expect(readableToString(converted.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(1);
expect(templateEngine.render).toHaveBeenCalledWith({
container: 'my-container',
children: [
{
identifier: `${container.path}d/`,
name: 'd/',
container: true,
},
{
identifier: `${container.path}a`,
name: 'a',
container: false,
},
{
identifier: `${container.path}b`,
name: 'b',
container: false,
},
{
identifier: `${container.path}ccc`,
name: 'ccc',
container: false,
},
],
});
});
it('converts the root container.', async(): Promise<void> => {
const container = { path: 'http://test.com/' };
const representation = new BasicRepresentation([
quad(nn(container.path), RDF.terms.type, LDP.terms.BasicContainer),
quad(nn(container.path), LDP.terms.contains, nn(`${container.path}a`)),
quad(nn(container.path), LDP.terms.contains, nn(`${container.path}b`)),
quad(nn(container.path), LDP.terms.contains, nn(`${container.path}c`)),
], 'internal/quads', false);
await converter.handle({ identifier: container, representation, preferences });
expect(templateEngine.render).toHaveBeenCalledTimes(1);
expect(templateEngine.render).toHaveBeenCalledWith({
container: '/',
children: expect.objectContaining({ length: 3 }),
});
});
});