@tdb/web
Version:
Common condiguration for serving a web-site and testing web-based UI components.
156 lines (135 loc) • 4.12 kB
text/typescript
import { fs, value as valueUtil } from '../common';
import {
IMarkdown,
IMarkdownSection,
IParseResponse,
IManifestMarkdown,
} from './types';
import { parseYaml } from './util.parse';
export type IMarkdownResponse = IParseResponse<IMarkdown>;
/**
* See:
* https://unified.js.org/
* https://remark.js.org/
*/
const unified = require('unified');
const parse = require('remark-parse');
const html = require('remark-html');
const processor = unified()
.use(parse, { commonmark: true })
.use(html);
/**
* Parses a markdown body and packages it as a manifest return type.
*/
export async function toManifestMarkdown(
path: string,
body?: string,
): Promise<IManifestMarkdown> {
const res = body ? await parseMarkdown(body) : undefined;
const markdown = res ? res.data : undefined;
const error = res ? res.error : undefined;
const result: IManifestMarkdown = {
type: 'FILE/markdown',
path,
markdown,
error,
};
return valueUtil.deleteUndefined(result);
}
/**
* Loads a markdown file and parses it.
*/
export async function parseMarkdownFile(
path: string,
): Promise<IMarkdownResponse> {
try {
const text = (await fs.readFile(path)).toString();
return parseMarkdown(text);
} catch (err) {
let message = `Failed to parse markdown while loading file.`;
message = `${message} ${err.message}`;
return { data: undefined, error: { message } };
}
}
/**
* Parses markdown into structure, converting YAML blocks
* into usable data.
*/
export async function parseMarkdown(text: string): Promise<IMarkdownResponse> {
const result: IMarkdownResponse = {};
type IMdAst = {
type: string;
value: string;
lang?: string;
meta?: string;
depth?: number;
};
try {
const ast = (await processor.parse(text)) as { children: IMdAst[] };
// Convert to raw AST parts.
const parts = ast.children.map((child, index) => {
const { type, lang, value, depth } = child;
const meta = child.meta ? child.meta : undefined;
const html = processor.stringify(child);
let data: object | undefined;
let error: IMarkdownResponse['error'] | undefined;
if (type === 'code' && lang === 'yaml' && value) {
const res = parseYaml(value);
data = res.data;
error = res.error;
}
return { type, depth, html, data, meta, error };
});
// State-machine to convert into flat list of sections.
let title: string | undefined;
let sections: IMarkdownSection[] = [];
const addHtml = (section: IMarkdownSection, html: string) =>
`${section.html || ''}\n${html || ''}`.trim();
parts.forEach(part => {
const isHeading = part.type === 'heading';
if (isHeading || sections.length === 0) {
const depth = part.depth || 0;
sections = [...sections, { index: -1, depth, data: [] }];
}
const section = { ...sections[sections.length - 1] };
if (isHeading) {
section.titleHtml = part.html;
section.title = stripTags(part.html);
}
if (part.data) {
const { meta, data: value } = part;
section.data = [...section.data, { meta, value }];
}
if (!part.data && !isHeading) {
section.html = addHtml(section, part.html);
}
if (part.data && part.meta && part.meta.includes('RENDER')) {
section.html = addHtml(section, part.html);
}
sections = [
...sections.slice(0, sections.length - 1),
valueUtil.deleteEmpty(section),
];
if (!title && isHeading && part.depth === 1) {
title = stripTags(part.html);
}
});
// Store data.
sections = sections.map((s, index) => ({ ...s, index }));
result.data = { title, sections };
} catch (err) {
let message = `Failed to parse markdown.`;
message = `${message} ${err.message}`;
result.error = { message };
}
return valueUtil.deleteUndefined(result);
}
/**
* HELPERS
*/
function stripTags(text: string) {
return text
.trim()
.replace(/^\<[a-z1-9]+\>/, '')
.replace(/\<\/[a-z1-9]+\>$/, '');
}