This commit is contained in:
pj committed 2026-08-28 09:22:58 +05:30
commit 852cba1840
252 files changed
+55050

No files matched your search

+145
View File
@@ -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 };
}
+256
View File
@@ -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");
});
});
+445
View File
@@ -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: ![alt](src) 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" });