From 6d3974e63a70155b3214f7da913c756d1c2734d8 Mon Sep 17 00:00:00 2001 From: Scott Willeke Date: Sat, 10 Aug 2019 23:49:41 -0700 Subject: [PATCH 1/9] wip: working on #17 --- README.md | 22 ++ src/LayoutContext.ts | 62 +++++ src/css/{CssBox.spec.ts => CssBoxImp.spec.ts} | 47 ++-- src/css/{CssBox.ts => CssBoxImp.ts} | 14 +- ...xBuilders.ts => DefaultBoxBuilderFuncs.ts} | 118 +++++----- src/css/layout/LayoutContext.ts | 53 ----- src/css/layout/LayoutContextImp.ts | 66 ++++++ src/css/layout/layout.spec.ts | 2 +- src/css/layout/layout.ts | 216 +++++++++++++----- src/index.ts | 111 ++++++++- .../html-to-mrkdwn/block-quote-nested.mrkdwn | 9 + .../html-to-mrkdwn/blockquote-empty.mrkdwn | 4 + .../blockquote-heading-and-paragraph.mrkdwn | 8 + ...ockquote.mrkdwn.todo => blockquote.mrkdwn} | 0 .../extending-blockbuilders.spec.ts | 53 +++++ 15 files changed, 561 insertions(+), 224 deletions(-) create mode 100644 src/LayoutContext.ts rename src/css/{CssBox.spec.ts => CssBoxImp.spec.ts} (70%) rename src/css/{CssBox.ts => CssBoxImp.ts} (88%) rename src/css/layout/{BoxBuilders.ts => DefaultBoxBuilderFuncs.ts} (62%) delete mode 100644 src/css/layout/LayoutContext.ts create mode 100644 src/css/layout/LayoutContextImp.ts create mode 100644 test-data/snapshots/html-to-mrkdwn/block-quote-nested.mrkdwn create mode 100644 test-data/snapshots/html-to-mrkdwn/blockquote-empty.mrkdwn create mode 100644 test-data/snapshots/html-to-mrkdwn/blockquote-heading-and-paragraph.mrkdwn rename test-data/snapshots/html-to-mrkdwn/{blockquote.mrkdwn.todo => blockquote.mrkdwn} (100%) create mode 100644 tests/integration/extending-blockbuilders.spec.ts diff --git a/README.md b/README.md index 2490db67..65f6f58f 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Agent Markdown is a [HTML user agent](https://en.wikipedia.org/wiki/User_agent) - [Features](#features) - [CLI Example](#cli-example) - [Live Example](#live-example) +- [Customize & Extend](#customize--extend) - [Show your support](#show-your-support) - [Contributing ๐Ÿค](#contributing-๐Ÿค) - [Release Process (Deploying to NPM) ๐Ÿš€](#release-process-deploying-to-npm-๐Ÿš€) @@ -76,6 +77,27 @@ yarn yarn start ``` +## Customize & Extend + +To customize how the markdown is generated or add support for new elements, implement the `BoxBuilder` interface to handle a particular HTML element. The BoxBuilder interface is a single function defined as follows: + +```TypeScript +export interface BoxBuilder { + (context: LayoutContext, element: HtmlNode): CssBox | null +} +``` + +```TypeScript +(context: LayoutContext, element: HtmlNode): CssBox | null => { + const kids = BoxBuilders.buildBoxes(context, element.children) + kids.unshift(new CssBox(BoxType.inline, sequence)) + kids.push(new CssBox(BoxType.inline, sequence)) + return new CssBox(BoxType.inline, "", kids) + } +``` + +An example of how the html `` element is implement is below: + ## Show your support Give a โญ๏ธ if this project helped you! diff --git a/src/LayoutContext.ts b/src/LayoutContext.ts new file mode 100644 index 00000000..5e886a83 --- /dev/null +++ b/src/LayoutContext.ts @@ -0,0 +1,62 @@ +import { CssBox, HtmlNode } from "." + +export interface LayoutBoxFactory { + /** + * Builds a set of @see CssBox objects for the specified elements. + */ + buildBoxes(context: LayoutContext, elements: HtmlNode[]): CssBox[] + /** + * Creates a new @see CssBox instance. + * @param textContent Returns any text content if this box has text to render. + * @param children Returns any child boxes of this box. + * @param debugNote A string to add to the box to help with debugging. + */ + createBlockBox( + textContent: string, + children: Iterable, + debugNote: string + ): CssBox + createBlockBox(textContent: string, children: Iterable): CssBox + createBlockBox(textContent: string): CssBox + /** + * Creates a new @see CssBox instance. + * @param textContent Returns any text content if this box has text to render. + * @param children Returns any child boxes of this box. + * @param debugNote A string to add to the box to help with debugging. + */ + createInlineBox( + textContent: string, + children: Iterable, + debugNote: string + ): CssBox + createInlineBox(textContent: string, children: Iterable): CssBox + createInlineBox(textContent: string): CssBox +} + +export interface LayoutContext extends LayoutBoxFactory { + /** + * Returns the specified stack. + * If the stack is not yet created it will return an empty stack. + * @param stackName The stack name (state key) to retrieve. + */ + getStateStack(stackName: string): TValue[] + /** + * Pushes the specified state onto the specified stack. + * If the stack does not yet exist, it will be created and the value pushed onto it. + * NOTE: If you want to evaluate the stack itself, use @see getStateStack and it will return the stack. + * @param stackName The ยดkey/name of the stack to push the value onto. + * @param value The value to push onto the top of the stack. + */ + pushState(stackName: string, value: TValue): void + /** + * Pops the top value from the specified stack and returns it. + * If the stack doesn't exist or is empty @see undefined is returned. + * @param stackName The key/name of the stack to pop the value from. + */ + popState(stackName: string): TValue | undefined + /** + * Returns the top value from the specified stack without removing it from the stack. + * @param stackName The key/name of the stack to peek at. + */ + peekState(stackName: string): TValue | undefined +} diff --git a/src/css/CssBox.spec.ts b/src/css/CssBoxImp.spec.ts similarity index 70% rename from src/css/CssBox.spec.ts rename to src/css/CssBoxImp.spec.ts index 9616629d..24b970e1 100644 --- a/src/css/CssBox.spec.ts +++ b/src/css/CssBoxImp.spec.ts @@ -1,4 +1,5 @@ -import { CssBox, BoxType } from "./CssBox" +import { CssBoxImp } from "./CssBoxImp" +import { BoxType, CssBox } from ".." describe("CSS 9.2.1.1 Anonymous block boxes", () => { /** @@ -7,11 +8,11 @@ describe("CSS 9.2.1.1 Anonymous block boxes", () => { */ it("should not insert boxes with only inline children", () => { const children = [ - new CssBox(BoxType.inline), - new CssBox(BoxType.inline), - new CssBox(BoxType.inline) + new CssBoxImp(BoxType.inline), + new CssBoxImp(BoxType.inline), + new CssBoxImp(BoxType.inline) ] - const rootBox = new CssBox(BoxType.block, "", children) + const rootBox = new CssBoxImp(BoxType.block, "", children) for (const child of children) { expect(rootBox.children).toContainEqual(child) } @@ -19,11 +20,11 @@ describe("CSS 9.2.1.1 Anonymous block boxes", () => { it("should not insert boxes with only block children", () => { const children = [ - new CssBox(BoxType.block), - new CssBox(BoxType.block), - new CssBox(BoxType.block) + new CssBoxImp(BoxType.block), + new CssBoxImp(BoxType.block), + new CssBoxImp(BoxType.block) ] - const rootBox = new CssBox(BoxType.block, "", children) + const rootBox = new CssBoxImp(BoxType.block, "", children) for (const child of children) { expect(rootBox.children).toContainEqual(child) } @@ -31,11 +32,11 @@ describe("CSS 9.2.1.1 Anonymous block boxes", () => { it("should insert anonymous block box with block & inline children", () => { const children = [ - new CssBox(BoxType.block), - new CssBox(BoxType.inline), - new CssBox(BoxType.block) + new CssBoxImp(BoxType.block), + new CssBoxImp(BoxType.inline), + new CssBoxImp(BoxType.block) ] - const rootBox = new CssBox(BoxType.block, "", children) + const rootBox = new CssBoxImp(BoxType.block, "", children) const actual: CssBox[] = Array.from(rootBox.children) expect(actual).toHaveLength(3) // should have the first and last: @@ -47,12 +48,12 @@ describe("CSS 9.2.1.1 Anonymous block boxes", () => { it("anonymous block box should collect sequences of adjacent inlines with block & inline children", () => { const children = [ - new CssBox(BoxType.block), - new CssBox(BoxType.inline), - new CssBox(BoxType.inline), - new CssBox(BoxType.block) + new CssBoxImp(BoxType.block), + new CssBoxImp(BoxType.inline), + new CssBoxImp(BoxType.inline), + new CssBoxImp(BoxType.block) ] - const rootBox = new CssBox(BoxType.block, "", children) + const rootBox = new CssBoxImp(BoxType.block, "", children) const actual: CssBox[] = Array.from(rootBox.children) expect(actual).toHaveLength(3) // should have the first and last: @@ -65,12 +66,12 @@ describe("CSS 9.2.1.1 Anonymous block boxes", () => { it("anonymous block box should not collect sequences of non-adjacent inlines with block & inline children", () => { const children = [ - new CssBox(BoxType.block), - new CssBox(BoxType.inline), - new CssBox(BoxType.block), - new CssBox(BoxType.inline) + new CssBoxImp(BoxType.block), + new CssBoxImp(BoxType.inline), + new CssBoxImp(BoxType.block), + new CssBoxImp(BoxType.inline) ] - const rootBox = new CssBox(BoxType.block, "", children) + const rootBox = new CssBoxImp(BoxType.block, "", children) const actual: CssBox[] = Array.from(rootBox.children) expect(actual).toHaveLength(4) // should have the blocks (first and third): diff --git a/src/css/CssBox.ts b/src/css/CssBoxImp.ts similarity index 88% rename from src/css/CssBox.ts rename to src/css/CssBoxImp.ts index b8cd586c..5443fe73 100644 --- a/src/css/CssBox.ts +++ b/src/css/CssBoxImp.ts @@ -1,15 +1,9 @@ -/* eslint-disable no-unused-vars */ -export enum BoxType { - block = 0, - inline = 1 -} - -/* eslint-enable no-unused-vars */ +import { CssBox, BoxType } from ".." /** * Represents a (simplified) a CSS box as described at https://www.w3.org/TR/CSS22/visuren.html#box-gen. */ -export class CssBox { +export class CssBoxImp implements CssBox { private readonly _children: CssBox[] /** @@ -17,7 +11,7 @@ export class CssBox { * @param type The type of this box. * @param textContent Returns any text content if this box has text to render. * @param children Returns any child boxes of this box. - * @param debugNote A string ot add to the box to help with debugging. + * @param debugNote A string to add to the box to help with debugging. */ public constructor( public type: BoxType, @@ -61,7 +55,7 @@ export class CssBox { if (child.type === BoxType.inline) { anonymousBox = anonymousBox ? anonymousBox - : new CssBox(BoxType.block, null, null, "Anonymous-Block-Box") + : new CssBoxImp(BoxType.block, null, null, "Anonymous-Block-Box") anonymousBox.addChild(child) } else { if (anonymousBox) { diff --git a/src/css/layout/BoxBuilders.ts b/src/css/layout/DefaultBoxBuilderFuncs.ts similarity index 62% rename from src/css/layout/BoxBuilders.ts rename to src/css/layout/DefaultBoxBuilderFuncs.ts index 8b8c5412..ce693560 100644 --- a/src/css/layout/BoxBuilders.ts +++ b/src/css/layout/DefaultBoxBuilderFuncs.ts @@ -1,28 +1,11 @@ import { HtmlNode } from "../../HtmlNode" -import { BoxType, CssBox } from "../CssBox" import { normalizeWhitespace, WhitespaceHandling } from "../" import { ListState } from "./ListState" -import { LayoutContext } from "./LayoutContext" +import { CssBox, BoxBuilder, LayoutContext } from "../.." import { decodeHtmlEntities } from "../../util" import { StyleState } from "./StyleState" -import { generateElementBox } from "./layout" -export interface BoxBuilder { - (context: LayoutContext, element: HtmlNode): CssBox | null -} - -export class BoxBuilders { - public static buildBoxes( - context: LayoutContext, - children: HtmlNode[] - ): CssBox[] { - const kids = children - ? children - .map(el => generateElementBox(context, el)) - .filter(childBox => childBox !== null) - : [] - return kids - } +export class DefaultBoxBuilderFuncs { /** * A @see BoxBuilder suitable for generic block elements. */ @@ -30,10 +13,9 @@ export class BoxBuilders { context: LayoutContext, element: HtmlNode ): CssBox | null { - return new CssBox( - BoxType.block, + return context.createBlockBox( "", - BoxBuilders.buildBoxes(context, element.children), + context.buildBoxes(context, element.children), "genericBlock" ) } @@ -47,14 +29,14 @@ export class BoxBuilders { const text = element.data ? normalizeWhitespace(element.data, WhitespaceHandling.normal) : "" - const kids = BoxBuilders.buildBoxes(context, element.children) - // if it has no text and no kids it doesnt affect layout so, don't create a box to affect layout: + const kids = context.buildBoxes(context, element.children) + // if it has no text and no kids it doesn't affect layout so, don't create a box to affect layout: if ((!text || text.length === 0) && kids.length === 0) { return null - } else return new CssBox(BoxType.inline, text, kids, "genericInline") + } else return context.createInlineBox(text, kids, "genericInline") } /** - * A @see BoxBuilder suitable for generic list item elements. + * A @see BoxBuilder suitable for list item elements. */ public static listItem( context: LayoutContext, @@ -70,15 +52,13 @@ export class BoxBuilders { } let markerBox: CssBox if (listState.getListType() === "ul") { - markerBox = new CssBox( - BoxType.inline, + markerBox = context.createInlineBox( indentSpaces + "* ", null, "li-marker-ul" ) } else if (listState.getListType() === "ol") { - markerBox = new CssBox( - BoxType.inline, + markerBox = context.createInlineBox( indentSpaces + `${listState.getListItemCount()}. `, null, "li-marker-ol" @@ -87,19 +67,17 @@ export class BoxBuilders { throw new Error("unexpected list type") } // add boxes for list item child elements - const contentChildBoxes: CssBox[] = BoxBuilders.buildBoxes( + const contentChildBoxes: CssBox[] = context.buildBoxes( context, element.children ) // prepare a single parent box for the list item's content (to keep it from breaking between the marker & content) - const contentBox = new CssBox( - BoxType.inline, + const contentBox = context.createInlineBox( "", contentChildBoxes, "li-content" ) - const principalBox = new CssBox( - BoxType.block, + const principalBox = context.createBlockBox( "", [markerBox, contentBox], "li-principal" @@ -111,39 +89,50 @@ export class BoxBuilders { throw new Error(`Unexpected list type "${element.name}"`) } const listState = new ListState(context) - const listBox = new CssBox(BoxType.block, "", [], element.name) + const listBox = context.createBlockBox("", [], element.name) listState.beginList(element.name as "ul" | "ol") - const kids = BoxBuilders.buildBoxes(context, element.children) + const kids = context.buildBoxes(context, element.children) listState.endList() kids.forEach(kid => listBox.addChild(kid)) return listBox } + + public static blockquote( + context: LayoutContext, + element: HtmlNode + ): CssBox | null { + const blockquoteBox = context.createBlockBox("", [], element.name) + const kids = context.buildBoxes(context, element.children) + kids.forEach(kid => blockquoteBox.addChild(kid)) + return blockquoteBox + } + public static headingThunk(headingLevel: number): BoxBuilder { return (context: LayoutContext, element: HtmlNode): CssBox | null => { - const kids = BoxBuilders.buildBoxes(context, element.children) + const kids = context.buildBoxes(context, element.children) const headingSequence = "#".repeat(headingLevel) - kids.unshift(new CssBox(BoxType.inline, headingSequence + " ")) - kids.push(new CssBox(BoxType.inline, " " + headingSequence)) - return new CssBox(BoxType.block, "", kids) + kids.unshift(context.createInlineBox(headingSequence + " ")) + kids.push(context.createInlineBox(" " + headingSequence)) + return context.createBlockBox("", kids) } } public static emphasisThunk(sequence: string): BoxBuilder { return (context: LayoutContext, element: HtmlNode): CssBox | null => { - const kids = BoxBuilders.buildBoxes(context, element.children) - kids.unshift(new CssBox(BoxType.inline, sequence)) - kids.push(new CssBox(BoxType.inline, sequence)) - return new CssBox(BoxType.inline, "", kids) + const kids = context.buildBoxes(context, element.children) + kids.unshift(context.createInlineBox(sequence)) + kids.push(context.createInlineBox(sequence)) + return context.createInlineBox("", kids) } } public static link(context: LayoutContext, element: HtmlNode): CssBox | null { - const linkBox = new CssBox(BoxType.inline, "", []) - const childContentBoxes = BoxBuilders.buildBoxes(context, element.children) + const linkBox = context.createInlineBox("") + const childContentBoxes = context.buildBoxes(context, element.children) // wrap the text in square brackets: childContentBoxes.unshift( - new CssBox(BoxType.inline, "[", [], "link-helper-open-text") + context.createInlineBox("[", [], "link-helper-open-text") ) childContentBoxes.push( - new CssBox(BoxType.inline, "]", [], "link-helper-close-text") + context.createInlineBox("]", [], "link-helper-close-text") ) // add destination/title syntax: const href = @@ -158,28 +147,23 @@ export class BoxBuilders { } destinationMarkup += ")" childContentBoxes.push( - new CssBox( - BoxType.inline, - destinationMarkup, - [], - "link-helper-close-text" - ) + context.createInlineBox(destinationMarkup, [], "link-helper-close-text") ) // add the child boxes: childContentBoxes.forEach(kid => linkBox.addChild(kid)) return linkBox } - public static hr(): CssBox | null { - return new CssBox(BoxType.block, "* * *") + public static hr(context: LayoutContext): CssBox | null { + return context.createBlockBox("* * *") } - public static br(): CssBox | null { - return new CssBox(BoxType.inline, "\n") + public static br(context: LayoutContext): CssBox | null { + return context.createInlineBox("\n") } public static pre(context: LayoutContext, element: HtmlNode): CssBox | null { const styleState = new StyleState(context) styleState.pushWhitespaceHandling(WhitespaceHandling.pre) // kids is likely a single text element - const kids = BoxBuilders.buildBoxes(context, element.children) + const kids = context.buildBoxes(context, element.children) const decode = (box: CssBox): void => { box.textContent = decodeHtmlEntities(box.textContent) for (const child of box.children) { @@ -187,19 +171,19 @@ export class BoxBuilders { } } kids.forEach(kid => decode(kid)) - kids.unshift(new CssBox(BoxType.block, "```")) - kids.push(new CssBox(BoxType.block, "```")) + kids.unshift(context.createBlockBox("```")) + kids.push(context.createBlockBox("```")) styleState.popWhitespaceHandling() - return new CssBox(BoxType.block, "", kids) + return context.createBlockBox("", kids) } public static code(context: LayoutContext, element: HtmlNode): CssBox | null { // kids is likely a single text element - const kids = BoxBuilders.buildBoxes(context, element.children) + const kids = context.buildBoxes(context, element.children) // If we're already nested inside of a
 element, don't output the inline code formatting (using the "whitespaceHandling" mode here is a bit of a risky assumption used to make this conclusion)
     if (new StyleState(context).whitespaceHandling != WhitespaceHandling.pre) {
-      kids.unshift(new CssBox(BoxType.inline, "`"))
-      kids.push(new CssBox(BoxType.inline, "`"))
+      kids.unshift(context.createInlineBox("`"))
+      kids.push(context.createInlineBox("`"))
     }
-    return new CssBox(BoxType.block, "", kids)
+    return context.createBlockBox("", kids)
   }
 }
