diff --git a/src/components/test/AnnotationDocumentInfo-test.js b/src/components/test/AnnotationDocumentInfo-test.js
index 5a2ae89..ad1b670 100644
--- a/src/components/test/AnnotationDocumentInfo-test.js
+++ b/src/components/test/AnnotationDocumentInfo-test.js
@@ -1,18 +1,35 @@
-import { checkAccessibility } from '@hypothesis/frontend-testing';
+import {
+ checkAccessibility,
+ mockImportedComponents,
+} from '@hypothesis/frontend-testing';
import { mount } from '@hypothesis/frontend-testing';
-import AnnotationDocumentInfo from '../AnnotationDocumentInfo';
+import AnnotationDocumentInfo, { $imports } from '../AnnotationDocumentInfo';
describe('AnnotationDocumentInfo', () => {
- const createAnnotationDocumentInfo = props => {
- return mount(
-
,
- );
+ let fakeAnnotation;
+ let fakeDomainAndTitle;
+
+ beforeEach(() => {
+ fakeAnnotation = {
+ links: { html: 'https://example.com' },
+ };
+ fakeDomainAndTitle = sinon.stub().returns({
+ titleText: 'Turtles',
+ titleLink: 'http://www.baz',
+ domain: 'www.foo.com',
+ });
+
+ $imports.$mock(mockImportedComponents());
+ $imports.$mock({
+ '../helpers/annotation-metadata': {
+ domainAndTitle: fakeDomainAndTitle,
+ },
+ });
+ });
+
+ const createAnnotationDocumentInfo = () => {
+ return mount(
);
};
it('should render the document title', () => {
@@ -32,7 +49,8 @@ describe('AnnotationDocumentInfo', () => {
it('does not link to document when no link available', () => {});
it('should render domain if available', () => {
- const wrapper = createAnnotationDocumentInfo({ link: '' });
+ fakeAnnotation.links.html = '';
+ const wrapper = createAnnotationDocumentInfo();
const link = wrapper.find('a');
assert.include(wrapper.text(), '"Turtles"');
@@ -42,9 +60,7 @@ describe('AnnotationDocumentInfo', () => {
it(
'should pass a11y checks',
checkAccessibility({
- content: () => {
- return createAnnotationDocumentInfo();
- },
+ content: () => createAnnotationDocumentInfo(),
}),
);
});
diff --git a/src/helpers/annotation-metadata.ts b/src/helpers/annotation-metadata.ts
new file mode 100644
index 0000000..ff38a72
--- /dev/null
+++ b/src/helpers/annotation-metadata.ts
@@ -0,0 +1,115 @@
+export type Annotation = {
+ uri: string;
+ document: { title: string };
+ links: {
+ /** A "bouncer" URL that takes the user to see the annotation in context */
+ incontext?: string;
+ /** URL to view the annotation by itself. */
+ html?: string;
+ };
+};
+
+export type DocumentMetadata = {
+ uri: string;
+ domain: string;
+ title: string;
+};
+
+/**
+ * Extract document metadata from an annotation.
+ */
+export function documentMetadata(annotation: Annotation): DocumentMetadata {
+ const uri = annotation.uri;
+
+ let domain;
+ try {
+ domain = new URL(uri).hostname;
+ } catch {
+ // Annotation URI parsing on the backend is very liberal compared to the URL
+ // constructor. There is also some historic invalid data in h (eg [1]).
+ // Hence, we must handle URL parsing failures in the client.
+ //
+ // [1] https://github.com/hypothesis/client/issues/3666
+ domain = '';
+ }
+ if (domain === 'localhost') {
+ domain = '';
+ }
+
+ let title = domain;
+ if (annotation.document && annotation.document.title) {
+ title = annotation.document.title[0];
+ }
+
+ return { uri, domain, title };
+}
+
+export type DomainAndTitle = {
+ domain: string;
+ titleText: string;
+ titleLink: string | null;
+};
+
+/**
+ * Return the domain and title of an annotation for display on an annotation
+ * card.
+ */
+export function domainAndTitle(annotation: Annotation): DomainAndTitle {
+ return {
+ domain: domainTextFromAnnotation(annotation),
+ titleText: titleTextFromAnnotation(annotation),
+ titleLink: titleLinkFromAnnotation(annotation),
+ };
+}
+
+function titleLinkFromAnnotation(annotation: Annotation): string | null {
+ let titleLink: string | null = annotation.uri;
+
+ if (
+ titleLink &&
+ !(titleLink.indexOf('http://') === 0 || titleLink.indexOf('https://') === 0)
+ ) {
+ // We only link to http(s) URLs.
+ titleLink = null;
+ }
+
+ if (annotation.links && annotation.links.incontext) {
+ titleLink = annotation.links.incontext;
+ }
+
+ return titleLink;
+}
+/**
+ * Returns the domain text from an annotation.
+ */
+function domainTextFromAnnotation(annotation: Annotation): string {
+ const document = documentMetadata(annotation);
+
+ let domainText = '';
+ if (document.uri && document.uri.indexOf('file://') === 0 && document.title) {
+ const parts = document.uri.split('/');
+ const filename = parts[parts.length - 1];
+ if (filename) {
+ domainText = filename;
+ }
+ } else if (document.domain && document.domain !== document.title) {
+ domainText = document.domain;
+ }
+
+ return domainText;
+}
+
+/**
+ * Returns the title text from an annotation and crops it to 30 chars
+ * if needed.
+ */
+function titleTextFromAnnotation(annotation: Annotation): string {
+ const document = documentMetadata(annotation);
+
+ let titleText = document.title;
+ if (titleText.length > 30) {
+ titleText = titleText.slice(0, 30) + '…';
+ }
+
+ return titleText;
+}
diff --git a/src/helpers/index.ts b/src/helpers/index.ts
index 361397c..96785bb 100644
--- a/src/helpers/index.ts
+++ b/src/helpers/index.ts
@@ -1,4 +1,6 @@
+export { documentMetadata } from './annotation-metadata';
export { processAndReplaceMentionElements } from './mentions';
+export type { DocumentMetadata, DomainAndTitle } from './annotation-metadata';
export type { Group, GroupType } from './groups';
export type { Mention, MentionMode, InvalidMentionContent } from './mentions';
diff --git a/src/helpers/test/annotation-metadata-test.js b/src/helpers/test/annotation-metadata-test.js
new file mode 100644
index 0000000..91d6e8f
--- /dev/null
+++ b/src/helpers/test/annotation-metadata-test.js
@@ -0,0 +1,174 @@
+import { documentMetadata, domainAndTitle } from '../annotation-metadata';
+
+const fakeAnnotation = (props = {}) => {
+ return {
+ document: {},
+ uri: 'http://example.com/a/page',
+ ...props,
+ };
+};
+
+describe('documentMetadata', () => {
+ it('returns the hostname from annotation.uri as the domain', () => {
+ const annotation = fakeAnnotation();
+ assert.equal(documentMetadata(annotation).domain, 'example.com');
+ });
+
+ context('when annotation.uri does not start with "urn"', () => {
+ it('uses annotation.uri as the uri', () => {
+ const annotation = fakeAnnotation();
+ assert.equal(
+ documentMetadata(annotation).uri,
+ 'http://example.com/a/page',
+ );
+ });
+ });
+
+ context('when document.title is an available', () => {
+ it('uses the first document title as the title', () => {
+ const annotation = fakeAnnotation({
+ document: {
+ title: ['My Document', 'My Other Document'],
+ },
+ });
+
+ assert.equal(
+ documentMetadata(annotation).title,
+ annotation.document.title[0],
+ );
+ });
+ });
+
+ context('when there is no document.title', () => {
+ it('returns the domain as the title', () => {
+ const annotation = fakeAnnotation();
+ assert.equal(documentMetadata(annotation).title, 'example.com');
+ });
+ });
+
+ ['http://localhost:5000', '[not a URL]'].forEach(uri => {
+ it('returns empty domain if URL is invalid or private', () => {
+ const annotation = fakeAnnotation({ uri });
+ const { domain } = documentMetadata(annotation);
+ assert.equal(domain, '');
+ });
+ });
+});
+
+describe('domainAndTitle', () => {
+ context('when an annotation has a non-http(s) uri', () => {
+ it('returns no title link', () => {
+ const annotation = fakeAnnotation({
+ uri: 'file:///example.pdf',
+ });
+
+ assert.equal(domainAndTitle(annotation).titleLink, null);
+ });
+ });
+
+ context('when an annotation has a direct link', () => {
+ it('returns the direct link as a title link', () => {
+ const annotation = {
+ uri: 'https://annotatedsite.com/',
+ links: {
+ incontext: 'https://example.com',
+ },
+ };
+
+ assert.equal(domainAndTitle(annotation).titleLink, 'https://example.com');
+ });
+ });
+
+ context('when an annotation has no direct link but has a http(s) uri', () => {
+ it('returns the uri as title link', () => {
+ const annotation = fakeAnnotation({
+ uri: 'https://example.com',
+ });
+
+ assert.equal(domainAndTitle(annotation).titleLink, 'https://example.com');
+ });
+ });
+
+ context('when the annotation title is shorter than 30 characters', () => {
+ it('returns the annotation title as title text', () => {
+ const annotation = fakeAnnotation({
+ uri: 'https://annotatedsite.com/',
+ document: {
+ title: ['A Short Document Title'],
+ },
+ });
+
+ assert.equal(
+ domainAndTitle(annotation).titleText,
+ 'A Short Document Title',
+ );
+ });
+ });
+
+ context('when the annotation title is longer than 30 characters', () => {
+ it('truncates the title text with ellipsis character "…"', () => {
+ const annotation = fakeAnnotation({
+ document: {
+ title: ['My Really Really Long Document Title'],
+ },
+ });
+
+ assert.equal(
+ domainAndTitle(annotation).titleText,
+ 'My Really Really Long Document…',
+ );
+ });
+ });
+
+ context('when the document uri refers to a filename', () => {
+ it('returns the filename as domain text', () => {
+ const annotation = fakeAnnotation({
+ uri: 'file:///path/to/example.pdf',
+ document: {
+ title: ['Document Title'],
+ },
+ });
+
+ assert.equal(domainAndTitle(annotation).domain, 'example.pdf');
+ });
+ });
+
+ context('when domain and title are the same', () => {
+ it('returns an empty domain text string', () => {
+ const annotation = fakeAnnotation({
+ uri: 'https://example.com',
+ document: {
+ title: ['example.com'],
+ },
+ });
+
+ assert.equal(domainAndTitle(annotation).domain, '');
+ });
+ });
+
+ context('when the document has no domain', () => {
+ it('returns an empty domain text string', () => {
+ const annotation = fakeAnnotation({
+ uri: 'doi:10.1234/5678',
+ document: {
+ title: ['example.com'],
+ },
+ });
+
+ assert.equal(domainAndTitle(annotation).domain, '');
+ });
+ });
+
+ context('when the document is a local file with a title', () => {
+ it('returns the filename', () => {
+ const annotation = fakeAnnotation({
+ uri: 'file:///home/seanh/MyFile.pdf',
+ document: {
+ title: ['example.com'],
+ },
+ });
+
+ assert.equal(domainAndTitle(annotation).domain, 'MyFile.pdf');
+ });
+ });
+});
diff --git a/src/index.ts b/src/index.ts
index eb41217..48c8fc2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,6 +7,7 @@ export {
MarkdownView,
MentionPopoverContent,
} from './components';
+export { documentMetadata } from './helpers';
export { renderMathAndMarkdown } from './utils';
export type {
@@ -19,6 +20,8 @@ export type {
MentionPopoverContentProps,
} from './components';
export type {
+ DocumentMetadata,
+ DomainAndTitle,
Group,
GroupType,
Mention,