Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MarkdownPlugin populates route property #20

Merged
merged 7 commits into from
Mar 21, 2017
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 37 additions & 19 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { StringOrTag } from "./plugins/plugin";
/**
* Copyright 2017-present Palantir Technologies, Inc. All rights reserved.
* Licensed under the BSD-3 License as modified (the “License”); you may obtain
Expand All @@ -7,16 +8,43 @@

/** Represents a single `@tag <value>` line from a file. */
export interface ITag {
/** Tag name. */
tag: string;
/** Tag value, exactly as written in source. */
value: string;
}

/**
* Represents a single `@#+ <value>` heading tag from a file. Note that all `@#+` tags
* (`@#` through `@######`) are emitted as `tag: "heading"` so only one renderer is necessary to
* capture all six levels.
*
* Heading tags include additional information over regular tags: fully-qualified `route` of the
* heading (which can be used as anchor `href`), and `level` to determine which `<h#>` tag to use.
*/
export interface IHeadingTag extends ITag {
tag: "heading";
/** Fully-qualified route of the heading, which can be used as anchor `href`. */
route: string;
/** Level of heading, from 1-6. Dictates which `<h#>` tag to render. */
level: number;
}

/** An entry in `contents` array: either an HTML string or an `@tag`. */
export type StringOrTag = string | ITag;

/** type guard to determine if a `contents` node is an `@tag` statement */
export function isTag(node: StringOrTag): node is ITag {
return (node as ITag).tag !== undefined;
/**
* Type guard to determine if a `contents` node is an `@tag` statement.
* Optionally tests tag name too, if `tagName` arg is provided.
*/
export function isTag(node: StringOrTag, tagName?: string): node is ITag {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handy!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep very proud of this

return node != null && (node as ITag).tag !== undefined
&& (tagName === undefined || (node as ITag).tag === tagName);
}

/** Type guard to deterimine if a `contents` node is an `@#+` heading tag. */
export function isHeadingTag(node: StringOrTag): node is IHeadingTag {
return isTag(node, "heading");
}

/**
Expand Down Expand Up @@ -67,19 +95,23 @@ export interface IPageData {
/** Unique identifier for addressing this page. */
reference: string;

/** Fully qualified route to this page: slash-separated references of all parent pages. */
route: string;

/** Human-friendly title of this page. */
title: string;
}

/** One page entry in a layout tree. */
export interface ITreeEntry {
depth?: number;
reference: string;
depth: number;
route: string;
title: string;
}

/** A page has ordered children composed of `@#+` and `@page` tags. */
export interface IPageNode extends ITreeEntry {
reference: string;
children: Array<IPageNode | IHeadingNode>;
}

Expand All @@ -97,17 +129,3 @@ export function isPageNode(node: any): node is IPageNode {
export function slugify(str: string) {
return str.toLowerCase().replace(/[^\w.\/]/g, "-");
}

/**
* Slugify heading text and join to page refernece with `.`.
*/
export function headingReference(parentReference: string, headingTitle: string) {
return [parentReference, slugify(headingTitle)].join(".");
}

/**
* Join page references with a `/` to indicate nesting.
*/
export function pageReference(parentReference: string, pageReference: string) {
return [parentReference, pageReference].join("/");
}
12 changes: 8 additions & 4 deletions src/compiler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as yaml from "js-yaml";
import * as marked from "marked";

import { IHeadingTag } from "./client";
import { IBlock, ICompiler, StringOrTag } from "./plugins/plugin";

/**
Expand Down Expand Up @@ -81,11 +82,14 @@ export class Compiler implements ICompiler {
const match = TAG_REGEX.exec(str);
if (match === null || reservedWords.indexOf(match[1]) >= 0) {
return str;
}
const tag = match[1];
const value = match[2];
if (/#+/.test(tag)) {
// NOTE: not enough information to populate `route` field yet
return { tag: "heading", value, level: tag.length } as IHeadingTag;
} else {
return {
tag: match[1],
value: match[2],
};
return { tag, value };
}
});
}
Expand Down
46 changes: 37 additions & 9 deletions src/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import * as path from "path";
import { IPageData, isTag } from "./client";
import { IHeadingNode, IPageData, IPageNode, isHeadingTag, isTag } from "./client";

export type PartialPageData = Pick<IPageData, "absolutePath" | "contentRaw" | "contents" | "metadata">;

Expand All @@ -18,8 +18,14 @@ export class PageMap {
* Use this for ingesting rendered blocks.
*/
public add(data: PartialPageData) {
const page = makePage(data);
this.set(page.reference, page);
const reference = getReference(data);
const page: IPageData = {
route: reference,
title: getTitle(data),
reference,
...data,
};
this.set(reference, page);
return page;
}

Expand Down Expand Up @@ -57,12 +63,34 @@ export class PageMap {
}
return object;
}

