mirror of
https://github.com/priyanshujain/margin-docs.git
synced 2026-10-02 19:17:05 +00:00
features
This commit is contained in:
commit
852cba1840
252 files changed
+55050
No files matched your search
@@ -0,0 +1,145 @@
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { schema } from "./schema";
|
||||
|
||||
export type DocumentKind = "markdown" | "text";
|
||||
|
||||
export const MARKDOWN_EXTENSIONS = ["md", "markdown", "mdown", "mkd", "mkdn"] as const;
|
||||
export const TEXT_EXTENSIONS = ["txt", "text"] as const;
|
||||
|
||||
/**
|
||||
* A file the editor can open. Everything else in the tree is greyed out and handed to the system.
|
||||
*/
|
||||
export function documentKindForPath(path: string): DocumentKind | null {
|
||||
const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
if (dot <= 0) return null;
|
||||
const ext = name.slice(dot + 1);
|
||||
if ((MARKDOWN_EXTENSIONS as readonly string[]).includes(ext)) return "markdown";
|
||||
if ((TEXT_EXTENSIONS as readonly string[]).includes(ext)) return "text";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An open document, as the document store holds it and as the bridge produces and consumes it.
|
||||
*
|
||||
* `frontmatter` is the leading metadata block exactly as it was read, delimiter lines and trailing
|
||||
* newline included, or null when the file has none. It is deliberately not a node in the schema:
|
||||
* once YAML becomes a tree of nodes, writing it back means re-emitting it, and a serializer will
|
||||
* reorder keys, requote strings, restyle lists and collapse blank lines. Held here as an opaque
|
||||
* string it is never parsed, so `frontmatter + serialize(doc)` reproduces the original bytes of
|
||||
* the metadata no matter what it contained, including formats the app does not understand at all
|
||||
* such as TOML fenced with +++.
|
||||
*
|
||||
* `source` is the whole file as it was read, kept so a save can be compared against it and so a
|
||||
* document whose round trip is not byte identical can be detected rather than silently rewritten.
|
||||
*/
|
||||
export interface MarkdownDocument {
|
||||
frontmatter: string | null;
|
||||
doc: ProseMirrorNode;
|
||||
source: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
export type CalloutKind = "note" | "tip" | "important" | "warning" | "caution";
|
||||
|
||||
export const CALLOUT_KINDS: readonly CalloutKind[] = ["note", "tip", "important", "warning", "caution"];
|
||||
|
||||
/** The label inside "> [!NOTE]" on disk. GitHub only recognises these five, in upper case. */
|
||||
export const CALLOUT_LABELS: Record<CalloutKind, string> = {
|
||||
note: "NOTE",
|
||||
tip: "TIP",
|
||||
important: "IMPORTANT",
|
||||
warning: "WARNING",
|
||||
caution: "CAUTION",
|
||||
};
|
||||
|
||||
export function calloutKindFromLabel(label: string): CalloutKind | null {
|
||||
const lower = label.trim().toLowerCase();
|
||||
return CALLOUT_KINDS.find((kind) => kind === lower) ?? null;
|
||||
}
|
||||
|
||||
/** GFM column alignment, from the delimiter row. null is the writer's default. */
|
||||
export type ColumnAlign = "left" | "center" | "right" | null;
|
||||
|
||||
export interface HeadingAttrs {
|
||||
level: HeadingLevel;
|
||||
}
|
||||
|
||||
export interface ImageAttrs {
|
||||
src: string;
|
||||
alt: string | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface LinkAttrs {
|
||||
href: string | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface CodeBlockAttrs {
|
||||
language: string | null;
|
||||
meta: string | null;
|
||||
}
|
||||
|
||||
export interface ListAttrs {
|
||||
tight: boolean;
|
||||
}
|
||||
|
||||
export interface OrderedListAttrs extends ListAttrs {
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface TaskItemAttrs {
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
export interface TableCellAttrs {
|
||||
colspan: number;
|
||||
rowspan: number;
|
||||
colwidth: number[] | null;
|
||||
align: ColumnAlign;
|
||||
}
|
||||
|
||||
export interface CalloutAttrs {
|
||||
kind: CalloutKind;
|
||||
}
|
||||
|
||||
export interface ToggleAttrs {
|
||||
summary: string;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface MathAttrs {
|
||||
latex: string;
|
||||
}
|
||||
|
||||
export interface RawAttrs {
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The only correct way to build a raw block: the text content starts out equal to the attribute,
|
||||
* which is the invariant the byte identical round trip depends on.
|
||||
*/
|
||||
export function rawNode(source: string): ProseMirrorNode {
|
||||
return schema.nodes.raw.create({ source }, source ? schema.text(source) : null);
|
||||
}
|
||||
|
||||
/** What the serializer writes for a raw block: the user's edit if there was one, else the file's own bytes. */
|
||||
export function rawOutput(node: ProseMirrorNode): string {
|
||||
return node.textContent;
|
||||
}
|
||||
|
||||
export function isRawUnchanged(node: ProseMirrorNode): boolean {
|
||||
return node.textContent === node.attrs.source;
|
||||
}
|
||||
|
||||
export function emptyDoc(): ProseMirrorNode {
|
||||
return schema.nodes.doc.create(null, schema.nodes.paragraph.create());
|
||||
}
|
||||
|
||||
export function emptyMarkdownDocument(path: string): MarkdownDocument {
|
||||
return { frontmatter: null, doc: emptyDoc(), source: "", path };
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Schema } from "@tiptap/pm/model";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { CALLOUT_KINDS, isRawUnchanged, rawNode } from "./doc";
|
||||
import { HEADING_LEVELS, schema } from "./schema";
|
||||
import type { MarkName, NodeName } from "./schema";
|
||||
|
||||
// The lists are typed against the name unions, so a rename that misses one end fails to compile
|
||||
// before it fails to run.
|
||||
const EXPECTED_NODES: NodeName[] = [
|
||||
"doc",
|
||||
"paragraph",
|
||||
"heading",
|
||||
"text",
|
||||
"hardBreak",
|
||||
"image",
|
||||
"mathInline",
|
||||
"blockquote",
|
||||
"bulletList",
|
||||
"orderedList",
|
||||
"listItem",
|
||||
"taskList",
|
||||
"taskItem",
|
||||
"codeBlock",
|
||||
"horizontalRule",
|
||||
"table",
|
||||
"tableRow",
|
||||
"tableHeader",
|
||||
"tableCell",
|
||||
"callout",
|
||||
"toggle",
|
||||
"mathBlock",
|
||||
"raw",
|
||||
];
|
||||
|
||||
const EXPECTED_MARKS: MarkName[] = ["link", "strong", "em", "strikethrough", "code"];
|
||||
|
||||
const n = schema.nodes;
|
||||
const m = schema.marks;
|
||||
|
||||
function kitchenSink(): ProseMirrorNode {
|
||||
return n.doc.createChecked(null, [
|
||||
n.heading.createChecked({ level: 2 }, schema.text("Title")),
|
||||
n.paragraph.createChecked(null, [
|
||||
schema.text("bold", [m.strong.create()]),
|
||||
schema.text(" plain "),
|
||||
schema.text("struck", [m.strikethrough.create()]),
|
||||
schema.text("span", [m.code.create()]),
|
||||
schema.text("link", [m.link.create({ href: "./other.md", title: "Other" })]),
|
||||
schema.text("slanted", [m.em.create()]),
|
||||
n.hardBreak.createChecked(),
|
||||
n.image.createChecked({ src: "assets/shot.png", alt: "A shot", title: null }),
|
||||
n.mathInline.createChecked({ latex: "x^2" }),
|
||||
]),
|
||||
n.blockquote.createChecked(null, n.paragraph.createChecked(null, schema.text("quoted"))),
|
||||
n.bulletList.createChecked({ tight: false }, [
|
||||
n.listItem.createChecked(null, n.paragraph.createChecked(null, schema.text("plain item"))),
|
||||
n.taskItem.createChecked({ checked: true }, n.paragraph.createChecked(null, schema.text("mixed in"))),
|
||||
]),
|
||||
n.orderedList.createChecked(
|
||||
{ start: 3 },
|
||||
n.listItem.createChecked(null, n.paragraph.createChecked(null, schema.text("third"))),
|
||||
),
|
||||
n.taskList.createChecked(
|
||||
null,
|
||||
n.taskItem.createChecked({ checked: false }, n.paragraph.createChecked(null, schema.text("todo"))),
|
||||
),
|
||||
n.codeBlock.createChecked({ language: "rust", meta: "ignore" }, schema.text("fn main() {}\n")),
|
||||
n.horizontalRule.createChecked(),
|
||||
n.table.createChecked(null, [
|
||||
n.tableRow.createChecked(null, [
|
||||
n.tableHeader.createChecked({ align: "center" }, schema.text("head")),
|
||||
n.tableHeader.createChecked({ align: "right" }, schema.text("count")),
|
||||
]),
|
||||
n.tableRow.createChecked(null, [
|
||||
n.tableCell.createChecked({ align: "center" }, schema.text("body")),
|
||||
n.tableCell.createChecked({ align: "right" }, schema.text("2")),
|
||||
]),
|
||||
]),
|
||||
n.callout.createChecked({ kind: "warning" }, n.paragraph.createChecked(null, schema.text("careful"))),
|
||||
n.toggle.createChecked(
|
||||
{ summary: "Show more", open: true },
|
||||
n.paragraph.createChecked(null, schema.text("hidden")),
|
||||
),
|
||||
n.mathBlock.createChecked({ latex: "\\int_0^1 x" }),
|
||||
rawNode("<figure>\n <img src=x>\n</figure>"),
|
||||
]);
|
||||
}
|
||||
|
||||
describe("schema", () => {
|
||||
it("is a prosemirror schema whose top node is doc", () => {
|
||||
expect(schema).toBeInstanceOf(Schema);
|
||||
expect(schema.topNodeType.name).toBe("doc");
|
||||
expect(n.doc.spec.content).toBe("block+");
|
||||
});
|
||||
|
||||
it("declares exactly the required nodes", () => {
|
||||
expect(Object.keys(schema.nodes).sort()).toEqual([...EXPECTED_NODES].sort());
|
||||
});
|
||||
|
||||
it("declares exactly the required marks", () => {
|
||||
expect(Object.keys(schema.marks).sort()).toEqual([...EXPECTED_MARKS].sort());
|
||||
});
|
||||
|
||||
it("fills an empty document with a paragraph", () => {
|
||||
const doc = n.doc.createAndFill();
|
||||
expect(doc?.childCount).toBe(1);
|
||||
expect(doc?.firstChild?.type.name).toBe("paragraph");
|
||||
});
|
||||
|
||||
it("round trips a document using every node and mark", () => {
|
||||
const doc = kitchenSink();
|
||||
doc.check();
|
||||
const json = doc.toJSON();
|
||||
const back = schema.nodeFromJSON(json);
|
||||
expect(back.toJSON()).toEqual(json);
|
||||
expect(back.eq(doc)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("heading", () => {
|
||||
it("accepts all six levels", () => {
|
||||
for (const level of HEADING_LEVELS) {
|
||||
const heading = n.heading.createChecked({ level }, schema.text(`level ${level}`));
|
||||
expect(heading.attrs.level).toBe(level);
|
||||
expect(schema.nodeFromJSON(heading.toJSON()).attrs.level).toBe(level);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a level that is not a number", () => {
|
||||
expect(() => schema.nodeFromJSON({ type: "heading", attrs: { level: "2" } })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("raw", () => {
|
||||
const source = "| a | b |\n|:-:|--:|\n<!-- kept -->\r\n\t\\| escaped |\n$$ not math $$\n";
|
||||
|
||||
it("is an editable code block that disallows marks", () => {
|
||||
expect(n.raw.isBlock).toBe(true);
|
||||
expect(n.raw.isAtom).toBe(false);
|
||||
expect(n.raw.spec.code).toBe(true);
|
||||
expect(n.raw.spec.marks).toBe("");
|
||||
expect(n.raw.allowsMarkType(m.strong)).toBe(false);
|
||||
expect(n.raw.allowsMarkType(m.code)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves an arbitrary source string through toJSON and nodeFromJSON", () => {
|
||||
const node = rawNode(source);
|
||||
expect(node.attrs.source).toBe(source);
|
||||
expect(node.textContent).toBe(source);
|
||||
|
||||
const back = schema.nodeFromJSON(node.toJSON());
|
||||
expect(back.attrs.source).toBe(source);
|
||||
expect(back.textContent).toBe(source);
|
||||
expect(back.eq(node)).toBe(true);
|
||||
});
|
||||
|
||||
it("holds an empty source without an empty text node", () => {
|
||||
const node = rawNode("");
|
||||
expect(node.childCount).toBe(0);
|
||||
expect(node.attrs.source).toBe("");
|
||||
expect(isRawUnchanged(node)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports an edited block as changed while the original source stays put", () => {
|
||||
const edited = n.raw.createChecked({ source }, schema.text("typed over"));
|
||||
expect(isRawUnchanged(edited)).toBe(false);
|
||||
expect(edited.attrs.source).toBe(source);
|
||||
});
|
||||
});
|
||||
|
||||
describe("code block", () => {
|
||||
it("holds plain text only", () => {
|
||||
expect(n.codeBlock.spec.code).toBe(true);
|
||||
expect(n.codeBlock.allowsMarkType(m.strong)).toBe(false);
|
||||
expect(n.codeBlock.create().attrs).toEqual({ language: null, meta: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("lists", () => {
|
||||
it("lets a plain list carry task items, because GFM lets one list mix them", () => {
|
||||
expect(n.bulletList.contentMatch.matchType(n.taskItem)).toBeTruthy();
|
||||
expect(n.bulletList.contentMatch.matchType(n.listItem)).toBeTruthy();
|
||||
expect(n.orderedList.contentMatch.matchType(n.taskItem)).toBeTruthy();
|
||||
expect(n.taskList.contentMatch.matchType(n.listItem)).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to a tight list starting at one", () => {
|
||||
expect(n.bulletList.create().attrs).toEqual({ tight: true });
|
||||
expect(n.orderedList.create().attrs).toEqual({ start: 1, tight: true });
|
||||
expect(n.taskItem.create().attrs).toEqual({ checked: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("tables", () => {
|
||||
it("carries the roles prosemirror-tables reads", () => {
|
||||
expect(n.table.spec.tableRole).toBe("table");
|
||||
expect(n.tableRow.spec.tableRole).toBe("row");
|
||||
expect(n.tableHeader.spec.tableRole).toBe("header_cell");
|
||||
expect(n.tableCell.spec.tableRole).toBe("cell");
|
||||
});
|
||||
|
||||
it("gives cells the span, width and alignment attributes", () => {
|
||||
expect(n.tableCell.create().attrs).toEqual({ colspan: 1, rowspan: 1, colwidth: null, align: null });
|
||||
expect(n.tableHeader.create().attrs).toEqual({ colspan: 1, rowspan: 1, colwidth: null, align: null });
|
||||
const sized = n.tableCell.createChecked({ colwidth: [120, 80] });
|
||||
expect(schema.nodeFromJSON(sized.toJSON()).attrs.colwidth).toEqual([120, 80]);
|
||||
});
|
||||
|
||||
it("keeps cells to inline content, which is all GFM can write", () => {
|
||||
expect(n.tableCell.contentMatch.matchType(n.paragraph)).toBeNull();
|
||||
expect(n.tableCell.contentMatch.matchType(n.text)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("callout and toggle", () => {
|
||||
it("round trips every alert kind", () => {
|
||||
for (const kind of CALLOUT_KINDS) {
|
||||
const node = n.callout.createChecked({ kind }, n.paragraph.createChecked(null, schema.text(kind)));
|
||||
expect(schema.nodeFromJSON(node.toJSON()).attrs.kind).toBe(kind);
|
||||
}
|
||||
expect(n.callout.create().attrs).toEqual({ kind: "note" });
|
||||
});
|
||||
|
||||
it("keeps the toggle summary and open state as attributes", () => {
|
||||
expect(n.toggle.create().attrs).toEqual({ summary: "", open: false });
|
||||
const node = n.toggle.createChecked(
|
||||
{ summary: "Details", open: true },
|
||||
n.paragraph.createChecked(null, schema.text("body")),
|
||||
);
|
||||
expect(schema.nodeFromJSON(node.toJSON()).attrs).toEqual({ summary: "Details", open: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("inline", () => {
|
||||
it("keeps image and link attributes", () => {
|
||||
expect(n.image.create().attrs).toEqual({ src: "", alt: null, title: null });
|
||||
expect(m.link.create().attrs).toEqual({ href: null, title: null });
|
||||
const link = m.link.create({ href: "./a.md", title: "A" });
|
||||
expect(link.toJSON()).toEqual({ type: "link", attrs: { href: "./a.md", title: "A" } });
|
||||
});
|
||||
|
||||
it("lets a code span sit inside a link but not inside emphasis", () => {
|
||||
const code = m.code.create();
|
||||
expect(code.isInSet(m.link.create({ href: "./a.md" }).addToSet([code]))).toBeTruthy();
|
||||
expect(m.strong.create().addToSet([code])).toEqual([code]);
|
||||
});
|
||||
|
||||
it("treats math and images as inline atoms", () => {
|
||||
expect(n.mathInline.isInline).toBe(true);
|
||||
expect(n.mathInline.isAtom).toBe(true);
|
||||
expect(n.image.isInline).toBe(true);
|
||||
expect(n.mathBlock.isBlock).toBe(true);
|
||||
expect(schema.nodeFromJSON(n.mathInline.create({ latex: "a_b" }).toJSON()).attrs.latex).toBe("a_b");
|
||||
});
|
||||
});
|
||||
@@ -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