stoat-for-desktop/components/markdown/prosemirror/from-model.ts

194 lines
4.6 KiB
TypeScript

import type { ListItem, PhrasingContent, Root, RootContent } from "mdast";
import { CodeBlockNodeName } from "prosemirror-codemirror-block";
import { Node } from "prosemirror-model";
import remarkStringify from "remark-stringify";
import { unifiedPipeline } from "..";
import { UNICODE_EMOJI_PACK_PUA } from "../emoji/UnicodeEmoji";
import { schema } from "./schema";
const pipeline = unifiedPipeline.use(remarkStringify);
// NB. https://github.com/syntax-tree/mdast
function map(node: Node): RootContent {
// apply marks
if (node.marks.length) {
const mark = node.marks[0];
switch (mark.type.name as keyof (typeof schema)["marks"]) {
case "strong":
return {
type: "strong",
children: [
map({
...node,
marks: node.marks.slice(1),
} as never) as PhrasingContent,
],
};
case "em":
return {
type: "emphasis",
children: [
map({
...node,
marks: node.marks.slice(1),
} as never) as PhrasingContent,
],
};
// todo: enable the gfm delete plugin ONLY
// case "strikethrough":
// return {
// type: "delete",
// children: [
// map({
// ...node,
// marks: node.marks.slice(1),
// } as never) as PhrasingContent,
// ],
// };
case "link":
return {
type: "link",
url: node.attrs.href,
title: node.attrs.title ?? node.attrs.href,
children: [
map({
...node,
marks: node.marks.slice(1),
} as never) as PhrasingContent,
],
};
case "code":
return {
type: "inlineCode",
value: node.text ?? "",
};
}
}
// apply node
switch (node.type.name as keyof (typeof schema)["nodes"]) {
case "paragraph":
return {
type: "paragraph",
children: node.children.map(map) as PhrasingContent[],
};
case "text":
return {
type: "text",
value: node.text!,
};
case CodeBlockNodeName:
return {
type: "code",
lang: node.attrs.lang,
value: node.textContent,
};
case "heading":
return {
type: "heading",
depth: node.attrs.level,
children: node.children.map(map) as PhrasingContent[],
};
case "bullet_list":
return {
type: "list",
ordered: false,
children: node.children.map(map) as ListItem[],
};
case "ordered_list":
return {
type: "list",
ordered: true,
start: node.attrs.order,
children: node.children.map(map) as ListItem[],
};
case "list_item":
return {
type: "listItem",
children: node.children.map(map) as never,
};
// RFM
case "rfm_custom_emoji":
return {
type: "text",
value: `:${node.attrs.id}:`,
};
case "rfm_unicode_emoji":
return {
type: "text",
value: `${UNICODE_EMOJI_PACK_PUA[node.attrs.pack as never] ?? ""}${node.attrs.id}`,
};
case "rfm_user_mention":
return {
type: "text",
value: `<@${node.attrs.id}>`,
};
case "rfm_role_mention":
return {
type: "text",
value: `<%${node.attrs.id}>`,
};
case "rfm_channel_mention":
return {
type: "text",
value: `<#${node.attrs.id}>`,
};
default:
console.info("Failing node:", node);
return {
type: "text",
value: `[missing ${node.type.name} serializer]`,
};
}
}
/**
* Regex for matching double new lines
*/
const RE_DOUBLE_NEW_LINES = /\n\n/g;
/**
* Regex for matching special marker in codeblock
*/
const RE_CODEBLOCK_MARKER = /(?<=`{3}[\s\S]*)\uF8FF(?=[\s\S]*`{3})/gm;
/**
* Regex for matching special marker
*/
const RE_MARKER = /\uF8FF/g;
export function markdownFromProseMirrorModel(model: Node) {
// console.info(model);
if (model.type.name !== "doc") {
throw "root node should be 'doc'?";
}
const root: Root = {
type: "root",
children: model.children.map(map),
};
// console.info(JSON.stringify(root));
// console.info(pipeline.stringify(root));
return (
pipeline
.stringify(root)
// Replace double newlines with special marker
.replace(RE_DOUBLE_NEW_LINES, "\uF8FF")
// Skip the ones in codeblocks
.replace(RE_CODEBLOCK_MARKER, "\n\n")
// And now put a single newline
.replace(RE_MARKER, "\n")
// Strip spaces and newlines at start and end
.trim()
);
}