From 4fdd8f1e32a6a7737bc19da60b9a194dc9071933 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 11 Jun 2025 10:23:35 +0200 Subject: [PATCH] Add helpers for AnnotationDocumentInfo --- src/components/AnnotationDocumentInfo.tsx | 28 ++- .../test/AnnotationDocumentInfo-test.js | 46 +++-- src/helpers/annotation-metadata.ts | 115 ++++++++++++ src/helpers/index.ts | 2 + src/helpers/test/annotation-metadata-test.js | 174 ++++++++++++++++++ src/index.ts | 3 + 6 files changed, 345 insertions(+), 23 deletions(-) create mode 100644 src/helpers/annotation-metadata.ts create mode 100644 src/helpers/test/annotation-metadata-test.js diff --git a/src/components/AnnotationDocumentInfo.tsx b/src/components/AnnotationDocumentInfo.tsx index f200403..8f6665d 100644 --- a/src/components/AnnotationDocumentInfo.tsx +++ b/src/components/AnnotationDocumentInfo.tsx @@ -1,21 +1,33 @@ import { Link } from '@hypothesis/frontend-shared'; +import type { Annotation } from '../helpers/annotation-metadata'; +import { domainAndTitle } from '../helpers/annotation-metadata'; + export type AnnotationDocumentInfoProps = { - /** The domain associated with the document */ - domain?: string; - /** A direct link to the document */ - link?: string; - title: string; + annotation: Annotation; }; + /** * Render some metadata about an annotation's document and link to it * if a link is available. */ export default function AnnotationDocumentInfo({ - domain, - link, - title, + annotation, }: AnnotationDocumentInfoProps) { + const { domain, titleText: title, titleLink } = domainAndTitle(annotation); + const annotationURL = annotation.links?.html || ''; + // There are some cases at present in which linking directly to an + // annotation's document is not immediately feasible—e.g in an LMS context + // where the original document might not be available outside of an + // assignment (e.g. Canvas files), and/or wouldn't be able to present + // any associated annotations. + // For the present, disable links to annotation documents for all third-party + // annotations until we have a more nuanced way of making linking determinations. + // The absence of a link to a single-annotation view is a signal that this + // is a third-party annotation. + // Also, of course, verify that there is a URL to the document (titleLink) + const link = annotationURL && titleLink ? titleLink : ''; + return (
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,