Skip to content

Commit

Permalink
tests passing for refactored plugin API and blockquote implemented us…
Browse files Browse the repository at this point in the history
…ing new API
  • Loading branch information
activescott committed Aug 17, 2019
1 parent 068c130 commit 06fbda0
Show file tree
Hide file tree
Showing 12 changed files with 203 additions and 172 deletions.
63 changes: 51 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-🚀)
Expand Down Expand Up @@ -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 `<b>` 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 `<bold>` 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

Expand Down
49 changes: 41 additions & 8 deletions src/css/CssBoxImp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<CssBox> {
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<CssBox> {
Expand Down
104 changes: 51 additions & 53 deletions src/css/layout/BlockquotePlugin.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
51 changes: 10 additions & 41 deletions src/css/layout/CssBoxFactory.ts
Original file line number Diff line number Diff line change
@@ -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<CssBox> = [],
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<CssBox>,
debugNote: string
): CssBox {
return new CssBoxImp(boxType, textContent, children, debugNote)
}
}

Expand Down
14 changes: 14 additions & 0 deletions src/css/layout/DefaultBoxBuilderFuncs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
9 changes: 2 additions & 7 deletions src/css/layout/LayoutManagerImp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -20,12 +21,6 @@ export class LayoutManagerImp implements LayoutManager {
children: Iterable<CssBox> = [],
debugNote: string = ""
): CssBox {
return this.boxFactory.createBox(
state,
boxType,
textContent,
children,
debugNote
)
return new CssBoxImp(boxType, textContent, children, debugNote)
}
}
2 changes: 1 addition & 1 deletion src/css/layout/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. <span>a</span> <span>b</span>) it will still figure out it's own formatting context.
Expand Down
Loading

0 comments on commit 06fbda0

Please sign in to comment.