diff --git a/src/css/layout/LayoutContext.ts b/src/css/layout/LayoutContext.ts
deleted file mode 100644
index fdef8dca..00000000
--- a/src/css/layout/LayoutContext.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * Provides context during layout.
- */
-export class LayoutContext {
-  private readonly state: Map = new Map() // eslint-disable-line @typescript-eslint/no-explicit-any
-
-  /**
-   * Returns the specified stack.
-   * If the stack is not yet created it will return an empty stack.
-   * @param stackName The stack name (state key) to retreive.
-   */
-  public getStateStack(stackName: string): TValue[] {
-    let stack = this.state.get(stackName) as TValue[]
-    if (!stack) {
-      stack = new Array()
-      this.state.set(stackName, stack)
-    }
-    return stack
-  }
-
-  /**
-   * Pushes the specified state onto the specified stack.
-   * If the stack does not yet exist, it will be created and the value pushed onto it.
-   * NOTE: If you want to evaluate the stack itself, use @see getStateStack and it will return the stack.
-   * @param stackName The key/name of the stack to push the value onto.
-   * @param value The value to push onto the top of the stack.
-   */
-  public pushState(stackName: string, value: TValue): void {
-    const stack = this.getStateStack(stackName)
-    stack.push(value)
-  }
-
-  /**
-   * Pops the top value from the specified stack and returns it.
-   * If the stack doesn't exist or is empty @see undefined is returned.
-   * @param stackName The key/name of the stack to pop the value from.
-   */
-  public popState(stackName: string): TValue | undefined {
-    const stack = this.getStateStack(stackName)
-    if (stack.length === 0) return undefined
-    else return stack.pop()
-  }
-
-  /**
-   * Returns the top value from the specified stack without removing it from the stack.
-   * @param stackName The key/name of the stack to peek at.
-   */
-  public peekState(stackName: string): TValue | undefined {
-    const stack = this.getStateStack(stackName)
-    if (stack.length === 0) return undefined
-    else return stack[stack.length - 1]
-  }
-}
diff --git a/src/css/layout/LayoutContextImp.ts b/src/css/layout/LayoutContextImp.ts
new file mode 100644
index 00000000..4ecd6ddd
--- /dev/null
+++ b/src/css/layout/LayoutContextImp.ts
@@ -0,0 +1,66 @@
+import { HtmlNode, CssBox, LayoutContext, BoxBuilder } from "../.."
+import { CssBoxConstructor } from "./layout"
+
+/**
+ * Provides context during layout.
+ */
+export class LayoutContextImp implements LayoutContext {
+  private readonly state: Map = new Map() // eslint-disable-line @typescript-eslint/no-explicit-any
+
+  public constructor(
+    private readonly blockBoxCreator: CssBoxConstructor,
+    private readonly inlineBoxCreator: CssBoxConstructor,
+    private readonly compositeBoxBuilder: BoxBuilder
+  ) {}
+
+  public buildBoxes(context: LayoutContext, elements: HtmlNode[]): CssBox[] {
+    const boxes = elements
+      ? elements
+          .map(el => this.compositeBoxBuilder(context, el))
+          .filter(childBox => childBox !== null)
+      : []
+    return boxes
+  }
+
+  public createBlockBox(
+    textContent: string = "",
+    children: Iterable = [],
+    debugNote: string = ""
+  ): CssBox {
+    return this.blockBoxCreator(this, textContent, children, debugNote)
+  }
+
+  public createInlineBox(
+    textContent: string = "",
+    children: Iterable = [],
+    debugNote: string = ""
+  ): CssBox {
+    return this.inlineBoxCreator(this, textContent, children, debugNote)
+  }
+
+  public getStateStack(stackName: string): TValue[] {
+    let stack = this.state.get(stackName) as TValue[]
+    if (!stack) {
+      stack = new Array()
+      this.state.set(stackName, stack)
+    }
+    return stack
+  }
+
+  public pushState(stackName: string, value: TValue): void {
+    const stack = this.getStateStack(stackName)
+    stack.push(value)
+  }
+
+  public popState(stackName: string): TValue | undefined {
+    const stack = this.getStateStack(stackName)
+    if (stack.length === 0) return undefined
+    else return stack.pop()
+  }
+
+  public peekState(stackName: string): TValue | undefined {
+    const stack = this.getStateStack(stackName)
+    if (stack.length === 0) return undefined
+    else return stack[stack.length - 1]
+  }
+}
diff --git a/src/css/layout/layout.spec.ts b/src/css/layout/layout.spec.ts
index 75e0d0d6..a46e5194 100644
--- a/src/css/layout/layout.spec.ts
+++ b/src/css/layout/layout.spec.ts
@@ -1,7 +1,7 @@
 import { MockHtmlNode } from "../../../tests/support"
 import { HtmlNode } from "../../HtmlNode"
 import { layout } from "./layout"
