UNPKG

@thi.ng/hiccup-markdown

Version:

Markdown parser & serializer from/to Hiccup format

592 lines (581 loc) 19.1 kB
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 };