| null {
+ const headers = Array.from(map.keys()).filter((k) => pattern.test(k.trim()));
+
+ if (headers.length !== 1) {
+ return null;
+ }
+
+ return map.get(headers[0]) || null;
+}
+
+function toDOM(cookedHTML: string): Object {
+ // Note: DomHandler is actually synchronous, in spite of the nodeback signature.
+ let dom;
+ const domHandler = new DomHandler((err, result) => {
+ if (err) throw err;
+ dom = result;
+ });
+
+ const htmlParser = new Parser(domHandler);
+ htmlParser.write(cookedHTML);
+ htmlParser.end();
+
+ // The .end() forces data to be flushed, so we know DomHandler calls the callback.
+ // But in case some implementation detail changes, add this error.
+ if (dom === undefined) {
+ throw new Error("DomHandler callback wasn't called after htmlParser.end()");
+ }
+
+ return dom;
+}
diff --git a/src/plugins/initiatives/htmlTemplate.test.js b/src/plugins/initiatives/htmlTemplate.test.js
new file mode 100644
index 0000000..9d28c62
--- /dev/null
+++ b/src/plugins/initiatives/htmlTemplate.test.js
@@ -0,0 +1,310 @@
+// @flow
+
+import {groupURLsByHeader, parseCookedHtml} from "./htmlTemplate";
+
+describe("plugins/initiatives/htmlTemplate", () => {
+ describe("parseCookedHtml", () => {
+ const sampleStatusIncomplete = `Status: Testing
`;
+ const sampleStatusComplete = `Status: Completed
`;
+ const sampleChampion = `
+ Champion?:
+
+ @ChampUser
+
+ `;
+ const sampleDependencies = `
+ Dependencies:
+
+ `;
+ const sampleReferences = `
+ References:
+
+ `;
+ const sampleContributions = `
+ Contributions:
+
+ `;
+
+ it("handles an example text", () => {
+ // Given
+ const sample = `
+ ${sampleStatusIncomplete}
+ ${sampleChampion}
+ ${sampleDependencies}
+ ${sampleReferences}
+ ${sampleContributions}
+ `;
+
+ // When
+ const partial = parseCookedHtml(sample);
+
+ // Then
+ expect(partial).toMatchInlineSnapshot(`
+ Object {
+ "champions": Array [
+ "/u/ChampUser",
+ ],
+ "completed": false,
+ "contributions": Array [
+ "https://foo.bar/t/contribution/7",
+ "https://foo.bar/t/contribution/8/2",
+ "https://github.com/sourcecred/sourcecred/pull/1416",
+ ],
+ "dependencies": Array [
+ "https://foo.bar/t/dependency/1",
+ "https://foo.bar/t/dependency/2",
+ "https://foo.bar/t/dependency/3",
+ ],
+ "references": Array [
+ "https://foo.bar/t/reference/4",
+ "https://foo.bar/t/reference/5/2",
+ "https://foo.bar/t/reference/6/4",
+ ],
+ }
+ `);
+ });
+
+ it("considers blank status incomplete", () => {
+ // Given
+ const sample = `
+ Example initiative
+ Status:
+ Champion:
+ Dependencies:
+ References:
+ Contributions:
+ `;
+
+ // When
+ const partial = parseCookedHtml(sample);
+
+ // Then
+ expect(partial.completed).toEqual(false);
+ });
+
+ it("throws for missing all headers", () => {
+ // Given
+ const sample = `
+ Example initiative
+ `;
+
+ // When
+ const fn = () => parseCookedHtml(sample);
+
+ // Then
+ expect(fn).toThrow(
+ `Missing or malformed headers ["champions","contributions","dependencies","references","status"]`
+ );
+ });
+
+ it("throws for missing status header", () => {
+ // Given
+ const sample = `
+
+ ${sampleChampion}
+ ${sampleDependencies}
+ ${sampleReferences}
+ ${sampleContributions}
+ `;
+
+ // When
+ const fn = () => parseCookedHtml(sample);
+
+ // Then
+ expect(fn).toThrow(`Missing or malformed headers ["status"]`);
+ });
+
+ it("throws for missing champions header", () => {
+ // Given
+ const sample = `
+ ${sampleStatusIncomplete}
+
+ ${sampleDependencies}
+ ${sampleReferences}
+ ${sampleContributions}
+ `;
+
+ // When
+ const fn = () => parseCookedHtml(sample);
+
+ // Then
+ expect(fn).toThrow(`Missing or malformed headers ["champions"]`);
+ });
+
+ it("throws for missing dependencies header", () => {
+ // Given
+ const sample = `
+ ${sampleStatusIncomplete}
+ ${sampleChampion}
+
+ ${sampleReferences}
+ ${sampleContributions}
+ `;
+
+ // When
+ const fn = () => parseCookedHtml(sample);
+
+ // Then
+ expect(fn).toThrow(`Missing or malformed headers ["dependencies"]`);
+ });
+
+ it("throws for missing references header", () => {
+ // Given
+ const sample = `
+ ${sampleStatusIncomplete}
+ ${sampleChampion}
+ ${sampleDependencies}
+
+ ${sampleContributions}
+ `;
+
+ // When
+ const fn = () => parseCookedHtml(sample);
+
+ // Then
+ expect(fn).toThrow(`Missing or malformed headers ["references"]`);
+ });
+
+ it("throws for missing contributions header", () => {
+ // Given
+ const sample = `
+ ${sampleStatusIncomplete}
+ ${sampleChampion}
+ ${sampleDependencies}
+ ${sampleReferences}
+
+ `;
+
+ // When
+ const fn = () => parseCookedHtml(sample);
+
+ // Then
+ expect(fn).toThrow(`Missing or malformed headers ["contributions"]`);
+ });
+
+ it("throws for conflicting status headers", () => {
+ // Given
+ const sample = `
+ ${sampleStatusIncomplete}
+ ${sampleStatusComplete}
+ ${sampleChampion}
+ ${sampleDependencies}
+ ${sampleReferences}
+ ${sampleContributions}
+ `;
+
+ // When
+ const fn = () => parseCookedHtml(sample);
+
+ // Then
+ expect(fn).toThrow(`Missing or malformed headers ["status"]`);
+ });
+
+ it("throws for duplicate headers", () => {
+ // Given
+ const sample = `
+ ${sampleStatusIncomplete}
+ ${sampleChampion}
+ ${sampleDependencies}
+ ${sampleDependencies}
+ ${sampleReferences}
+ ${sampleContributions}
+ `;
+
+ // When
+ const fn = () => parseCookedHtml(sample);
+
+ // Then
+ expect(fn).toThrow(`Unsupported duplicate header "Dependencies:" found`);
+ });
+ });
+
+ describe("groupURLsByHeader", () => {
+ it("handles an example text", () => {
+ // Given
+ const sample = `
+ This is a title
+
+ Things to talk about.
+ With links
+
+ Seems unmarkdownly formatted
+ Some funky section:
+
+ With
+ More
+
+
+ Links
+
+ Listed things?:
+
+ Ordered things:
+
+ - Yet
+ - More
+ - Links
+
+ `;
+
+ // When
+ const map = groupURLsByHeader(sample);
+
+ // Then
+ expect(map).toMatchInlineSnapshot(`
+ Map {
+ "This is a title" => Array [
+ "https://foo.bar/1",
+ ],
+ "Some funky section:" => Array [
+ "https://foo.bar/2",
+ "https://foo.bar/3",
+ "https://foo.bar/4",
+ ],
+ "Listed things?:" => Array [
+ "https://foo.bar/5",
+ "https://foo.bar/6",
+ "https://foo.bar/7",
+ ],
+ "Ordered things:" => Array [
+ "https://foo.bar/8",
+ "https://foo.bar/9",
+ "https://foo.bar/10",
+ ],
+ }
+ `);
+ });
+
+ it("throws for duplicate headers", () => {
+ // Given
+ const sample = `
+ This is a title
+ This is a title
+ `;
+
+ // When
+ const fn = () => groupURLsByHeader(sample);
+
+ // Then
+ expect(fn).toThrow(
+ `Unsupported duplicate header "This is a title" found`
+ );
+ });
+ });
+});