mirror of
https://github.com/priyanshujain/margin-docs.git
synced 2026-10-02 11:07:05 +00:00
features
This commit is contained in:
commit
852cba1840
252 files changed
+55050
No files matched your search
@@ -0,0 +1,445 @@
|
||||
import { Schema } from "@tiptap/pm/model";
|
||||
import type { AttributeSpec, Attrs, MarkSpec, Node as ProseMirrorNode, NodeSpec } from "@tiptap/pm/model";
|
||||
|
||||
// This schema is the contract between the markdown bridge and the editor. The bridge may only
|
||||
// produce nodes declared here, and the serializer must be able to write every one of them back to
|
||||
// disk, so the set is closed: anything markdown can express that has no node becomes `raw`.
|
||||
//
|
||||
// Names are camelCase to match TipTap, so an M2 extension can adopt a spec from this file rather
|
||||
// than restating it. The order of the entries is the schema order, and it matters: the first node
|
||||
// in the `block` group is what ProseMirror fills empty content with, so `paragraph` comes first.
|
||||
|
||||
export const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const;
|
||||
|
||||
export type NodeName =
|
||||
| "doc"
|
||||
| "paragraph"
|
||||
| "heading"
|
||||
| "text"
|
||||
| "hardBreak"
|
||||
| "image"
|
||||
| "mathInline"
|
||||
| "blockquote"
|
||||
| "bulletList"
|
||||
| "orderedList"
|
||||
| "listItem"
|
||||
| "taskList"
|
||||
| "taskItem"
|
||||
| "codeBlock"
|
||||
| "horizontalRule"
|
||||
| "table"
|
||||
| "tableRow"
|
||||
| "tableHeader"
|
||||
| "tableCell"
|
||||
| "callout"
|
||||
| "toggle"
|
||||
| "mathBlock"
|
||||
| "raw";
|
||||
|
||||
export type MarkName = "link" | "strong" | "em" | "strikethrough" | "code";
|
||||
|
||||
function validateColwidth(value: unknown): void {
|
||||
if (value === null) return;
|
||||
if (Array.isArray(value) && value.every((n) => typeof n === "number")) return;
|
||||
throw new RangeError("colwidth must be null or an array of numbers");
|
||||
}
|
||||
|
||||
// colspan, rowspan and colwidth exist for prosemirror-tables, which reads those exact names.
|
||||
// GFM has no spanning cells, so a table where either span is not 1 cannot be serialized and the
|
||||
// bridge has to fall back to a raw block. align is the GFM per column alignment from the delimiter
|
||||
// row; it is carried on every cell in the column so a cell can be styled without walking upwards.
|
||||
const cellAttrs: Record<string, AttributeSpec> = {
|
||||
colspan: { default: 1, validate: "number" },
|
||||
rowspan: { default: 1, validate: "number" },
|
||||
colwidth: { default: null, validate: validateColwidth },
|
||||
align: { default: null, validate: "string|null" },
|
||||
};
|
||||
|
||||
function cellAttrsFromDOM(dom: HTMLElement): Attrs {
|
||||
const widths = dom.getAttribute("data-colwidth");
|
||||
return {
|
||||
colspan: Number(dom.getAttribute("colspan") ?? 1),
|
||||
rowspan: Number(dom.getAttribute("rowspan") ?? 1),
|
||||
colwidth: widths && /^\d+(,\d+)*$/.test(widths) ? widths.split(",").map(Number) : null,
|
||||
align: dom.getAttribute("data-align") || null,
|
||||
};
|
||||
}
|
||||
|
||||
function cellAttrsToDOM(node: ProseMirrorNode): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (node.attrs.colspan !== 1) out.colspan = String(node.attrs.colspan);
|
||||
if (node.attrs.rowspan !== 1) out.rowspan = String(node.attrs.rowspan);
|
||||
if (node.attrs.colwidth) out["data-colwidth"] = (node.attrs.colwidth as number[]).join(",");
|
||||
if (node.attrs.align) out["data-align"] = String(node.attrs.align);
|
||||
return out;
|
||||
}
|
||||
|
||||
export const nodes: { [name in NodeName]: NodeSpec } = {
|
||||
doc: {
|
||||
content: "block+",
|
||||
},
|
||||
|
||||
// `whitespace: "pre"` because a newline inside a paragraph is the author's own line wrap and not
|
||||
// a space this editor may reflow. src/markdown/parse.ts keeps the soft breaks mdast gives it
|
||||
// inside the text so a hand wrapped file is written back wrapped where it was, and prose.css
|
||||
// draws a paragraph pre-wrap so those newlines are on screen exactly where the author put them.
|
||||
// This field is the third part of that sentence, and it was the missing one: prosemirror-view
|
||||
// reads it to decide how to parse the editor's own DOM back after a keystroke, and left at the
|
||||
// default every soft wrap in the paragraph being typed into came back as a `hardBreak`, so one
|
||||
// character typed into a hand wrapped paragraph put a backslash at every wrap point in it. It is
|
||||
// also what lets Backspace join two hand wrapped paragraphs without rewriting both their wraps
|
||||
// as breaks, since prosemirror-transform asks the same field before it joins.
|
||||
//
|
||||
// The parse rule says the opposite on purpose, and a rule's own answer outranks the node's:
|
||||
// whitespace is significant in a paragraph THIS editor rendered, and meaningless in a `<p>` off
|
||||
// somebody else's page, where the newlines are the html source's own indentation and keeping
|
||||
// them would put line breaks and runs of spaces through the middle of a pasted sentence.
|
||||
paragraph: {
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
whitespace: "pre",
|
||||
parseDOM: [{ tag: "p", preserveWhitespace: false }],
|
||||
toDOM: () => ["p", 0],
|
||||
},
|
||||
|
||||
heading: {
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
attrs: { level: { default: 1, validate: "number" } },
|
||||
parseDOM: HEADING_LEVELS.map((level) => ({ tag: `h${level}`, attrs: { level } })),
|
||||
toDOM: (node) => [`h${node.attrs.level}`, 0],
|
||||
},
|
||||
|
||||
text: {
|
||||
group: "inline",
|
||||
},
|
||||
|
||||
hardBreak: {
|
||||
inline: true,
|
||||
group: "inline",
|
||||
selectable: false,
|
||||
linebreakReplacement: true,
|
||||
parseDOM: [{ tag: "br" }],
|
||||
toDOM: () => ["br"],
|
||||
},
|
||||
|
||||
// Markdown has no block image:  is phrasing content, and a picture on its own line is
|
||||
// a paragraph whose only child is this node. Modelling it as a block would rewrite the file.
|
||||
image: {
|
||||
inline: true,
|
||||
group: "inline",
|
||||
draggable: true,
|
||||
attrs: {
|
||||
src: { default: "", validate: "string" },
|
||||
alt: { default: null, validate: "string|null" },
|
||||
title: { default: null, validate: "string|null" },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "img[src]",
|
||||
getAttrs: (dom) => ({
|
||||
src: dom.getAttribute("src") ?? "",
|
||||
alt: dom.getAttribute("alt"),
|
||||
title: dom.getAttribute("title"),
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => ["img", { src: node.attrs.src, alt: node.attrs.alt, title: node.attrs.title }],
|
||||
},
|
||||
|
||||
mathInline: {
|
||||
inline: true,
|
||||
group: "inline",
|
||||
atom: true,
|
||||
attrs: { latex: { default: "", validate: "string" } },
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "span[data-latex]",
|
||||
getAttrs: (dom) => ({ latex: dom.getAttribute("data-latex") ?? "" }),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => ["span", { class: "math-inline", "data-latex": node.attrs.latex }, node.attrs.latex],
|
||||
},
|
||||
|
||||
blockquote: {
|
||||
content: "block+",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{ tag: "blockquote" }],
|
||||
toDOM: () => ["blockquote", 0],
|
||||
},
|
||||
|
||||
// Task items are allowed in the plain lists as well as in taskList, because GFM lets a single
|
||||
// list mix "- [ ] done" items with ordinary ones. The bridge emits taskList only when every item
|
||||
// carries a checkbox; a mixed list is a bulletList or orderedList holding both kinds of item.
|
||||
bulletList: {
|
||||
content: "(listItem | taskItem)+",
|
||||
group: "block",
|
||||
attrs: { tight: { default: true, validate: "boolean" } },
|
||||
parseDOM: [{ tag: "ul", getAttrs: (dom) => ({ tight: !dom.hasAttribute("data-loose") }) }],
|
||||
toDOM: (node) => ["ul", { "data-loose": node.attrs.tight ? null : "" }, 0],
|
||||
},
|
||||
|
||||
orderedList: {
|
||||
content: "(listItem | taskItem)+",
|
||||
group: "block",
|
||||
attrs: {
|
||||
start: { default: 1, validate: "number" },
|
||||
tight: { default: true, validate: "boolean" },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "ol",
|
||||
getAttrs: (dom) => ({
|
||||
start: Number(dom.getAttribute("start") ?? 1),
|
||||
tight: !dom.hasAttribute("data-loose"),
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
"ol",
|
||||
{ start: node.attrs.start === 1 ? null : node.attrs.start, "data-loose": node.attrs.tight ? null : "" },
|
||||
0,
|
||||
],
|
||||
},
|
||||
|
||||
// An item opens with a paragraph, which is what makes Enter, Tab and Backspace behave and what
|
||||
// the TipTap list commands assume. Markdown does allow an item that starts with a nested list or
|
||||
// a fence, so the bridge writes those items' list out as a raw block instead of mangling it.
|
||||
listItem: {
|
||||
content: "paragraph block*",
|
||||
defining: true,
|
||||
parseDOM: [{ tag: "li" }],
|
||||
toDOM: () => ["li", 0],
|
||||
},
|
||||
|
||||
taskList: {
|
||||
content: "taskItem+",
|
||||
group: "block",
|
||||
attrs: { tight: { default: true, validate: "boolean" } },
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "ul[data-task-list]",
|
||||
priority: 60,
|
||||
getAttrs: (dom) => ({ tight: !dom.hasAttribute("data-loose") }),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => ["ul", { class: "task-list", "data-task-list": "", "data-loose": node.attrs.tight ? null : "" }, 0],
|
||||
},
|
||||
|
||||
taskItem: {
|
||||
content: "paragraph block*",
|
||||
defining: true,
|
||||
attrs: { checked: { default: false, validate: "boolean" } },
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "li[data-checked]",
|
||||
priority: 60,
|
||||
getAttrs: (dom) => ({ checked: dom.getAttribute("data-checked") !== "false" }),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => ["li", { class: "task-item", "data-checked": node.attrs.checked ? "true" : "false" }, 0],
|
||||
},
|
||||
|
||||
// meta is everything after the language on the opening fence, ```ts twoslash. Dropping it would
|
||||
// silently rewrite the file, so it rides along even though nothing reads it yet.
|
||||
codeBlock: {
|
||||
content: "text*",
|
||||
marks: "",
|
||||
group: "block",
|
||||
code: true,
|
||||
defining: true,
|
||||
whitespace: "pre",
|
||||
attrs: {
|
||||
language: { default: null, validate: "string|null" },
|
||||
meta: { default: null, validate: "string|null" },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "pre",
|
||||
preserveWhitespace: "full",
|
||||
getAttrs: (dom) => ({ language: dom.getAttribute("data-language"), meta: null }),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => ["pre", { "data-language": node.attrs.language }, ["code", 0]],
|
||||
},
|
||||
|
||||
horizontalRule: {
|
||||
group: "block",
|
||||
parseDOM: [{ tag: "hr" }],
|
||||
toDOM: () => ["hr"],
|
||||
},
|
||||
|
||||
table: {
|
||||
content: "tableRow+",
|
||||
group: "block",
|
||||
tableRole: "table",
|
||||
isolating: true,
|
||||
parseDOM: [{ tag: "table" }],
|
||||
toDOM: () => ["table", ["tbody", 0]],
|
||||
},
|
||||
|
||||
tableRow: {
|
||||
content: "(tableCell | tableHeader)*",
|
||||
tableRole: "row",
|
||||
parseDOM: [{ tag: "tr" }],
|
||||
toDOM: () => ["tr", 0],
|
||||
},
|
||||
|
||||
// Cells hold inline content, not blocks: a GFM cell cannot contain a paragraph, a list or a
|
||||
// fenced block, and allowing them would let the editor build a table that cannot be written out.
|
||||
tableHeader: {
|
||||
content: "inline*",
|
||||
attrs: cellAttrs,
|
||||
tableRole: "header_cell",
|
||||
isolating: true,
|
||||
parseDOM: [{ tag: "th", getAttrs: (dom) => cellAttrsFromDOM(dom) }],
|
||||
toDOM: (node) => ["th", cellAttrsToDOM(node), 0],
|
||||
},
|
||||
|
||||
tableCell: {
|
||||
content: "inline*",
|
||||
attrs: cellAttrs,
|
||||
tableRole: "cell",
|
||||
isolating: true,
|
||||
parseDOM: [{ tag: "td", getAttrs: (dom) => cellAttrsFromDOM(dom) }],
|
||||
toDOM: (node) => ["td", cellAttrsToDOM(node), 0],
|
||||
},
|
||||
|
||||
// A GitHub alert: a blockquote whose first line is "> [!NOTE]".
|
||||
callout: {
|
||||
content: "block+",
|
||||
group: "block",
|
||||
defining: true,
|
||||
attrs: { kind: { default: "note", validate: "string" } },
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "div[data-callout]",
|
||||
getAttrs: (dom) => ({ kind: dom.getAttribute("data-callout") || "note" }),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => ["div", { class: "callout", "data-callout": node.attrs.kind }, 0],
|
||||
},
|
||||
|
||||
// <details> on disk. The summary is an attribute rather than a child node, so it is plain text:
|
||||
// a <summary> carrying markup is not modellable and belongs in a raw block.
|
||||
toggle: {
|
||||
content: "block+",
|
||||
group: "block",
|
||||
defining: true,
|
||||
attrs: {
|
||||
summary: { default: "", validate: "string" },
|
||||
open: { default: false, validate: "boolean" },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "details",
|
||||
contentElement: (dom) => dom.querySelector<HTMLElement>("[data-toggle-body]") ?? dom,
|
||||
getAttrs: (dom) => ({
|
||||
summary: dom.querySelector("summary")?.textContent ?? "",
|
||||
open: dom.hasAttribute("open"),
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
"details",
|
||||
{ class: "toggle", open: node.attrs.open ? "" : null },
|
||||
["summary", {}, node.attrs.summary],
|
||||
["div", { "data-toggle-body": "" }, 0],
|
||||
],
|
||||
},
|
||||
|
||||
mathBlock: {
|
||||
group: "block",
|
||||
atom: true,
|
||||
attrs: { latex: { default: "", validate: "string" } },
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "div[data-math-block]",
|
||||
getAttrs: (dom) => ({ latex: dom.getAttribute("data-latex") ?? "" }),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
"div",
|
||||
{ class: "math-block", "data-math-block": "", "data-latex": node.attrs.latex },
|
||||
node.attrs.latex,
|
||||
],
|
||||
},
|
||||
|
||||
// The safety net. Anything the bridge cannot model, an HTML block, a footnote definition, a
|
||||
// table with spanning cells, a construct nobody thought of, becomes one of these and MUST round
|
||||
// trip byte identical: the serializer writes the text content out verbatim, with no escaping, no
|
||||
// reflowing and no trailing newline of its own.
|
||||
//
|
||||
// The node holds the source twice on purpose. `source` is the slice exactly as it was read from
|
||||
// disk and never changes; the text content is the same string, but editable, because the block
|
||||
// is shown as a monospace field the user can type in. While the two are equal the block is
|
||||
// provably untouched and the bytes are the file's own. Once they differ the user has edited it
|
||||
// and the text content wins. Never write to `source` after parsing.
|
||||
raw: {
|
||||
content: "text*",
|
||||
marks: "",
|
||||
group: "block",
|
||||
atom: false,
|
||||
code: true,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
whitespace: "pre",
|
||||
attrs: { source: { default: "", validate: "string" } },
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "pre[data-raw]",
|
||||
priority: 60,
|
||||
preserveWhitespace: "full",
|
||||
getAttrs: (dom) => ({ source: dom.textContent ?? "" }),
|
||||
},
|
||||
],
|
||||
toDOM: () => ["pre", { class: "raw-block", "data-raw": "" }, ["code", 0]],
|
||||
},
|
||||
};
|
||||
|
||||
export const marks: { [name in MarkName]: MarkSpec } = {
|
||||
link: {
|
||||
inclusive: false,
|
||||
attrs: {
|
||||
href: { default: null, validate: "string|null" },
|
||||
title: { default: null, validate: "string|null" },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "a[href]",
|
||||
getAttrs: (dom) => ({ href: dom.getAttribute("href"), title: dom.getAttribute("title") }),
|
||||
},
|
||||
],
|
||||
toDOM: (mark) => ["a", { href: mark.attrs.href, title: mark.attrs.title }, 0],
|
||||
},
|
||||
|
||||
strong: {
|
||||
group: "formatting",
|
||||
parseDOM: [{ tag: "strong" }, { tag: "b" }],
|
||||
toDOM: () => ["strong", 0],
|
||||
},
|
||||
|
||||
em: {
|
||||
group: "formatting",
|
||||
parseDOM: [{ tag: "em" }, { tag: "i" }],
|
||||
toDOM: () => ["em", 0],
|
||||
},
|
||||
|
||||
strikethrough: {
|
||||
group: "formatting",
|
||||
parseDOM: [{ tag: "s" }, { tag: "del" }, { tag: "strike" }],
|
||||
toDOM: () => ["s", 0],
|
||||
},
|
||||
|
||||
// A code span is literal, so it excludes the emphasis marks, but not link: [`x`](y) is valid.
|
||||
code: {
|
||||
code: true,
|
||||
excludes: "formatting code",
|
||||
parseDOM: [{ tag: "code" }],
|
||||
toDOM: () => ["code", 0],
|
||||
},
|
||||
};
|
||||
|
||||
export const schema = new Schema<NodeName, MarkName>({ nodes, marks, topNode: "doc" });
|
||||
Reference in new issue
Block a user