From 40a3dcbdb2e359766f4ce172ae811717b7d5eea4 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Wed, 21 Jul 2021 22:58:40 +0200 Subject: [PATCH] feat: Add container breadcrumbs. --- .../converters/markdown.json | 3 +- .../ContainerToTemplateConverter.ts | 40 +++++++++++--- templates/container.md.hbs | 11 +++- templates/styles/main.css | 32 ++++++++++- .../ContainerToTemplateConverter.test.ts | 54 ++++++++++++++++--- 5 files changed, 121 insertions(+), 19 deletions(-) diff --git a/config/util/representation-conversion/converters/markdown.json b/config/util/representation-conversion/converters/markdown.json index 779845494..0ed217aba 100644 --- a/config/util/representation-conversion/converters/markdown.json +++ b/config/util/representation-conversion/converters/markdown.json @@ -23,7 +23,8 @@ "@type": "HandlebarsTemplateEngine", "template": "$PACKAGE_ROOT/templates/container.md.hbs" }, - "contentType": "text/markdown" + "contentType": "text/markdown", + "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" } } ] } diff --git a/src/storage/conversion/ContainerToTemplateConverter.ts b/src/storage/conversion/ContainerToTemplateConverter.ts index e142d2d9e..8351a4290 100644 --- a/src/storage/conversion/ContainerToTemplateConverter.ts +++ b/src/storage/conversion/ContainerToTemplateConverter.ts @@ -3,8 +3,10 @@ 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 type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; import { INTERNAL_QUADS } from '../../util/ContentTypes'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import type { IdentifierStrategy } from '../../util/identifiers/IdentifierStrategy'; import { isContainerIdentifier, isContainerPath } from '../../util/PathUtil'; import { endOfStream } from '../../util/StreamUtil'; import type { TemplateEngine } from '../../util/templates/TemplateEngine'; @@ -22,13 +24,15 @@ interface ResourceDetails { * A {@link RepresentationConverter} that creates a templated representation of a container. */ export class ContainerToTemplateConverter extends TypedRepresentationConverter { + private readonly identifierStrategy: IdentifierStrategy; private readonly templateEngine: TemplateEngine; private readonly contentType: string; - public constructor(templateEngine: TemplateEngine, contentType: string) { + public constructor(templateEngine: TemplateEngine, contentType: string, identifierStrategy: IdentifierStrategy) { super(INTERNAL_QUADS, contentType); this.templateEngine = templateEngine; this.contentType = contentType; + this.identifierStrategy = identifierStrategy; } public async canHandle(args: RepresentationConverterArgs): Promise { @@ -40,8 +44,11 @@ export class ContainerToTemplateConverter extends TypedRepresentationConverter { public async handle({ identifier, representation }: RepresentationConverterArgs): Promise { const rendered = await this.templateEngine.render({ - container: this.getLocalName(identifier.path), - children: await this.getChildResources(identifier.path, representation.data), + identifier: identifier.path, + 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); } @@ -49,11 +56,11 @@ export class ContainerToTemplateConverter extends TypedRepresentationConverter { /** * Collects the children of the container as simple objects. */ - private async getChildResources(container: string, quads: Readable): Promise { + private async getChildResources(container: ResourceIdentifier, quads: Readable): Promise { // Collect the needed bits of information from the containment triples const resources = new Set(); 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); } }); @@ -70,11 +77,28 @@ export class ContainerToTemplateConverter extends TypedRepresentationConverter { 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. */ - private getLocalName(iri: string, keepTrailingSlash = false): string { - const match = /:\/+[^/]+.*\/(([^/]+)\/?)$/u.exec(iri); - return match ? decodeURIComponent(match[keepTrailingSlash ? 1 : 2]) : '/'; + private getLocalName(iri: string): string { + const match = /:\/+([^/]+).*?\/([^/]*)\/?$/u.exec(iri); + return match?.[2] ? decodeURIComponent(match[2]) : match?.[1] ?? iri; } } diff --git a/templates/container.md.hbs b/templates/container.md.hbs index 07b0c6804..d43ea8637 100644 --- a/templates/container.md.hbs +++ b/templates/container.md.hbs @@ -1,8 +1,15 @@ -# Contents of {{container}} +# Contents of {{name}} + + diff --git a/templates/styles/main.css b/templates/styles/main.css index 4af1c3722..00b9f7a67 100644 --- a/templates/styles/main.css +++ b/templates/styles/main.css @@ -14,6 +14,7 @@ html { --solid-purple: #7c4dff; --solid-blue: #18a9e6; + --solid-gray: #8a8a8a; height: 100%; @@ -179,7 +180,36 @@ ul.container > li { margin: 0.25em 0; list-style-type: none; } - ul.container > li.container > a { 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; +} diff --git a/test/unit/storage/conversion/ContainerToTemplateConverter.test.ts b/test/unit/storage/conversion/ContainerToTemplateConverter.test.ts index acda1ed93..dcec42253 100644 --- a/test/unit/storage/conversion/ContainerToTemplateConverter.test.ts +++ b/test/unit/storage/conversion/ContainerToTemplateConverter.test.ts @@ -1,12 +1,14 @@ import { namedNode as nn, quad } from '@rdfjs/data-model'; import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; import { ContainerToTemplateConverter } from '../../../../src/storage/conversion/ContainerToTemplateConverter'; +import { SingleRootIdentifierStrategy } from '../../../../src/util/identifiers/SingleRootIdentifierStrategy'; 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 = {}; + const identifierStrategy = new SingleRootIdentifierStrategy('http://test.com/'); let templateEngine: jest.Mocked; let converter: ContainerToTemplateConverter; @@ -14,7 +16,7 @@ describe('A ContainerToTemplateConverter', (): void => { templateEngine = { render: jest.fn().mockReturnValue(Promise.resolve('')), }; - converter = new ContainerToTemplateConverter(templateEngine, 'text/html'); + converter = new ContainerToTemplateConverter(templateEngine, 'text/html', identifierStrategy); }); it('supports containers.', async(): Promise => { @@ -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}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}d/`), LDP.terms.contains, nn(`${container.path}d`)), ], 'internal/quads', false); @@ -50,11 +52,13 @@ describe('A ContainerToTemplateConverter', (): void => { expect(templateEngine.render).toHaveBeenCalledTimes(1); expect(templateEngine.render).toHaveBeenCalledWith({ - container: 'my-container', + identifier: container.path, + name: 'my-container', + container: true, children: [ { identifier: `${container.path}d/`, - name: 'd/', + name: 'd', container: true, }, { @@ -68,11 +72,28 @@ describe('A ContainerToTemplateConverter', (): void => { container: false, }, { - identifier: `${container.path}ccc`, - name: 'ccc', + identifier: `${container.path}c%20c`, + name: 'c c', 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).toHaveBeenCalledWith({ - container: '/', + identifier: container.path, + name: 'test.com', + container: true, children: expect.objectContaining({ length: 3 }), + parents: [], + }); + }); + + it('converts an improperly named container.', async(): Promise => { + 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: [], }); }); });