-import { BoxType } from "../CssBox"
+import { BoxType } from "../.."
 
 it("should recognize block element", () => {
   const doc: HtmlNode[] = [new MockHtmlNode("tag", "div")]
diff --git a/src/css/layout/layout.ts b/src/css/layout/layout.ts
index b949e5d3..aef2ce63 100644
--- a/src/css/layout/layout.ts
+++ b/src/css/layout/layout.ts
@@ -1,26 +1,85 @@
 import { HtmlNode } from "../../HtmlNode"
-import { BoxType, CssBox } from "../CssBox"
+import {
+  BoxBuilder,
+  BoxType,
+  CssBox,
+  LayoutContext,
+  RenderPlugin,
+  BoxMapper
+} from "../../"
 import { normalizeWhitespace } from "../"
-import { LayoutContext } from "./LayoutContext"
+import { LayoutContextImp } from "./LayoutContextImp"
 import { StyleState } from "./StyleState"
-import { BoxBuilders, BoxBuilder } from "./BoxBuilders"
+import { CssBoxImp } from "../CssBoxImp"
+import { DefaultBoxBuilderFuncs } from "./DefaultBoxBuilderFuncs"
 
 /**
  * Implements the CSS Visual Formatting model's box generation algorithm. It turns HTML elements into a set of CSS Boxes.
  * See https://www.w3.org/TR/CSS22/visuren.html#visual-model-intro
  */
-export function layout(document: Iterable): CssBox {
-  const context = new LayoutContext()
+export function layout(
+  document: Iterable,
+  plugins: RenderPlugin[]
+): CssBox {
+  const boxBuilderMap = createBoxBuilderMap(plugins)
+  const compositeBoxBuilder: BoxBuilder = (
+    context: LayoutContext,
+    element: HtmlNode
+  ): CssBox => {
+    return buildBoxForElementImp(context, element, boxBuilderMap)
+  }
+
+  const context: LayoutContext = new LayoutContextImp(
+    createCssBoxConstructor(plugins, BoxType.block),
+    createCssBoxConstructor(plugins, BoxType.inline),
+    compositeBoxBuilder
+  )
   // NOTE: we want a single root box so that the if the incoming HTML is a fragment (e.g. a b) it will still figure out it's own formatting context.
-  const body = new CssBox(BoxType.block, "", [], "body")
+  const body = new CssBoxImp(BoxType.block, "", [], "body")
   for (const node of document) {
-    const box = generateElementBox(context, node)
+    const box = compositeBoxBuilder(context, node)
     box && body.addChild(box)
   }
   //console.debug(traceBoxTree(body))
   return body
 }
 
+function createBoxBuilderMap(plugins: RenderPlugin[]): Map {
+  const builders = new Map([
+    ["a", DefaultBoxBuilderFuncs.link],
+    ["b", DefaultBoxBuilderFuncs.emphasisThunk("**")],
+    ["blockquote", DefaultBoxBuilderFuncs.blockquote],
+    ["br", DefaultBoxBuilderFuncs.br],
+    ["code", DefaultBoxBuilderFuncs.code],
+    ["del", DefaultBoxBuilderFuncs.emphasisThunk("~")],
+    ["li", DefaultBoxBuilderFuncs.listItem],
+    ["ol", DefaultBoxBuilderFuncs.list],
+    ["ul", DefaultBoxBuilderFuncs.list],
+    /* eslint-disable no-magic-numbers */
+    ["h1", DefaultBoxBuilderFuncs.headingThunk(1)],
+    ["h2", DefaultBoxBuilderFuncs.headingThunk(2)],
+    ["h3", DefaultBoxBuilderFuncs.headingThunk(3)],
+    ["h4", DefaultBoxBuilderFuncs.headingThunk(4)],
+    ["h5", DefaultBoxBuilderFuncs.headingThunk(5)],
+    ["h6", DefaultBoxBuilderFuncs.headingThunk(6)],
+    /* eslint-enable no-magic-numbers */
+    ["hr", DefaultBoxBuilderFuncs.hr],
+    ["i", DefaultBoxBuilderFuncs.emphasisThunk("*")],
+    ["em", DefaultBoxBuilderFuncs.emphasisThunk("*")],
+    ["pre", DefaultBoxBuilderFuncs.pre],
+    ["s", DefaultBoxBuilderFuncs.emphasisThunk("~")],
+    ["strike", DefaultBoxBuilderFuncs.emphasisThunk("~")],
+    ["strong", DefaultBoxBuilderFuncs.emphasisThunk("**")],
+    ["u", DefaultBoxBuilderFuncs.emphasisThunk("_")]
+  ])
+  if (plugins) {
+    for (const plugin of plugins) {
+      builders.set(plugin.elementName, plugin.renderer)
+    }
+  }
+  return builders
+}
+
 /* eslint-disable-next-line @typescript-eslint/no-unused-vars */
 function traceBoxTree(box: CssBox, indent = 0): string {
   const typeStr = (type: BoxType): string =>
@@ -44,15 +103,42 @@ function traceBoxTree(box: CssBox, indent = 0): string {
   return output
 }
 
+/**
+ * Returns @see BlockBuilder for specified element.
+ * @param elementName name/tag of element
+ */
+function getBoxBuilderForElement(
+  elementName: string,
+  builders: Map
+): BoxBuilder {
+  if (!builders) throw new Error("builders must not be null!!")
+  let builder = builders.get(elementName)
+  if (!builder) {
+    const display = getElementDisplay(elementName)
+    if (display === CssDisplayValue.block) {
+      builder = DefaultBoxBuilderFuncs.genericBlock
+    } else if (display === CssDisplayValue.inline) {
+      builder = DefaultBoxBuilderFuncs.genericInline
+    } else if (display === CssDisplayValue.listItem) {
+      builder = DefaultBoxBuilderFuncs.listItem
+    } else {
+      throw new Error("unexpected element and unexpected display")
+    }
+  }
+  return builder
+}
+
 /**
  * Generates zero or more CSS boxes for the specified element.
  * See https://www.w3.org/TR/CSS22/visuren.html#propdef-display
  * @param element The element to generate a box for
  */
-export function generateElementBox(
+export function buildBoxForElementImp(
   context: LayoutContext,
-  element: HtmlNode
+  element: HtmlNode,
+  builders: Map
 ): CssBox | null {
+  if (!builders) throw new Error("builders must not be null!")
   let box: CssBox = null
   if (element.type === "text") {
     const text = normalizeWhitespace(
@@ -61,16 +147,16 @@ export function generateElementBox(
     )
     if (text) {
       // only create a box if normalizeWhitespace left something over
-      box = new CssBox(BoxType.inline, text, [], "textNode")
+      box = context.createInlineBox(text, [], "textNode")
     }
   } else if (element.type === "tag") {
-    const boxBuilder = getBoxBuilderForElement(element.name)
+    const boxBuilderFunc = getBoxBuilderForElement(element.name, builders)
     try {
-      box = boxBuilder(context, element)
+      box = boxBuilderFunc(context, element)
     } catch (e) {
       throw new Error(
         `boxbuilder (${JSON.stringify(
-          boxBuilder
+          boxBuilderFunc
         )}) error for element ${JSON.stringify(element.name)}: ${e}`
       )
     }
@@ -83,54 +169,6 @@ export function generateElementBox(
   return box
 }
 
-/**
- * Returns @see BlockBuilder for specified element.
- * @param elementName name/tag of element
- */
-function getBoxBuilderForElement(elementName: string): BoxBuilder {
-  const builders = new Map([
-    ["ul", BoxBuilders.list],
-    ["ol", BoxBuilders.list],
-    ["li", BoxBuilders.listItem],
-    /* eslint-disable no-magic-numbers */
-
-    ["h1", BoxBuilders.headingThunk(1)],
-    ["h2", BoxBuilders.headingThunk(2)],
-    ["h3", BoxBuilders.headingThunk(3)],
-    ["h4", BoxBuilders.headingThunk(4)],
-    ["h5", BoxBuilders.headingThunk(5)],
-    ["h6", BoxBuilders.headingThunk(6)],
-    /* eslint-enable no-magic-numbers */
-    ["b", BoxBuilders.emphasisThunk("**")],
-    ["strong", BoxBuilders.emphasisThunk("**")],
-    ["i", BoxBuilders.emphasisThunk("*")],
-    ["em", BoxBuilders.emphasisThunk("*")],
-    ["u", BoxBuilders.emphasisThunk("_")],
-    ["s", BoxBuilders.emphasisThunk("~")],
-    ["strike", BoxBuilders.emphasisThunk("~")],
-    ["del", BoxBuilders.emphasisThunk("~")],
-    ["a", BoxBuilders.link],
-    ["hr", BoxBuilders.hr],
-    ["br", BoxBuilders.br],
-    ["pre", BoxBuilders.pre],
-    ["code", BoxBuilders.code]
-  ])
-  let builder = builders.get(elementName)
-  if (!builder) {
-    const display = getElementDisplay(elementName)
-    if (display === CssDisplayValue.block) {
-      builder = BoxBuilders.genericBlock
-    } else if (display === CssDisplayValue.inline) {
-      builder = BoxBuilders.genericInline
-    } else if (display === CssDisplayValue.listItem) {
-      builder = BoxBuilders.listItem
-    } else {
-      throw new Error("unexpected element and unexpected display")
-    }
-  }
-  return builder
-}
-
 /**
  * https://www.w3.org/TR/CSS22/visuren.html#propdef-display
  */
@@ -181,7 +219,10 @@ const elementToDisplayMap: Map = new Map<
  * @param elementTypeName The name of a document language element type (e.g. div, span, etc.).
  */
 function getElementDisplay(elementTypeName: string): CssDisplayValue {
-  // See https://www.w3.org/TR/CSS22/sample.html for how we identify block elements in HTML:
+  /**
+   * See https://www.w3.org/TR/CSS22/sample.html for how we identify block elements in HTML.
+   * A less concise but more current reference for HTML5 is at https://html.spec.whatwg.org/multipage/rendering.html#the-css-user-agent-style-sheet-and-presentational-hints
+   */
   let display = elementToDisplayMap.get(elementTypeName)
   if (display === undefined) {
     // default to inline:
@@ -189,3 +230,54 @@ function getElementDisplay(elementTypeName: string): CssDisplayValue {
   }
   return display
 }
+
+export interface CssBoxConstructor {
+  (
+    context: LayoutContext,
+    textContent: string,
+    children: Iterable,
+    debugNote: string
+  ): CssBox
+}
+
+/**
+ * Creates the function that creates @see CssBox objects. It will handle incorporating any mappers from plugins.
+ * We use this to make sure that plugins can modify the boxes created by other plugins/builders.
+ * @param plugins The list of plugins
+ * @param mapperName Whether we're dealing with inline or block boxes.
+ */
+function createCssBoxConstructor(
+  plugins: RenderPlugin[],
+  boxType: BoxType
+): CssBoxConstructor {
+  let creator: CssBoxConstructor = (
+    context: LayoutContext,
+    textContent: string,
+    children: CssBox[],
+    debugNote: string
+  ): CssBox => new CssBoxImp(boxType, textContent, children, debugNote)
+  /* for reference, this is what an identity BoxMapper looks like:
+   * const identityMapper: BoxMapper = (context: LayoutContext, box: CssBox) => box
+   */
+  if (plugins) {
+    const mapperName: string =
+      boxType === BoxType.inline ? "inlineBoxMapper" : "blockBoxMapper"
+    const mappers: BoxMapper[] = plugins
+      .filter(p => p[mapperName])
+      .map(p => p[mapperName])
+    for (const mapper of mappers) {
+      creator = (
+        context: LayoutContext,
+        textContent: string,
+        children: CssBox[],
+        debugNote: string
+      ): CssBox => {
+        return mapper(
+          context,
+          creator(context, textContent, children, debugNote)
+        )
+      }
+    }
+  }
+  return creator
+}
diff --git a/src/index.ts b/src/index.ts
index 379cd716..374958f2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,7 +2,35 @@ import { parseHtml } from "./parseHtml"
 import { TextWriter } from "./TextWriter"
 import { DefaultTextWriter } from "./DefaultTextWriter"
 import { layout } from "./css"
-import { CssBox, BoxType } from "./css/CssBox"
+import { HtmlNode } from "./HtmlNode"
+import { LayoutContext } from "./LayoutContext"
+export { LayoutContext } from "./LayoutContext"
+export { HtmlNode } from "./HtmlNode"
+
+export interface RenderOptions {
+  /**
+   * The HTML to render to markdown.
+   */
+  html: string
+  /**
+   * Use to customize the rendering of HTML elements or provide HTML-to-markdown rendering for an element that isn't handled by default.
+   * A map of "element name" => @see BoxBuilder where the key is the element name and the associated value is a @see BoxBuilder implementations that render markdown for a specified HTML element.
+   * Any elements in this map that conflict with the default intrinsic implementations will override the default rendering.
+   */
+  renderPlugins?: RenderPlugin[]
+}
+
+export interface MarkdownOutput {
+  markdown: string
+  images: ImageReference[]
+}
+
+export interface ImageReference {
+  /**
+   * A url that refers to an image.
+   */
+  url: string
+}
 
 /**
  * An HTML to markdown converter.
@@ -19,25 +47,92 @@ import { CssBox, BoxType } from "./css/CssBox"
  * - Allows overriding the MD generators for nodes so the user can customize output.
  */
 export class AgentMarkdown {
-  // generate to stream or https://developer.mozilla.org/en-US/docs/Web/API/WritableStream maybe https://github.com/MattiasBuelens/web-streams-polyfill
+  /**
+   * @deprecated Use @see render instead.
+   */
   public static async produce(html: string): Promise {
-    const dom = await parseHtml(html)
+    const result = await AgentMarkdown.render({ html })
+    return result.markdown
+  }
+
+  /**
+   * Renders the the specified HTML to markdown.
+   */
+  public static async render(options: RenderOptions): Promise {
+    const dom = await parseHtml(options.html)
     //console.debug(traceHtmlNodes(dom))
     const writer = new DefaultTextWriter()
-    const docStructure = layout(dom)
-    render(writer, docStructure.children)
-    return writer.toString()
+    const docStructure = layout(dom, options.renderPlugins)
+    renderImp(writer, docStructure.children)
+    return {
+      markdown: writer.toString(),
+      images: []
+    }
   }
 }
 
-function render(writer: TextWriter, boxes: Iterable): void {
+function renderImp(writer: TextWriter, boxes: Iterable): void {
   let isFirst = true
   for (const box of boxes) {
     if (box.type == BoxType.block && !isFirst) {
       writer.newLine()
     }
     box.textContent && writer.writeTextContent(box.textContent)
-    box.children && render(writer, box.children)
+    box.children && renderImp(writer, box.children)
     isFirst = false
   }
 }
+
+/**
+ * Defines the function used to create a @see CssBox from an HTML element.
+ */
+export interface BoxBuilder {
+  (context: LayoutContext, element: HtmlNode): CssBox | null
+}
+
+/**
+ * Defines a function to map a @see CssBox to another @css CssBox (or just returns the same box).
+ */
+export interface BoxMapper {
+  (context: LayoutContext, box: CssBox): CssBox
+}
+
+export interface RenderPlugin {
+  /**
+   * Specifies the name of the HTML element that this plugin renders markdown for.
+   * NOTE: Must be all lowercase
+   */
+  elementName: string
+  /**
+   * This is the core of the implementation that will be called for each instance of the HTML element that this plugin is registered for.
+   */
+  renderer: BoxBuilder
+  /**
+   * In some rare cases the BoxBuilder will need to manipulate the boxes created by other @see RenderPlugin objects.
+   * If specified, this function will be called for every inline box created.
+   */
+  inlineBoxMapper?: BoxMapper
+  /**
+   * In some rare cases the BoxBuilder will need to manipulate the boxes created by other @see RenderPlugin objects.
+   * If specified, this function will be called for every block box created.
+   */
+  blockBoxMapper?: BoxMapper
+}
+
+/* eslint-disable no-unused-vars */
+export enum BoxType {
+  block = 0,
+  inline = 1
+}
+/* eslint-enable no-unused-vars */
+
+/**
+ * Represents a (simplified) a CSS box as described at https://www.w3.org/TR/CSS22/visuren.html#box-gen.
+ */
+export interface CssBox {
+  children: IterableIterator
+  type: BoxType
+  textContent: string
+  readonly debugNote: string
+  addChild(box: CssBox): void
+}
diff --git a/test-data/snapshots/html-to-mrkdwn/block-quote-nested.mrkdwn b/test-data/snapshots/html-to-mrkdwn/block-quote-nested.mrkdwn
new file mode 100644
index 00000000..5f8f0f3f
--- /dev/null
+++ b/test-data/snapshots/html-to-mrkdwn/block-quote-nested.mrkdwn
@@ -0,0 +1,9 @@
+
+
+
+

foo bar

+
+
+
+=== +> > > foo bar diff --git a/test-data/snapshots/html-to-mrkdwn/blockquote-empty.mrkdwn b/test-data/snapshots/html-to-mrkdwn/blockquote-empty.mrkdwn new file mode 100644 index 00000000..5d00c2df --- /dev/null +++ b/test-data/snapshots/html-to-mrkdwn/blockquote-empty.mrkdwn @@ -0,0 +1,4 @@ +
+
+=== +> \ No newline at end of file diff --git a/test-data/snapshots/html-to-mrkdwn/blockquote-heading-and-paragraph.mrkdwn b/test-data/snapshots/html-to-mrkdwn/blockquote-heading-and-paragraph.mrkdwn new file mode 100644 index 00000000..28b7eeff --- /dev/null +++ b/test-data/snapshots/html-to-mrkdwn/blockquote-heading-and-paragraph.mrkdwn @@ -0,0 +1,8 @@ +
+

Foo

+

bar +baz

+
+==== +># Foo # +>bar baz \ No newline at end of file diff --git a/test-data/snapshots/html-to-mrkdwn/blockquote.mrkdwn.todo b/test-data/snapshots/html-to-mrkdwn/blockquote.mrkdwn similarity index 100% rename from test-data/snapshots/html-to-mrkdwn/blockquote.mrkdwn.todo rename to test-data/snapshots/html-to-mrkdwn/blockquote.mrkdwn diff --git a/tests/integration/extending-blockbuilders.spec.ts b/tests/integration/extending-blockbuilders.spec.ts new file mode 100644 index 00000000..225b6f5a --- /dev/null +++ b/tests/integration/extending-blockbuilders.spec.ts @@ -0,0 +1,53 @@ +import { AgentMarkdown, CssBox, LayoutContext, HtmlNode } from "../../src" + +function customEmphasisRenderer( + context: LayoutContext, + element: HtmlNode +): CssBox | null { + const kids = context.buildBoxes(context, element.children) + kids.unshift(context.createInlineBox("_")) + kids.push(context.createInlineBox("_")) + return context.createInlineBox("", kids) +} + +it("should allow overriding existing elements with custom BoxBuilder", async () => { + const result = await AgentMarkdown.render({ + html: "my bold", + renderPlugins: [ + { + elementName: "b", + renderer: customEmphasisRenderer + } + ] + }) + expect(result.markdown).toEqual("_my bold_") +}) + +it("should allow rendering new elements with custom BoxBuilder", async () => { + const result = await AgentMarkdown.render({ + html: "custom content", + renderPlugins: [ + { + elementName: "mycustomelement", + renderer: customEmphasisRenderer + } + ] + }) + expect(result.markdown).toEqual("_custom content_") +}) + +it("should allow customizing created boxes with BoxMapper", async () => { + const result = await AgentMarkdown.render({ + html: "
my bold
", + renderPlugins: [ + { + elementName: "b", + renderer: customEmphasisRenderer, + blockBoxMapper: (context: LayoutContext, box: CssBox): CssBox => { + return context.createBlockBox("> ", [box]) + } + } + ] + }) + expect(result.markdown).toEqual("> _my bold_") +}) From ab7c98e13b7de425876da30c96c9edc33dcd751b Mon Sep 17 00:00:00 2001 From: Scott Willeke Date: Sat, 10 Aug 2019 23:56:12 -0700 Subject: [PATCH 2/9] wip: minor fixes in ts syntax (why are these caught on travis and not locally?) --- src/css/layout/ListState.ts | 2 +- src/css/layout/StyleState.ts | 2 +- src/css/layout/layout.spec.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/css/layout/ListState.ts b/src/css/layout/ListState.ts index 115ec929..6e5e9364 100644 --- a/src/css/layout/ListState.ts +++ b/src/css/layout/ListState.ts @@ -1,4 +1,4 @@ -import { LayoutContext } from "./LayoutContext" +import { LayoutContext } from "../.." type ListType = "ul" | "ol" diff --git a/src/css/layout/StyleState.ts b/src/css/layout/StyleState.ts index da6bc389..ed25f0be 100644 --- a/src/css/layout/StyleState.ts +++ b/src/css/layout/StyleState.ts @@ -1,5 +1,5 @@ -import { LayoutContext } from "./LayoutContext" import { WhitespaceHandling } from "../" +import { LayoutContext } from "../.." /** * Helps manage @see LayoutContext state for misc CSS style state. diff --git a/src/css/layout/layout.spec.ts b/src/css/layout/layout.spec.ts index a46e5194..8e3e59da 100644 --- a/src/css/layout/layout.spec.ts +++ b/src/css/layout/layout.spec.ts @@ -5,14 +5,14 @@ import { BoxType } from "../.." it("should recognize block element", () => { const doc: HtmlNode[] = [new MockHtmlNode("tag", "div")] - const actual = Array.from(layout(doc).children) + const actual = Array.from(layout(doc, []).children) expect(actual).toHaveLength(1) expect(actual[0]).toHaveProperty("type", BoxType.block) }) it("should recognize inline element", () => { const doc: HtmlNode[] = [new MockHtmlNode("tag", "span", "hello")] - const actual = Array.from(layout(doc).children) + const actual = Array.from(layout(doc, []).children) expect(actual).toHaveLength(1) expect(actual[0]).toHaveProperty("type", BoxType.inline) }) @@ -26,7 +26,7 @@ it("should create children", () => { new MockHtmlNode("tag", "span", null, null, childNodes) ] - const actual = Array.from(layout(doc).children) + const actual = Array.from(layout(doc, []).children) expect(actual).toHaveLength(1) expect(actual[0]).toHaveProperty("children") expect(Array.from(actual[0].children)).toHaveLength(2) From 068c1305b99d7c7c97cd0b0df85a97a7985064ff Mon Sep 17 00:00:00 2001 From: Scott Willeke Date: Thu, 15 Aug 2019 23:22:21 -0700 Subject: [PATCH 3/9] wip: extensibility refactoring and blockquote --- src/LayoutContext.ts | 37 +-- src/LayoutManager.ts | 13 + src/css/CssBoxImp.ts | 23 ++ src/css/layout/BlockquotePlugin.ts | 69 +++++ src/css/layout/BoxBuilderManager.ts | 179 +++++++++++ src/css/layout/CssBoxFactory.ts | 73 +++++ src/css/layout/DefaultBoxBuilderFuncs.ts | 180 +++++++---- src/css/layout/LayoutContextImp.ts | 34 +-- src/css/layout/LayoutManagerImp.ts | 31 ++ src/css/layout/layout.ts | 281 +----------------- src/index.ts | 69 +++-- .../html-to-mrkdwn/blockquote-empty.mrkdwn | 3 +- .../blockquote-heading-and-paragraph.mrkdwn | 4 +- ...nested.mrkdwn => blockquote-nested.mrkdwn} | 4 +- .../blockquote-paragraph.mrkdwn | 3 + .../html-to-mrkdwn/blockquote.mrkdwn | 2 +- test-data/snapshots/snapshots.spec.ts | 2 +- .../extending-blockbuilders.spec.ts | 67 +++-- 18 files changed, 636 insertions(+), 438 deletions(-) create mode 100644 src/LayoutManager.ts create mode 100644 src/css/layout/BlockquotePlugin.ts create mode 100644 src/css/layout/BoxBuilderManager.ts create mode 100644 src/css/layout/CssBoxFactory.ts create mode 100644 src/css/layout/LayoutManagerImp.ts rename test-data/snapshots/html-to-mrkdwn/{block-quote-nested.mrkdwn => blockquote-nested.mrkdwn} (84%) create mode 100644 test-data/snapshots/html-to-mrkdwn/blockquote-paragraph.mrkdwn diff --git a/src/LayoutContext.ts b/src/LayoutContext.ts index 5e886a83..50557eb5 100644 --- a/src/LayoutContext.ts +++ b/src/LayoutContext.ts @@ -1,39 +1,4 @@ -import { CssBox, HtmlNode } from "." - -export interface LayoutBoxFactory { - /** - * Builds a set of @see CssBox objects for the specified elements. - */ - buildBoxes(context: LayoutContext, elements: HtmlNode[]): CssBox[] - /** - * Creates a new @see CssBox instance. - * @param textContent Returns any text content if this box has text to render. - * @param children Returns any child boxes of this box. - * @param debugNote A string to add to the box to help with debugging. - */ - createBlockBox( - textContent: string, - children: Iterable, - debugNote: string - ): CssBox - createBlockBox(textContent: string, children: Iterable): CssBox - createBlockBox(textContent: string): CssBox - /** - * Creates a new @see CssBox instance. - * @param textContent Returns any text content if this box has text to render. - * @param children Returns any child boxes of this box. - * @param debugNote A string to add to the box to help with debugging. - */ - createInlineBox( - textContent: string, - children: Iterable, - debugNote: string - ): CssBox - createInlineBox(textContent: string, children: Iterable): CssBox - createInlineBox(textContent: string): CssBox -} - -export interface LayoutContext extends LayoutBoxFactory { +export interface LayoutContext { /** * Returns the specified stack. * If the stack is not yet created it will return an empty stack. diff --git a/src/LayoutManager.ts b/src/LayoutManager.ts new file mode 100644 index 00000000..18515517 --- /dev/null +++ b/src/LayoutManager.ts @@ -0,0 +1,13 @@ +import { CssBox, HtmlNode, LayoutContext } from "." +import { CssBoxFactoryFunc } from "./css/layout/CssBoxFactory" + +export interface LayoutManager { + /** + * Creates a new @see CssBox instance. + */ + createBox: CssBoxFactoryFunc + /** + * Lays out a set of @see CssBox objects for the specified HTML elements. + */ + layout(context: LayoutContext, elements: HtmlNode[]): CssBox[] +} diff --git a/src/css/CssBoxImp.ts b/src/css/CssBoxImp.ts index 5443fe73..34c8a7b5 100644 --- a/src/css/CssBoxImp.ts +++ b/src/css/CssBoxImp.ts @@ -22,6 +22,29 @@ export class CssBoxImp implements CssBox { this._children = children ? Array.from(children) : [] } + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + public static traceBoxTree(box: CssBox, indent = 0): string { + const typeStr = (type: BoxType): string => + type === BoxType.inline ? "inline" : "block" + const boxStr = (b: CssBox): string => + "CssBox " + + (b == null + ? "" + : JSON.stringify({ + type: typeStr(b.type), + text: b.textContent, + debug: b.debugNote + })) + + "\n" + let output = " ".repeat(indent) + boxStr(box) + if (box) { + for (const child of box.children) { + output += CssBoxImp.traceBoxTree(child, indent + 1) + } + } + return output + } + public addChild(box: CssBox): void { if (!box) throw new Error("box must be provided") this._children.push(box) diff --git a/src/css/layout/BlockquotePlugin.ts b/src/css/layout/BlockquotePlugin.ts new file mode 100644 index 00000000..3dd1bbbf --- /dev/null +++ b/src/css/layout/BlockquotePlugin.ts @@ -0,0 +1,69 @@ +import { LayoutPlugin, HtmlNode, LayoutContext, CssBox, BoxType } from "../.." +import { LayoutManager } from "../../LayoutManager" +import { CssBoxFactoryFunc } from "./CssBoxFactory" + +class BlockquotePlugin implements LayoutPlugin { + public elementName: string = "blockquote" + + public layout( + context: LayoutContext, + manager: LayoutManager, + element: HtmlNode + ): CssBox | null { + const state = new BlockquoteState(context) + state.beginBlockquote() + const kids = manager.layout(context, element.children) + state.endBlockquote() + return manager.createBox(context, BoxType.block, "", kids) + } + + // for any block box (i.e. those that create a new line), insert blockquote styling: + public transform( + context: LayoutContext, + boxFactory: CssBoxFactoryFunc, + box: CssBox + ): CssBox { + if (box.type === BoxType.inline) return box + let finalBox = box + const state = new BlockquoteState(context) + const depth = state.blockquoteNestingDepth + if (depth > 0) { + //console.debug("DEPTH:", depth) + // we're within at least one level of blockquote: + const stylingPrefix = "> ".repeat(depth) + const styleBox: CssBox = boxFactory( + context, + BoxType.block, + stylingPrefix, + [box], + "blockQuoteAnon" + ) + finalBox = styleBox + } + return finalBox + } +} + +export default BlockquotePlugin + +class BlockquoteState { + private static readonly BlockquoteNestingDepthKey: string = + "blockquote-nesting-depth-key" + + public constructor(readonly context: LayoutContext) {} + + public endBlockquote(): void { + this.context.popState(BlockquoteState.BlockquoteNestingDepthKey) + } + + public beginBlockquote(): void { + this.context.pushState(BlockquoteState.BlockquoteNestingDepthKey, 0) + } + + public get blockquoteNestingDepth(): number { + const stack = this.context.getStateStack( + BlockquoteState.BlockquoteNestingDepthKey + ) + return stack.length + } +} diff --git a/src/css/layout/BoxBuilderManager.ts b/src/css/layout/BoxBuilderManager.ts new file mode 100644 index 00000000..473b781d --- /dev/null +++ b/src/css/layout/BoxBuilderManager.ts @@ -0,0 +1,179 @@ +import { + LayoutPlugin, + CssBox, + LayoutContext, + HtmlNode, + BoxType, + LayoutGenerator +} from "../.." +import { DefaultBoxBuilderFuncs } from "./DefaultBoxBuilderFuncs" +import { normalizeWhitespace } from ".." +import { StyleState } from "./StyleState" +import { CssBoxFactory } from "./CssBoxFactory" +import { LayoutManager } from "../../LayoutManager" + +/** + * Given a list of plugins, manages the list of @see BoxBuilder plugins to build boxes for an element. + */ +export default class BoxBuilderManager { + private readonly boxBuilderMap: Map + + public constructor( + plugins: LayoutPlugin[], + private readonly boxFactory: CssBoxFactory + ) { + this.boxBuilderMap = BoxBuilderManager.createLayoutGeneratorMap(plugins) + } + + private static createLayoutGeneratorMap( + plugins: LayoutPlugin[] + ): Map { + const builders = new Map() + for (const plugin of plugins) { + builders.set(plugin.elementName, plugin.layout) + } + return builders + } + + /** + * Generates zero or more CSS boxes for the specified element. + * See https://www.w3.org/TR/CSS22/visuren.html#propdef-display + * @param element The element to generate a box for + */ + public buildBox( + context: LayoutContext, + manager: LayoutManager, + element: HtmlNode + ): CssBox | null { + let box: CssBox = null + if (element.type === "text") { + const text = normalizeWhitespace( + element.data, + new StyleState(context).whitespaceHandling + ) + if (text) { + // only create a box if normalizeWhitespace left something over + box = this.boxFactory.createBox( + context, + BoxType.inline, + text, + [], + "textNode" + ) + } + } else if (element.type === "tag") { + const boxBuilderFunc = this.getBoxBuilderForElement(element.name) + try { + box = boxBuilderFunc(context, manager, element) + } catch (e) { + throw new Error( + `boxbuilder (${JSON.stringify( + boxBuilderFunc + )}) error for element ${JSON.stringify(element.name)}: ${e}` + ) + } + } else if (element.type === "comment") { + // deliberately ignored + } else { + console.error(`Ignoring element with type ${element.type}`) + box = null + } + return box + } + + public buildBoxes( + context: LayoutContext, + manager: LayoutManager, + elements: HtmlNode[] + ): CssBox[] { + const boxes = elements + ? elements + .map(el => this.buildBox(context, manager, el)) + .filter(childBox => childBox !== null) + : [] + return boxes + } + + /** + * Returns @see BlockBuilder for specified element. + * @param elementName name/tag of element + */ + private getBoxBuilderForElement(elementName: string): LayoutGenerator { + let builder = this.boxBuilderMap.get(elementName) + if (!builder) { + const display = getElementDisplay(elementName) + if (display === CssDisplayValue.block) { + builder = DefaultBoxBuilderFuncs.genericBlock + } else if (display === CssDisplayValue.inline) { + builder = DefaultBoxBuilderFuncs.genericInline + } else if (display === CssDisplayValue.listItem) { + builder = DefaultBoxBuilderFuncs.listItem + } else { + throw new Error("unexpected element and unexpected display") + } + } + return builder + } +} + +/** + * https://www.w3.org/TR/CSS22/visuren.html#propdef-display + */ +enum CssDisplayValue { + block, + inline, + listItem, + none +} + +const elementToDisplayMap: Map = new Map< + string, + CssDisplayValue +>([ + ["html", CssDisplayValue.block], + ["address", CssDisplayValue.block], + ["blockquote", CssDisplayValue.block], + ["body", CssDisplayValue.block], + ["dd", CssDisplayValue.block], + ["div", CssDisplayValue.block], + ["dl", CssDisplayValue.block], + ["dt", CssDisplayValue.block], + ["fieldset", CssDisplayValue.block], + ["form", CssDisplayValue.block], + ["frame", CssDisplayValue.block], + ["frameset", CssDisplayValue.block], + ["h1", CssDisplayValue.block], + ["h2", CssDisplayValue.block], + ["h3", CssDisplayValue.block], + ["h4", CssDisplayValue.block], + ["h5", CssDisplayValue.block], + ["h6", CssDisplayValue.block], + ["noframes", CssDisplayValue.block], + ["ol", CssDisplayValue.block], + ["p", CssDisplayValue.block], + ["ul", CssDisplayValue.block], + ["center", CssDisplayValue.block], + ["dir", CssDisplayValue.block], + ["hr", CssDisplayValue.block], + ["menu", CssDisplayValue.block], + ["pre", CssDisplayValue.block], + ["li", CssDisplayValue.listItem], + ["head", CssDisplayValue.none] +]) + +/** + * Returns the CSS Display type for the specified element + * @param elementTypeName The name of a document language element type (e.g. div, span, etc.). + */ +function getElementDisplay(elementTypeName: string): CssDisplayValue { + /** + * See https://www.w3.org/TR/CSS22/sample.html for how we identify block elements in HTML. + * A less concise but more current reference for HTML5 is at https://html.spec.whatwg.org/multipage/rendering.html#the-css-user-agent-style-sheet-and-presentational-hints + */ + let display = elementToDisplayMap.get(elementTypeName) + if (display === undefined) { + // default to inline: + display = CssDisplayValue.inline + } + return display +} diff --git a/src/css/layout/CssBoxFactory.ts b/src/css/layout/CssBoxFactory.ts new file mode 100644 index 00000000..e6116f6e --- /dev/null +++ b/src/css/layout/CssBoxFactory.ts @@ -0,0 +1,73 @@ +import { LayoutPlugin, BoxType, CssBox, LayoutTransformer } from "../.." +import { CssBoxImp } from "../CssBoxImp" +import { LayoutContext } from "../../LayoutContext" + +export class CssBoxFactory { + public createBox: CssBoxFactoryFunc + + public constructor(plugins: LayoutPlugin[]) { + const transformers = plugins.filter(p => p.transform).map(p => p.transform) + this.createBox = CssBoxFactory.createBoxFactoryFunc(transformers) + } + + /** + * Creates the function that creates @see CssBox objects. It will handle incorporating any transformers from plugins. + * We use this to make sure that plugins can modify the boxes created by other plugins/transformers. + */ + private static createBoxFactoryFunc( + transformers: LayoutTransformer[] + ): CssBoxFactoryFunc { + const transform = CssBoxFactory.createTransformFunc(transformers) + return ( + context: LayoutContext, + boxType: BoxType, + textContent: string = "", + children: Iterable = [], + debugNote: string = "" + ) => { + let box: CssBox = new CssBoxImp(boxType, textContent, children, debugNote) + box = transform(context, box) + return box + } + } + + private static createTransformFunc( + transformers: LayoutTransformer[] + ): (context: LayoutContext, box: CssBox) => CssBox { + return (context: LayoutContext, box: CssBox) => { + transformers.forEach((transform, index) => { + // create a box factory that includes all the prior transformers, but not the current one (because it could also create a box and cause a infinite loop) + const boxFactory = CssBoxFactory.createBoxFactoryFunc( + transformers.slice(0, index) + ) + box = transform(context, boxFactory, box) + }) + return box + } + } +} + +export interface CssBoxFactoryFunc { + /** + * Creates a new @see CssBox instance. + * @param textContent Returns any text content if this box has text to render. + * @param children Returns any child boxes of this box. + * @param debugNote A string to add to the box to help with debugging. + */ + ( + state: LayoutContext, + boxType: BoxType, + textContent: string, + children: Iterable, + debugNote: string + ): CssBox + + ( + state: LayoutContext, + boxType: BoxType, + textContent: string, + children: Iterable + ): CssBox + + (state: LayoutContext, boxType: BoxType, textContent: string): CssBox +} diff --git a/src/css/layout/DefaultBoxBuilderFuncs.ts b/src/css/layout/DefaultBoxBuilderFuncs.ts index ce693560..5e55ed15 100644 --- a/src/css/layout/DefaultBoxBuilderFuncs.ts +++ b/src/css/layout/DefaultBoxBuilderFuncs.ts @@ -1,9 +1,10 @@ import { HtmlNode } from "../../HtmlNode" import { normalizeWhitespace, WhitespaceHandling } from "../" import { ListState } from "./ListState" -import { CssBox, BoxBuilder, LayoutContext } from "../.." +import { CssBox, LayoutContext, BoxType, LayoutGenerator } from "../.." import { decodeHtmlEntities } from "../../util" import { StyleState } from "./StyleState" +import { LayoutManager } from "../../LayoutManager" export class DefaultBoxBuilderFuncs { /** @@ -11,11 +12,14 @@ export class DefaultBoxBuilderFuncs { */ public static genericBlock( context: LayoutContext, + manager: LayoutManager, element: HtmlNode ): CssBox | null { - return context.createBlockBox( + return manager.createBox( + context, + BoxType.block, "", - context.buildBoxes(context, element.children), + manager.layout(context, element.children), "genericBlock" ) } @@ -24,22 +28,31 @@ export class DefaultBoxBuilderFuncs { */ public static genericInline( context: LayoutContext, + manager: LayoutManager, element: HtmlNode ): CssBox | null { const text = element.data ? normalizeWhitespace(element.data, WhitespaceHandling.normal) : "" - const kids = context.buildBoxes(context, element.children) + const kids = manager.layout(context, element.children) // if it has no text and no kids it doesn't affect layout so, don't create a box to affect layout: if ((!text || text.length === 0) && kids.length === 0) { return null - } else return context.createInlineBox(text, kids, "genericInline") + } else + return manager.createBox( + context, + BoxType.inline, + text, + kids, + "genericInline" + ) } /** * A @see BoxBuilder suitable for list item elements. */ public static listItem( context: LayoutContext, + manager: LayoutManager, element: HtmlNode ): CssBox | null { const listState = new ListState(context) @@ -52,13 +65,17 @@ export class DefaultBoxBuilderFuncs { } let markerBox: CssBox if (listState.getListType() === "ul") { - markerBox = context.createInlineBox( + markerBox = manager.createBox( + context, + BoxType.inline, indentSpaces + "* ", null, "li-marker-ul" ) } else if (listState.getListType() === "ol") { - markerBox = context.createInlineBox( + markerBox = manager.createBox( + context, + BoxType.inline, indentSpaces + `${listState.getListItemCount()}. `, null, "li-marker-ol" @@ -67,72 +84,104 @@ export class DefaultBoxBuilderFuncs { throw new Error("unexpected list type") } // add boxes for list item child elements - const contentChildBoxes: CssBox[] = context.buildBoxes( + const contentChildBoxes: CssBox[] = manager.layout( context, element.children ) // prepare a single parent box for the list item's content (to keep it from breaking between the marker & content) - const contentBox = context.createInlineBox( + const contentBox = manager.createBox( + context, + BoxType.inline, "", contentChildBoxes, "li-content" ) - const principalBox = context.createBlockBox( + const principalBox = manager.createBox( + context, + BoxType.block, "", [markerBox, contentBox], "li-principal" ) return principalBox } - public static list(context: LayoutContext, element: HtmlNode): CssBox | null { + public static list( + context: LayoutContext, + manager: LayoutManager, + element: HtmlNode + ): CssBox | null { if (!["ul", "ol"].includes(element.name)) { throw new Error(`Unexpected list type "${element.name}"`) } const listState = new ListState(context) - const listBox = context.createBlockBox("", [], element.name) + const listBox = manager.createBox( + context, + BoxType.block, + "", + [], + element.name + ) listState.beginList(element.name as "ul" | "ol") - const kids = context.buildBoxes(context, element.children) + const kids = manager.layout(context, element.children) listState.endList() kids.forEach(kid => listBox.addChild(kid)) return listBox } - public static blockquote( - context: LayoutContext, - element: HtmlNode - ): CssBox | null { - const blockquoteBox = context.createBlockBox("", [], element.name) - const kids = context.buildBoxes(context, element.children) - kids.forEach(kid => blockquoteBox.addChild(kid)) - return blockquoteBox - } - - public static headingThunk(headingLevel: number): BoxBuilder { - return (context: LayoutContext, element: HtmlNode): CssBox | null => { - const kids = context.buildBoxes(context, element.children) + public static headingThunk(headingLevel: number): LayoutGenerator { + return ( + context: LayoutContext, + manager: LayoutManager, + element: HtmlNode + ): CssBox | null => { + const kids = manager.layout(context, element.children) const headingSequence = "#".repeat(headingLevel) - kids.unshift(context.createInlineBox(headingSequence + " ")) - kids.push(context.createInlineBox(" " + headingSequence)) - return context.createBlockBox("", kids) + kids.unshift( + manager.createBox(context, BoxType.inline, headingSequence + " ") + ) + kids.push( + manager.createBox(context, BoxType.inline, " " + headingSequence) + ) + return manager.createBox(context, BoxType.block, "", kids) } } - public static emphasisThunk(sequence: string): BoxBuilder { - return (context: LayoutContext, element: HtmlNode): CssBox | null => { - const kids = context.buildBoxes(context, element.children) - kids.unshift(context.createInlineBox(sequence)) - kids.push(context.createInlineBox(sequence)) - return context.createInlineBox("", kids) + public static emphasisThunk(sequence: string): LayoutGenerator { + return ( + context: LayoutContext, + manager: LayoutManager, + element: HtmlNode + ): CssBox | null => { + const kids = manager.layout(context, element.children) + kids.unshift(manager.createBox(context, BoxType.inline, sequence)) + kids.push(manager.createBox(context, BoxType.inline, sequence)) + return manager.createBox(context, BoxType.inline, "", kids) } } - public static link(context: LayoutContext, element: HtmlNode): CssBox | null { - const linkBox = context.createInlineBox("") - const childContentBoxes = context.buildBoxes(context, element.children) + public static link( + context: LayoutContext, + manager: LayoutManager, + element: HtmlNode + ): CssBox | null { + const linkBox = manager.createBox(context, BoxType.inline, "") + const childContentBoxes = manager.layout(context, element.children) // wrap the text in square brackets: childContentBoxes.unshift( - context.createInlineBox("[", [], "link-helper-open-text") + manager.createBox( + context, + BoxType.inline, + "[", + [], + "link-helper-open-text" + ) ) childContentBoxes.push( - context.createInlineBox("]", [], "link-helper-close-text") + manager.createBox( + context, + BoxType.inline, + "]", + [], + "link-helper-close-text" + ) ) // add destination/title syntax: const href = @@ -147,23 +196,40 @@ export class DefaultBoxBuilderFuncs { } destinationMarkup += ")" childContentBoxes.push( - context.createInlineBox(destinationMarkup, [], "link-helper-close-text") + manager.createBox( + context, + BoxType.inline, + destinationMarkup, + [], + "link-helper-close-text" + ) ) // add the child boxes: childContentBoxes.forEach(kid => linkBox.addChild(kid)) return linkBox } - public static hr(context: LayoutContext): CssBox | null { - return context.createBlockBox("* * *") + public static hr( + context: LayoutContext, + manager: LayoutManager + ): CssBox | null { + return manager.createBox(context, BoxType.block, "* * *") } - public static br(context: LayoutContext): CssBox | null { - return context.createInlineBox("\n") + + public static br( + context: LayoutContext, + manager: LayoutManager + ): CssBox | null { + return manager.createBox(context, BoxType.inline, "\n") } - public static pre(context: LayoutContext, element: HtmlNode): CssBox | null { + public static pre( + context: LayoutContext, + manager: LayoutManager, + element: HtmlNode + ): CssBox | null { const styleState = new StyleState(context) styleState.pushWhitespaceHandling(WhitespaceHandling.pre) // kids is likely a single text element - const kids = context.buildBoxes(context, element.children) + const kids = manager.layout(context, element.children) const decode = (box: CssBox): void => { box.textContent = decodeHtmlEntities(box.textContent) for (const child of box.children) { @@ -171,19 +237,23 @@ export class DefaultBoxBuilderFuncs { } } kids.forEach(kid => decode(kid)) - kids.unshift(context.createBlockBox("```")) - kids.push(context.createBlockBox("```")) + kids.unshift(manager.createBox(context, BoxType.block, "```")) + kids.push(manager.createBox(context, BoxType.block, "```")) styleState.popWhitespaceHandling() - return context.createBlockBox("", kids) + return manager.createBox(context, BoxType.block, "", kids) } - public static code(context: LayoutContext, element: HtmlNode): CssBox | null { + public static code( + context: LayoutContext, + manager: LayoutManager, + element: HtmlNode + ): CssBox | null { // kids is likely a single text element - const kids = context.buildBoxes(context, element.children) + const kids = manager.layout(context, element.children) // If we're already nested inside of a
 element, don't output the inline code formatting (using the "whitespaceHandling" mode here is a bit of a risky assumption used to make this conclusion)
     if (new StyleState(context).whitespaceHandling != WhitespaceHandling.pre) {
-      kids.unshift(context.createInlineBox("`"))
-      kids.push(context.createInlineBox("`"))
+      kids.unshift(manager.createBox(context, BoxType.inline, "`"))
+      kids.push(manager.createBox(context, BoxType.inline, "`"))
     }
-    return context.createBlockBox("", kids)
+    return manager.createBox(context, BoxType.block, "", kids)
   }
 }
diff --git a/src/css/layout/LayoutContextImp.ts b/src/css/layout/LayoutContextImp.ts
index 4ecd6ddd..78b3df62 100644
--- a/src/css/layout/LayoutContextImp.ts
+++ b/src/css/layout/LayoutContextImp.ts
@@ -1,5 +1,4 @@
-import { HtmlNode, CssBox, LayoutContext, BoxBuilder } from "../.."
-import { CssBoxConstructor } from "./layout"
+import { LayoutContext } from "../.."
 
 /**
  * Provides context during layout.
@@ -7,36 +6,7 @@ import { CssBoxConstructor } from "./layout"
 export class LayoutContextImp implements LayoutContext {
   private readonly state: Map = new Map() // eslint-disable-line @typescript-eslint/no-explicit-any
 
-  public constructor(
-    private readonly blockBoxCreator: CssBoxConstructor,
-    private readonly inlineBoxCreator: CssBoxConstructor,
-    private readonly compositeBoxBuilder: BoxBuilder
-  ) {}
-
-  public buildBoxes(context: LayoutContext, elements: HtmlNode[]): CssBox[] {
-    const boxes = elements
-      ? elements
-          .map(el => this.compositeBoxBuilder(context, el))
-          .filter(childBox => childBox !== null)
-      : []
-    return boxes
-  }
-
-  public createBlockBox(
-    textContent: string = "",
-    children: Iterable = [],
-    debugNote: string = ""
-  ): CssBox {
-    return this.blockBoxCreator(this, textContent, children, debugNote)
-  }
-
-  public createInlineBox(
-    textContent: string = "",
-    children: Iterable = [],
-    debugNote: string = ""
-  ): CssBox {
-    return this.inlineBoxCreator(this, textContent, children, debugNote)
-  }
+  public constructor() {}
 
   public getStateStack(stackName: string): TValue[] {
     let stack = this.state.get(stackName) as TValue[]
diff --git a/src/css/layout/LayoutManagerImp.ts b/src/css/layout/LayoutManagerImp.ts
new file mode 100644
index 00000000..e36739f1
--- /dev/null
+++ b/src/css/layout/LayoutManagerImp.ts
@@ -0,0 +1,31 @@
+import { CssBox, HtmlNode, LayoutContext, BoxType } from "../.."
+import { CssBoxFactory } from "./CssBoxFactory"
+import BoxBuilderManager from "./BoxBuilderManager"
+import { LayoutManager } from "../../LayoutManager"
+
+export class LayoutManagerImp implements LayoutManager {
+  public constructor(
+    private readonly boxFactory: CssBoxFactory,
+    private readonly builder: BoxBuilderManager
+  ) {}
+
+  public layout(context: LayoutContext, elements: HtmlNode[]): CssBox[] {
+    return this.builder.buildBoxes(context, this, elements)
+  }
+
+  public createBox(
+    state: LayoutContext,
+    boxType: BoxType,
+    textContent: string = "",
+    children: Iterable = [],
+    debugNote: string = ""
+  ): CssBox {
+    return this.boxFactory.createBox(
+      state,
+      boxType,
+      textContent,
+      children,
+      debugNote
+    )
+  }
+}
diff --git a/src/css/layout/layout.ts b/src/css/layout/layout.ts
index aef2ce63..fadd05f8 100644
--- a/src/css/layout/layout.ts
+++ b/src/css/layout/layout.ts
@@ -1,283 +1,22 @@
 import { HtmlNode } from "../../HtmlNode"
-import {
-  BoxBuilder,
-  BoxType,
-  CssBox,
-  LayoutContext,
-  RenderPlugin,
-  BoxMapper
-} from "../../"
-import { normalizeWhitespace } from "../"
+import { BoxType, CssBox, LayoutContext, LayoutPlugin } from "../../"
 import { LayoutContextImp } from "./LayoutContextImp"
-import { StyleState } from "./StyleState"
 import { CssBoxImp } from "../CssBoxImp"
-import { DefaultBoxBuilderFuncs } from "./DefaultBoxBuilderFuncs"
+import { CssBoxFactory } from "./CssBoxFactory"
+import { LayoutManagerImp } from "./LayoutManagerImp"
+import BoxBuilderManager from "./BoxBuilderManager"
 
 /**
  * Implements the CSS Visual Formatting model's box generation algorithm. It turns HTML elements into a set of CSS Boxes.
  * See https://www.w3.org/TR/CSS22/visuren.html#visual-model-intro
  */
-export function layout(
-  document: Iterable,
-  plugins: RenderPlugin[]
-): CssBox {
-  const boxBuilderMap = createBoxBuilderMap(plugins)
-  const compositeBoxBuilder: BoxBuilder = (
-    context: LayoutContext,
-    element: HtmlNode
-  ): CssBox => {
-    return buildBoxForElementImp(context, element, boxBuilderMap)
-  }
-
-  const context: LayoutContext = new LayoutContextImp(
-    createCssBoxConstructor(plugins, BoxType.block),
-    createCssBoxConstructor(plugins, BoxType.inline),
-    compositeBoxBuilder
-  )
+export function layout(document: HtmlNode[], plugins: LayoutPlugin[]): CssBox {
+  const context: LayoutContext = new LayoutContextImp()
+  const boxFactory = new CssBoxFactory(plugins)
+  const boxBuilder = new BoxBuilderManager(plugins, boxFactory)
+  const manager = new LayoutManagerImp(boxFactory, boxBuilder)
   // NOTE: we want a single root box so that the if the incoming HTML is a fragment (e.g. a b) it will still figure out it's own formatting context.
   const body = new CssBoxImp(BoxType.block, "", [], "body")
-  for (const node of document) {
-    const box = compositeBoxBuilder(context, node)
-    box && body.addChild(box)
-  }
-  //console.debug(traceBoxTree(body))
+  manager.layout(context, document).forEach(box => box && body.addChild(box))
   return body
 }
-
-function createBoxBuilderMap(plugins: RenderPlugin[]): Map {
-  const builders = new Map([
-    ["a", DefaultBoxBuilderFuncs.link],
-    ["b", DefaultBoxBuilderFuncs.emphasisThunk("**")],
-    ["blockquote", DefaultBoxBuilderFuncs.blockquote],
-    ["br", DefaultBoxBuilderFuncs.br],
-    ["code", DefaultBoxBuilderFuncs.code],
-    ["del", DefaultBoxBuilderFuncs.emphasisThunk("~")],
-    ["li", DefaultBoxBuilderFuncs.listItem],
-    ["ol", DefaultBoxBuilderFuncs.list],
-    ["ul", DefaultBoxBuilderFuncs.list],
-    /* eslint-disable no-magic-numbers */
-    ["h1", DefaultBoxBuilderFuncs.headingThunk(1)],
-    ["h2", DefaultBoxBuilderFuncs.headingThunk(2)],
-    ["h3", DefaultBoxBuilderFuncs.headingThunk(3)],
-    ["h4", DefaultBoxBuilderFuncs.headingThunk(4)],
-    ["h5", DefaultBoxBuilderFuncs.headingThunk(5)],
-    ["h6", DefaultBoxBuilderFuncs.headingThunk(6)],
-    /* eslint-enable no-magic-numbers */
-    ["hr", DefaultBoxBuilderFuncs.hr],
-    ["i", DefaultBoxBuilderFuncs.emphasisThunk("*")],
-    ["em", DefaultBoxBuilderFuncs.emphasisThunk("*")],
-    ["pre", DefaultBoxBuilderFuncs.pre],
-    ["s", DefaultBoxBuilderFuncs.emphasisThunk("~")],
-    ["strike", DefaultBoxBuilderFuncs.emphasisThunk("~")],
-    ["strong", DefaultBoxBuilderFuncs.emphasisThunk("**")],
-    ["u", DefaultBoxBuilderFuncs.emphasisThunk("_")]
-  ])
-  if (plugins) {
-    for (const plugin of plugins) {
-      builders.set(plugin.elementName, plugin.renderer)
-    }
-  }
-  return builders
-}
-
-/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
-function traceBoxTree(box: CssBox, indent = 0): string {
-  const typeStr = (type: BoxType): string =>
-    type === BoxType.inline ? "inline" : "block"
-  const boxStr = (b: CssBox): string =>
-    "CssBox " +
-    (b == null
-      ? ""
-      : JSON.stringify({
-          type: typeStr(b.type),
-          text: b.textContent,
-          debug: b.debugNote
-        })) +
-    "\n"
-  let output = "  ".repeat(indent) + boxStr(box)
-  if (box) {
-    for (const child of box.children) {
-      output += traceBoxTree(child, indent + 1)
-    }
-  }
-  return output
-}
-
-/**
- * Returns @see BlockBuilder for specified element.
- * @param elementName name/tag of element
- */
-function getBoxBuilderForElement(
-  elementName: string,
-  builders: Map
-): BoxBuilder {
-  if (!builders) throw new Error("builders must not be null!!")
-  let builder = builders.get(elementName)
-  if (!builder) {
-    const display = getElementDisplay(elementName)
-    if (display === CssDisplayValue.block) {
-      builder = DefaultBoxBuilderFuncs.genericBlock
-    } else if (display === CssDisplayValue.inline) {
-      builder = DefaultBoxBuilderFuncs.genericInline
-    } else if (display === CssDisplayValue.listItem) {
-      builder = DefaultBoxBuilderFuncs.listItem
-    } else {
-      throw new Error("unexpected element and unexpected display")
-    }
-  }
-  return builder
-}
-
-/**
- * Generates zero or more CSS boxes for the specified element.
- * See https://www.w3.org/TR/CSS22/visuren.html#propdef-display
- * @param element The element to generate a box for
- */
-export function buildBoxForElementImp(
-  context: LayoutContext,
-  element: HtmlNode,
-  builders: Map
-): CssBox | null {
-  if (!builders) throw new Error("builders must not be null!")
-  let box: CssBox = null
-  if (element.type === "text") {
-    const text = normalizeWhitespace(
-      element.data,
-      new StyleState(context).whitespaceHandling
-    )
-    if (text) {
-      // only create a box if normalizeWhitespace left something over
-      box = context.createInlineBox(text, [], "textNode")
-    }
-  } else if (element.type === "tag") {
-    const boxBuilderFunc = getBoxBuilderForElement(element.name, builders)
-    try {
-      box = boxBuilderFunc(context, element)
-    } catch (e) {
-      throw new Error(
-        `boxbuilder (${JSON.stringify(
-          boxBuilderFunc
-        )}) error for element ${JSON.stringify(element.name)}: ${e}`
-      )
-    }
-  } else if (element.type === "comment") {
-    // deliberately ignored
-  } else {
-    console.error(`Ignoring element with type ${element.type}`)
-    box = null
-  }
-  return box
-}
-
-/**
- * https://www.w3.org/TR/CSS22/visuren.html#propdef-display
- */
-enum CssDisplayValue {
-  block,
-  inline,
-  listItem,
-  none
-}
-
-const elementToDisplayMap: Map = new Map<
-  string,
-  CssDisplayValue
->([
-  ["html", CssDisplayValue.block],
-  ["address", CssDisplayValue.block],
-  ["blockquote", CssDisplayValue.block],
-  ["body", CssDisplayValue.block],
-  ["dd", CssDisplayValue.block],
-  ["div", CssDisplayValue.block],
-  ["dl", CssDisplayValue.block],
-  ["dt", CssDisplayValue.block],
-  ["fieldset", CssDisplayValue.block],
-  ["form", CssDisplayValue.block],
-  ["frame", CssDisplayValue.block],
-  ["frameset", CssDisplayValue.block],
-  ["h1", CssDisplayValue.block],
-  ["h2", CssDisplayValue.block],
-  ["h3", CssDisplayValue.block],
-  ["h4", CssDisplayValue.block],
-  ["h5", CssDisplayValue.block],
-  ["h6", CssDisplayValue.block],
-  ["noframes", CssDisplayValue.block],
-  ["ol", CssDisplayValue.block],
-  ["p", CssDisplayValue.block],
-  ["ul", CssDisplayValue.block],
-  ["center", CssDisplayValue.block],
-  ["dir", CssDisplayValue.block],
-  ["hr", CssDisplayValue.block],
-  ["menu", CssDisplayValue.block],
-  ["pre", CssDisplayValue.block],
-  ["li", CssDisplayValue.listItem],
-  ["head", CssDisplayValue.none]
-])
-
-/**
- * Returns the CSS Display type for the specified element
- * @param elementTypeName The name of a document language element type (e.g. div, span, etc.).
- */
-function getElementDisplay(elementTypeName: string): CssDisplayValue {
-  /**
-   * See https://www.w3.org/TR/CSS22/sample.html for how we identify block elements in HTML.
-   * A less concise but more current reference for HTML5 is at https://html.spec.whatwg.org/multipage/rendering.html#the-css-user-agent-style-sheet-and-presentational-hints
-   */
-  let display = elementToDisplayMap.get(elementTypeName)
-  if (display === undefined) {
-    // default to inline:
-    display = CssDisplayValue.inline
-  }
-  return display
-}
-
-export interface CssBoxConstructor {
-  (
-    context: LayoutContext,
-    textContent: string,
-    children: Iterable,
-    debugNote: string
-  ): CssBox
-}
-
-/**
- * Creates the function that creates @see CssBox objects. It will handle incorporating any mappers from plugins.
- * We use this to make sure that plugins can modify the boxes created by other plugins/builders.
- * @param plugins The list of plugins
- * @param mapperName Whether we're dealing with inline or block boxes.
- */
-function createCssBoxConstructor(
-  plugins: RenderPlugin[],
-  boxType: BoxType
-): CssBoxConstructor {
-  let creator: CssBoxConstructor = (
-    context: LayoutContext,
-    textContent: string,
-    children: CssBox[],
-    debugNote: string
-  ): CssBox => new CssBoxImp(boxType, textContent, children, debugNote)
-  /* for reference, this is what an identity BoxMapper looks like:
-   * const identityMapper: BoxMapper = (context: LayoutContext, box: CssBox) => box
-   */
-  if (plugins) {
-    const mapperName: string =
-      boxType === BoxType.inline ? "inlineBoxMapper" : "blockBoxMapper"
-    const mappers: BoxMapper[] = plugins
-      .filter(p => p[mapperName])
-      .map(p => p[mapperName])
-    for (const mapper of mappers) {
-      creator = (
-        context: LayoutContext,
-        textContent: string,
-        children: CssBox[],
-        debugNote: string
-      ): CssBox => {
-        return mapper(
-          context,
-          creator(context, textContent, children, debugNote)
-        )
-      }
-    }
-  }
-  return creator
-}
diff --git a/src/index.ts b/src/index.ts
index 374958f2..b962816f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,6 +4,10 @@ import { DefaultTextWriter } from "./DefaultTextWriter"
 import { layout } from "./css"
 import { HtmlNode } from "./HtmlNode"
 import { LayoutContext } from "./LayoutContext"
+import { DefaultBoxBuilderFuncs } from "./css/layout/DefaultBoxBuilderFuncs"
+import BlockquotePlugin from "./css/layout/BlockquotePlugin"
+import { CssBoxFactoryFunc } from "./css/layout/CssBoxFactory"
+import { LayoutManager } from "./LayoutManager"
 export { LayoutContext } from "./LayoutContext"
 export { HtmlNode } from "./HtmlNode"
 
@@ -17,7 +21,7 @@ export interface RenderOptions {
    * A map of "element name" => @see BoxBuilder where the key is the element name and the associated value is a @see BoxBuilder implementations that render markdown for a specified HTML element.
    * Any elements in this map that conflict with the default intrinsic implementations will override the default rendering.
    */
-  renderPlugins?: RenderPlugin[]
+  layoutPlugins?: LayoutPlugin[]
 }
 
 export interface MarkdownOutput {
@@ -32,6 +36,34 @@ export interface ImageReference {
   url: string
 }
 
+const defaultPlugins: LayoutPlugin[] = [
+  { elementName: "a", layout: DefaultBoxBuilderFuncs.link },
+  { elementName: "b", layout: DefaultBoxBuilderFuncs.emphasisThunk("**") },
+  { elementName: "br", layout: DefaultBoxBuilderFuncs.br },
+  { elementName: "code", layout: DefaultBoxBuilderFuncs.code },
+  { elementName: "del", layout: DefaultBoxBuilderFuncs.emphasisThunk("~") },
+  { elementName: "li", layout: DefaultBoxBuilderFuncs.listItem },
+  { elementName: "ol", layout: DefaultBoxBuilderFuncs.list },
+  { elementName: "ul", layout: DefaultBoxBuilderFuncs.list },
+  /* eslint-disable no-magic-numbers */
+  { elementName: "h1", layout: DefaultBoxBuilderFuncs.headingThunk(1) },
+  { elementName: "h2", layout: DefaultBoxBuilderFuncs.headingThunk(2) },
+  { elementName: "h3", layout: DefaultBoxBuilderFuncs.headingThunk(3) },
+  { elementName: "h4", layout: DefaultBoxBuilderFuncs.headingThunk(4) },
+  { elementName: "h5", layout: DefaultBoxBuilderFuncs.headingThunk(5) },
+  { elementName: "h6", layout: DefaultBoxBuilderFuncs.headingThunk(6) },
+  /* eslint-enable no-magic-numbers */
+  { elementName: "hr", layout: DefaultBoxBuilderFuncs.hr },
+  { elementName: "i", layout: DefaultBoxBuilderFuncs.emphasisThunk("*") },
+  { elementName: "em", layout: DefaultBoxBuilderFuncs.emphasisThunk("*") },
+  { elementName: "pre", layout: DefaultBoxBuilderFuncs.pre },
+  { elementName: "s", layout: DefaultBoxBuilderFuncs.emphasisThunk("~") },
+  { elementName: "strike", layout: DefaultBoxBuilderFuncs.emphasisThunk("~") },
+  { elementName: "strong", layout: DefaultBoxBuilderFuncs.emphasisThunk("**") },
+  { elementName: "u", layout: DefaultBoxBuilderFuncs.emphasisThunk("_") },
+  new BlockquotePlugin()
+]
+
 /**
  * An HTML to markdown converter.
  * Goals:
@@ -62,7 +94,11 @@ export class AgentMarkdown {
     const dom = await parseHtml(options.html)
     //console.debug(traceHtmlNodes(dom))
     const writer = new DefaultTextWriter()
-    const docStructure = layout(dom, options.renderPlugins)
+    const plugins = options.layoutPlugins
+      ? defaultPlugins.concat(options.layoutPlugins)
+      : defaultPlugins
+    const docStructure = layout(dom, plugins)
+    //console.log("! docStructure !:\n", CssBoxImp.traceBoxTree(docStructure))
     renderImp(writer, docStructure.children)
     return {
       markdown: writer.toString(),
@@ -86,18 +122,22 @@ function renderImp(writer: TextWriter, boxes: Iterable): void {
 /**
  * Defines the function used to create a @see CssBox from an HTML element.
  */
-export interface BoxBuilder {
-  (context: LayoutContext, element: HtmlNode): CssBox | null
+export interface LayoutGenerator {
+  (
+    context: LayoutContext,
+    manager: LayoutManager,
+    element: HtmlNode
+  ): CssBox | null
 }
 
 /**
- * Defines a function to map a @see CssBox to another @css CssBox (or just returns the same box).
+ * A function to map a @see CssBox to another @see CssBox.
  */
-export interface BoxMapper {
-  (context: LayoutContext, box: CssBox): CssBox
+export interface LayoutTransformer {
+  (context: LayoutContext, boxFactory: CssBoxFactoryFunc, box: CssBox): CssBox
 }
 
-export interface RenderPlugin {
+export interface LayoutPlugin {
   /**
    * Specifies the name of the HTML element that this plugin renders markdown for.
    * NOTE: Must be all lowercase
@@ -106,17 +146,12 @@ export interface RenderPlugin {
   /**
    * This is the core of the implementation that will be called for each instance of the HTML element that this plugin is registered for.
    */
-  renderer: BoxBuilder
-  /**
-   * In some rare cases the BoxBuilder will need to manipulate the boxes created by other @see RenderPlugin objects.
-   * If specified, this function will be called for every inline box created.
-   */
-  inlineBoxMapper?: BoxMapper
+  layout: LayoutGenerator
   /**
-   * In some rare cases the BoxBuilder will need to manipulate the boxes created by other @see RenderPlugin objects.
-   * If specified, this function will be called for every block box created.
+   * In some rare cases the plugin will need to manipulate the boxes created by other @see LayoutPlugin objects (especially when those boxes are for child HTML elements that the plugin is interested in).
+   * If specified, this function will be called for every box created during layout.
    */
-  blockBoxMapper?: BoxMapper
+  transform?: LayoutTransformer
 }
 
 /* eslint-disable no-unused-vars */
diff --git a/test-data/snapshots/html-to-mrkdwn/blockquote-empty.mrkdwn b/test-data/snapshots/html-to-mrkdwn/blockquote-empty.mrkdwn
index 5d00c2df..4c6e71cb 100644
--- a/test-data/snapshots/html-to-mrkdwn/blockquote-empty.mrkdwn
+++ b/test-data/snapshots/html-to-mrkdwn/blockquote-empty.mrkdwn
@@ -1,4 +1,3 @@
 
-=== -> \ No newline at end of file +==== diff --git a/test-data/snapshots/html-to-mrkdwn/blockquote-heading-and-paragraph.mrkdwn b/test-data/snapshots/html-to-mrkdwn/blockquote-heading-and-paragraph.mrkdwn index 28b7eeff..008dddd3 100644 --- a/test-data/snapshots/html-to-mrkdwn/blockquote-heading-and-paragraph.mrkdwn +++ b/test-data/snapshots/html-to-mrkdwn/blockquote-heading-and-paragraph.mrkdwn @@ -4,5 +4,5 @@ baz

==== -># Foo # ->bar baz \ No newline at end of file +> # Foo # +> bar baz \ No newline at end of file diff --git a/test-data/snapshots/html-to-mrkdwn/block-quote-nested.mrkdwn b/test-data/snapshots/html-to-mrkdwn/blockquote-nested.mrkdwn similarity index 84% rename from test-data/snapshots/html-to-mrkdwn/block-quote-nested.mrkdwn rename to test-data/snapshots/html-to-mrkdwn/blockquote-nested.mrkdwn index 5f8f0f3f..b8fcae20 100644 --- a/test-data/snapshots/html-to-mrkdwn/block-quote-nested.mrkdwn +++ b/test-data/snapshots/html-to-mrkdwn/blockquote-nested.mrkdwn @@ -5,5 +5,5 @@ -=== -> > > foo bar +==== +> > > foo bar \ No newline at end of file diff --git a/test-data/snapshots/html-to-mrkdwn/blockquote-paragraph.mrkdwn b/test-data/snapshots/html-to-mrkdwn/blockquote-paragraph.mrkdwn new file mode 100644 index 00000000..d1e86f0d --- /dev/null +++ b/test-data/snapshots/html-to-mrkdwn/blockquote-paragraph.mrkdwn @@ -0,0 +1,3 @@ +

blockquote

+==== +> blockquote \ No newline at end of file diff --git a/test-data/snapshots/html-to-mrkdwn/blockquote.mrkdwn b/test-data/snapshots/html-to-mrkdwn/blockquote.mrkdwn index 9d55a80a..0a6dd305 100644 --- a/test-data/snapshots/html-to-mrkdwn/blockquote.mrkdwn +++ b/test-data/snapshots/html-to-mrkdwn/blockquote.mrkdwn @@ -1,3 +1,3 @@
blockquote
==== -> blockquote +> blockquote \ No newline at end of file diff --git a/test-data/snapshots/snapshots.spec.ts b/test-data/snapshots/snapshots.spec.ts index 73d47c88..bc4b89d4 100644 --- a/test-data/snapshots/snapshots.spec.ts +++ b/test-data/snapshots/snapshots.spec.ts @@ -22,7 +22,7 @@ function getAllSnapshots(dir: string = __dirname): string[] { return snapshotPaths } -const table = getAllSnapshots() +const table = getAllSnapshots() //.filter(s => s.endsWith("blockquote-paragraph.mrkdwn")) describe("snapshots", () => { // NOTE: many of the tests were originally from https://github.com/integrations/html-to-mrkdwn/tree/master/test/fixtures, but they were crazy wrong (like headings wrapped in * instead of #). So they're fixed herein. diff --git a/tests/integration/extending-blockbuilders.spec.ts b/tests/integration/extending-blockbuilders.spec.ts index 225b6f5a..4ddf5c7a 100644 --- a/tests/integration/extending-blockbuilders.spec.ts +++ b/tests/integration/extending-blockbuilders.spec.ts @@ -1,22 +1,32 @@ -import { AgentMarkdown, CssBox, LayoutContext, HtmlNode } from "../../src" +import { + AgentMarkdown, + CssBox, + LayoutContext, + HtmlNode, + LayoutGenerator, + BoxType +} from "../../src" +import { LayoutManager } from "../../src/LayoutManager" +import { CssBoxFactoryFunc } from "../../src/css/layout/CssBoxFactory" -function customEmphasisRenderer( +const customEmphasisLayout: LayoutGenerator = ( context: LayoutContext, + manager: LayoutManager, element: HtmlNode -): CssBox | null { - const kids = context.buildBoxes(context, element.children) - kids.unshift(context.createInlineBox("_")) - kids.push(context.createInlineBox("_")) - return context.createInlineBox("", kids) +): CssBox | null => { + const kids = manager.layout(context, element.children) + kids.unshift(manager.createBox(context, BoxType.inline, "_")) + kids.push(manager.createBox(context, BoxType.inline, "_")) + return manager.createBox(context, BoxType.inline, "", kids) } it("should allow overriding existing elements with custom BoxBuilder", async () => { const result = await AgentMarkdown.render({ html: "my bold", - renderPlugins: [ + layoutPlugins: [ { elementName: "b", - renderer: customEmphasisRenderer + layout: customEmphasisLayout } ] }) @@ -26,28 +36,47 @@ it("should allow overriding existing elements with custom BoxBuilder", async () it("should allow rendering new elements with custom BoxBuilder", async () => { const result = await AgentMarkdown.render({ html: "custom content", - renderPlugins: [ + layoutPlugins: [ { elementName: "mycustomelement", - renderer: customEmphasisRenderer + layout: customEmphasisLayout } ] }) expect(result.markdown).toEqual("_custom content_") }) -it("should allow customizing created boxes with BoxMapper", async () => { +it("should allow customizing created boxes with transform", async () => { const result = await AgentMarkdown.render({ - html: "
my bold
", - renderPlugins: [ + html: "my bold", + layoutPlugins: [ { - elementName: "b", - renderer: customEmphasisRenderer, - blockBoxMapper: (context: LayoutContext, box: CssBox): CssBox => { - return context.createBlockBox("> ", [box]) + elementName: "customblockquote", + layout: ( + context: LayoutContext, + manager: LayoutManager, + element: HtmlNode + ): CssBox | null => { + return manager.createBox( + context, + BoxType.block, + "", + manager.layout(context, element.children) + ) + }, + transform: ( + context: LayoutContext, + boxFactory: CssBoxFactoryFunc, + box: CssBox + ): CssBox => { + if (box.type === BoxType.block) { + return boxFactory(context, BoxType.block, "> ", [box]) + } else { + return box + } } } ] }) - expect(result.markdown).toEqual("> _my bold_") + expect(result.markdown).toEqual("> **my bold**") }) From 06fbda051148e8dec649cf8e0d68b4a754558c90 Mon Sep 17 00:00:00 2001 From: Scott Willeke Date: Sat, 17 Aug 2019 16:36:09 -0700 Subject: [PATCH 4/9] tests passing for refactored plugin API and blockquote implemented using new API --- README.md | 63 +++++++++-- src/css/CssBoxImp.ts | 49 +++++++-- src/css/layout/BlockquotePlugin.ts | 104 +++++++++--------- src/css/layout/CssBoxFactory.ts | 51 ++------- src/css/layout/DefaultBoxBuilderFuncs.ts | 14 +++ src/css/layout/LayoutManagerImp.ts | 9 +- src/css/layout/layout.ts | 2 +- src/index.ts | 20 ++-- .../blockquote-nested-blocks.mrkdwn | 9 ++ .../blockquote-nested-twice.mrkdwn | 15 +++ test-data/snapshots/snapshots.spec.ts | 3 +- .../extending-blockbuilders.spec.ts | 36 ------ 12 files changed, 203 insertions(+), 172 deletions(-) create mode 100644 test-data/snapshots/html-to-mrkdwn/blockquote-nested-blocks.mrkdwn create mode 100644 test-data/snapshots/html-to-mrkdwn/blockquote-nested-twice.mrkdwn diff --git a/README.md b/README.md index 65f6f58f..55483155 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Agent Markdown is a [HTML user agent](https://en.wikipedia.org/wiki/User_agent) - [Features](#features) - [CLI Example](#cli-example) - [Live Example](#live-example) -- [Customize & Extend](#customize--extend) +- [Customize & Extend with Plugins](#customize--extend-with-plugins) - [Show your support](#show-your-support) - [Contributing ๐Ÿค](#contributing-๐Ÿค) - [Release Process (Deploying to NPM) ๐Ÿš€](#release-process-deploying-to-npm-๐Ÿš€) @@ -77,26 +77,65 @@ yarn yarn start ``` -## Customize & Extend +## Customize & Extend with Plugins -To customize how the markdown is generated or add support for new elements, implement the `BoxBuilder` interface to handle a particular HTML element. The BoxBuilder interface is a single function defined as follows: +To customize how the markdown is generated or add support for new elements, implement the `LayoutPlugin` interface to handle a particular HTML element. The `LayoutPlugin` is defined as follows: ```TypeScript -export interface BoxBuilder { - (context: LayoutContext, element: HtmlNode): CssBox | null +export interface LayoutPlugin { + /** + * Specifies the name of the HTML element that this plugin renders markdown for. + * NOTE: Must be all lowercase + */ + elementName: string + /** + * This is the core of the implementation that will be called for each instance of the HTML element that this plugin is registered for. + */ + layout: LayoutGenerator } ``` +The `LayoutGenerator` is a single function that performs a [CSS2 box generation layout algorithm](https://www.w3.org/TR/CSS22/visuren.html#box-gen) on the an HTML element. Essentially it creates a zero or more boxes for the given element that AgentMarkdown will later render to text. The function definition is the following: + +```TypeScript +export interface LayoutGenerator { + ( + context: LayoutContext, + manager: LayoutManager, + element: HtmlNode + ): CssBox | null +} +``` + +An example of how the HTML `` element could be implemented as a plugin is below: + ```TypeScript -(context: LayoutContext, element: HtmlNode): CssBox | null => { - const kids = BoxBuilders.buildBoxes(context, element.children) - kids.unshift(new CssBox(BoxType.inline, sequence)) - kids.push(new CssBox(BoxType.inline, sequence)) - return new CssBox(BoxType.inline, "", kids) - } +class BoldPlugin { + elementName: "b" + + layout: LayoutGenerator = ( + context: LayoutContext, + manager: LayoutManager, + element: HtmlNode + ): CssBox | null => { + const kids = manager.layout(context, element.children) + kids.unshift(manager.createBox(context, BoxType.inline, "*")) + kids.push(manager.createBox(context, BoxType.inline, "*")) + return manager.createBox(context, BoxType.inline, "", kids) + } +} ``` -An example of how the html `` element is implement is below: +To initialize AgentMarkdown with plugins pass them in as an option as follows: + +```TypeScript +const result = await AgentMarkdown.render({ + html: myHtmlString, + layoutPlugins: [ + new BoldPlugin() + ] + }) +``` ## Show your support diff --git a/src/css/CssBoxImp.ts b/src/css/CssBoxImp.ts index 34c8a7b5..1f1cdd5a 100644 --- a/src/css/CssBoxImp.ts +++ b/src/css/CssBoxImp.ts @@ -5,6 +5,11 @@ import { CssBox, BoxType } from ".." */ export class CssBoxImp implements CssBox { private readonly _children: CssBox[] + private readonly childBoxTypeCache: { + hasBlock: boolean + hasInline: boolean + needsCalculated: boolean + } = { hasBlock: false, hasInline: false, needsCalculated: true } /** * Initializes a new @see CssBox. @@ -48,21 +53,49 @@ export class CssBoxImp implements CssBox { public addChild(box: CssBox): void { if (!box) throw new Error("box must be provided") this._children.push(box) + this.childBoxTypeCache.needsCalculated = true + } + + public prependChild(box: CssBox): void { + if (!box) throw new Error("box must be provided") + this._children.unshift(box) + this.childBoxTypeCache.needsCalculated = true } public get children(): IterableIterator { return this.childrenBoxGenerator() } - private get containsInlineAndBlockBoxes(): boolean { - //TODO: PERF: Cache this value. - let hasBlock = false - let hasInline = false - for (const child of this._children) { - hasBlock = hasBlock || child.type === BoxType.block - hasInline = hasInline || child.type == BoxType.inline + public get doesEstablishBlockFormattingContext(): boolean { + return this.containsBlockBoxes + } + + private get childBoxTypes(): { hasBlock: boolean; hasInline: boolean } { + if (this.childBoxTypeCache.needsCalculated) { + let hasBlock = false + let hasInline = false + // Deliberately avoiding anonymous boxes created in this.children() with this._children + for (const child of this._children) { + hasBlock = hasBlock || child.type === BoxType.block + hasInline = hasInline || child.type === BoxType.inline + } + this.childBoxTypeCache.hasBlock = hasBlock + this.childBoxTypeCache.hasInline = hasInline + this.childBoxTypeCache.needsCalculated = false } - return hasBlock && hasInline + return this.childBoxTypeCache + } + + private get containsInlineBoxes(): boolean { + return this.childBoxTypes.hasInline + } + + private get containsBlockBoxes(): boolean { + return this.childBoxTypes.hasBlock + } + + private get containsInlineAndBlockBoxes(): boolean { + return this.containsInlineBoxes && this.containsBlockBoxes } private *childrenBoxGenerator(): IterableIterator { diff --git a/src/css/layout/BlockquotePlugin.ts b/src/css/layout/BlockquotePlugin.ts index 3dd1bbbf..273521b1 100644 --- a/src/css/layout/BlockquotePlugin.ts +++ b/src/css/layout/BlockquotePlugin.ts @@ -1,69 +1,67 @@ import { LayoutPlugin, HtmlNode, LayoutContext, CssBox, BoxType } from "../.." import { LayoutManager } from "../../LayoutManager" -import { CssBoxFactoryFunc } from "./CssBoxFactory" class BlockquotePlugin implements LayoutPlugin { public elementName: string = "blockquote" + private static insertBlockquotePrefixes( + box: CssBox, + context: LayoutContext, + manager: LayoutManager + ): void { + /* insert the blockquote "> " prefix at the beginning of each block box that contains inlines (i.e. does /not/ start a block formatting context). + * This approach handles things like one block box containing another (which without this check would put two prefixes in the same rendered line) by putting the prefix only on a single block that will actually create an inline + */ + const needChecked: CssBox[] = [] + needChecked.push(box) + const needPrefix: CssBox[] = [] + while (needChecked.length > 0) { + const parent: CssBox = needChecked.pop() + for (const box of parent.children) { + if (parent.doesEstablishBlockFormattingContext) { + // go one level deeper, as his children (or grandchildren) each will create a new line + needChecked.push(box) + } else { + // then this guy and all of his siblings need prefixes; they are the deepest nodes in the tree that are inlines + console.assert( + !parent.doesEstablishBlockFormattingContext, + `Expected the parent '${parent.debugNote}' to be establishing a new block formatting context` + ) + //Array.prototype.forEach.call(parent.children, b => needPrefix.push(b)) + needPrefix.push(parent) + break // we only need one prefix per line - don't add another for each child. + } + } + } + needPrefix.forEach(b => { + b.prependChild( + manager.createBox( + context, + BoxType.inline, + "> ", + [], + "blockquote-prefix" + ) + ) + }) + } + public layout( context: LayoutContext, manager: LayoutManager, element: HtmlNode ): CssBox | null { - const state = new BlockquoteState(context) - state.beginBlockquote() const kids = manager.layout(context, element.children) - state.endBlockquote() - return manager.createBox(context, BoxType.block, "", kids) - } - - // for any block box (i.e. those that create a new line), insert blockquote styling: - public transform( - context: LayoutContext, - boxFactory: CssBoxFactoryFunc, - box: CssBox - ): CssBox { - if (box.type === BoxType.inline) return box - let finalBox = box - const state = new BlockquoteState(context) - const depth = state.blockquoteNestingDepth - if (depth > 0) { - //console.debug("DEPTH:", depth) - // we're within at least one level of blockquote: - const stylingPrefix = "> ".repeat(depth) - const styleBox: CssBox = boxFactory( - context, - BoxType.block, - stylingPrefix, - [box], - "blockQuoteAnon" - ) - finalBox = styleBox - } - return finalBox + const box = manager.createBox( + context, + BoxType.block, + "", + kids, + "blockquote" + ) + BlockquotePlugin.insertBlockquotePrefixes(box, context, manager) + return box } } export default BlockquotePlugin - -class BlockquoteState { - private static readonly BlockquoteNestingDepthKey: string = - "blockquote-nesting-depth-key" - - public constructor(readonly context: LayoutContext) {} - - public endBlockquote(): void { - this.context.popState(BlockquoteState.BlockquoteNestingDepthKey) - } - - public beginBlockquote(): void { - this.context.pushState(BlockquoteState.BlockquoteNestingDepthKey, 0) - } - - public get blockquoteNestingDepth(): number { - const stack = this.context.getStateStack( - BlockquoteState.BlockquoteNestingDepthKey - ) - return stack.length - } -} diff --git a/src/css/layout/CssBoxFactory.ts b/src/css/layout/CssBoxFactory.ts index e6116f6e..dd84e98a 100644 --- a/src/css/layout/CssBoxFactory.ts +++ b/src/css/layout/CssBoxFactory.ts @@ -1,49 +1,18 @@ -import { LayoutPlugin, BoxType, CssBox, LayoutTransformer } from "../.." +import { BoxType, CssBox } from "../.." import { CssBoxImp } from "../CssBoxImp" import { LayoutContext } from "../../LayoutContext" export class CssBoxFactory { - public createBox: CssBoxFactoryFunc + public constructor() {} - public constructor(plugins: LayoutPlugin[]) { - const transformers = plugins.filter(p => p.transform).map(p => p.transform) - this.createBox = CssBoxFactory.createBoxFactoryFunc(transformers) - } - - /** - * Creates the function that creates @see CssBox objects. It will handle incorporating any transformers from plugins. - * We use this to make sure that plugins can modify the boxes created by other plugins/transformers. - */ - private static createBoxFactoryFunc( - transformers: LayoutTransformer[] - ): CssBoxFactoryFunc { - const transform = CssBoxFactory.createTransformFunc(transformers) - return ( - context: LayoutContext, - boxType: BoxType, - textContent: string = "", - children: Iterable = [], - debugNote: string = "" - ) => { - let box: CssBox = new CssBoxImp(boxType, textContent, children, debugNote) - box = transform(context, box) - return box - } - } - - private static createTransformFunc( - transformers: LayoutTransformer[] - ): (context: LayoutContext, box: CssBox) => CssBox { - return (context: LayoutContext, box: CssBox) => { - transformers.forEach((transform, index) => { - // create a box factory that includes all the prior transformers, but not the current one (because it could also create a box and cause a infinite loop) - const boxFactory = CssBoxFactory.createBoxFactoryFunc( - transformers.slice(0, index) - ) - box = transform(context, boxFactory, box) - }) - return box - } + public createBox( + state: LayoutContext, + boxType: BoxType, + textContent: string, + children: Iterable, + debugNote: string + ): CssBox { + return new CssBoxImp(boxType, textContent, children, debugNote) } } diff --git a/src/css/layout/DefaultBoxBuilderFuncs.ts b/src/css/layout/DefaultBoxBuilderFuncs.ts index 5e55ed15..4751d45a 100644 --- a/src/css/layout/DefaultBoxBuilderFuncs.ts +++ b/src/css/layout/DefaultBoxBuilderFuncs.ts @@ -23,6 +23,20 @@ export class DefaultBoxBuilderFuncs { "genericBlock" ) } + /** + * Same as using @see genericBlock, but allows specifying a debug note to be added to each box. + * @param debugNote the note to be added to each box created. + */ + public static blockThunk(debugNote: string = ""): LayoutGenerator { + return ( + context: LayoutContext, + manager: LayoutManager, + element: HtmlNode + ): CssBox | null => { + const kids = manager.layout(context, element.children) + return manager.createBox(context, BoxType.block, "", kids, debugNote) + } + } /** * A @see BoxBuilder suitable for generic inline elements. */ diff --git a/src/css/layout/LayoutManagerImp.ts b/src/css/layout/LayoutManagerImp.ts index e36739f1..954c7100 100644 --- a/src/css/layout/LayoutManagerImp.ts +++ b/src/css/layout/LayoutManagerImp.ts @@ -2,6 +2,7 @@ import { CssBox, HtmlNode, LayoutContext, BoxType } from "../.." import { CssBoxFactory } from "./CssBoxFactory" import BoxBuilderManager from "./BoxBuilderManager" import { LayoutManager } from "../../LayoutManager" +import { CssBoxImp } from "../CssBoxImp" export class LayoutManagerImp implements LayoutManager { public constructor( @@ -20,12 +21,6 @@ export class LayoutManagerImp implements LayoutManager { children: Iterable = [], debugNote: string = "" ): CssBox { - return this.boxFactory.createBox( - state, - boxType, - textContent, - children, - debugNote - ) + return new CssBoxImp(boxType, textContent, children, debugNote) } } diff --git a/src/css/layout/layout.ts b/src/css/layout/layout.ts index fadd05f8..f2559286 100644 --- a/src/css/layout/layout.ts +++ b/src/css/layout/layout.ts @@ -12,7 +12,7 @@ import BoxBuilderManager from "./BoxBuilderManager" */ export function layout(document: HtmlNode[], plugins: LayoutPlugin[]): CssBox { const context: LayoutContext = new LayoutContextImp() - const boxFactory = new CssBoxFactory(plugins) + const boxFactory = new CssBoxFactory() const boxBuilder = new BoxBuilderManager(plugins, boxFactory) const manager = new LayoutManagerImp(boxFactory, boxBuilder) // NOTE: we want a single root box so that the if the incoming HTML is a fragment (e.g. a b) it will still figure out it's own formatting context. diff --git a/src/index.ts b/src/index.ts index b962816f..ce66acc7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,6 @@ import { HtmlNode } from "./HtmlNode" import { LayoutContext } from "./LayoutContext" import { DefaultBoxBuilderFuncs } from "./css/layout/DefaultBoxBuilderFuncs" import BlockquotePlugin from "./css/layout/BlockquotePlugin" -import { CssBoxFactoryFunc } from "./css/layout/CssBoxFactory" import { LayoutManager } from "./LayoutManager" export { LayoutContext } from "./LayoutContext" export { HtmlNode } from "./HtmlNode" @@ -42,6 +41,7 @@ const defaultPlugins: LayoutPlugin[] = [ { elementName: "br", layout: DefaultBoxBuilderFuncs.br }, { elementName: "code", layout: DefaultBoxBuilderFuncs.code }, { elementName: "del", layout: DefaultBoxBuilderFuncs.emphasisThunk("~") }, + { elementName: "div", layout: DefaultBoxBuilderFuncs.blockThunk("div") }, { elementName: "li", layout: DefaultBoxBuilderFuncs.listItem }, { elementName: "ol", layout: DefaultBoxBuilderFuncs.list }, { elementName: "ul", layout: DefaultBoxBuilderFuncs.list }, @@ -56,6 +56,7 @@ const defaultPlugins: LayoutPlugin[] = [ { elementName: "hr", layout: DefaultBoxBuilderFuncs.hr }, { elementName: "i", layout: DefaultBoxBuilderFuncs.emphasisThunk("*") }, { elementName: "em", layout: DefaultBoxBuilderFuncs.emphasisThunk("*") }, + { elementName: "p", layout: DefaultBoxBuilderFuncs.blockThunk("p") }, { elementName: "pre", layout: DefaultBoxBuilderFuncs.pre }, { elementName: "s", layout: DefaultBoxBuilderFuncs.emphasisThunk("~") }, { elementName: "strike", layout: DefaultBoxBuilderFuncs.emphasisThunk("~") }, @@ -130,13 +131,6 @@ export interface LayoutGenerator { ): CssBox | null } -/** - * A function to map a @see CssBox to another @see CssBox. - */ -export interface LayoutTransformer { - (context: LayoutContext, boxFactory: CssBoxFactoryFunc, box: CssBox): CssBox -} - export interface LayoutPlugin { /** * Specifies the name of the HTML element that this plugin renders markdown for. @@ -147,11 +141,6 @@ export interface LayoutPlugin { * This is the core of the implementation that will be called for each instance of the HTML element that this plugin is registered for. */ layout: LayoutGenerator - /** - * In some rare cases the plugin will need to manipulate the boxes created by other @see LayoutPlugin objects (especially when those boxes are for child HTML elements that the plugin is interested in). - * If specified, this function will be called for every box created during layout. - */ - transform?: LayoutTransformer } /* eslint-disable no-unused-vars */ @@ -169,5 +158,10 @@ export interface CssBox { type: BoxType textContent: string readonly debugNote: string + /** + * Returns true if this box establishes new block formatting contexts for it's contents as explained in [9.4.1 Block formatting contexts](https://www.w3.org/TR/CSS22/visuren.html#normal-flow). + */ + doesEstablishBlockFormattingContext: boolean addChild(box: CssBox): void + prependChild(box: CssBox): void } diff --git a/test-data/snapshots/html-to-mrkdwn/blockquote-nested-blocks.mrkdwn b/test-data/snapshots/html-to-mrkdwn/blockquote-nested-blocks.mrkdwn new file mode 100644 index 00000000..4b69ee75 --- /dev/null +++ b/test-data/snapshots/html-to-mrkdwn/blockquote-nested-blocks.mrkdwn @@ -0,0 +1,9 @@ +
+
+

foo

+

bar

+
+
+==== +> foo +> bar \ No newline at end of file diff --git a/test-data/snapshots/html-to-mrkdwn/blockquote-nested-twice.mrkdwn b/test-data/snapshots/html-to-mrkdwn/blockquote-nested-twice.mrkdwn new file mode 100644 index 00000000..d200a27e --- /dev/null +++ b/test-data/snapshots/html-to-mrkdwn/blockquote-nested-twice.mrkdwn @@ -0,0 +1,15 @@ +
+
+

foo

+

bar

+
+
+

doe

+

rae

+
+
+==== +> foo +> bar +> doe +> rae \ No newline at end of file diff --git a/test-data/snapshots/snapshots.spec.ts b/test-data/snapshots/snapshots.spec.ts index bc4b89d4..f9e6edfb 100644 --- a/test-data/snapshots/snapshots.spec.ts +++ b/test-data/snapshots/snapshots.spec.ts @@ -22,7 +22,8 @@ function getAllSnapshots(dir: string = __dirname): string[] { return snapshotPaths } -const table = getAllSnapshots() //.filter(s => s.endsWith("blockquote-paragraph.mrkdwn")) +const table = getAllSnapshots() +//const table = getAllSnapshots().filter(s => s.endsWith("blockquote-heading-and-paragraph.mrkdwn")) describe("snapshots", () => { // NOTE: many of the tests were originally from https://github.com/integrations/html-to-mrkdwn/tree/master/test/fixtures, but they were crazy wrong (like headings wrapped in * instead of #). So they're fixed herein. diff --git a/tests/integration/extending-blockbuilders.spec.ts b/tests/integration/extending-blockbuilders.spec.ts index 4ddf5c7a..cb5b1236 100644 --- a/tests/integration/extending-blockbuilders.spec.ts +++ b/tests/integration/extending-blockbuilders.spec.ts @@ -7,7 +7,6 @@ import { BoxType } from "../../src" import { LayoutManager } from "../../src/LayoutManager" -import { CssBoxFactoryFunc } from "../../src/css/layout/CssBoxFactory" const customEmphasisLayout: LayoutGenerator = ( context: LayoutContext, @@ -45,38 +44,3 @@ it("should allow rendering new elements with custom BoxBuilder", async () => { }) expect(result.markdown).toEqual("_custom content_") }) - -it("should allow customizing created boxes with transform", async () => { - const result = await AgentMarkdown.render({ - html: "my bold", - layoutPlugins: [ - { - elementName: "customblockquote", - layout: ( - context: LayoutContext, - manager: LayoutManager, - element: HtmlNode - ): CssBox | null => { - return manager.createBox( - context, - BoxType.block, - "", - manager.layout(context, element.children) - ) - }, - transform: ( - context: LayoutContext, - boxFactory: CssBoxFactoryFunc, - box: CssBox - ): CssBox => { - if (box.type === BoxType.block) { - return boxFactory(context, BoxType.block, "> ", [box]) - } else { - return box - } - } - } - ] - }) - expect(result.markdown).toEqual("> **my bold**") -}) From fcef6c928f09f6682be74575ff0d17c92815d9fa Mon Sep 17 00:00:00 2001 From: Scott Willeke Date: Sat, 17 Aug 2019 17:18:59 -0700 Subject: [PATCH 5/9] updates for new linter versions. readme update --- README.md | 2 +- src/css/layout/CssBoxFactory.ts | 2 - src/css/layout/LayoutContextImp.ts | 2 - yarn.lock | 302 ++++++++++++++++++----------- 4 files changed, 189 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 55483155..3efda722 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ yarn (`yarn add agentmarkdown`) or npm (`npm install agentmarkdown`) - Supports nested lists - Supports [implied paragraphs](https://html.spec.whatwg.org/#paragraphs) / [CSS anonymous bock box layout](https://www.w3.org/TR/CSS22/visuren.html#anonymous-block-level) - Can be used client side (in the browser) or server side (with Node.js) -- Extensible to allow extended or customized output? +- Add support for new elements [with plugins](#customize--extend-with-plugins) - Fast? ## CLI Example diff --git a/src/css/layout/CssBoxFactory.ts b/src/css/layout/CssBoxFactory.ts index dd84e98a..8f9d2221 100644 --- a/src/css/layout/CssBoxFactory.ts +++ b/src/css/layout/CssBoxFactory.ts @@ -3,8 +3,6 @@ import { CssBoxImp } from "../CssBoxImp" import { LayoutContext } from "../../LayoutContext" export class CssBoxFactory { - public constructor() {} - public createBox( state: LayoutContext, boxType: BoxType, diff --git a/src/css/layout/LayoutContextImp.ts b/src/css/layout/LayoutContextImp.ts index 78b3df62..2dbf9d31 100644 --- a/src/css/layout/LayoutContextImp.ts +++ b/src/css/layout/LayoutContextImp.ts @@ -6,8 +6,6 @@ import { LayoutContext } from "../.." export class LayoutContextImp implements LayoutContext { private readonly state: Map = new Map() // eslint-disable-line @typescript-eslint/no-explicit-any - public constructor() {} - public getStateStack(stackName: string): TValue[] { let stack = this.state.get(stackName) as TValue[] if (!stack) { diff --git a/yarn.lock b/yarn.lock index 96655f36..7bbe7b5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -139,6 +139,35 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@commitlint/execute-rule@^8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@commitlint/execute-rule/-/execute-rule-8.1.0.tgz#e8386bd0836b3dcdd41ebb9d5904bbeb447e4715" + integrity sha512-+vpH3RFuO6ypuCqhP2rSqTjFTQ7ClzXtUvXphpROv9v9+7zH4L+Ex+wZLVkL8Xj2cxefSLn/5Kcqa9XyJTn3kg== + +"@commitlint/load@>6.1.1": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@commitlint/load/-/load-8.1.0.tgz#63b72ae5bb9152b8fa5b17c5428053032a9a49c8" + integrity sha512-ra02Dvmd7Gp1+uFLzTY3yGOpHjPzl5T9wYg/xrtPJNiOWXvQ0Mw7THw+ucd1M5iLUWjvdavv2N87YDRc428wHg== + dependencies: + "@commitlint/execute-rule" "^8.1.0" + "@commitlint/resolve-extends" "^8.1.0" + babel-runtime "^6.23.0" + chalk "2.4.2" + cosmiconfig "^5.2.0" + lodash "4.17.14" + resolve-from "^5.0.0" + +"@commitlint/resolve-extends@^8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@commitlint/resolve-extends/-/resolve-extends-8.1.0.tgz#ed67f2ee484160ac8e0078bae52f172625157472" + integrity sha512-r/y+CeKW72Oa9BUctS1+I/MFCDiI3lfhwfQ65Tpfn6eZ4CuBYKzrCRi++GTHeAFKE3y8q1epJq5Rl/1GBejtBw== + dependencies: + "@types/node" "^12.0.2" + import-fresh "^3.0.0" + lodash "4.17.14" + resolve-from "^5.0.0" + resolve-global "^1.0.0" + "@jest/console@^24.7.1": version "24.7.1" resolved "https://registry.yarnpkg.com/@jest/console/-/console-24.7.1.tgz#32a9e42535a97aedfe037e725bd67e954b459545" @@ -536,6 +565,11 @@ dependencies: "@types/jest-diff" "*" +"@types/json-schema@^7.0.3": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636" + integrity sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A== + "@types/minimatch@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" @@ -546,6 +580,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.0.10.tgz#51babf9c7deadd5343620055fc8aff7995c8b031" integrity sha512-LcsGbPomWsad6wmMNv7nBLw7YYYyfdYcz6xryKYQhx89c3XXan+8Q6AJ43G5XDIaklaVkK3mE4fCb0SBvMiPSQ== +"@types/node@^12.0.2": + version "12.7.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.7.2.tgz#c4e63af5e8823ce9cc3f0b34f7b998c2171f0c44" + integrity sha512-dyYO+f6ihZEtNPDcWNR1fkoTDf3zAK3lAABDze3mz6POyIercH0lEUawUFXlG8xaQZmm1yEBON/4TsYv/laDYg== + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -566,42 +605,43 @@ resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-12.0.12.tgz#45dd1d0638e8c8f153e87d296907659296873916" integrity sha512-SOhuU4wNBxhhTHxYaiG5NY4HBhDIDnJF60GU+2LqHAdKKer86//e4yg69aENCtQ04n0ovz+tq2YPME5t5yp4pw== -"@typescript-eslint/eslint-plugin@^1.12.0": - version "1.12.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-1.12.0.tgz#96b4e08b5f998a198b8414508b1a289f9e8c549a" - integrity sha512-J/ZTZF+pLNqjXBGNfq5fahsoJ4vJOkYbitWPavA05IrZ7BXUaf4XWlhUB/ic1lpOGTRpLWF+PLAePjiHp6dz8g== +"@typescript-eslint/eslint-plugin@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.0.0.tgz#609a5d7b00ce21a6f94d7ef282eba9da57ca1e42" + integrity sha512-Mo45nxTTELODdl7CgpZKJISvLb+Fu64OOO2ZFc2x8sYSnUpFrBUW3H+H/ZGYmEkfnL6VkdtOSxgdt+Av79j0sA== dependencies: - "@typescript-eslint/experimental-utils" "1.12.0" - eslint-utils "^1.3.1" + "@typescript-eslint/experimental-utils" "2.0.0" + eslint-utils "^1.4.0" functional-red-black-tree "^1.0.1" regexpp "^2.0.1" - tsutils "^3.7.0" + tsutils "^3.14.0" -"@typescript-eslint/experimental-utils@1.12.0": - version "1.12.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-1.12.0.tgz#98417ee2e0c6fe8d1e50d934a6535d9c0f4277b6" - integrity sha512-s0soOTMJloytr9GbPteMLNiO2HvJ+qgQkRNplABXiVw6vq7uQRvidkby64Gqt/nA7pys74HksHwRULaB/QRVyw== +"@typescript-eslint/experimental-utils@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.0.0.tgz#f3d298bb411357f35c4184e24280b256b6321949" + integrity sha512-XGJG6GNBXIEx/mN4eTRypN/EUmsd0VhVGQ1AG+WTgdvjHl0G8vHhVBHrd/5oI6RRYBRnedNymSYWW1HAdivtmg== dependencies: - "@typescript-eslint/typescript-estree" "1.12.0" + "@types/json-schema" "^7.0.3" + "@typescript-eslint/typescript-estree" "2.0.0" eslint-scope "^4.0.0" -"@typescript-eslint/parser@^1.12.0": - version "1.12.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-1.12.0.tgz#9965895ec4745578185965d63f21510f93a3f35a" - integrity sha512-0uzbaa9ZLCA5yMWJywnJJ7YVENKGWVUhJDV5UrMoldC5HoI54W5kkdPhTfmtFKpPFp93MIwmJj0/61ztvmz5Dw== +"@typescript-eslint/parser@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.0.0.tgz#4273bb19d03489daf8372cdaccbc8042e098178f" + integrity sha512-ibyMBMr0383ZKserIsp67+WnNVoM402HKkxqXGlxEZsXtnGGurbnY90pBO3e0nBUM7chEEOcxUhgw9aPq7fEBA== dependencies: "@types/eslint-visitor-keys" "^1.0.0" - "@typescript-eslint/experimental-utils" "1.12.0" - "@typescript-eslint/typescript-estree" "1.12.0" + "@typescript-eslint/experimental-utils" "2.0.0" + "@typescript-eslint/typescript-estree" "2.0.0" eslint-visitor-keys "^1.0.0" -"@typescript-eslint/typescript-estree@1.12.0": - version "1.12.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-1.12.0.tgz#d8dd0a7cffb5e3c0c3e98714042d83e316dfc9a9" - integrity sha512-nwN6yy//XcVhFs0ZyU+teJHB8tbCm7AIA8mu6E2r5hu6MajwYBY3Uwop7+rPZWUN/IUOHpL8C+iUPMDVYUU3og== +"@typescript-eslint/typescript-estree@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.0.0.tgz#c9f6c0efd1b11475540d6a55dc973cc5b9a67e77" + integrity sha512-NXbmzA3vWrSgavymlzMWNecgNOuiMMp62MO3kI7awZRLRcsA1QrYWo6q08m++uuAGVbXH/prZi2y1AWuhSu63w== dependencies: lodash.unescape "4.0.1" - semver "5.5.0" + semver "^6.2.0" JSONStream@^1.0.4, JSONStream@^1.3.4: version "1.3.5" @@ -947,6 +987,14 @@ babel-preset-jest@^24.6.0: "@babel/plugin-syntax-object-rest-spread" "^7.0.0" babel-plugin-jest-hoist "^24.6.0" +babel-runtime@^6.23.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" + integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= + dependencies: + core-js "^2.4.0" + regenerator-runtime "^0.11.0" + balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" @@ -1159,10 +1207,10 @@ cache-base@^1.0.1: union-value "^1.0.0" unset-value "^1.0.0" -cachedir@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.1.0.tgz#b448c32b44cd9c0cd6ce4c419fa5b3c112c02191" - integrity sha512-xGBpPqoBvn3unBW7oxgb8aJn42K0m9m1/wyjmazah10Fq7bROGG3kRAE6OIyr3U3PIJUqGuebhCEdMk9OKJG0A== +cachedir@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.2.0.tgz#19afa4305e05d79e417566882e0c8f960f62ff0e" + integrity sha512-VvxA0xhNqIIfg0V9AmJkDg91DaJwryutH5rVEZAhcNi4iJFj9f+QxmAjgK1LT9I8OgToX27fypX6/MeCXVbBjQ== call-limit@~1.1.0: version "1.1.1" @@ -1237,7 +1285,7 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= -chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.2, chalk@^2.4.1, chalk@^2.4.2: +chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.2, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -1422,26 +1470,26 @@ commander@~2.20.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422" integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ== -commitizen@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/commitizen/-/commitizen-3.1.1.tgz#0135c8c68df52ce348d718f79b23eb03b8713918" - integrity sha512-n5pnG8sNM5a3dS3Kkh3rYr+hFdPWZlqV6pfz6KGLmWV/gsIiTqAwhTgFKkcF/paKUpfIMp0x4YZlD0xLBNTW9g== +commitizen@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/commitizen/-/commitizen-4.0.3.tgz#c19a4213257d0525b85139e2f36db7cc3b4f6dae" + integrity sha512-lxu0F/Iq4dudoFeIl5pY3h3CQJzkmQuh3ygnaOvqhAD8Wu2pYBI17ofqSuPHNsBTEOh1r1AVa9kR4Hp0FAHKcQ== dependencies: - cachedir "2.1.0" - cz-conventional-changelog "2.1.0" + cachedir "2.2.0" + cz-conventional-changelog "3.0.1" dedent "0.7.0" - detect-indent "^5.0.0" + detect-indent "6.0.0" find-node-modules "2.0.0" find-root "1.1.0" - fs-extra "^7.0.0" - glob "7.1.3" - inquirer "6.2.0" + fs-extra "8.1.0" + glob "7.1.4" + inquirer "6.5.0" is-utf8 "^0.2.1" - lodash "4.17.11" + lodash "4.17.15" minimist "1.2.0" shelljs "0.7.6" - strip-bom "3.0.0" - strip-json-comments "2.0.1" + strip-bom "4.0.0" + strip-json-comments "3.0.1" compare-func@^1.3.1: version "1.3.2" @@ -1570,12 +1618,17 @@ copy-descriptor@^0.1.0: resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= +core-js@^2.4.0: + version "2.6.9" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2" + integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A== + core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= -cosmiconfig@^5.0.1: +cosmiconfig@^5.0.1, cosmiconfig@^5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" integrity sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA== @@ -1653,16 +1706,34 @@ cyclist@~0.2.2: resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" integrity sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA= -cz-conventional-changelog@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cz-conventional-changelog/-/cz-conventional-changelog-2.1.0.tgz#2f4bc7390e3244e4df293e6ba351e4c740a7c764" - integrity sha1-L0vHOQ4yROTfKT5ro1Hkx0Cnx2Q= +cz-conventional-changelog@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/cz-conventional-changelog/-/cz-conventional-changelog-3.0.1.tgz#b1f207ae050355e7ada65aad5c52e9de3d0c8e5b" + integrity sha512-7KASIwB8/ClEyCRvQrCPbN7WkQnUSjSSVNyPM+gDJ0jskLi8h8N2hrdpyeCk7fIqKMRzziqVSOBTB8yyLTMHGQ== + dependencies: + chalk "^2.4.1" + conventional-commit-types "^2.0.0" + lodash.map "^4.5.1" + longest "^2.0.1" + right-pad "^1.0.1" + word-wrap "^1.0.3" + optionalDependencies: + "@commitlint/load" ">6.1.1" + +cz-conventional-changelog@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/cz-conventional-changelog/-/cz-conventional-changelog-3.0.2.tgz#f6b9a406177ab07f9a3a087e06103a045b376260" + integrity sha512-MPxERbtQyVp0nnpCBiwzKGKmMBSswmCV3Jpef3Axqd5f3c/SOc6VFiSUlclOyZXBn3Xtf4snzt4O15hBTRb2gA== dependencies: + chalk "^2.4.1" + commitizen "^4.0.3" conventional-commit-types "^2.0.0" lodash.map "^4.5.1" - longest "^1.0.1" + longest "^2.0.1" right-pad "^1.0.1" word-wrap "^1.0.3" + optionalDependencies: + "@commitlint/load" ">6.1.1" dashdash@^1.12.0: version "1.14.1" @@ -1812,7 +1883,12 @@ detect-file@^1.0.0: resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7" integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc= -detect-indent@^5.0.0, detect-indent@~5.0.0: +detect-indent@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd" + integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA== + +detect-indent@~5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d" integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50= @@ -2069,6 +2145,13 @@ eslint-utils@^1.3.1: resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.3.1.tgz#9a851ba89ee7c460346f97cf8939c7298827e512" integrity sha512-Z7YjnIldX+2XMcjr7ZkgEsOj/bREONV60qYeB/bjMAqqqZ4zxKyWX+BOUkdmRmA9riiIPVvo5x86m5elviOk0Q== +eslint-utils@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.0.tgz#e2c3c8dba768425f897cf0f9e51fe2e241485d4c" + integrity sha512-7ehnzPaP5IIEh1r1tkjuIrxqhNkzUJa9z3R92tLJdZIVdWaczEhr3EbhGtsMrVxi1KeR8qA7Off6SWc5WNQqyQ== + dependencies: + eslint-visitor-keys "^1.0.0" + eslint-visitor-keys@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d" @@ -2247,7 +2330,7 @@ extend@~3.0.2: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== -external-editor@^3.0.0, external-editor@^3.0.3: +external-editor@^3.0.3: version "3.1.0" resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== @@ -2479,6 +2562,15 @@ from2@^2.1.0, from2@^2.3.0: inherits "^2.0.1" readable-stream "^2.0.0" +fs-extra@8.1.0, fs-extra@^8.0.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" + fs-extra@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" @@ -2488,15 +2580,6 @@ fs-extra@^7.0.0: jsonfile "^4.0.0" universalify "^0.1.0" -fs-extra@^8.0.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" - integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== - dependencies: - graceful-fs "^4.2.0" - jsonfile "^4.0.0" - universalify "^0.1.0" - fs-minipass@^1.2.5: version "1.2.6" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.6.tgz#2c5cc30ded81282bfe8a0d7c7c1853ddeb102c07" @@ -2657,19 +2740,7 @@ glob-parent@^5.0.0: dependencies: is-glob "^4.0.1" -glob@7.1.3: - version "7.1.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" - integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: +glob@7.1.4, glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: version "7.1.4" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A== @@ -2681,7 +2752,7 @@ glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: once "^1.3.0" path-is-absolute "^1.0.0" -global-dirs@^0.1.0: +global-dirs@^0.1.0, global-dirs@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445" integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU= @@ -3038,26 +3109,7 @@ init-package-json@^1.10.3: validate-npm-package-license "^3.0.1" validate-npm-package-name "^3.0.0" -inquirer@6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.0.tgz#51adcd776f661369dc1e894859c2560a224abdd8" - integrity sha512-QIEQG4YyQ2UYZGDC4srMZ7BjHOmNk1lR2JQj5UknBapklm6WHA+VVH7N+sUdX3A7NeCfGF8o4X1S3Ao7nAcIeg== - dependencies: - ansi-escapes "^3.0.0" - chalk "^2.0.0" - cli-cursor "^2.1.0" - cli-width "^2.0.0" - external-editor "^3.0.0" - figures "^2.0.0" - lodash "^4.17.10" - mute-stream "0.0.7" - run-async "^2.2.0" - rxjs "^6.1.0" - string-width "^2.1.0" - strip-ansi "^4.0.0" - through "^2.3.6" - -inquirer@^6.2.2: +inquirer@6.5.0, inquirer@^6.2.2: version "6.5.0" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.5.0.tgz#2303317efc9a4ea7ec2e2df6f86569b734accf42" integrity sha512-scfHejeG/lVZSpvCXpsB4j/wQNPM5JC8kiElOI0OUTwmc1RTpXr4H32/HOlQHcZiYl2z2VElwuCVDRG8vFmbnA== @@ -4210,25 +4262,25 @@ lodash.without@~4.4.0: resolved "https://registry.yarnpkg.com/lodash.without/-/lodash.without-4.4.0.tgz#3cd4574a00b67bae373a94b748772640507b7aac" integrity sha1-PNRXSgC2e643OpS3SHcmQFB7eqw= -lodash@4.17.11: - version "4.17.11" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" - integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== - -lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.4, lodash@^4.2.1: +lodash@4.17.14, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.4, lodash@^4.2.1: version "4.17.14" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba" integrity sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw== +lodash@4.17.15: + version "4.17.15" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" + integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== + log-driver@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.7.tgz#63b95021f0702fedfa2c9bb0a24e7797d71871d8" integrity sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg== -longest@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" - integrity sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc= +longest@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/longest/-/longest-2.0.1.tgz#781e183296aa94f6d4d916dc335d0d17aefa23f8" + integrity sha1-eB4YMpaqlPbU2RbcM10NF676I/g= loose-envify@^1.0.0: version "1.4.0" @@ -5867,6 +5919,11 @@ redeyed@~2.1.0: dependencies: esprima "~4.0.0" +regenerator-runtime@^0.11.0: + version "0.11.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" + integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== + regex-not@^1.0.0, regex-not@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" @@ -5997,6 +6054,13 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== +resolve-global@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-global/-/resolve-global-1.0.0.tgz#a2a79df4af2ca3f49bf77ef9ddacd322dad19255" + integrity sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw== + dependencies: + global-dirs "^0.1.1" + resolve-url@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" @@ -6078,7 +6142,7 @@ run-queue@^1.0.0, run-queue@^1.0.3: dependencies: aproba "^1.1.1" -rxjs@^6.1.0, rxjs@^6.4.0: +rxjs@^6.4.0: version "6.5.2" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.2.tgz#2e35ce815cd46d84d02a209fb4e5921e051dbec7" integrity sha512-HUb7j3kvb7p7eCUHE3FqjoDsC1xfZQ4AHFWfTKSpZ+sAhhz5X1WX0ZuUqWbzB2QhSLp3DoLUG+hMdEDKqWo2Zg== @@ -6178,16 +6242,16 @@ semver-regex@^2.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b" integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA== -semver@5.5.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.0.tgz#dc4bbc7a6ca9d916dee5d43516f0092b58f7b8ab" - integrity sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA== - semver@^6.0.0: version "6.1.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.1.1.tgz#53f53da9b30b2103cd4f15eab3a18ecbcb210c9b" integrity sha512-rWYq2e5iYW+fFe/oPPtYJxYgjBm8sC4rmoGdUOgBB7VnwKt6HrL793l2voH1UlsyYZpJ4g0wfjnTEO1s1NP2eQ== +semver@^6.2.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + semver@~5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" @@ -6648,7 +6712,12 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0: dependencies: ansi-regex "^4.1.0" -strip-bom@3.0.0, strip-bom@^3.0.0: +strip-bom@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" + integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== + +strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= @@ -6663,7 +6732,12 @@ strip-indent@^2.0.0: resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68" integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g= -strip-json-comments@2.0.1, strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: +strip-json-comments@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7" + integrity sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw== + +strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= @@ -6905,10 +6979,10 @@ tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== -tsutils@^3.7.0: - version "3.14.0" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.14.0.tgz#bf8d5a7bae5369331fa0f2b0a5a10bd7f7396c77" - integrity sha512-SmzGbB0l+8I0QwsPgjooFRaRvHLBLNYM8SeQ0k6rtNDru5sCGeLJcZdwilNndN+GysuFjF5EIYgN8GfFG6UeUw== +tsutils@^3.14.0: + version "3.17.1" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759" + integrity sha512-kzeQ5B8H3w60nFY2g8cJIuH7JDpsALXySGtwGJ0p2LSjLgay3NdIpqq5SoOBe46bKDW2iq25irHCr8wjomUS2g== dependencies: tslib "^1.8.1" From d1fd1bbcb8e655ad2eccb2b1373b1dbb270c1edb Mon Sep 17 00:00:00 2001 From: Scott Willeke Date: Sat, 17 Aug 2019 17:34:57 -0700 Subject: [PATCH 6/9] chore: es-lint no-console --- .eslintrc.yaml | 2 ++ src/cli/agentmarkdown.ts | 2 ++ src/css/layout/BlockquotePlugin.ts | 2 ++ src/css/layout/BoxBuilderManager.ts | 1 - src/css/layout/ListState.ts | 2 ++ tests/support/index.ts | 1 + 6 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.eslintrc.yaml b/.eslintrc.yaml index a61bf6d9..e971ab3a 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -27,6 +27,8 @@ rules: - ignore: - 0 - 1 + no-console: + - warn # References for TS rules: https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#supported-rules "@typescript-eslint/explicit-function-return-type": - error diff --git a/src/cli/agentmarkdown.ts b/src/cli/agentmarkdown.ts index bd9542d7..c3ea1069 100644 --- a/src/cli/agentmarkdown.ts +++ b/src/cli/agentmarkdown.ts @@ -42,6 +42,7 @@ export class Cli { try { markdown = await AgentMarkdown.produce(html) } catch (err) { + // eslint-disable-next-line no-console console.error("Error converting HTML to markdown.") process.exit(EXIT_ERR_CONVERTING) return false @@ -50,6 +51,7 @@ export class Cli { process.stdout.write(markdown) process.stdout.end() } catch (err) { + // eslint-disable-next-line no-console console.error("Error writing to stdout.") process.exit(EXIT_ERR_STDOUT) return false diff --git a/src/css/layout/BlockquotePlugin.ts b/src/css/layout/BlockquotePlugin.ts index 273521b1..c8fbdc3b 100644 --- a/src/css/layout/BlockquotePlugin.ts +++ b/src/css/layout/BlockquotePlugin.ts @@ -23,6 +23,8 @@ class BlockquotePlugin implements LayoutPlugin { needChecked.push(box) } else { // then this guy and all of his siblings need prefixes; they are the deepest nodes in the tree that are inlines + // this should /really/ never happen unless there is a bug here so leaving the console + // eslint-disable-next-line no-console console.assert( !parent.doesEstablishBlockFormattingContext, `Expected the parent '${parent.debugNote}' to be establishing a new block formatting context` diff --git a/src/css/layout/BoxBuilderManager.ts b/src/css/layout/BoxBuilderManager.ts index 473b781d..e1b72712 100644 --- a/src/css/layout/BoxBuilderManager.ts +++ b/src/css/layout/BoxBuilderManager.ts @@ -75,7 +75,6 @@ export default class BoxBuilderManager { } else if (element.type === "comment") { // deliberately ignored } else { - console.error(`Ignoring element with type ${element.type}`) box = null } return box diff --git a/src/css/layout/ListState.ts b/src/css/layout/ListState.ts index 6e5e9364..debd0273 100644 --- a/src/css/layout/ListState.ts +++ b/src/css/layout/ListState.ts @@ -31,12 +31,14 @@ export class ListState { public newListItem(): void { const stack = this.context.getStateStack(ListState.ItemCountKey) + // eslint-disable-next-line no-console console.assert(stack.length > 0, "expected ItemCount state") stack[stack.length - 1] = stack[stack.length - 1] + 1 } public getListItemCount(): number { const stack = this.context.getStateStack(ListState.ItemCountKey) + // eslint-disable-next-line no-console console.assert(stack.length > 0, "expected ItemCount state") return stack[stack.length - 1] } diff --git a/tests/support/index.ts b/tests/support/index.ts index 09b1e345..1afe2c5b 100644 --- a/tests/support/index.ts +++ b/tests/support/index.ts @@ -5,6 +5,7 @@ export async function toMarkdown(html: string): Promise { const out = await AgentMarkdown.produce(html) if (process.env.DEBUG) { + /* eslint-disable no-console */ console.log("----- HTML -----") console.log(html) console.log("----- MD -----") From 79a5d81e8723291ed64c4ada6212ccd0b8f89c94 Mon Sep 17 00:00:00 2001 From: Scott Willeke Date: Sat, 17 Aug 2019 22:12:54 -0700 Subject: [PATCH 7/9] docs(README): updated examples in Customize & Extend with Plugins --- README.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3efda722..19ff4a02 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ yarn start ## Customize & Extend with Plugins -To customize how the markdown is generated or add support for new elements, implement the `LayoutPlugin` interface to handle a particular HTML element. The `LayoutPlugin` is defined as follows: +To customize how the markdown is generated or add support for new elements, implement the `LayoutPlugin` interface to handle a particular HTML element. The `LayoutPlugin` interface is defined as follows: ```TypeScript export interface LayoutPlugin { @@ -95,7 +95,7 @@ export interface LayoutPlugin { } ``` -The `LayoutGenerator` is a single function that performs a [CSS2 box generation layout algorithm](https://www.w3.org/TR/CSS22/visuren.html#box-gen) on the an HTML element. Essentially it creates a zero or more boxes for the given element that AgentMarkdown will later render to text. The function definition is the following: +The `LayoutGenerator` is a single function that performs a [CSS2 box generation layout algorithm](https://www.w3.org/TR/CSS22/visuren.html#box-gen) on the an HTML element. Essentially it creates zero or more boxes for the given element that AgentMarkdown will render to text. A box can contain text content and/or other boxes, and eacn box has a type of `inline` or `block`. Inline blocks are laid out horizontally. Block boxes are laid out vertically (i.e. they have new line characters before and after their contents). The `LayoutGenerator` function definition is as follows: ```TypeScript export interface LayoutGenerator { @@ -107,7 +107,7 @@ export interface LayoutGenerator { } ``` -An example of how the HTML `` element could be implemented as a plugin is below: +An example of how the HTML `` element could be implemented as a plugin like the following: ```TypeScript class BoldPlugin { @@ -118,15 +118,18 @@ class BoldPlugin { manager: LayoutManager, element: HtmlNode ): CssBox | null => { + // let the manager use other plugins to layout any child elements: const kids = manager.layout(context, element.children) - kids.unshift(manager.createBox(context, BoxType.inline, "*")) - kids.push(manager.createBox(context, BoxType.inline, "*")) + // wrap the child elements in the markdown ** syntax for bold/strong: + kids.unshift(manager.createBox(context, BoxType.inline, "**")) + kids.push(manager.createBox(context, BoxType.inline, "**")) + // return a new box containing everything: return manager.createBox(context, BoxType.inline, "", kids) } } ``` -To initialize AgentMarkdown with plugins pass them in as an option as follows: +To initialize AgentMarkdown with plugins pass them in as an array value for the `layoutPlugins` option as follows. To customize the rendering an element you can just specify a plugin for the elementName and your plugin will override the built-in plugin. ```TypeScript const result = await AgentMarkdown.render({ From c5ded97eb1850a3c6332e8766a8c9abc241d2120 Mon Sep 17 00:00:00 2001 From: Scott Willeke Date: Sat, 17 Aug 2019 22:54:30 -0700 Subject: [PATCH 8/9] chore: eradicate BoxBuilder from naming --- README.md | 1 + docs/todo.md | 8 +-- ...derFuncs.ts => DefaultLayoutGenerators.ts} | 8 +-- ...erManager.ts => LayoutGeneratorManager.ts} | 50 +++++++++-------- src/css/layout/LayoutManagerImp.ts | 6 +- src/css/layout/layout.ts | 6 +- src/index.ts | 55 ++++++++++--------- ...ders.spec.ts => extending-plugins.spec.ts} | 4 +- 8 files changed, 73 insertions(+), 65 deletions(-) rename src/css/layout/{DefaultBoxBuilderFuncs.ts => DefaultLayoutGenerators.ts} (97%) rename src/css/layout/{BoxBuilderManager.ts => LayoutGeneratorManager.ts} (76%) rename tests/integration/{extending-blockbuilders.spec.ts => extending-plugins.spec.ts} (86%) diff --git a/README.md b/README.md index 19ff4a02..9e96be40 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,7 @@ see [/docs/todo.md](docs/todo.md) # Alternatives - http://domchristie.github.io/turndown/ +- https://github.com/rehypejs/rehype-remark ## License ๐Ÿ“ diff --git a/docs/todo.md b/docs/todo.md index 0b661aaf..a83863ae 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -10,8 +10,9 @@ - multi-paragraph list items - Feat: Extensibility - - Allow customizing the conversion. Should the caller be able to provide custom `BoxBuilder`? - - We're using uinst (inadvertantly) so maybe allow different uinst frontends and extensibility in uinst as well as the CssBox BoxBuilder backend: https://unified.js.org/create-a-plugin.html + + Allow customizing the conversion. Should the caller be able to provide custom `LayoutGenerator`? + + We're *nearly* using [uinst](https://github.com/syntax-tree/unist) inadvertently so maybe allow different uinst frontends and extensibility in uinst as well as the CssBox LayoutGenerator backend: https://unified.js.org/create-a-plugin.html + + Okay, HtmlNode is not quite close enough to uninst. We focus more on node hierarchy and unist does that plus on position within text. Interesting bot not trivial amount of work and for potentially little benefit. - support elements (WITH TESTS): + links @@ -36,5 +37,4 @@ - benchmarks (some other lib had benchmarks) # code smells # -- the css layout code started off pure based on the css specification only generating three general types of boxes: inline, block, and list-item - as defined in the CSS spec. The only determination on the type of box generated was a lookup to the CSS-defined `display` value for the corresponding HTML element name. Interestingly, this very simplistic layout algorithm actually produced pretty descent markdown from almost any HTML! It didn't have inline formatting but the general layout, line breaking, and list generation was perfect as far as I recall. -All that smelled fine, but it started growing beyond pure CSS and introducing "special" non-standard boxes to handle markdown-specific formatting for certain elements (e.g. headings, emphasis, link, br, hr, etc.). This is perfectly fine as long as the only target is markdown but does start coupling the otherwise pure CSS box-generation/layout algorithm to markdown. It would be better to maybe make that CSS layout/box-generation code slightly extensible by allowing the caller to pass in a map of `BoxBuilder`s that could customize the box generation. +- the css layout code started off pure based on the css specification only generating three general types of boxes: inline, block, and list-item - as defined in the CSS spec. The only determination on the type of box generated was a lookup to the CSS-defined `display` value for the corresponding HTML element name. Interestingly, this very simplistic layout algorithm actually produced pretty descent markdown from almost any HTML! It didn't have inline formatting but the general layout, line breaking, and list generation was perfect as far as I recall. All that smelled fine, but it started growing beyond pure CSS and introducing "special" non-standard boxes to handle markdown-specific formatting for certain elements (e.g. headings, emphasis, link, br, hr, etc.). This is perfectly fine as long as the only target is markdown but does start coupling the otherwise pure CSS box-generation/layout algorithm to markdown. It would be better to maybe make that CSS layout/box-generation code slightly extensible by allowing the caller to pass in a map of `LayoutGenerator`s that could customize the box generation. diff --git a/src/css/layout/DefaultBoxBuilderFuncs.ts b/src/css/layout/DefaultLayoutGenerators.ts similarity index 97% rename from src/css/layout/DefaultBoxBuilderFuncs.ts rename to src/css/layout/DefaultLayoutGenerators.ts index 4751d45a..1730668d 100644 --- a/src/css/layout/DefaultBoxBuilderFuncs.ts +++ b/src/css/layout/DefaultLayoutGenerators.ts @@ -6,9 +6,9 @@ import { decodeHtmlEntities } from "../../util" import { StyleState } from "./StyleState" import { LayoutManager } from "../../LayoutManager" -export class DefaultBoxBuilderFuncs { +export class DefaultLayoutGenerators { /** - * A @see BoxBuilder suitable for generic block elements. + * A @see LayoutGenerator suitable for generic block elements. */ public static genericBlock( context: LayoutContext, @@ -38,7 +38,7 @@ export class DefaultBoxBuilderFuncs { } } /** - * A @see BoxBuilder suitable for generic inline elements. + * A @see LayoutGenerator suitable for generic inline elements. */ public static genericInline( context: LayoutContext, @@ -62,7 +62,7 @@ export class DefaultBoxBuilderFuncs { ) } /** - * A @see BoxBuilder suitable for list item elements. + * A @see LayoutGenerator suitable for list item elements. */ public static listItem( context: LayoutContext, diff --git a/src/css/layout/BoxBuilderManager.ts b/src/css/layout/LayoutGeneratorManager.ts similarity index 76% rename from src/css/layout/BoxBuilderManager.ts rename to src/css/layout/LayoutGeneratorManager.ts index e1b72712..9342f99a 100644 --- a/src/css/layout/BoxBuilderManager.ts +++ b/src/css/layout/LayoutGeneratorManager.ts @@ -6,33 +6,35 @@ import { BoxType, LayoutGenerator } from "../.." -import { DefaultBoxBuilderFuncs } from "./DefaultBoxBuilderFuncs" +import { DefaultLayoutGenerators } from "./DefaultLayoutGenerators" import { normalizeWhitespace } from ".." import { StyleState } from "./StyleState" import { CssBoxFactory } from "./CssBoxFactory" import { LayoutManager } from "../../LayoutManager" /** - * Given a list of plugins, manages the list of @see BoxBuilder plugins to build boxes for an element. + * Given a list of plugins, manages the list of @see LayoutGenerator plugins to build boxes for an element. */ -export default class BoxBuilderManager { - private readonly boxBuilderMap: Map +export default class LayoutGeneratorManager { + private readonly layoutGeneratorMap: Map public constructor( plugins: LayoutPlugin[], private readonly boxFactory: CssBoxFactory ) { - this.boxBuilderMap = BoxBuilderManager.createLayoutGeneratorMap(plugins) + this.layoutGeneratorMap = LayoutGeneratorManager.createLayoutGeneratorMap( + plugins + ) } private static createLayoutGeneratorMap( plugins: LayoutPlugin[] ): Map { - const builders = new Map() + const generators = new Map() for (const plugin of plugins) { - builders.set(plugin.elementName, plugin.layout) + generators.set(plugin.elementName, plugin.layout) } - return builders + return generators } /** @@ -40,7 +42,7 @@ export default class BoxBuilderManager { * See https://www.w3.org/TR/CSS22/visuren.html#propdef-display * @param element The element to generate a box for */ - public buildBox( + public generateBox( context: LayoutContext, manager: LayoutManager, element: HtmlNode @@ -62,13 +64,15 @@ export default class BoxBuilderManager { ) } } else if (element.type === "tag") { - const boxBuilderFunc = this.getBoxBuilderForElement(element.name) + const layoutGeneratorFunc = this.getLayoutGeneratorForElement( + element.name + ) try { - box = boxBuilderFunc(context, manager, element) + box = layoutGeneratorFunc(context, manager, element) } catch (e) { throw new Error( - `boxbuilder (${JSON.stringify( - boxBuilderFunc + `LayoutGenerator (${JSON.stringify( + layoutGeneratorFunc )}) error for element ${JSON.stringify(element.name)}: ${e}` ) } @@ -80,38 +84,38 @@ export default class BoxBuilderManager { return box } - public buildBoxes( + public generateBoxes( context: LayoutContext, manager: LayoutManager, elements: HtmlNode[] ): CssBox[] { const boxes = elements ? elements - .map(el => this.buildBox(context, manager, el)) + .map(el => this.generateBox(context, manager, el)) .filter(childBox => childBox !== null) : [] return boxes } /** - * Returns @see BlockBuilder for specified element. + * Returns @see LayoutGenerator for specified element. * @param elementName name/tag of element */ - private getBoxBuilderForElement(elementName: string): LayoutGenerator { - let builder = this.boxBuilderMap.get(elementName) - if (!builder) { + private getLayoutGeneratorForElement(elementName: string): LayoutGenerator { + let generator = this.layoutGeneratorMap.get(elementName) + if (!generator) { const display = getElementDisplay(elementName) if (display === CssDisplayValue.block) { - builder = DefaultBoxBuilderFuncs.genericBlock + generator = DefaultLayoutGenerators.genericBlock } else if (display === CssDisplayValue.inline) { - builder = DefaultBoxBuilderFuncs.genericInline + generator = DefaultLayoutGenerators.genericInline } else if (display === CssDisplayValue.listItem) { - builder = DefaultBoxBuilderFuncs.listItem + generator = DefaultLayoutGenerators.listItem } else { throw new Error("unexpected element and unexpected display") } } - return builder + return generator } } diff --git a/src/css/layout/LayoutManagerImp.ts b/src/css/layout/LayoutManagerImp.ts index 954c7100..ea564b67 100644 --- a/src/css/layout/LayoutManagerImp.ts +++ b/src/css/layout/LayoutManagerImp.ts @@ -1,17 +1,17 @@ import { CssBox, HtmlNode, LayoutContext, BoxType } from "../.." import { CssBoxFactory } from "./CssBoxFactory" -import BoxBuilderManager from "./BoxBuilderManager" +import LayoutGeneratorManager from "./LayoutGeneratorManager" import { LayoutManager } from "../../LayoutManager" import { CssBoxImp } from "../CssBoxImp" export class LayoutManagerImp implements LayoutManager { public constructor( private readonly boxFactory: CssBoxFactory, - private readonly builder: BoxBuilderManager + private readonly generator: LayoutGeneratorManager ) {} public layout(context: LayoutContext, elements: HtmlNode[]): CssBox[] { - return this.builder.buildBoxes(context, this, elements) + return this.generator.generateBoxes(context, this, elements) } public createBox( diff --git a/src/css/layout/layout.ts b/src/css/layout/layout.ts index f2559286..7f3eb408 100644 --- a/src/css/layout/layout.ts +++ b/src/css/layout/layout.ts @@ -4,7 +4,7 @@ import { LayoutContextImp } from "./LayoutContextImp" import { CssBoxImp } from "../CssBoxImp" import { CssBoxFactory } from "./CssBoxFactory" import { LayoutManagerImp } from "./LayoutManagerImp" -import BoxBuilderManager from "./BoxBuilderManager" +import LayoutGeneratorManager from "./LayoutGeneratorManager" /** * Implements the CSS Visual Formatting model's box generation algorithm. It turns HTML elements into a set of CSS Boxes. @@ -13,8 +13,8 @@ import BoxBuilderManager from "./BoxBuilderManager" export function layout(document: HtmlNode[], plugins: LayoutPlugin[]): CssBox { const context: LayoutContext = new LayoutContextImp() const boxFactory = new CssBoxFactory() - const boxBuilder = new BoxBuilderManager(plugins, boxFactory) - const manager = new LayoutManagerImp(boxFactory, boxBuilder) + const LayoutGenerator = new LayoutGeneratorManager(plugins, boxFactory) + const manager = new LayoutManagerImp(boxFactory, LayoutGenerator) // NOTE: we want a single root box so that the if the incoming HTML is a fragment (e.g. a b) it will still figure out it's own formatting context. const body = new CssBoxImp(BoxType.block, "", [], "body") manager.layout(context, document).forEach(box => box && body.addChild(box)) diff --git a/src/index.ts b/src/index.ts index ce66acc7..a9690955 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import { DefaultTextWriter } from "./DefaultTextWriter" import { layout } from "./css" import { HtmlNode } from "./HtmlNode" import { LayoutContext } from "./LayoutContext" -import { DefaultBoxBuilderFuncs } from "./css/layout/DefaultBoxBuilderFuncs" +import { DefaultLayoutGenerators } from "./css/layout/DefaultLayoutGenerators" import BlockquotePlugin from "./css/layout/BlockquotePlugin" import { LayoutManager } from "./LayoutManager" export { LayoutContext } from "./LayoutContext" @@ -17,7 +17,7 @@ export interface RenderOptions { html: string /** * Use to customize the rendering of HTML elements or provide HTML-to-markdown rendering for an element that isn't handled by default. - * A map of "element name" => @see BoxBuilder where the key is the element name and the associated value is a @see BoxBuilder implementations that render markdown for a specified HTML element. + * A map of "element name" => @see LayoutPlugin where the key is the element name and the associated value is a @see LayoutPlugin implementations that render markdown for a specified HTML element. * Any elements in this map that conflict with the default intrinsic implementations will override the default rendering. */ layoutPlugins?: LayoutPlugin[] @@ -36,32 +36,35 @@ export interface ImageReference { } const defaultPlugins: LayoutPlugin[] = [ - { elementName: "a", layout: DefaultBoxBuilderFuncs.link }, - { elementName: "b", layout: DefaultBoxBuilderFuncs.emphasisThunk("**") }, - { elementName: "br", layout: DefaultBoxBuilderFuncs.br }, - { elementName: "code", layout: DefaultBoxBuilderFuncs.code }, - { elementName: "del", layout: DefaultBoxBuilderFuncs.emphasisThunk("~") }, - { elementName: "div", layout: DefaultBoxBuilderFuncs.blockThunk("div") }, - { elementName: "li", layout: DefaultBoxBuilderFuncs.listItem }, - { elementName: "ol", layout: DefaultBoxBuilderFuncs.list }, - { elementName: "ul", layout: DefaultBoxBuilderFuncs.list }, + { elementName: "a", layout: DefaultLayoutGenerators.link }, + { elementName: "b", layout: DefaultLayoutGenerators.emphasisThunk("**") }, + { elementName: "br", layout: DefaultLayoutGenerators.br }, + { elementName: "code", layout: DefaultLayoutGenerators.code }, + { elementName: "del", layout: DefaultLayoutGenerators.emphasisThunk("~") }, + { elementName: "div", layout: DefaultLayoutGenerators.blockThunk("div") }, + { elementName: "li", layout: DefaultLayoutGenerators.listItem }, + { elementName: "ol", layout: DefaultLayoutGenerators.list }, + { elementName: "ul", layout: DefaultLayoutGenerators.list }, /* eslint-disable no-magic-numbers */ - { elementName: "h1", layout: DefaultBoxBuilderFuncs.headingThunk(1) }, - { elementName: "h2", layout: DefaultBoxBuilderFuncs.headingThunk(2) }, - { elementName: "h3", layout: DefaultBoxBuilderFuncs.headingThunk(3) }, - { elementName: "h4", layout: DefaultBoxBuilderFuncs.headingThunk(4) }, - { elementName: "h5", layout: DefaultBoxBuilderFuncs.headingThunk(5) }, - { elementName: "h6", layout: DefaultBoxBuilderFuncs.headingThunk(6) }, + { elementName: "h1", layout: DefaultLayoutGenerators.headingThunk(1) }, + { elementName: "h2", layout: DefaultLayoutGenerators.headingThunk(2) }, + { elementName: "h3", layout: DefaultLayoutGenerators.headingThunk(3) }, + { elementName: "h4", layout: DefaultLayoutGenerators.headingThunk(4) }, + { elementName: "h5", layout: DefaultLayoutGenerators.headingThunk(5) }, + { elementName: "h6", layout: DefaultLayoutGenerators.headingThunk(6) }, /* eslint-enable no-magic-numbers */ - { elementName: "hr", layout: DefaultBoxBuilderFuncs.hr }, - { elementName: "i", layout: DefaultBoxBuilderFuncs.emphasisThunk("*") }, - { elementName: "em", layout: DefaultBoxBuilderFuncs.emphasisThunk("*") }, - { elementName: "p", layout: DefaultBoxBuilderFuncs.blockThunk("p") }, - { elementName: "pre", layout: DefaultBoxBuilderFuncs.pre }, - { elementName: "s", layout: DefaultBoxBuilderFuncs.emphasisThunk("~") }, - { elementName: "strike", layout: DefaultBoxBuilderFuncs.emphasisThunk("~") }, - { elementName: "strong", layout: DefaultBoxBuilderFuncs.emphasisThunk("**") }, - { elementName: "u", layout: DefaultBoxBuilderFuncs.emphasisThunk("_") }, + { elementName: "hr", layout: DefaultLayoutGenerators.hr }, + { elementName: "i", layout: DefaultLayoutGenerators.emphasisThunk("*") }, + { elementName: "em", layout: DefaultLayoutGenerators.emphasisThunk("*") }, + { elementName: "p", layout: DefaultLayoutGenerators.blockThunk("p") }, + { elementName: "pre", layout: DefaultLayoutGenerators.pre }, + { elementName: "s", layout: DefaultLayoutGenerators.emphasisThunk("~") }, + { elementName: "strike", layout: DefaultLayoutGenerators.emphasisThunk("~") }, + { + elementName: "strong", + layout: DefaultLayoutGenerators.emphasisThunk("**") + }, + { elementName: "u", layout: DefaultLayoutGenerators.emphasisThunk("_") }, new BlockquotePlugin() ] diff --git a/tests/integration/extending-blockbuilders.spec.ts b/tests/integration/extending-plugins.spec.ts similarity index 86% rename from tests/integration/extending-blockbuilders.spec.ts rename to tests/integration/extending-plugins.spec.ts index cb5b1236..6bb7d8bf 100644 --- a/tests/integration/extending-blockbuilders.spec.ts +++ b/tests/integration/extending-plugins.spec.ts @@ -19,7 +19,7 @@ const customEmphasisLayout: LayoutGenerator = ( return manager.createBox(context, BoxType.inline, "", kids) } -it("should allow overriding existing elements with custom BoxBuilder", async () => { +it("should allow overriding existing elements with custom LayoutPlugin", async () => { const result = await AgentMarkdown.render({ html: "my bold", layoutPlugins: [ @@ -32,7 +32,7 @@ it("should allow overriding existing elements with custom BoxBuilder", async () expect(result.markdown).toEqual("_my bold_") }) -it("should allow rendering new elements with custom BoxBuilder", async () => { +it("should allow rendering new elements with custom LayoutPlugin", async () => { const result = await AgentMarkdown.render({ html: "custom content", layoutPlugins: [ From d1b5d890d1a6e7d4ca8cd9d87e94a61d772649f9 Mon Sep 17 00:00:00 2001 From: Scott Willeke Date: Sat, 17 Aug 2019 23:51:54 -0700 Subject: [PATCH 9/9] chore: upgrade htmlparser2 --- package.json | 8 +- src/HtmlNode.ts | 2 +- src/css/layout/DefaultLayoutGenerators.ts | 8 +- src/css/layout/LayoutGeneratorManager.ts | 4 +- src/parseHtml.spec.ts | 46 +++++---- src/parseHtml.ts | 2 +- tests/support/MockHtmlNode.ts | 2 +- yarn.lock | 112 +++++++--------------- 8 files changed, 78 insertions(+), 106 deletions(-) diff --git a/package.json b/package.json index 0fa4b419..d3348f11 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,8 @@ "coverage-publish": "cat ./coverage/lcov.info | coveralls" }, "devDependencies": { - "@types/domhandler": "^2.4.1", - "@types/htmlparser2": "^3.10.0", "@types/jest": "^24.0.15", - "@types/node": "^12.0.10", + "@types/node": "^12.7.2", "@typescript-eslint/eslint-plugin": "^2.0.0", "@typescript-eslint/parser": "^2.0.0", "commitizen": "^4.0.3", @@ -55,8 +53,8 @@ "typescript": "^3.5.2" }, "dependencies": { - "domhandler": "^2.4.2", - "htmlparser2": "^3.10.1" + "domhandler": "^3.0.0", + "htmlparser2": "^4.0.0" }, "config": { "commitizen": { diff --git a/src/HtmlNode.ts b/src/HtmlNode.ts index e970d9df..9e10caee 100644 --- a/src/HtmlNode.ts +++ b/src/HtmlNode.ts @@ -13,7 +13,7 @@ export interface HtmlNode { /** * The name of the node when @see type is "tag" */ - name: string + tagName?: string attribs?: AttribsType children?: HtmlNode[] } diff --git a/src/css/layout/DefaultLayoutGenerators.ts b/src/css/layout/DefaultLayoutGenerators.ts index 1730668d..9275edf2 100644 --- a/src/css/layout/DefaultLayoutGenerators.ts +++ b/src/css/layout/DefaultLayoutGenerators.ts @@ -124,8 +124,8 @@ export class DefaultLayoutGenerators { manager: LayoutManager, element: HtmlNode ): CssBox | null { - if (!["ul", "ol"].includes(element.name)) { - throw new Error(`Unexpected list type "${element.name}"`) + if (!["ul", "ol"].includes(element.tagName)) { + throw new Error(`Unexpected list type "${element.tagName}"`) } const listState = new ListState(context) const listBox = manager.createBox( @@ -133,9 +133,9 @@ export class DefaultLayoutGenerators { BoxType.block, "", [], - element.name + element.tagName ) - listState.beginList(element.name as "ul" | "ol") + listState.beginList(element.tagName as "ul" | "ol") const kids = manager.layout(context, element.children) listState.endList() kids.forEach(kid => listBox.addChild(kid)) diff --git a/src/css/layout/LayoutGeneratorManager.ts b/src/css/layout/LayoutGeneratorManager.ts index 9342f99a..e0082748 100644 --- a/src/css/layout/LayoutGeneratorManager.ts +++ b/src/css/layout/LayoutGeneratorManager.ts @@ -65,7 +65,7 @@ export default class LayoutGeneratorManager { } } else if (element.type === "tag") { const layoutGeneratorFunc = this.getLayoutGeneratorForElement( - element.name + element.tagName ) try { box = layoutGeneratorFunc(context, manager, element) @@ -73,7 +73,7 @@ export default class LayoutGeneratorManager { throw new Error( `LayoutGenerator (${JSON.stringify( layoutGeneratorFunc - )}) error for element ${JSON.stringify(element.name)}: ${e}` + )}) error for element ${JSON.stringify(element.tagName)}: ${e}` ) } } else if (element.type === "comment") { diff --git a/src/parseHtml.spec.ts b/src/parseHtml.spec.ts index a95ec365..4e911885 100644 --- a/src/parseHtml.spec.ts +++ b/src/parseHtml.spec.ts @@ -3,28 +3,40 @@ import { parseHtml } from "../src/parseHtml" it("simple", async () => { const html = `
hi
` const dom = await parseHtml(html) - const actual = JSON.stringify(dom, domStringifyReplacer) - const expected = - '[{"type":"tag","name":"div","attribs":{},"children":[{"data":"hi","type":"text","next":null,"prev":null,"parent":null}],"next":null,"prev":null,"parent":null}]' - //console.log({actual}) - expect(actual).toEqual(expected) + const actual = dom + const expected = [ + { + type: "tag", + name: "div", + attribs: {}, + children: [{ data: "hi", type: "text" }] + } + ] + expect(actual).toMatchObject(expected) }) it("two", async () => { const html = "Xyz