Skip to content

Commit f4e5890

Browse files
Render markdown tables in summaries
Add table parsing, editor schema support, and markdown round-trip tests for generated summary tables.
1 parent 14c55ed commit f4e5890

3 files changed

Lines changed: 329 additions & 0 deletions

File tree

packages/editor/src/markdown.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Node as PMNode } from "prosemirror-model";
12
import { describe, expect, test } from "vitest";
23

34
import {
@@ -8,6 +9,7 @@ import {
89
md2json,
910
parseJsonContent,
1011
} from "./markdown";
12+
import { schema as noteSchema } from "./note/schema";
1113

1214
describe("json2md", () => {
1315
test("renders underline as html tags", () => {
@@ -106,6 +108,69 @@ describe("json2md", () => {
106108
'![alt text](https://example.com/image.png "char-editor-width=42|Example")',
107109
);
108110
});
111+
112+
test("renders table nodes as markdown tables", () => {
113+
const markdown = json2md({
114+
type: "doc",
115+
content: [
116+
{
117+
type: "table",
118+
content: [
119+
{
120+
type: "tableRow",
121+
content: [
122+
{
123+
type: "tableHeader",
124+
content: [
125+
{
126+
type: "paragraph",
127+
content: [{ type: "text", text: "Name" }],
128+
},
129+
],
130+
},
131+
{
132+
type: "tableHeader",
133+
content: [
134+
{
135+
type: "paragraph",
136+
content: [{ type: "text", text: "Role | Notes" }],
137+
},
138+
],
139+
},
140+
],
141+
},
142+
{
143+
type: "tableRow",
144+
content: [
145+
{
146+
type: "tableCell",
147+
content: [
148+
{
149+
type: "paragraph",
150+
content: [{ type: "text", text: "Alasdair" }],
151+
},
152+
],
153+
},
154+
{
155+
type: "tableCell",
156+
content: [
157+
{
158+
type: "paragraph",
159+
content: [{ type: "text", text: "Account Executive" }],
160+
},
161+
],
162+
},
163+
],
164+
},
165+
],
166+
},
167+
],
168+
});
169+
170+
expect(markdown).toBe(
171+
"| Name | Role \\| Notes |\n| --- | --- |\n| Alasdair | Account Executive |",
172+
);
173+
});
109174
});
110175

111176
describe("md2json", () => {
@@ -212,6 +277,23 @@ We appreciate your patience while you wait.`);
212277
expect(json.content![0].attrs?.src).toBe("https://example.com/welcome.png");
213278
expect(json.content![1].type).toBe("paragraph");
214279
});
280+
281+
test("converts markdown tables to editor-compatible table JSON", () => {
282+
const json = md2json(`| Name | Company | Role / Notes |
283+
| --- | --- | --- |
284+
| Alasdair | Cloudflare | Account Executive |
285+
| Rick | Cloudflare | Solutions Engineer |`);
286+
287+
const table = json.content?.[0];
288+
expect(table?.type).toBe("table");
289+
expect(table?.content?.[0]?.type).toBe("tableRow");
290+
expect(table?.content?.[0]?.content?.[0]?.type).toBe("tableHeader");
291+
expect(
292+
table?.content?.[0]?.content?.[0]?.content?.[0]?.content?.[0]?.text,
293+
).toBe("Name");
294+
expect(table?.content?.[1]?.content?.[0]?.type).toBe("tableCell");
295+
expect(() => PMNode.fromJSON(noteSchema, json)).not.toThrow();
296+
});
215297
});
216298

217299
describe("roundtrip", () => {

packages/editor/src/markdown.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,38 @@ import {
2626
// JSON post-processing (see liftBlockImages / wrapBlockImages).
2727
// ---------------------------------------------------------------------------
2828

29+
const tableCellAttrs = {
30+
colspan: { default: 1 },
31+
rowspan: { default: 1 },
32+
colwidth: { default: null },
33+
};
34+
35+
function getTableCellAttrs(dom: Node | string) {
36+
if (typeof dom === "string") return {};
37+
const element = dom as HTMLElement;
38+
const widthAttr = element.getAttribute("data-colwidth");
39+
const widths =
40+
widthAttr && /^\d+(,\d+)*$/.test(widthAttr)
41+
? widthAttr.split(",").map((value) => Number(value))
42+
: null;
43+
const colspan = Number(element.getAttribute("colspan") || 1);
44+
45+
return {
46+
colspan,
47+
rowspan: Number(element.getAttribute("rowspan") || 1),
48+
colwidth: widths && widths.length === colspan ? widths : null,
49+
};
50+
}
51+
52+
function setTableCellAttrs(node: PMNode) {
53+
const attrs: Record<string, string | number> = {};
54+
if (node.attrs.colspan !== 1) attrs.colspan = node.attrs.colspan;
55+
if (node.attrs.rowspan !== 1) attrs.rowspan = node.attrs.rowspan;
56+
if (node.attrs.colwidth)
57+
attrs["data-colwidth"] = node.attrs.colwidth.join(",");
58+
return attrs;
59+
}
60+
2961
const nodes: Record<string, NodeSpec> = {
3062
doc: { content: "block+" },
3163

@@ -135,6 +167,48 @@ const nodes: Record<string, NodeSpec> = {
135167
},
136168
},
137169

170+
table: {
171+
content: "tableRow+",
172+
group: "block",
173+
isolating: true,
174+
tableRole: "table",
175+
parseDOM: [{ tag: "table" }],
176+
toDOM() {
177+
return ["table", ["tbody", 0]];
178+
},
179+
},
180+
181+
tableRow: {
182+
content: "(tableCell | tableHeader)*",
183+
tableRole: "row",
184+
parseDOM: [{ tag: "tr" }],
185+
toDOM() {
186+
return ["tr", 0];
187+
},
188+
},
189+
190+
tableCell: {
191+
content: "block+",
192+
attrs: tableCellAttrs,
193+
isolating: true,
194+
tableRole: "cell",
195+
parseDOM: [{ tag: "td", getAttrs: getTableCellAttrs }],
196+
toDOM(node) {
197+
return ["td", setTableCellAttrs(node), 0];
198+
},
199+
},
200+
201+
tableHeader: {
202+
content: "block+",
203+
attrs: tableCellAttrs,
204+
isolating: true,
205+
tableRole: "header_cell",
206+
parseDOM: [{ tag: "th", getAttrs: getTableCellAttrs }],
207+
toDOM(node) {
208+
return ["th", setTableCellAttrs(node), 0];
209+
},
210+
},
211+
138212
taskList: {
139213
content: "taskItem+",
140214
group: "block",
@@ -560,6 +634,39 @@ function taskListPlugin(md: MarkdownIt) {
560634
});
561635
}
562636

637+
function tableCellParagraphsPlugin(md: MarkdownIt) {
638+
md.core.ruler.after("inline", "table_cell_paragraphs", (state) => {
639+
const tokens = state.tokens;
640+
const out: Token[] = [];
641+
642+
for (let i = 0; i < tokens.length; i++) {
643+
const token = tokens[i];
644+
const prev = tokens[i - 1];
645+
const next = tokens[i + 1];
646+
647+
if (
648+
token.type === "inline" &&
649+
(prev?.type === "th_open" || prev?.type === "td_open") &&
650+
(next?.type === "th_close" || next?.type === "td_close")
651+
) {
652+
const open = new state.Token("paragraph_open", "p", 1);
653+
open.level = token.level;
654+
const inline = new state.Token("inline", "", 0);
655+
Object.assign(inline, token);
656+
inline.level = token.level + 1;
657+
const close = new state.Token("paragraph_close", "p", -1);
658+
close.level = token.level;
659+
660+
out.push(open, inline, close);
661+
} else {
662+
out.push(token);
663+
}
664+
}
665+
666+
state.tokens = out;
667+
});
668+
}
669+
563670
function findInlineToken(tokens: Token[], fromIdx: number): number {
564671
for (let i = fromIdx + 1; i < tokens.length; i++) {
565672
if (tokens[i].type === "inline") return i;
@@ -850,6 +957,8 @@ function getParser(): MarkdownParser {
850957
md.use(strikethroughPlugin);
851958
md.use(underlinePlugin);
852959
md.use(highlightPlugin);
960+
md.enable("table");
961+
md.use(tableCellParagraphsPlugin);
853962
md.use(taskListPlugin);
854963
md.use(clipPlugin);
855964
md.use(fileAttachmentPlugin);
@@ -891,6 +1000,12 @@ function getParser(): MarkdownParser {
8911000
},
8921001
},
8931002
hardbreak: { node: "hardBreak" },
1003+
table: { block: "table" },
1004+
thead: { ignore: true },
1005+
tbody: { ignore: true },
1006+
tr: { block: "tableRow" },
1007+
th: { block: "tableHeader" },
1008+
td: { block: "tableCell" },
8941009

8951010
em: { mark: "italic" },
8961011
strong: { mark: "bold" },
@@ -960,6 +1075,38 @@ function backticksFor(node: PMNode, side: number): string {
9601075
return result;
9611076
}
9621077

1078+
function escapeTableCell(markdown: string): string {
1079+
return markdown
1080+
.replace(/\n+/g, "<br>")
1081+
.replace(/\\/g, "\\\\")
1082+
.replace(/\|/g, "\\|")
1083+
.trim();
1084+
}
1085+
1086+
function tableCellToMarkdown(cell: PMNode): string {
1087+
const parts: string[] = [];
1088+
1089+
cell.forEach((child) => {
1090+
const doc = markdownSchema.node("doc", null, [child]);
1091+
parts.push(getSerializer().serialize(doc));
1092+
});
1093+
1094+
return escapeTableCell(parts.join("<br>"));
1095+
}
1096+
1097+
function tableRowToMarkdown(row: PMNode): string {
1098+
const cells: string[] = [];
1099+
row.forEach((cell) => {
1100+
cells.push(tableCellToMarkdown(cell));
1101+
});
1102+
1103+
return `| ${cells.join(" | ")} |`;
1104+
}
1105+
1106+
function tableDelimiterRow(columnCount: number): string {
1107+
return `| ${Array.from({ length: columnCount }, () => "---").join(" | ")} |`;
1108+
}
1109+
9631110
let _serializer: MarkdownSerializer | null = null;
9641111

9651112
function getSerializer(): MarkdownSerializer {
@@ -1010,6 +1157,32 @@ function getSerializer(): MarkdownSerializer {
10101157
state.renderContent(node);
10111158
},
10121159

1160+
table(state, node) {
1161+
const rows: PMNode[] = [];
1162+
node.forEach((row) => rows.push(row));
1163+
if (rows.length === 0) {
1164+
state.closeBlock(node);
1165+
return;
1166+
}
1167+
1168+
state.write(tableRowToMarkdown(rows[0]));
1169+
state.write("\n");
1170+
state.write(tableDelimiterRow(rows[0].childCount));
1171+
1172+
for (let i = 1; i < rows.length; i++) {
1173+
state.write("\n");
1174+
state.write(tableRowToMarkdown(rows[i]));
1175+
}
1176+
1177+
state.closeBlock(node);
1178+
},
1179+
1180+
tableRow() {},
1181+
1182+
tableCell() {},
1183+
1184+
tableHeader() {},
1185+
10131186
taskList(state, node) {
10141187
state.renderList(node, " ", () => "- ");
10151188
},

0 commit comments

Comments
 (0)