/* RUBRIC v2024H */
/* Markdown’s cooler cousin. */

/*

[https://satyrs.eu/licence CC0 public domain.]

## Changelog

- **2024L** (2024-06-10): Converted to ES modules
- **2024H** (2024-04-26): Added proper alignment for media
- **2023T** (2023-09-20): Added “inline” option, as well as a helper “parseMediaSource” function
- **2023Q** (2023-08-14): Slashes no longer start italics after < signs.
- **2023P3** (2023-07-23): Changed how custom attributes work (the joy of alpha software!); most of them now go on a separate line above the element
	- **2023P3a** (2023-07-25): Fixed a bug where images without alt text would break in HTML.
- **2023P2** (2023-07-22): Custom attributes now apply to all block elements
- **2023P1** (2023-07-19): Headings can now take up multiple lines. Added a default soft hyphen template.
- **2023N1** (2023-07-12): First version! :party emoji:
	- **2023N1a** (2023-07-13): Fixed(?) backslash escaping

*/

import grimm from "./grimm.js";

const rubric = {
	options: {
		default: {
			allowMedia: false,
			allowRawCode: false,
			allowReplies: false,
			allowSidenotes: false,
			allowTemplates: false,
			fullParagraphs: true,
			highlightCode: false,
			hyperlinkHeadings: false,
			minHeadingLevel: 1,
			html_smallCapsClasses: { lower: "smallcaps", all: "all-sc" },
			customTemplates: {}
		},
		risky: {
			allowRawCode: true,
			allowMedia: true,
			allowSidenotes: true,
			allowTemplates: true,
			highlightCode: true,
			hyperlinkHeadings: true
		},
		inlineRisky: {
			allowRawCode: true,
			inline: true
		},
		comments: {
			allowReplies: true,
			minHeadingLevel: 2,
			highlightCode: true
		}
	},
	templates: {
		"-": {
			html: (node, opt, lang) => "&shy;"
		}
	},
	patterns: {
		block: {
			pre: {
				starts: /^``` *(\S*?)$/u,
				continues: /.*/,
				ends: /^```$/,
				parseRule: /^``` *(\S*?)\n(.*)\n?```\s*$/su,
				leaf: (opt, match, language, text) => ({
					language: language,
					text: text
				})
			},
			attributeNext: {
				starts: /^(?:[:.#](?:-|_|[^\s\p{P}\p{S}])+)+$/u,
				parseRule: /^(?:[:.#](?:-|_|[^\s\p{P}\p{S}])+)+$/u
			},
			div: {
				starts: /^::: *(\S*?(?:[:.#](?:-|_|[^\s\p{P}\p{S}])+)*)?$/u,
				continues: /.*/,
				ends: /^:::$/,
				parseRule:
					/^::: *(\S*?(?:[:.#](?:-|_|[^\s\p{P}\p{S}])+)*)?\n(.*)\n:::\s*$/su,
				leaf: (opt, match, attrs, text) => ({
					attributes: rubric.parseAttrs(attrs, opt),
					text: rubric.parseBlocks(
						text,
						Object.assign({}, opt, { fullParagraphs: false })
					)
				})
			},
			quote: {
				starts: /^> (.*?)$/u,
				continues: /^>( .*?)?$/,
				parseRule: /^> (.*?)$/su,
				leaf: (opt, match, text) => ({
					text: rubric.parseBlocks(text.replace(/^> ?/gm, ""), opt)
				})
			},
			heading: {
				starts: /^(#{1,6}) (.*?)$/u,
				continues: /^#{1,6}( .*?)?$/,
				parseRule: /^(#{1,6}) (.*?)$/su,
				leaf: (opt, match, level, text) => ({
					level: Math.max(level.length, opt.minHeadingLevel),
					text: rubric.parseInline(
						text.replace(/^#{1,6} ?/gm, ""),
						opt
					)
				})
			},
			spoiler: {
				starts: /^~{3,} .*?$/u,
				continues: /.*/,
				ends: /^~{3,}$/,
				parseRule: /^~{3,} (.*?)\n(.*?)\n?~{3,}$/su,
				nestable: true,
				leaf: (opt, match, summary, details) => ({
					summary: rubric.parseInline(summary, opt),
					text: rubric.parseBlocks(details, opt)
				})
			},
			media: {
				starts: /^\[([>:<]?)\[(.*?(\.[a-z0-9]+?)?)((?: :\S+)+)?( .*?)?\]( .*?)?\]$/u,
				parseRule:
					/^\[([>:<]?)\[(.*?(\.[a-z0-9]+?)?)((?: :\S+)+)?( .*?)?\]( .*?)?\]$/u,
				nestable: true,
				leaf: (opt, match, align, src, format, flags, alt, caption) => {
					const videoFormats = [
						".webm",
						".mkv",
						".mov",
						".avi",
						".mp4"
					];
					const audioFormats = [
						".alac",
						".aac",
						".flac",
						".ogg",
						".mp3",
						".wav"
					];
					let alignment;
					switch (align) {
						case ">":
							alignment = "end";
							break;
						case "<":
							alignment = "start";
							break;
						case ":":
							alignment = "full";
							break;
						default:
							alignment = undefined;
					}

					return {
						format: src.match(/^[a-z-]+\:/)
							? "custom"
							: videoFormats.includes(format)
							? "video"
							: audioFormats.includes(format)
							? "audio"
							: "image",
						source: src,
						flags:
							flags === undefined
								? []
								: flags.split(" :").slice(1),
						alt: alt === undefined ? undefined : alt.trim(),
						align: alignment,
						isFigure: caption !== undefined,
						text:
							caption === undefined
								? undefined
								: rubric.parseInline(caption.trim(), opt)
					};
				}
			},
			list: {
				starts: /^(-|[0-9]+\.|[a-z]\.|[lxvi]+\.) .*?$/u,
				continues: /^(\t+|(-|[0-9]+\.|[a-z]\.|[lxvi]+\.) )/,
				parseRule: /^(-|[0-9]+\.|[a-z]\.|[lxvi]+\.) (.*?)$/su,
				leaf: (opt, match, type, text) => ({
					ordered: type != "-",
					style:
						type == "-"
							? "unordered"
							: type.match(/[0-9]/)
							? "numbered"
							: type.match(/^[abcdefghjklmnopqrstuwxyz]\.$/)
							? "lettered"
							: "roman",
					text: rubric.parseList(`${type} ${text}`, opt)
				})
			},
			sidenoteBody: {
				starts: /^\[\^.*?\]: .*?$/u,
				continues: /^\t/,
				parseRule: /^\[\^(.*?)\]: (.*?)$/su,
				leaf: (opt, match, label, contents) => ({
					label: label,
					text: rubric.parseBlocks(contents.replace(/^\t/gm, ""), opt)
				})
			},
			division: {
				starts: /^---+$/u,
				parseRule: /^---+$/u,
				leaf: (opt, match) => ({
					kind: "division"
				})
			}
		},
		inline: {
			code: {
				parse: /`(.+?)`/g,
				priority: 10,
				leaf: (opt, buffer, match, text) => ({
					text: rubric.unbuffer(text, buffer)
				})
			},
			template: {
				parse: /{{(\S+?)(?: (.*?))?}}/g,
				priority: 11,
				leaf: (opt, buffer, match, temp, text) => ({
					template: temp,
					text: text
						? rubric?.templates?.[temp]?.noParse
							? rubric.unbuffer(text, buffer)
							: rubric.parseInline(
									rubric.unbuffer(text, buffer),
									opt,
									"template",
									false
							  )
						: undefined
				})
			},
			span: {
				parse: /\{(\S*?(?:[:.#](?:-|_|[^\s\p{P}\p{S}])+)* )?(.*?)\}/gu,
				priority: 15,
				leaf: (opt, buffer, match, attrs, text) => ({
					attributes: rubric.parseAttrs(attrs, opt),
					text: rubric.parseInline(
						rubric.unbuffer(text, buffer),
						opt,
						"span",
						false
					)
				})
			},
			sidenoteNote: {
				parse: /\[\^(.*?)\]((?:[:.#](?:-|_|[^\s\p{P}\p{S}])+)+)?/g,
				priority: 17,
				leaf: (opt, buffer, match, text, attrs) => ({
					attributes: rubric.parseAttrs(attrs, opt),
					text: rubric.unbuffer(text, buffer)
				})
			},
			link: {
				parse: /(\[([^\s⟪⟫]+?) (.+?)\])|(?<!\S)[a-z]+\:\/\/[^\s⟪⟫]+/g,
				priority: 20,
				leaf: (opt, buffer, match, rich, url, text) => ({
					href: rich ? rubric.unescape(url) : rubric.unescape(match),
					text: rich
						? rubric.parseInline(
								rubric.unbuffer(text, buffer),
								opt,
								"link",
								false
						  )
						: rubric.unescape(match)
				})
			},
			superscript: {
				parse: /(?<!\^)\^([^\^]+?)\b/g,
				priority: 50,
				leaf: (opt, buffer, match, text) => ({
					text: rubric.parseInline(
						rubric.unbuffer(text, buffer),
						opt,
						"superscript",
						false
					)
				})
			},
			subscript: {
				parse: /(?<!_)_([^_]+?)\b/g,
				priority: 50,
				leaf: (opt, buffer, match, text) => ({
					text: rubric.parseInline(
						rubric.unbuffer(text, buffer),
						opt,
						"subscript",
						false
					)
				})
			},
			commentReply: {
				parse: /@([0-9]+)\b/g,
				priority: 100,
				leaf: (opt, buffer, match, comment) => ({
					commentID: parseInt(comment),
					text: `@${comment}`
				})
			}
		},
		// Welcome to hell.
		symmetrical: {
			boldStrong: {
				token: "\\*+",
				leaf: (opt, length, text) => ({
					kind: length >= 2 ? "strong" : "bold",
					text: text
				})
			},
			italicEmphasis: {
				token: "(?<!<)\\/+",
				leaf: (opt, length, text) => ({
					kind: length >= 2 ? "emphasis" : "italic",
					text: text
				})
			},
			smallStrike: {
				token: "~+",
				leaf: (opt, length, text) => ({
					kind: length >= 2 ? "strikethrough" : "small",
					text: text
				})
			},
			inserted: {
				token: "(?:\\+\\+)+",
				leaf: (opt, length, text) => ({
					kind: "inserted",
					text: text
				})
			},
			deleted: {
				token: "(?:--)+",
				leaf: (opt, length, text) => ({
					kind: "deleted",
					text: text
				})
			},
			superscript: {
				token: "(?:\\^\\^)+",
				priority: 50,
				leaf: (opt, length, text) => ({
					kind: "superscript",
					text: text
				})
			},
			subscript: {
				token: "(?:__)+",
				priority: 50,
				leaf: (opt, length, text) => ({
					kind: "subscript",
					text: text
				})
			},
			smallCaps: {
				token: "(?:\\:\\:)+",
				priority: 70,
				leaf: (opt, length, text) => ({
					kind: "smallCaps",
					case: text == text.toUpperCase() ? "all" : "lower",
					text: text
				})
			}
		}
	},
	langs: {
		html: {
			els: {
				pre: (node, opt, lang) =>
					`\n<pre${lang.attribute(
						node.attributes
					)}><code>${lang.escape(node.text)}</code></pre>\n`,
				heading: (node, opt, lang) =>
					`\n<h${node.level}${lang.attribute(
						node.attributes
					)}>${lang.parse(node.text, opt)}</h${node.level}>\n`,
				spoiler: (node, opt, lang) =>
					`\n<details${lang.attribute(
						node.attributes
					)}>\n<summary>${lang.parse(
						node.summary,
						opt
					)}</summary>\n${lang.parse(node.text, opt)}</details>\n`,
				media: (node, opt, lang) => {
					let figure = "";
					const captions = node.flags
						.map(x => x.match(/^(st|cc)\:(.+?)@(.+?)$/))
						.filter(x => x)
						.map((el, idx) =>
							rubric.langs.html.writeCaption(...el, idx == 0)
						)
						.join("");
					const alignment = node?.align
						? ` class="align-${node.align}"`
						: "";
					switch (node.format) {
						case "custom":
							figure += `<iframe class="rubric-youtube" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen src="https://www.youtube-nocookie.com/embed/${
								node.source.match(/:(.*?)$/)[1]
							}"></iframe>`;
							break;
						case "audio":
							figure += `<audio controls src="${lang.escapeQuotes(
								rubric.langs.html.parseMediaSource(node.source)
							)}">${
								node.alt ? lang.escape(node.alt) : ""
							}${captions}</audio>`;
							break;
						case "video":
							figure += `<video ${
								node.flags.includes("gif")
									? "autoplay loop muted"
									: "controls"
							} src="${lang.escapeQuotes(
								rubric.langs.html.parseMediaSource(node.source)
							)}">${
								node.alt ? lang.escape(node.alt) : ""
							}${captions}</video>`;
							break;
						default:
							figure += `<img src="${lang.escapeQuotes(
								rubric.langs.html.parseMediaSource(node.source)
							)}"${
								node.alt
									? ` alt="${lang.escapeQuotes(node.alt)}"`
									: ""
							}>`;
					}
					if (node.isFigure) {
						figure = `<figure${lang.attribute(
							node.attributes
						)}${alignment}>\n${figure}\n<figcaption>${lang.parse(
							node.text,
							opt
						)}</figcaption>\n</figure>`;
					} else {
						figure = figure.replace(
							/<(video|audio|img)/,
							`<$1${lang.attribute(node.attributes)}${alignment} `
						);
					}
					return `\n${figure}\n`;
				},
				list: (node, opt, lang) => {
					const parsed = lang.parse(node.text, opt);
					switch (node.style) {
						case "unordered":
							return `\n<ul${lang.attribute(
								node.attributes
							)}>${parsed}</ul>\n`;
						case "numbered":
							return `\n<ol type="1"${lang.attribute(
								node.attributes
							)}>${parsed}</ol>\n`;
						case "lettered":
							return `\n<ol type="a"${lang.attribute(
								node.attributes
							)}>${parsed}</ol>\n`;
						case "roman":
							return `\n<ol type="i"${lang.attribute(
								node.attributes
							)}>${parsed}</ol>\n`;
						default:
							return `\n<ol${lang.attribute(
								node.attributes
							)}>${parsed}</ol>\n`;
					}
				},
				code: (node, opt, lang) =>
					`<code${lang.attribute(node.attributes)}>${lang.escape(
						node.text
					)}</code>`,
				commentReply: (node, opt, lang) =>
					`<a href="#comment-${node.commentID}"${lang.attribute(
						node.attributes
					)}>${node.text}</a>`,
				link: (node, opt, lang) =>
					`<a href="${lang.escapeQuotes(node.href)}"${lang.attribute(
						node.attributes
					)}>${lang.parse(node.text, opt)}</a>`,
				smallCaps: (node, opt, lang) =>
					`<span class="${
						opt.html_smallCapsClasses?.[node.case]
					}"${lang.attribute(node.attributes)}>${lang.parse(
						node.text,
						opt
					)}</span>`,
				sidenoteBody: (node, opt, lang) =>
					`\n<aside class="note" id="sn-body-${lang.escapeQuotes(
						node.label
					)}" sn-slug="${lang.escapeQuotes(
						node.label
					)}"${lang.attribute(
						node.attributes
					)}>\n<a href="#sn-ref-${lang.escapeQuotes(
						node.label
					)}" class="note-number">${lang.escapeQuotes(
						node.label
					)}</a>\n<div>${lang.parse(
						node.text,
						opt
					)}</div>\n</aside>\n`,
				sidenoteNote: (node, opt, lang) =>
					`<a class="sidenote-ref" id="sn-ref-${lang.escapeQuotes(
						node.text
					)}" href="#sn-body-${lang.escapeQuotes(
						node.text
					)}" sn-slug="${lang.escapeQuotes(
						node.text
					)}"${lang.attribute(
						node.attributes
					)}><sup class="note">${lang.escape(node.text)}</sup></a>`,
				div: (node, opt, lang) =>
					rubric.langs.html.element(
						node?.attributes?.element ?? "div",
						lang.parse(node.text, opt),
						node.attributes
					),
				span: (node, opt, lang) =>
					rubric.langs.html.element(
						node?.attributes?.element ?? "span",
						lang.parse(node.text, opt),
						node.attributes
					),
				division: (node, opt, lang) =>
					`<hr${lang.attribute(node.attributes)}>`,
				template: (node, opt, lang) =>
					node.template in rubric.templates &&
					"html" in rubric?.templates?.[node.template]
						? rubric.templates[node.template].html(node, opt, lang)
						: `{{${node.template} ${lang.parse(node.text, opt)}}}`
			},
			aliases: {
				para: "p",
				item: "li",
				italic: "i",
				emphasis: "em",
				bold: "b",
				quote: "blockquote",
				strikethrough: "s",
				subscript: "sub",
				superscript: "sup",
				inserted: "ins",
				deleted: "del",
				division: "hr"
			},
			blockEls: [
				"div",
				"h1",
				"h2",
				"h3",
				"h4",
				"h5",
				"h6",
				"details",
				"summary",
				"li",
				"ul",
				"ol",
				"img",
				"figure",
				"figcaption",
				"video",
				"audio",
				"blockquote",
				"p",
				"aside",
				"main",
				"body"
			],
			attribute: list =>
				typeof list != "object"
					? ""
					: Object.entries(list)
							.filter(x => x[0] != "element")
							.map(
								x =>
									` ${x[0]}="${rubric.langs.html.escapeQuotes(
										typeof x[1] == "string"
											? x[1]
											: x[1].join(" ")
									)}"`
							)
							.join(""),
			element: (el, text = undefined, attrs = {}) =>
				`${
					rubric.langs.html.blockEls.includes(el) ? "\n" : ""
				}<${el}${rubric.langs.html.attribute(attrs)}>${
					text !== undefined && text !== null ? `${text}</${el}>` : ""
				}${rubric.langs.html.blockEls.includes(el) ? "\n" : ""}`,
			escape: text => text.replace(/</g, "&lt;").replace(/>/g, "&gt;"),
			escapeQuotes: text =>
				text
					.replace(/</g, "&lt;")
					.replace(/>/g, "&gt;")
					.replace(/"/g, "&quot;"),
			parseMediaSource: src => src,
			parse: (tree, options) => {
				const lang = rubric.langs.html;
				if (typeof tree == "string") {
					return options.allowRawCode ? tree : lang.escape(tree);
				}

				if (!Array.isArray(tree)) {
					if (tree.kind in lang.els) {
						return lang.els[tree.kind](tree, options, lang);
					}
					if ("text" in tree) {
						return lang.element(
							lang.aliases?.[tree.kind] ?? tree.kind,
							lang.parse(tree.text, options),
							tree.attributes
						);
					}
					return lang.element(tree.kind, null, tree.attributes);
				}

				return tree.map(x => lang.parse(x, options)).join("");
			},
			writeCaption: (raw, type, lang, url, isDefault) => {
				const grimmHasLang = !!grimm?.langs?.[lang];
				return (
					'<track kind="' +
					(type == "cc" ? "captions" : "subtitles") +
					'" srclang="' +
					(grimmHasLang ? grimm.langs[lang].long : lang) +
					'" label="' +
					(grimmHasLang
						? grimm.langs[lang].name
						: new Intl.DisplayNames([lang], {
								type: "language"
						  }).of(lang)) +
					'" src="' +
					rubric.langs.html.parseMediaSource(url) +
					'"' +
					(isDefault ? " default" : "") +
					"/>"
				);
			}
		}
	},
	escape: (text, backslashes = false) => {
		let escapedText = text.replace(
			/[⟨⟩⟪⟫]/g,
			match => `⟨${match.charCodeAt(0)}⟩`
		);
		if (backslashes) {
			escapedText = escapedText.replace(
				/\\([\p{P}\p{S}])/gu,
				match => `⟨${match.charCodeAt(1)}⟩`
			);
		}
		return escapedText;
	},
	unescape: text =>
		text.replace(/⟨([0-9]+)⟩/g, (match, code) => String.fromCharCode(code)),
	unbuffer: (text, buffer) => {
		const unbuffered = text
			.split(/(⟪[0-9]+⟫)/g)
			.map(x =>
				x.match(/(⟪[0-9]+⟫)/)
					? buffer[parseInt(x.slice(1, -1))]
					: rubric.unescape(x)
			);
		return unbuffered.length == 1 ? unbuffered[0] : unbuffered;
	},
	parseAttrs: (text, options) => {
		let attrs = { class: [] };
		if ((text === undefined) | (text === null) | (text === "")) {
			return {};
		}
		const splitArray = text
			.trim()
			.split(/([\.\:#][^\.\:#]*)/g)
			.filter(x => x != "");
		for (const attr of splitArray) {
			switch (attr[0]) {
				case ".":
					attrs.class.push(attr.slice(1));
					break;
				case "#":
					attrs.id = attr.slice(1);
					break;
				case ":":
					attrs.lang = attr.slice(1);
					break;
				default:
					attrs.element = attr;
			}
		}
		if (attrs.class.length == 0) {
			delete attrs.class;
		}
		if (!options.allowRawCode) {
			return attrs.lang ? { lang: attrs.lang } : {};
		}
		return attrs;
	},
	parseInline: (text, options, startsFrom = undefined, escape = true) => {
		const inlinePatterns = Object.entries(rubric.patterns.inline)
			.filter(
				x =>
					(options.allowSidenotes || x[0] != "sidenoteNote") &&
					(options.allowReplies || x[0] != "commentReply")
			)
			.map(entry => Object.assign(entry[1], { kind: entry[0] }))
			.sort((a, b) =>
				a.priority < b.priority ? -1 : a.priority > b.priority ? 1 : 0
			);

		if (startsFrom) {
			while (inlinePatterns[0].kind != startsFrom) {
				inlinePatterns.shift();
			}
		}

		let leafBuffer = [];

		let smooshedText = "";
		if (typeof text == "object") {
			for (const node of text) {
				if (typeof node == "string") {
					smooshedText += rubric.escape(node);
				} else {
					smooshedText += `⟪${leafBuffer.push(node) - 1}⟫`;
				}
			}
			text = smooshedText;
		}

		if (escape) {
			text = rubric.escape(text, true);
		}

		for (const pattern of inlinePatterns) {
			text = text.replace(
				pattern.parse,
				(...args) =>
					`⟪${
						leafBuffer.push(
							Object.assign(
								pattern.leaf(options, leafBuffer, ...args),
								{
									kind: pattern.kind
								}
							)
						) - 1
					}⟫`
			);
		}

		const symmetricalPatterns = Object.values(rubric.patterns.symmetrical);
		const tokens = symmetricalPatterns.map(x => x.token);

		const splitRegex = new RegExp(`(${tokens.join("|")}|⟪[0-9]+⟫)`, `g`);
		const matchRegex = new RegExp(`^(${tokens.join(")|(")})$`);
		const startsWithSpace = /^\s/;
		const endsWithSpace = /\s$/;

		let nodes = text.split(splitRegex);
		let delimiters = [];

		const smooshNodeList = list => {
			list = list.filter(x => x !== null);
			let hungryNode = null;

			for (let i = 0; i < list.length; i++) {
				if (typeof list[i] != "string") {
					hungryNode = null;
					continue;
				}
				if (hungryNode === null) {
					hungryNode = i;
					continue;
				}
				list[hungryNode] += list[i];
				list[i] = null;
			}

			const filteredList = list.filter(x => x !== null);

			return filteredList.length > 1 ? filteredList : filteredList[0];
		};

		// Generate delimiter stack
		nodes.forEach((node, i) => {
			if (node.match(/⟪[0-9]+⟫/)) {
				nodes[i] = leafBuffer[parseInt(node.slice(1, -1))];
				return;
			}
			if (!node.match(matchRegex)) {
				nodes[i] = rubric.unescape(node);
				return;
			}
			const spaceLeft =
				i == 0 ? true : nodes[i - 1].match(endsWithSpace) !== null;
			const spaceRight =
				i + 1 == nodes.length
					? true
					: nodes[i + 1].match(startsWithSpace) !== null;
			const noSpace = !spaceLeft && !spaceRight;

			if (spaceLeft && spaceRight) {
				return;
			}

			delimiters.push({
				position: i,
				kind: node.match(matchRegex).indexOf(node, 1) - 1,
				length: node.length,
				active: true,
				canStart: noSpace ? true : spaceLeft,
				canEnd: noSpace ? true : spaceRight
			});
		});

		// Scan delimiters
		while (delimiters.length > 0) {
			if (delimiters[0].canEnd && !delimiters[0].canStart) {
				delimiters.shift();
				continue;
			}

			let closer = null;
			let closerIndex = null;
			for (let i = 1; i < delimiters.length; i++) {
				if (delimiters[i].canEnd) {
					closer = delimiters[i];
					closerIndex = i;
					break;
				}
			}

			if (closer === null) {
				delimiters = [];
				break;
			}

			let opener = null;
			let openerIndex = null;
			for (let j = closerIndex - 1; j >= 0; j--) {
				if (
					delimiters[j].canStart &&
					delimiters[j].kind == closer.kind
				) {
					opener = delimiters[j];
					openerIndex = j;
					break;
				}
			}

			if (opener === null) {
				if (closerIndex == delimiters.length) {
					delimiters.pop();
				}
				if (!closer.canStart) {
					delimiters = delimiters.filter(x => x != closer);
					continue;
				}
				delimiters[closerIndex].canEnd = false;
				continue;
			}

			const delimiterLength = Math.min(opener.length, closer.length, 2);

			opener.length -= delimiterLength;
			closer.length -= delimiterLength;

			nodes[opener.position] =
				nodes[opener.position].slice(delimiterLength) || null;
			nodes[closer.position] =
				nodes[closer.position].slice(delimiterLength) || null;

			nodes[opener.position + 1] = symmetricalPatterns[opener.kind].leaf(
				options,
				delimiterLength,
				smooshNodeList(
					nodes.slice(opener.position + 1, closer.position)
				)
			);

			nodes = nodes.map((node, nodeIdx) =>
				nodeIdx <= opener.position + 1 || nodeIdx >= closer.position
					? node
					: null
			);

			delimiters = delimiters.filter(
				(delimiter, delimiterIdx) =>
					(delimiterIdx >= closerIndex ||
						delimiterIdx <= openerIndex) &&
					delimiter.length
			);
		}

		return smooshNodeList(nodes);
	},
	parseList: (list, options) => {
		return list.split(/\n(?!\t)/g).map(li => ({
			kind: "item",
			text: rubric.parseBlocks(
				li.replace(/^.*? /, "").replace(/\n\t/g, "\n"),
				Object.assign({}, options, { fullParagraphs: false })
			)
		}));
	},
	parseBlocks: (text, options = {}) => {
		const lines = text.split(/(?:\n|\r|\r\n|\n\r)/g);

		const blocks = rubric.patterns.block;
		let allowedBlocks = Object.keys(blocks).filter(
			x =>
				(options.allowMedia || x != "media") &&
				(options.allowSidenotes || x != "sidenoteBody") &&
				(options.allowRawCode || x != "div")
		);

		let context = null;
		let nestContext = 0;
		let contextBuffer = [];
		let documentTree = [];
		let nextAttributes = null;

		const pushToTree = () => {
			if (context) {
				let toSend = [contextBuffer.join("\n")];
				if ("parseRule" in blocks[context]) {
					toSend = toSend[0].match(blocks[context].parseRule);
				}
				documentTree.push(
					Object.assign(
						blocks[context].leaf(
							Object.assign({}, options, {
								fullParagraphs: true
							}),
							...toSend
						),
						{
							kind: context,
							attributes: nextAttributes || undefined
						}
					)
				);
			} else {
				// paragraph logic
				if (
					contextBuffer.length == 1 &&
					options.fullParagraphs == false
				) {
					documentTree.push(
						rubric.parseInline(contextBuffer[0].trim(), options)
					);
				} else {
					documentTree.push(
						...contextBuffer
							.join("\n")
							.trim()
							.split(/\n{2,}/g)
							.map(x => x.trim())
							.filter(x => x != "")
							.map((x, idx) => ({
								kind: "para",
								attributes:
									idx == 0
										? nextAttributes || undefined
										: undefined,
								text: rubric.parseInline(x, options)
							}))
					);
				}
			}
			context = null;
			contextBuffer = [];
			nextAttributes = null;
		};

		lines.forEach((line, index) => {
			if (context) {
				const pattern = blocks[context];
				if ("ends" in pattern && line.match(pattern.ends)) {
					contextBuffer.push(line);
					if (nestContext == 0) {
						pushToTree();
					} else {
						nestContext--;
					}
					return;
				} else if (
					"continues" in pattern &&
					line.match(pattern.continues)
				) {
					contextBuffer.push(line);
					if (pattern?.nestable && line.match(pattern.starts)) {
						nestContext++;
					}
					if (index + 1 == lines.length) {
						pushToTree();
					}
					return;
				} else {
					pushToTree();
				}
			}

			for (const tag of allowedBlocks) {
				if (line.match(blocks[tag].starts)) {
					if (!context && contextBuffer.length > 0) {
						pushToTree();
					}
					if (tag == "attributeNext") {
						nextAttributes = rubric.parseAttrs(line, options);
						return;
					}
					context = tag;
					contextBuffer.push(line);
					break;
				}
			}
			if (!context) {
				contextBuffer.push(line);
			}
			if (index + 1 == lines.length) {
				pushToTree();
			}
		});

		return documentTree;
	},
	parse: (text, target = "html", options = {}) => {
		options = Object.assign({}, rubric.options.default, options);

		if (options.customTemplates) {
			rubric.templates = Object.assign(
				rubric.templates,
				options.customTemplates
			);
		}

		if (!(target in rubric.langs)) {
			return undefined;
		}

		return rubric.langs[target].parse(
			options.inline
				? rubric.parseInline(text, options)
				: rubric.parseBlocks(text, options),
			options
		);
	}
};

export default rubric;

/*

line break: paragraph

- list items (nested with tabs)
1., 2.… ordered list (same as above)

> blockquote

``` code block (first line specifies language for highlighting)

--- horizontal line

#, ##, ###… heading

~~~ spoiler (<details>, first line is <summary> element)

*x* bold
**x** strong

/x/ italic
//x// emphasis

~x~ small
~~x~~ strikethrough <s>

++x++ underline <ins>
--x-- strikethrough <del>

asdf^x ^^x^^ superscript <sup>
asdf_y __y__ subscript <sub>

`x` code

::x:: small caps

Table syntax: Graphic
|# Column|# Column|# Column|
|# Row|Test|Post|
|Please|Ignore|This|

|# Heading| <th>
|<# Left| `text-align: start` across column
|:# Centre| `text-align: center` across column
|># Right| `text-align: end` across column

|,2 Wide| colspan="2"
|,,3 Long| rowspan="3"

|---| Separate <thead>, <tbody>, and <tfoot>
|~ Caption (<caption>)

[https://asdf.com x] link
[[x.png alt text]] image or other media
[[x.png alt text] caption] figure with caption

{.class:language#id x} span with arbitrary attributes, quite dangerous
{{template x}} arbitrary extendable templates ← TBA!!!

[^x] sidenote/footnote link
[^x]: sidenote body, which can
	contain paragraphs and such when indented with a tab

\ escape

*/