@thi.ng/hiccup-markdown
Version:
Markdown parser & serializer from/to Hiccup format
592 lines (581 loc) • 19.1 kB
JavaScript
import { peek } from "@thi.ng/arrays/peek";
import { isArray } from "@thi.ng/checks/is-array";
import { isPlainObject } from "@thi.ng/checks/is-plain-object";
import { isPrimitive } from "@thi.ng/checks/is-primitive";
import { isString } from "@thi.ng/checks/is-string";
import { DEFAULT, defmulti } from "@thi.ng/defmulti";
import { EMOJI } from "@thi.ng/emoji/emoji";
import { defContext } from "@thi.ng/parse/context";
import { defGrammar } from "@thi.ng/parse/grammar";
import { escapeEntities } from "@thi.ng/strings/entities";
import { slugifyGH } from "@thi.ng/strings/slugify";
const GRAMMAR = defGrammar(`
DNL1: <DNL>+ => discard ;
DNL2: <NL>{2,} ;
lbr: '\\\\'! <NL>! ;
esc: '\\\\'! [_*\`~:\\<>(){}#.!|\\\\\\u002d\\u005b\\u005d] ;
cesc: '\\\\'! ('\`'| '\\\\') ;
cbesc: '\\\\'! '\`' ;
cuesc: '\\\\'! ':' ;
mesc: '\\\\'! '}' ;
idelim: ("![" | [[_\`<] | "**" | "~~" | " :" | <lbr>) ;
delim: (<idelim> | <DNL2>) ;
ebody: (<esc>|.) ;
cbody: (<cesc>|.) ;
cbbody: (<cbesc>|.) ;
cubody: (<cuesc>|.) ;
mbody: (<mesc>|.) ;
body: <ebody>(?-<delim>!) => join ;
ibody: <ebody>(?-<idelim>!) => join ;
comment: "<!--"! .(?+"-->") => discard ;
wikiref: "[["! <ebody>(?+"]]"!) => join ;
fnref: "[^"! <label> ;
fnote: <LSTART> "[^"! <fnlabel> <WS1> <para> ;
fnlabel: <ebody>(?+"]:"!) => join ;
label: <ebody>(?+']'!) => join ;
target: <ebody>(?+')'!) => join ;
link: '['! <linklabel> '('! <target> ;
linkref: '['! <linklabel> '['! <label> ;
linkdef: <LSTART> '['! <label> ':'! <WS1> <ldtarget> ;
ldtarget: <ebody>(?+<DNL1>) => join ;
linklabel: (<img> | <bold> | <italic> | <strike> | <code> | <emoji> | <kbd> | <sub> | <sup> | <linkbody>)* ']'! ;
linkdelim: ("![" | [\\u005d_\`\\u005b] | "**" | "~~") ;
linkbody: <ebody>(?-<linkdelim>!) => join ;
img: "!["! <label> '('! <target> ;
bold: "**"! (<wikiref> | <img> | <fnref> | <linkref> | <link> | <italic> | <strike> | <code> | <emoji> | <kbd> | <sub> | <sup> | <ibody>)* "**"! ;
italic: '_'! (<wikiref> | <img> | <fnref> | <linkref> | <link> | <bold> | <strike> | <code> | <emoji> | <kbd> | <sub> | <sup> | <ibody>)* '_'! ;
strike: "~~"! (<wikiref> | <img> | <fnref> | <linkref> | <link> | <bold> | <italic> | <code> | <emoji> | <kbd> | <sub> | <sup> | <ibody>)* "~~"! ;
code: '\`'! <cbody>(?+'\`'!) => join ;
kbd: "<kbd>"! <ebody>(?+"</kbd>"!) => join ;
sub: "<sub>"! <ebody>(?+"</sub>"!) => join ;
sup: "<sup>"! <ebody>(?+"</sup>"!) => join ;
emoji: ' '? ':'! (<ALPHA_NUM> | '+')(?+':'!) => join ;
para: (<wikiref> | <img> | <fnref> | <linkref> | <link> | <bold> | <italic> | <strike> | <code> | <kbd> | <sub> | <sup> | <emoji> | <lbr> | <comment> | <body>)* <DNL2>! ;
hdlevel: '#'+ => count ;
hdid: "{#"! (<ALPHA_NUM> | '-')+ '}'! => join ;
hddelim: (<idelim> | <NL> | "{#") ;
hdbody: <ebody>(?-<hddelim>!) => join ;
hd: <LSTART> <hdlevel> <WS0>
(<wikiref> | <img> | <fnref> | <link> | <bold> | <italic> | <strike> | <code> | <emoji> | <kbd> | <sub> | <sup> | <hdbody>)* <hdid>? <DNL1> ;
lilevel: ' '* => count ;
olid: (<ALPHA>+|<DIGIT>+) => join ;
ulinit: <DNL> <WS0> '-'! ;
olinit: <DNL> <WS0> <olid>! '.'! ;
lidelim: (<delim> | <ulinit> | <olinit>) ;
libody: <ebody>(?-<lidelim>!) => join ;
todo: '['! [ xX] ']'! <WS1> => hoistR ;
ulitem: <LSTART> <lilevel> "- "! <todo>?
(<wikiref> | <img> | <fnref> | <link> | <bold> | <italic> | <strike> | <code> | <emoji> | <kbd> | <sub> | <sup> | <libody> )* <DNL> ;
olitem: <LSTART> <lilevel> <olid> ". "! <todo>?
(<wikiref> | <img> | <fnref> | <link> | <bold> | <italic> | <strike> | <code> | <emoji> | <kbd> | <sub> | <sup> | <libody> )* <DNL> ;
list: (<ulitem> | <olitem>)+ <DNL1> ;
cbdelim: <LSTART> "\`\`\`"! ;
codeblock: <cbdelim>! <codemeta> (<cbdelim>! | <codebody>) <DNL1> ;
codemeta: .(?+<NL>!) => join ;
codebody: <cbbody>(?+<cbdelim>) => join ;
cudelim: <LSTART> ":::"! ;
customblock: <cudelim>! <custommeta> (<cudelim>! | <custombody>) <DNL1> ;
custommeta: .(?+<NL>!) => join ;
custombody: <cubody>(?+<cudelim>) => join ;
metablock: <LSTART> "{{{"! <metabody> <DNL1> ;
metabody: <mbody>(?+<metaend>!) => join ;
metaend: "}}}" <LEND> ;
bqlevel: '>'+ => count ;
bqdelim: (<idelim> | <NL>) ;
bqbody: <ebody>(?-<bqdelim>!) => join ;
bqline: <LSTART> <bqlevel> ' '?!
(<wikiref> | <img> | <fnref> | <link> | <bold> | <italic> | <strike> | <code> | <emoji> | <kbd> | <sub> | <sup> | <bqlbr> | <bqbody>)* <DNL> ;
bqlbr: '\\\\'!(?-<DNL>) ;
bquote: <bqline>+ <DNL1> ;
tdelim: (<idelim> | '|' ) ;
tbody: <ebody>(?-<tdelim>!) => join ;
tcell: <WS0> (<wikiref> | <img> | <fnref> | <link> | <bold> | <italic> | <strike> | <code> | <emoji> | <kbd> | <sub> | <sup> | <tbody> )* '|'! ;
trow: <LSTART> '|'! <tcell>(?+<DNL>) ;
table: <trow>+ <DNL1> ;
hr: "--" '-'+ <DNL1> => join ;
main: <WS0> (<hd> | <list> | <bquote> | <codeblock> | <customblock> | <metablock> | <table> | <hr> | <fnote> | <linkdef> | <comment> | <para> )* ;
`);
const DEFAULT_TAG_TRANSFORMS = {
bold: (_, body) => ["strong", {}, ...body],
blockquote: (_, body, meta) => ["blockquote", withMeta({}, meta), ...body],
br: () => ["br", {}],
code: (_, body) => ["code", {}, body],
codeblock: (_, lang, __head, body, meta) => [
"pre",
withMeta({ data: { lang }, __head }, meta),
["code", {}, body]
],
custom: (_, type, __head, body, meta) => [
"custom",
withMeta({ type, __head }, meta),
body
],
emoji: (_, id) => EMOJI[id] || id,
footnote: (_, id, body, meta) => [
"li",
withMeta({ id: `fn-${id}` }, meta),
["sup", {}, `[${id}] `],
...body,
" ",
["a", { href: `#fnref-${id}` }, "\u21A9\uFE0E"]
],
footnoteRef: (_, id) => [
"sup",
{},
["a", { id: `fnref-${id}`, href: `#fn-${id}` }, `[${id}]`]
],
footnoteWrapper: (_, notes) => [
"ul",
{ id: "footnotes" },
...Object.keys(notes).sort().map((id) => notes[id])
],
heading: (_, level, id, body, meta) => [
level > 6 ? "p" : `h${level}`,
withMeta({ id }, meta),
...body
],
hr: (_, __length, meta) => ["hr", withMeta({ __length }, meta)],
img: (_, alt, src, title) => ["img", { src, alt, title }],
italic: (_, body) => ["em", {}, ...body],
kbd: (_, key) => ["kbd", {}, key],
link: (_, href, title, body) => ["a", { href, title }, ...body],
linkRef: (ctx, refID, body) => [
"a",
{
href: () => ctx.linkRefs[refID]?.[0],
title: () => ctx.linkRefs[refID]?.[1]
},
...body
],
meta: (_, body) => body,
olitem: (_, attribs, index, body) => [
"li",
{ ...attribs, __index: index },
...body
],
ol: (_, items, meta) => ["ol", withMeta({}, meta), ...items],
para: (_, body, meta) => ["p", withMeta({}, meta), ...body],
strike: (_, body) => ["s", {}, ...body],
sub: (_, body) => ["sub", {}, body],
sup: (_, body) => ["sup", {}, body],
table: (ctx, head, rows, meta) => [
"table",
withMeta({ __align: ctx.align }, meta),
["thead", {}, head],
["tbody", {}, ...rows]
],
tableCell: (_, body) => ["td", {}, ...body],
tableHead: (_, body) => ["th", {}, ...body],
tableRow: (_, cells) => ["tr", {}, ...cells],
ul: (_, items, meta) => ["ul", withMeta({}, meta), ...items],
ulitem: (_, attribs, body) => ["li", attribs, ...body],
wikiref: (_, id, label) => [
"a",
{ class: "wikiref", href: encodeURI(id.replace(/\s+/g, "_")) },
label || id
]
};
class ParseError extends Error {
constructor(state) {
super(
state ? `stopped line: ${state.l}, col: ${state.c} (pos: ${state.p})` : void 0
);
this.state = state;
}
state;
}
const parse = (src, {
tags,
opts,
linkRefs,
logger
} = {}) => {
const parseCtx = parseRaw(src, opts?.retain);
const mdCtx = defTransformContext(tags, opts, linkRefs, logger);
const result = [];
transformScope(parseCtx.root, mdCtx, result);
return {
result,
ctx: mdCtx,
complete: !!parseCtx.done,
state: parseCtx.state
};
};
const parseRaw = (src, retain = false) => {
const ctx = defContext(src + "\n\n", { retain });
if (!GRAMMAR.rules.main(ctx)) throw new ParseError(ctx.state);
return ctx;
};
const defTransformContext = (tags, opts, linkRefs, logger) => ({
footnotes: {},
headings: [],
linkRefs: linkRefs || {},
hasFootnotes: false,
logger,
meta: null,
align: [],
column: 0,
row: 0,
opts: {
escape: false,
retain: false,
...opts
},
tags: { ...DEFAULT_TAG_TRANSFORMS, ...tags }
});
const transformScope = defmulti(
(x, ctx) => {
ctx.logger?.debug(x);
return x.id;
},
{
bqbody: "body",
bqlbr: "lbr",
bqline: "repeat0",
hdbody: "body",
ibody: "body",
label: "body",
libody: "body",
linkbody: "body",
main: "root",
repeat1: "repeat0",
tbody: "body"
},
{
[DEFAULT]: (scope, ctx) => {
throw new Error(
`unknown ID: ${scope.id}, ctx: ${JSON.stringify(ctx)}`
);
},
root: ({ children }, ctx, acc) => {
if (!children) return;
transformScope(children[0], ctx, acc);
if (ctx.hasFootnotes) {
__collect(acc, ctx.tags.footnoteWrapper(ctx, ctx.footnotes));
}
},
main: (scope, ctx, acc) => transformScope(scope.children[0], ctx, acc),
repeat0: (scope, ctx, acc) => scope.children && __children(ctx, scope.children, acc),
body: (scope, ctx, acc) => __collect(acc, __escape(ctx, scope.result)),
bold: (scope, ctx, acc) => __collect(
acc,
ctx.tags.bold(ctx, __children(ctx, scope.children))
),
bquote: (scope, ctx, acc) => {
const stack = [[]];
const children = scope.children[0].children;
const $unwind = (level) => {
while (level < stack.length) {
const nested = stack.pop();
__collect(
peek(stack),
ctx.tags.blockquote(ctx, __trimBody(nested))
);
}
return peek(stack);
};
for (let i = 0, n = children.length - 1; i <= n; i++) {
const [{ result: level }, bqline] = children[i].children;
let body = peek(stack);
if (level > stack.length) {
while (level > stack.length) stack.push(body = []);
} else if (level < stack.length) {
body = $unwind(level);
} else if (body.length) {
const prev = children[i - 1].children[1];
if (!bqline.children) {
__collect(body, ctx.tags.br(ctx));
__collect(body, ctx.tags.br(ctx));
} else if (prev.children && peek(prev.children).id !== "bqlbr") {
body.push(" ");
}
}
transformScope(bqline, ctx, body);
}
__collect(
acc,
ctx.tags.blockquote(ctx, __trimBody($unwind(1)), ctx.meta)
);
ctx.meta = null;
},
code: (scope, ctx, acc) => __collect(acc, ctx.tags.code(ctx, __escape(ctx, scope.result))),
codeblock: ({ children }, ctx, acc) => {
const [lang, ...head] = children[0].result.split(" ");
const body = children[1]?.result.trim() ?? "";
__collect(
acc,
ctx.tags.codeblock(
ctx,
lang,
head,
__escape(ctx, body),
ctx.meta
)
);
ctx.meta = null;
},
customblock: ({ children }, ctx, acc) => {
const [type, ...head] = children[0].result.split(" ");
__collect(
acc,
ctx.tags.custom(
ctx,
type,
head,
children[1]?.result.trim() ?? "",
ctx.meta
)
);
ctx.meta = null;
},
emoji: ({ result }, ctx, acc) => {
if (result[0] === " ") {
__collect(acc, " ");
result = result.substring(1);
}
__collect(acc, ctx.tags.emoji(ctx, result));
},
fnote: ({ children }, ctx) => {
const body = [];
const id = children[0].result;
transformScope(children[1].children[0], ctx, body);
const res = ctx.tags.footnote(ctx, id, body, ctx.meta);
if (res != null) {
ctx.hasFootnotes = true;
ctx.footnotes[id] = res;
}
ctx.meta = null;
},
fnref: (scope, ctx, acc) => __collect(
acc,
ctx.tags.footnoteRef(ctx, scope.children[0].result)
),
hd: ({ children }, ctx, acc) => {
const body = [];
const level = children[0].result;
transformScope(children[1], ctx, body);
__trimBody(body);
const slug = children[2]?.result || slugifyGH(extractBody(body).join(""));
ctx.headings.push({ level, body });
__collect(acc, ctx.tags.heading(ctx, level, slug, body, ctx.meta));
ctx.meta = null;
},
hr: (scope, ctx, acc) => {
__collect(acc, ctx.tags.hr(ctx, scope.result.length, ctx.meta));
ctx.meta = null;
},
img: ({ children }, ctx, acc) => __collect(
acc,
ctx.tags.img(
ctx,
__escape(ctx, children[0].result.trim()),
...__linkTitle(children[1].result)
)
),
italic: (scope, ctx, acc) => __collect(
acc,
ctx.tags.italic(ctx, __children(ctx, scope.children))
),
kbd: (scope, ctx, acc) => __collectPrim(scope, ctx, acc, "kbd"),
lbr: (_, ctx, acc) => __collect(acc, ctx.tags.br(ctx)),
link: ({ children }, ctx, acc) => __collect(
acc,
ctx.tags.link(
ctx,
...__linkTitle(children[1].result),
__children(ctx, children[0].children)
)
),
linkdef: ({ children }, ctx) => {
ctx.linkRefs[children[0].result] = __linkTitle(
children[1].result
);
},
linkref: ({ children }, ctx, acc) => __collect(
acc,
ctx.tags.linkRef(
ctx,
children[1].result,
__children(ctx, children[0].children)
)
),
list: (scope, ctx, acc) => {
const children = scope.children[0].children;
const stack = [
[children[0].id === "ulitem" ? "ul" : "ol"]
];
const levels = [0];
for (const item of children) {
const currLevel = item.children[0].result;
if (currLevel > peek(levels)) {
const sublist = [item.id === "ulitem" ? "ul" : "ol"];
const parent = peek(stack);
parent.length > 1 ? peek(parent).push(sublist) : parent.push([
parent[0] === "ul" ? "ulitem" : "olitem",
{},
sublist
]);
stack.push(sublist);
levels.push(currLevel);
} else if (currLevel < peek(levels)) {
while (currLevel < peek(levels)) {
stack.pop();
levels.pop();
}
}
transformScope(item, ctx, peek(stack));
}
const $list = (root, isRoot = false) => ctx.tags[root[0]](
ctx,
root.slice(1).map($item),
isRoot ? ctx.meta : null
);
const $item = (item) => {
let last = item[item.length - 1];
if (last[0] === "ul" || last[0] === "ol")
item[item.length - 1] = $list(last);
return item[0] === "ulitem" ? ctx.tags.ulitem(ctx, item[1], item.slice(2)) : ctx.tags.olitem(ctx, item[1], item[2], item.slice(3));
};
__collect(acc, $list(stack[0], true));
ctx.meta = null;
},
metablock: ({ children }, ctx) => {
ctx.meta = ctx.tags.meta(ctx, children[0].result.trim());
},
olitem: ({ children }, ctx, acc) => {
const body = [];
transformScope(children[3], ctx, body);
__collect(acc, [
"olitem",
__listItemAttribs(children[2]),
children[1].result,
...__trimBody(body)
]);
},
para: (scope, ctx, acc) => {
__collect(
acc,
ctx.tags.para(
ctx,
__trimBody(__children(ctx, scope.children)),
ctx.meta
)
);
ctx.meta = null;
},
strike: (scope, ctx, acc) => __collect(
acc,
ctx.tags.strike(ctx, __children(ctx, scope.children))
),
sub: (scope, ctx, acc) => __collectPrim(scope, ctx, acc, "sub"),
sup: (scope, ctx, acc) => __collectPrim(scope, ctx, acc, "sup"),
table: (scope, ctx, acc) => {
const children = scope.children[0].children;
const head = [];
const rows = [];
ctx.row = 0;
if (children.length > 1) {
ctx.align = __columnAlignments(
children[1].children[0].children
);
for (let i = 2, n = children.length; i < n; i++) {
ctx.row = i - 1;
transformScope(children[i], ctx, rows);
}
} else {
ctx.align = new Array(
children[0].children[0].children.length
).fill("left");
}
ctx.row = 0;
transformScope(children[0], ctx, head);
__collect(acc, ctx.tags.table(ctx, head[0], rows, ctx.meta));
ctx.meta = null;
},
tcell: ({ children }, ctx, acc) => __collect(
acc,
(ctx.row > 0 ? ctx.tags.tableCell : ctx.tags.tableHead)(
ctx,
__trimBody(__children(ctx, children))
)
),
trow: (scope, ctx, acc) => {
const cells = [];
scope.children[0].children.forEach((c, i) => {
ctx.column = i;
transformScope(c, ctx, cells);
});
__collect(acc, ctx.tags.tableRow(ctx, cells));
},
ulitem: ({ children }, ctx, acc) => {
const body = [];
transformScope(children[2], ctx, body);
__collect(acc, [
"ulitem",
__listItemAttribs(children[1]),
...__trimBody(body)
]);
},
wikiref: (scope, ctx, acc) => {
const [id, label] = scope.result.split("|");
__collect(acc, ctx.tags.wikiref(ctx, id.trim(), label?.trim()));
}
}
);
const withMeta = (target, meta) => {
if (meta != null) target.__meta = meta;
return target;
};
const extractBody = (body, acc = []) => {
for (const x of isPlainObject(body[1]) ? body.slice(2) : body) {
if (isPrimitive(x)) acc.push(x);
else if (isArray(x)) extractBody(x, acc);
}
return acc;
};
const __collect = (acc, x) => x != null && acc.push(x);
const __collectPrim = (scope, ctx, acc, tag) => __collect(acc, ctx.tags[tag](ctx, scope.result));
const __children = (ctx, children, acc = []) => {
for (const c of children) transformScope(c, ctx, acc);
return acc;
};
const __escape = (ctx, x) => ctx.opts.escape ? escapeEntities(x) : x;
const __listItemAttribs = (scope) => scope?.id === "todo" ? {
__todo: true,
__done: scope.result === "x"
} : {};
const __trimBody = (body) => {
if (body.length === 1 && isString(body[0])) body[0] = body[0].trim();
else {
const last = peek(body);
if (isString(last) && /^\s+$/.test(last)) body.pop();
}
return body;
};
const __columnAlignments = (children) => {
const align = [];
for (const c of children) {
const raw = c.children[0].children[0].result.trim();
const isLeft = raw.startsWith(":-") ? 1 : 0;
const isRight = raw.endsWith("-:") ? 2 : 0;
align.push(
["default", "left", "right", "center"][isLeft | isRight]
);
}
return align;
};
const __linkTitle = (src) => {
const match = /\s"(.+)"$/.exec(src);
return match ? [src.substring(0, match.index), match[1]] : [src, void 0];
};
export {
DEFAULT_TAG_TRANSFORMS,
GRAMMAR,
ParseError,
defTransformContext,
extractBody,
parse,
parseRaw,
transformScope,
withMeta
};