Repository: daisy/ace (js)

Issue:
Report link info from the Package Document
It would be useful (but hardly critical) to get a list of links along with the metadata, pairing rel to href.

The reason I ask is that conformance to the a11y spec is done through a link, so I can't pick up if the metadata is already set in the publication:

```
<link rel="dcterms:conformsTo" href="http://www.idpf.org/epub/a11y/accessibility-20170105.html#wcag-aa"/>
```

The same is also true for the certifierReport link and potentially also the certifierCredential.

It's not going to be common, but I can see two scenarios where this metadata might be present: 1) someone sets the metadata prematurely/automatically, and 2) someone needs to reverify a publication.

Without access to the link info, I can't reset these fields in the verification tool.

Pull request:
feat(data): extract and report link info from the Package Document
Publication links (defined as `link` elements in the Package Document) are extracted
and copied under a new `links` property of the `earl:testSubject` object.

For instance, the following OPF snippet:

```
<link rel="a11y:certifierReport" href="http://www.example.com/report.html"/>
```

Would be reported as:

```
"earl:testSubject" {
  …
  "links": {
    "a11y:certifierReport": "http://www.example.com/report.html"
  }
}
```

Only publication-level links (i.e. with no `refines` attribute) are extracted.

Fixes #96

Patch:
diff --git a/packages/ace-report/src/report-builders.js b/packages/ace-report/src/report-builders.js
index 067fbef..9b60806 100644
--- a/packages/ace-report/src/report-builders.js
+++ b/packages/ace-report/src/report-builders.js
@@ -34,11 +34,12 @@ function withAssertions(obj, assertions) {
   return obj;
 }
 
-function withTestSubject(obj, url, title = '', identifier = '', metadata = null) {
+function withTestSubject(obj, url, title = '', identifier = '', metadata = null, links = null) {
   const testSubject = { url };
   if (title.length > 0) testSubject['dct:title'] = title;
   if (identifier.length > 0) testSubject['dct:identifier'] = identifier;
   if (metadata !== undefined && metadata != null) testSubject.metadata = metadata;
+  if (links !== undefined && links != null) testSubject.links = links;
   obj['earl:testSubject'] = testSubject;
   return obj;
 }
@@ -149,8 +150,8 @@ class ReportBuilder {
     });
     return this;
   }
-  withTestSubject(url, title, identifier, metadata) {
-    withTestSubject(this._json, url, title, identifier, metadata);
+  withTestSubject(url, title, identifier, metadata, links) {
+    withTestSubject(this._json, url, title, identifier, metadata, links);
     return this;
   }
 }
