feat: Add container breadcrumbs.

This commit is contained in:
Ruben Verborgh 2021-07-21 22:58:40 +02:00 committed by Joachim Van Herwegen
parent 1394b9cb56
commit 40a3dcbdb2
5 changed files with 121 additions and 19 deletions

View File

@ -23,7 +23,8 @@
"@type": "HandlebarsTemplateEngine", "@type": "HandlebarsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/container.md.hbs" "template": "$PACKAGE_ROOT/templates/container.md.hbs"
}, },
"contentType": "text/markdown" "contentType": "text/markdown",
"identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }
} }
] ]
} }

View File

@ -3,8 +3,10 @@ import orderBy from 'lodash.orderby';
import type { Quad } from 'rdf-js'; import type { Quad } from 'rdf-js';
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 { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
import { INTERNAL_QUADS } from '../../util/ContentTypes'; import { INTERNAL_QUADS } from '../../util/ContentTypes';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import type { IdentifierStrategy } from '../../util/identifiers/IdentifierStrategy';
import { isContainerIdentifier, isContainerPath } from '../../util/PathUtil'; import { isContainerIdentifier, isContainerPath } from '../../util/PathUtil';
import { endOfStream } from '../../util/StreamUtil'; import { endOfStream } from '../../util/StreamUtil';
import type { TemplateEngine } from '../../util/templates/TemplateEngine'; import type { TemplateEngine } from '../../util/templates/TemplateEngine';
@ -22,13 +24,15 @@ interface ResourceDetails {
* A {@link RepresentationConverter} that creates a templated representation of a container. * A {@link RepresentationConverter} that creates a templated representation of a container.
*/ */
export class ContainerToTemplateConverter extends TypedRepresentationConverter { export class ContainerToTemplateConverter extends TypedRepresentationConverter {
private readonly identifierStrategy: IdentifierStrategy;
private readonly templateEngine: TemplateEngine; private readonly templateEngine: TemplateEngine;
private readonly contentType: string; private readonly contentType: string;
public constructor(templateEngine: TemplateEngine, contentType: string) { public constructor(templateEngine: TemplateEngine, contentType: string, identifierStrategy: IdentifierStrategy) {
super(INTERNAL_QUADS, contentType); super(INTERNAL_QUADS, contentType);
this.templateEngine = templateEngine; this.templateEngine = templateEngine;
this.contentType = contentType; this.contentType = contentType;
this.identifierStrategy = identifierStrategy;
} }
public async canHandle(args: RepresentationConverterArgs): Promise<void> { public async canHandle(args: RepresentationConverterArgs): Promise<void> {
@ -40,8 +44,11 @@ export class ContainerToTemplateConverter extends TypedRepresentationConverter {
public async handle({ identifier, representation }: RepresentationConverterArgs): Promise<Representation> { public async handle({ identifier, representation }: RepresentationConverterArgs): Promise<Representation> {
const rendered = await this.templateEngine.render({ const rendered = await this.templateEngine.render({
container: this.getLocalName(identifier.path), identifier: identifier.path,
children: await this.getChildResources(identifier.path, representation.data), name: this.getLocalName(identifier.path),
container: true,
children: await this.getChildResources(identifier, representation.data),
parents: this.getParentContainers(identifier),
}); });
return new BasicRepresentation(rendered, representation.metadata, this.contentType); return new BasicRepresentation(rendered, representation.metadata, this.contentType);
} }
@ -49,11 +56,11 @@ export class ContainerToTemplateConverter extends TypedRepresentationConverter {
/** /**
* Collects the children of the container as simple objects. * Collects the children of the container as simple objects.
*/ */
private async getChildResources(container: string, quads: Readable): Promise<ResourceDetails[]> { private async getChildResources(container: ResourceIdentifier, quads: Readable): Promise<ResourceDetails[]> {
// Collect the needed bits of information from the containment triples // Collect the needed bits of information from the containment triples
const resources = new Set<string>(); const resources = new Set<string>();
quads.on('data', ({ subject, predicate, object }: Quad): void => { quads.on('data', ({ subject, predicate, object }: Quad): void => {
if (subject.value === container && predicate.equals(LDP.terms.contains)) { if (subject.value === container.path && predicate.equals(LDP.terms.contains)) {
resources.add(object.value); resources.add(object.value);
} }
}); });
@ -70,11 +77,28 @@ export class ContainerToTemplateConverter extends TypedRepresentationConverter {
return orderBy(children, [ 'container', 'identifier' ], [ 'desc', 'asc' ]); return orderBy(children, [ 'container', 'identifier' ], [ 'desc', 'asc' ]);
} }
/**
* Collects the ancestors of the container as simple objects.
*/
private getParentContainers(container: ResourceIdentifier): ResourceDetails[] {
const parents = [];
let current = container;
while (!this.identifierStrategy.isRootContainer(current)) {
current = this.identifierStrategy.getParentContainer(current);
parents.push({
identifier: current.path,
name: this.getLocalName(current.path),
container: true,
});
}
return parents.reverse();
}
/** /**
* Derives a short name for the given resource. * Derives a short name for the given resource.
*/ */
private getLocalName(iri: string, keepTrailingSlash = false): string { private getLocalName(iri: string): string {
const match = /:\/+[^/]+.*\/(([^/]+)\/?)$/u.exec(iri); const match = /:\/+([^/]+).*?\/([^/]*)\/?$/u.exec(iri);
return match ? decodeURIComponent(match[keepTrailingSlash ? 1 : 2]) : '/'; return match?.[2] ? decodeURIComponent(match[2]) : match?.[1] ?? iri;
} }
} }

View File

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

View File

@ -14,6 +14,7 @@
html { html {
--solid-purple: #7c4dff; --solid-purple: #7c4dff;
--solid-blue: #18a9e6; --solid-blue: #18a9e6;
--solid-gray: #8a8a8a;
height: 100%; height: 100%;
@ -179,7 +180,36 @@ ul.container > li {
margin: 0.25em 0; margin: 0.25em 0;
list-style-type: none; list-style-type: none;
} }
ul.container > li.container > a { ul.container > li.container > a {
font-weight: 800; font-weight: 800;
} }
ol.breadcrumbs {
list-style: none;
padding: 0;
font-size: 90%;
}
ol.breadcrumbs > li {
display: inline;
}
ol.breadcrumbs > li > a {
color: var(--solid-gray);
font-weight: 400;
white-space: nowrap;
}
ol.breadcrumbs > li > a:hover {
color: var(--solid-purple);
}
ol.breadcrumbs > li:not(:last-child):after {
padding: 0 .25em;
content: ' > ';
font-weight: 300;
color: var(--solid-gray);
}
ol.breadcrumbs > li:last-child {
font-weight: 500;
color: var(--solid-purple);
}
ol.breadcrumbs > li:only-child {
display: none;
}

View File

@ -1,12 +1,14 @@
import { namedNode as nn, quad } from '@rdfjs/data-model'; import { namedNode as nn, quad } from '@rdfjs/data-model';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import { ContainerToTemplateConverter } from '../../../../src/storage/conversion/ContainerToTemplateConverter'; import { ContainerToTemplateConverter } from '../../../../src/storage/conversion/ContainerToTemplateConverter';
import { SingleRootIdentifierStrategy } from '../../../../src/util/identifiers/SingleRootIdentifierStrategy';
import { readableToString } from '../../../../src/util/StreamUtil'; import { readableToString } from '../../../../src/util/StreamUtil';
import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine';
import { LDP, RDF } from '../../../../src/util/Vocabularies'; import { LDP, RDF } from '../../../../src/util/Vocabularies';
describe('A ContainerToTemplateConverter', (): void => { describe('A ContainerToTemplateConverter', (): void => {
const preferences = {}; const preferences = {};
const identifierStrategy = new SingleRootIdentifierStrategy('http://test.com/');
let templateEngine: jest.Mocked<TemplateEngine>; let templateEngine: jest.Mocked<TemplateEngine>;
let converter: ContainerToTemplateConverter; let converter: ContainerToTemplateConverter;
@ -14,7 +16,7 @@ describe('A ContainerToTemplateConverter', (): void => {
templateEngine = { templateEngine = {
render: jest.fn().mockReturnValue(Promise.resolve('<html>')), render: jest.fn().mockReturnValue(Promise.resolve('<html>')),
}; };
converter = new ContainerToTemplateConverter(templateEngine, 'text/html'); converter = new ContainerToTemplateConverter(templateEngine, 'text/html', identifierStrategy);
}); });
it('supports containers.', async(): Promise<void> => { it('supports containers.', async(): Promise<void> => {
@ -38,7 +40,7 @@ describe('A ContainerToTemplateConverter', (): void => {
quad(nn(container.path), LDP.terms.contains, nn(`${container.path}b`)), 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}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}c%20c`)),
quad(nn(container.path), LDP.terms.contains, nn(`${container.path}d/`)), quad(nn(container.path), LDP.terms.contains, nn(`${container.path}d/`)),
quad(nn(`${container.path}d/`), LDP.terms.contains, nn(`${container.path}d`)), quad(nn(`${container.path}d/`), LDP.terms.contains, nn(`${container.path}d`)),
], 'internal/quads', false); ], 'internal/quads', false);
@ -50,11 +52,13 @@ describe('A ContainerToTemplateConverter', (): void => {
expect(templateEngine.render).toHaveBeenCalledTimes(1); expect(templateEngine.render).toHaveBeenCalledTimes(1);
expect(templateEngine.render).toHaveBeenCalledWith({ expect(templateEngine.render).toHaveBeenCalledWith({
container: 'my-container', identifier: container.path,
name: 'my-container',
container: true,
children: [ children: [
{ {
identifier: `${container.path}d/`, identifier: `${container.path}d/`,
name: 'd/', name: 'd',
container: true, container: true,
}, },
{ {
@ -68,11 +72,28 @@ describe('A ContainerToTemplateConverter', (): void => {
container: false, container: false,
}, },
{ {
identifier: `${container.path}ccc`, identifier: `${container.path}c%20c`,
name: 'ccc', name: 'c c',
container: false, container: false,
}, },
], ],
parents: [
{
identifier: 'http://test.com/',
name: 'test.com',
container: true,
},
{
identifier: 'http://test.com/foo/',
name: 'foo',
container: true,
},
{
identifier: 'http://test.com/foo/bar/',
name: 'bar',
container: true,
},
],
}); });
}); });
@ -88,8 +109,27 @@ describe('A ContainerToTemplateConverter', (): void => {
expect(templateEngine.render).toHaveBeenCalledTimes(1); expect(templateEngine.render).toHaveBeenCalledTimes(1);
expect(templateEngine.render).toHaveBeenCalledWith({ expect(templateEngine.render).toHaveBeenCalledWith({
container: '/', identifier: container.path,
name: 'test.com',
container: true,
children: expect.objectContaining({ length: 3 }), children: expect.objectContaining({ length: 3 }),
parents: [],
});
});
it('converts an improperly named container.', async(): Promise<void> => {
const container = { path: 'http//test.com/foo/bar' };
const representation = new BasicRepresentation([], 'internal/quads', false);
jest.spyOn(identifierStrategy, 'isRootContainer').mockReturnValueOnce(true);
await converter.handle({ identifier: container, representation, preferences });
expect(templateEngine.render).toHaveBeenCalledTimes(1);
expect(templateEngine.render).toHaveBeenCalledWith({
identifier: container.path,
name: container.path,
container: true,
children: [],
parents: [],
}); });
}); });
}); });