public toTree(id: string, depth = 0): IPageNode {
const page = this.get(id);
if (page === undefined) {
throw new Error(`Unknown @page '${id}' in toTree()`);
}
const pageNode = initPageNode(page, depth);
page.contents.forEach((node) => {
// we only care about @page and @#+ tag nodes
if (isTag(node, "page")) {
pageNode.children.push(this.toTree(node.value, depth + 1));
} else if (isHeadingTag(node) && node.level > 1) {
// use heading strength - 1 cuz h1 is the title
pageNode.children.push(initHeadingNode(node.value, pageNode.depth + node.level - 1));
}
});
return pageNode;
}
}

function initPageNode({ reference, title }: IPageData, depth: number = 0): IPageNode {
// NOTE: `route` may be overwritten in MarkdownPlugin based on nesting.
return { children: [], depth, reference, route: reference, title };
}

function makePage(props: PartialPageData): IPageData {
const title = getTitle(props);
const reference = getReference(props);
return { ...props, reference, title };
function initHeadingNode(title: string, depth: number): IHeadingNode {
// NOTE: `route` will be added in MarkdownPlugin.
return { depth, title } as IHeadingNode;
}

function getReference(data: PartialPageData) {
Expand All @@ -78,8 +106,8 @@ function getTitle(data: PartialPageData) {
}

const first = data.contents[0];
if (isTag(first) && first.tag.match(/^#+$/)) {
return first.value as string;
if (isHeadingTag(first)) {
return first.value;
}

return "(untitled)";
Expand Down
118 changes: 31 additions & 87 deletions src/plugins/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,7 @@
* repository.
*/

import {
headingReference,
IHeadingNode,
IPageData,
IPageNode,
isPageNode,
isTag,
pageReference,
slugify,
StringOrTag,
} from "../client";
import { IHeadingNode, IPageData, IPageNode, isHeadingTag, isPageNode, slugify, StringOrTag } from "../client";
import { PageMap } from "../page";
import { ICompiler, IFile, IPlugin } from "./plugin";

Expand All @@ -39,18 +29,6 @@ export interface IMarkdownPluginOptions {
* @default
*/
navPage: string;

/**
* Create a reference for a heading title within a page.
* Default implementation slugifies heading and joins the references with `.`.
*/
headingReference: (pageReference: string, headingTitle: string) => string;

/**
* Create a reference for a page nested within another page.
* Default implementation joins the references with a `/`.
*/
pageReference: (parentReference: string, pageReference: string) => string;
}

export class MarkdownPlugin implements IPlugin<IMarkdownPluginData> {
Expand All @@ -59,8 +37,6 @@ export class MarkdownPlugin implements IPlugin<IMarkdownPluginData> {
public constructor(options: Partial<IMarkdownPluginOptions> = {}) {
this.options = {
navPage: "_nav",
headingReference,
pageReference,
...options,
};
}
Expand All @@ -73,10 +49,15 @@ export class MarkdownPlugin implements IPlugin<IMarkdownPluginData> {
const pageStore = this.buildPageMap(markdownFiles, compiler);
// nav must be generated before pages because it rewrites references
const nav = this.buildNavTree(pageStore);
this.buildPageObject(pageStore, nav);
const pages = pageStore.toObject();
return { nav, pages };
}

private buildNavTree(pages: PageMap) {
return pages.toTree(this.options.navPage).children as IPageNode[];
}

private buildPageMap(markdownFiles: IFile[], { renderBlock }: ICompiler) {
const pageStore: PageMap = new PageMap();
markdownFiles
Expand Down Expand Up @@ -107,70 +88,33 @@ export class MarkdownPlugin implements IPlugin<IMarkdownPluginData> {
return pageStore;
}

private buildNavTree(pages: PageMap) {
// navPage is used to construct the sidebar menu
const navRoot = pages.get(this.options.navPage);
if (navRoot === undefined) {
console.warn(`navPage '${this.options.navPage}' not found, returning empty array.`);
return [];
private buildPageObject(pages: PageMap, nav: IPageNode[]) {
function recurseRoute(node: IPageNode | IHeadingNode, parent: IPageNode) {
const route = isPageNode(node)
? [parent.route, node.reference].join("/")
: [parent.route, slugify(node.title)].join(".");
node.route = route;

if (isPageNode(node)) {
const page = pages.get(node.reference)!;
page.route = route;

page.contents.forEach((content) => {
// inject `route` field into heading tags
if (isHeadingTag(content)) {
if (content.level > 1) {
content.route = [route, slugify(content.value)].join(".");
} else {
content.route = route;
}
}
});
node.children.forEach((child) => recurseRoute(child, node));
}
}

const roots = createNavigableTree(pages, navRoot).children as IPageNode[];
// nav page is not a real docs page so we can remove it from output
pages.remove(this.options.navPage);
roots.forEach((page) => {
if (isPageNode(page)) {
page.children.forEach((child) => this.nestChildPage(pages, page, child));
}
nav.forEach((page) => {
page.children.forEach((node) => recurseRoute(node, page));
});

return roots;
}

private nestChildPage(pages: PageMap, parent: IPageNode, child: IPageNode | IHeadingNode) {
const originalRef = child.reference;

// update entry reference to include parent reference
const nestedRef = isPageNode(child)
? this.options.pageReference(parent.reference, child.reference)
: this.options.headingReference(parent.reference, child.title);
child.reference = nestedRef;

if (isPageNode(child)) {
// rename nested pages to be <parent>.<child> and remove old <child> entry.
// (we know this ref exists because isPageNode(child) and originalRef = child.reference)
const page = pages.remove(originalRef)!;
pages.set(nestedRef, { ...page, reference: nestedRef });
// recurse through page children
child.children.forEach((innerchild) => this.nestChildPage(pages, child, innerchild));
}
}
}

function createNavigableTree(pages: PageMap, page: IPageData, depth = 0) {
const pageNode: IPageNode = initPageNode(page, depth);
page.contents.forEach((node: StringOrTag, i: number) => {
if (isTag(node)) {
if (node.tag === "page") {
const subpage = pages.get(node.value);
if (subpage === undefined) {
throw new Error(`Unknown @page '${node.value}' referenced in '${page.reference}'`);
}
pageNode.children.push(createNavigableTree(pages, subpage, depth + 1));
}
if (i !== 0 && node.tag.match(/^#+$/)) {
// use heading strength - 1 cuz h1 is the title
pageNode.children.push(initHeadingNode(node.value, depth + node.tag.length - 1));
}
}
});
return pageNode;
}

function initPageNode({ reference, title }: IPageData, depth: number): IPageNode {
return { children: [], depth, reference, title };
}

function initHeadingNode(title: string, depth: number): IHeadingNode {
return { depth, reference: slugify(title), title };
}