diff --git a/packages/ace-report/src/report.js b/packages/ace-report/src/report.js
index b405239..a7f1724 100644
--- a/packages/ace-report/src/report.js
+++ b/packages/ace-report/src/report.js
@@ -41,8 +41,8 @@ function aggregateHTMLOutlines(outlines) {
 module.exports = class Report {
   constructor(epub) {
     this._builder = new builders.ReportBuilder()
-      .withTestSubject(epub.path, '', '', epub.metadata)
-      .withA11yMeta(a11yMetaChecker.analyze(epub.metadata));
+      .withTestSubject(epub.path, '', '', epub.metadata, epub.links)
+      .withA11yMeta(a11yMetaChecker.analyze(epub.metadata))
   }
 
   get json() {
@@ -56,10 +56,6 @@ module.exports = class Report {
   addData(data) {
     this._builder.withData(data);
   }
-  addOutline(outline) {
-    this._builder.withHOutline(outline);
-    return this;
-  }
   addHeadings(headings) {
     this._builder.withHeadingsOutline(headingsToOutline(headings));
     return this;
diff --git a/packages/epub-utils/src/epub-parse.js b/packages/epub-utils/src/epub-parse.js
index 5ed153e..6fc3c15 100644
--- a/packages/epub-utils/src/epub-parse.js
+++ b/packages/epub-utils/src/epub-parse.js
@@ -83,6 +83,24 @@ function parseMetadata(doc, select) {
   return result;
 }
 
+
+function addLink(rel, href, link) {
+  if (!link[rel]) {
+    link[rel] = href;
+  } else if (!(link[rel] instanceof Array)) {
+    link[rel] = [link[rel], href];
+  } else {
+    link[rel].push(href);
+  }
+}
+function parseLinks(doc, select) {
+  const result = {};
+  select('//opf:link[not(@refines)]', doc).forEach((link) => {
+    addLink(link.getAttribute('rel'), link.getAttribute('href'), result);
+  });
+  return result;
+}
+
 // override the default of XHTML
 EpubParser.prototype.setContentDocMediaType = function(mediaType) {
   this.contentDocMediaType = mediaType;
@@ -114,6 +132,7 @@ EpubParser.prototype.parseData = function(packageDocPath, epubDir) {
     { opf: 'http://www.idpf.org/2007/opf',
       dc: 'http://purl.org/dc/elements/1.1/'});
   this.metadata = parseMetadata(doc, select);
+  this.links = parseLinks(doc, select);
 
   const spineItemIdrefs = select('//opf:itemref/@idref', doc);
   spineItemIdrefs.forEach((idref) => {


Test patch:
diff --git a/packages/ace-report/src/report-builders.test.js b/packages/ace-report/src/report-builders.test.js
index 0e59d06..6524c54 100644
--- a/packages/ace-report/src/report-builders.test.js
+++ b/packages/ace-report/src/report-builders.test.js
@@ -70,7 +70,8 @@ describe('report builder', () => {
       });
     });
   });
-  describe('adding assertions', () => {
+
+  describe('withAssertions', () => {
     let assertions;
     test('adding null assertion is ignored', () => {
       expect(report.withAssertions(null)).toBeDefined();
@@ -119,7 +120,8 @@ describe('report builder', () => {
       expect(assertions[1]).toEqual({ foo: 'foo' });
     });
   });
-  describe('adding data', () => {
+
+  describe('withData', () => {
     let data;
     beforeEach(() => {
       data = report.build().data;
@@ -152,7 +154,8 @@ describe('report builder', () => {
       expect(data).toEqual({ foo: ['foo', 'bar'] });
     });
   });
-  describe('adding properties', () => {
+
+  describe('withProperties', () => {
     let properties;
     beforeEach(() => {
       properties = report.build().properties;
@@ -189,4 +192,68 @@ describe('report builder', () => {
       expect(properties).toEqual({ foo: true });
     });
   });
+
+  describe('withTestSubject', () => {
+    test('with URL', () => {
+      expect(report.withTestSubject('https://example.com')).toBeDefined();
+      const testSubject = report.build()['earl:testSubject'];
+      expect(testSubject).toBeDefined();
+      expect(testSubject).toEqual({
+        url: 'https://example.com'
+      })
+    });
+    test('with title', () => {
+      expect(report.withTestSubject('https://example.com', 'title')).toBeDefined();
+      const testSubject = report.build()['earl:testSubject'];
+      expect(testSubject).toBeDefined();
+      expect(testSubject).toEqual({
+        url: 'https://example.com',
+        'dct:title': 'title',
+      })
+    });
+    test('with identifier', () => {
+      expect(report.withTestSubject('https://example.com', '', 'uid')).toBeDefined();
+      const testSubject = report.build()['earl:testSubject'];
+      expect(testSubject).toBeDefined();
+      expect(testSubject).toEqual({
+        url: 'https://example.com',
+        'dct:identifier': 'uid',
+      })
+    });
+    test('with metadata null', () => {
+      expect(report.withTestSubject('https://example.com', '', '', null)).toBeDefined();
+      const testSubject = report.build()['earl:testSubject'];
+      expect(testSubject).toBeDefined();
+      expect(testSubject).toEqual({
+        url: 'https://example.com',
+      })
+    });
+    test('with metadata', () => {
+      expect(report.withTestSubject('https://example.com', '', '', { foo: 'bar' })).toBeDefined();
+      const testSubject = report.build()['earl:testSubject'];
+      expect(testSubject).toBeDefined();
+      expect(testSubject).toEqual({
+        url: 'https://example.com',
+        metadata: { foo: 'bar' }
+      })
+    });
+
+    test('with link null', () => {
+      expect(report.withTestSubject('https://example.com', '', '', null, null)).toBeDefined();
+      const testSubject = report.build()['earl:testSubject'];
+      expect(testSubject).toBeDefined();
+      expect(testSubject).toEqual({
+        url: 'https://example.com',
+      })
+    });
+    test('with links', () => {
+      expect(report.withTestSubject('https://example.com', '', '', null, { foo: 'bar' })).toBeDefined();
+      const testSubject = report.build()['earl:testSubject'];
+      expect(testSubject).toBeDefined();
+      expect(testSubject).toEqual({
+        url: 'https://example.com',
+        links: { foo: 'bar' }
+      })
+    });
+  });
 });
diff --git a/tests/__tests__/report_json.test.js b/tests/__tests__/report_json.test.js
index e7a8c96..5ab72dd 100644
--- a/tests/__tests__/report_json.test.js
+++ b/tests/__tests__/report_json.test.js
@@ -145,6 +145,15 @@ describe('check data', () => {
     });
   });
 
+  test('extract links', async () => {
+    const report = await ace(path.join(__dirname, '../data/feat-links'));
+    expect(report['earl:testSubject']).toMatchObject({
+      links: {
+        "a11y:certifierReport": "http://www.example.com/report.html"
+      },
+    });
+  });
+
   test('extract videos', async () => {
     const report = await ace(path.join(__dirname, '../data/feat-video'));
     expect(report.data).toMatchObject({
diff --git a/tests/data/feat-links/EPUB/content_001.xhtml b/tests/data/feat-links/EPUB/content_001.xhtml
new file mode 100644
index 0000000..c55a9e8
--- /dev/null
+++ b/tests/data/feat-links/EPUB/content_001.xhtml
@@ -0,0 +1,9 @@
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en">
+<head>
+<title>Minimal EPUB</title>
+</head>
+<body>
+<h1>Loomings</h1>
+<p>Call me Ishmael.</p>
+</body>
+</html>
diff --git a/tests/data/feat-links/EPUB/nav.xhtml b/tests/data/feat-links/EPUB/nav.xhtml
new file mode 100644
index 0000000..1d538c1
--- /dev/null
+++ b/tests/data/feat-links/EPUB/nav.xhtml
@@ -0,0 +1,12 @@
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en">
+<head>
+<title>Minimal Nav</title>
+</head>
+<body>
+<nav epub:type="toc">
+<ol>
+<li><a href="content_001.xhtml">content 001</a></li>
+</ol>
+</nav>
+</body>
+</html>
diff --git a/tests/data/feat-links/EPUB/package.opf b/tests/data/feat-links/EPUB/package.opf
new file mode 100644
index 0000000..5e11ea0
--- /dev/null
+++ b/tests/data/feat-links/EPUB/package.opf
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<package xmlns="http://www.idpf.org/2007/opf" version="3.0" xml:lang="en" unique-identifier="uid"
+  prefix="a11y: http://www.idpf.org/epub/vocab/package/a11y/#">
+<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
+  <dc:title id="title">Minimal EPUB 3.0</dc:title>
+  <dc:language>en</dc:language>
+  <dc:identifier id="uid">NOID</dc:identifier>
+  <meta property="dcterms:modified">2017-01-01T00:00:01Z</meta>
+  <meta property="schema:accessibilityFeature">structuralNavigation</meta>
+  <meta property="schema:accessibilitySummary">everything OK!</meta>
+  <meta property="schema:accessibilityHazard">noFlashingHazard</meta>
+  <meta property="schema:accessibilityHazard">noSoundHazard</meta>
+  <meta property="schema:accessibilityHazard">noMotionSimulationHazard</meta>
+  <meta property="schema:accessMode">textual</meta>
+  <meta property="schema:accessModeSufficient">textual</meta>
+  <link rel="a11y:certifierReport" href="http://www.example.com/report.html"/>
+</metadata>
+<manifest>
+  <item id="nav"  href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
+  <item id="content_001"  href="content_001.xhtml" media-type="application/xhtml+xml"/>
+</manifest>
+<spine>
+  <itemref idref="content_001" />
+</spine>
+</package>
diff --git a/tests/data/feat-links/META-INF/container.xml b/tests/data/feat-links/META-INF/container.xml
new file mode 100644
index 0000000..2cf0065
--- /dev/null
+++ b/tests/data/feat-links/META-INF/container.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
+   <rootfiles>
+      <rootfile full-path="EPUB/package.opf" media-type="application/oebps-package+xml"/>
+   </rootfiles>
+</container>
diff --git a/tests/data/feat-links/mimetype b/tests/data/feat-links/mimetype
new file mode 100644
index 0000000..57ef03f
--- /dev/null
+++ b/tests/data/feat-links/mimetype
@@ -0,0 +1,1 @@
+application/epub+zip
\ No newline at end of file