mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add container breadcrumbs.
This commit is contained in:
parent
1394b9cb56
commit
40a3dcbdb2
@ -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" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user