Compare commits

...

4 Commits

Author SHA1 Message Date
Tom Moor bff6a80b67 styles 2022-06-25 19:07:18 +02:00
Tom Moor 07ad87f65f fix: Don't send email notification with empty diff 2022-06-24 09:57:53 +02:00
Tom Moor dd471328db fix: Collection name missing in notification email
fix: Email styles
2022-06-24 09:50:39 +02:00
Tom Moor 04f0983e20 Bringing across still relevant work from email-diff branch 2022-06-23 10:54:11 +02:00
19 changed files with 642 additions and 44 deletions
+1
View File
@@ -130,6 +130,7 @@
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
"natural-sort": "^1.0.0",
"node-htmldiff": "^0.9.3",
"nodemailer": "^6.6.1",
"outline-icons": "^1.42.0",
"oy-vey": "^0.10.0",
+1
View File
@@ -21,6 +21,7 @@ export default async function revisionCreator({
{
name: "revisions.create",
documentId: document.id,
collectionId: document.collectionId,
modelId: revision.id,
teamId: document.teamId,
actorId: user.id,
+9
View File
@@ -67,6 +67,7 @@ export default abstract class BaseEmail<T extends EmailProps, S = any> {
previewText: this.preview(data),
component: this.render(data),
text: this.renderAsText(data),
headCSS: this.headCSS ? this.headCSS(data) : undefined,
});
Metrics.increment("email.sent", {
templateName,
@@ -114,6 +115,14 @@ export default abstract class BaseEmail<T extends EmailProps, S = any> {
*/
protected abstract render(props: S & T): JSX.Element;
/**
* Allows injecting additional CSS into the head of the email.
*
* @param props Props in email constructor
* @returns A string of CSS
*/
protected headCSS?(props: T): string;
/**
* beforeSend hook allows async loading additional data that was not passed
* through the serialized worker props.
@@ -4,11 +4,13 @@ import { Document } from "@server/models";
import BaseEmail from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import Diff from "./components/Diff";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
import { css } from "./components/css";
type InputProps = {
to: string;
@@ -18,6 +20,7 @@ type InputProps = {
eventName: string;
teamUrl: string;
unsubscribeUrl: string;
content: string;
};
type BeforeSend = {
@@ -45,7 +48,11 @@ export default class DocumentNotificationEmail extends BaseEmail<
}
protected preview({ actorName, eventName }: Props): string {
return `${actorName} ${eventName} a new document`;
return `${actorName} ${eventName} a document`;
}
protected headCSS(): string {
return css;
}
protected renderAsText({
@@ -71,7 +78,10 @@ Open Document: ${teamUrl}${document.url}
eventName = "published",
teamUrl,
unsubscribeUrl,
content,
}: Props) {
const link = `${teamUrl}${document.url}?ref=notification-email`;
return (
<EmailTemplate>
<Header />
@@ -84,12 +94,17 @@ Open Document: ${teamUrl}${document.url}
{actorName} {eventName} the document "{document.title}", in the{" "}
{collectionName} collection.
</p>
<hr />
<EmptySpace height={10} />
<p>{document.getSummary()}</p>
<EmptySpace height={10} />
{content && (
<>
<EmptySpace height={20} />
<Diff href={link}>
<div dangerouslySetInnerHTML={{ __html: content }} />
</Diff>
<EmptySpace height={20} />
</>
)}
<p>
<Button href={`${teamUrl}${document.url}`}>Open Document</Button>
<Button href={link}>Open Document</Button>
</p>
</Body>
+1 -1
View File
@@ -57,7 +57,7 @@ Join now: ${teamUrl}
</p>
<EmptySpace height={10} />
<p>
<Button href={teamUrl}>Join now</Button>
<Button href={`${teamUrl}?ref=invite-email`}>Join now</Button>
</p>
</Body>
@@ -59,7 +59,9 @@ If you haven't signed up yet, you can do so here: ${teamUrl}
<p>If you haven't signed up yet, you can do so here:</p>
<EmptySpace height={10} />
<p>
<Button href={teamUrl}>Join now</Button>
<Button href={`${teamUrl}?ref=invite-reminder-email`}>
Join now
</Button>
</p>
</Body>
+3 -1
View File
@@ -59,7 +59,9 @@ ${teamUrl}/home
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}/home`}>View my dashboard</Button>
<Button href={`${teamUrl}/home?ref=welcome-email`}>
View my dashboard
</Button>
</p>
</Body>
@@ -0,0 +1,25 @@
import * as React from "react";
import theme from "@shared/styles/theme";
type Props = {
children: React.ReactNode;
href?: string;
};
export default ({ children, ...rest }: Props) => {
const style = {
borderRadius: "4px",
background: theme.secondaryBackground,
padding: ".5em 1em",
color: theme.text,
display: "block",
textDecoration: "none",
width: "100%",
};
return (
<a style={style} className="content-diff" {...rest}>
{children}
</a>
);
};
+218
View File
@@ -0,0 +1,218 @@
import { transparentize } from "polished";
import theme from "@shared/styles/theme";
export const css = `
.content-diff {
font-family: ${theme.fontFamily};
font-weight: ${theme.fontWeight};
font-size: 1em;
line-height: 1.7em;
}
pre {
white-space: pre-wrap;
}
.content-diff img {
text-align: center;
max-width: 100%;
max-height: 75vh;
clear: both;
}
.content-diff img.image-right-50 {
float: right;
width: 50%;
margin-left: 2em;
margin-bottom: 1em;
clear: initial;
}
.content-diff img.image-left-50 {
float: left;
width: 50%;
margin-right: 2em;
margin-bottom: 1em;
clear: initial;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 1em 0 0.5em;
font-weight: 500;
}
.notice {
display: flex;
align-items: center;
background: ${transparentize(0.9, theme.noticeInfoBackground)};
border-left: 4px solid ${theme.noticeInfoBackground};
color: ${theme.noticeInfoText};
border-radius: 4px;
padding: 8px 10px 8px 8px;
margin: 8px 0;
}
.notice-tip {
background: ${transparentize(0.9, theme.noticeTipBackground)};
border-left: 4px solid ${theme.noticeTipBackground};
color: ${theme.noticeTipText};
}
.notice-warning {
background: ${transparentize(0.9, theme.noticeWarningBackground)};
border-left: 4px solid ${theme.noticeWarningBackground};
color: ${theme.noticeWarningText};
}
b,
strong {
font-weight: 600;
}
p {
margin: 0;
}
a {
color: ${theme.link};
}
ins {
background-color: #128a2929;
text-decoration: none;
}
del {
background-color: ${theme.slateLight};
color: ${theme.slate};
text-decoration: strikethrough;
}
hr {
position: relative;
height: 1em;
border: 0;
}
hr:before {
content: "";
display: block;
position: absolute;
border-top: 1px solid ${theme.horizontalRule};
top: 0.5em;
left: 0;
right: 0;
}
hr.page-break {
page-break-after: always;
}
hr.page-break:before {
border-top: 1px dashed ${theme.horizontalRule};
}
code {
border-radius: 4px;
border: 1px solid ${theme.codeBorder};
padding: 3px 4px;
font-family: ${theme.fontFamilyMono};
font-size: 85%;
}
mark {
border-radius: 1px;
color: ${theme.textHighlightForeground};
background: ${theme.textHighlight};
a {
color: ${theme.textHighlightForeground};
}
}
ul {
padding-left: 0;
}
.checkbox-list-item {
list-style: none;
padding: 4px 0;
margin: 0;
}
.checkbox {
font-size: 0;
display: block;
float: left;
white-space: nowrap;
width: 12px;
height: 12px;
margin-top: 2px;
margin-right: 8px;
border: 1px solid ${theme.textSecondary};
border-radius: 3px;
}
pre {
display: block;
overflow-x: auto;
padding: 0.75em 1em;
line-height: 1.4em;
position: relative;
background: ${theme.codeBackground};
border-radius: 4px;
border: 1px solid ${theme.codeBorder};
-webkit-font-smoothing: initial;
font-family: ${theme.fontFamilyMono};
font-size: 13px;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
margin: 0;
}
pre code {
font-size: 13px;
background: none;
padding: 0;
border: 0;
}
blockquote {
margin: 0;
padding-left: 1.5em;
font-style: italic;
overflow: hidden;
position: relative;
}
blockquote:before {
content: "";
display: inline-block;
width: 2px;
border-radius: 1px;
position: absolute;
top: 0;
bottom: 0;
background: ${theme.quote};
}
.content-diff table {
width: 100%;
border-collapse: collapse;
border-radius: 4px;
margin-top: 1em;
box-sizing: border-box;
}
.content-diff table * {
box-sizing: border-box;
}
.content-diff table tr {
position: relative;
border-bottom: 1px solid ${theme.tableDivider};
}
.content-diff table td,
.content-diff table th {
position: relative;
vertical-align: top;
border: 1px solid ${theme.tableDivider};
position: relative;
padding: 4px 8px;
min-width: 100px;
}
`;
@@ -1,3 +1,4 @@
import env from "@server/env";
import Document from "@server/models/Document";
import { Event } from "@server/types";
import { globalEventQueue } from "..";
@@ -15,7 +16,8 @@ export default class DebounceProcessor extends BaseProcessor {
globalEventQueue.add(
{ ...event, name: "documents.update.delayed" },
{
delay: 5 * 60 * 1000,
// Revision creation time is lowered to half a minute in development
delay: (env.ENVIRONMENT === "production" ? 5 : 0.5) * 60 * 1000,
}
);
break;
@@ -1,5 +1,5 @@
import DocumentNotificationEmail from "@server/emails/templates/DocumentNotificationEmail";
import { View, NotificationSetting } from "@server/models";
import { View, NotificationSetting, Revision } from "@server/models";
import {
buildDocument,
buildCollection,
@@ -102,6 +102,7 @@ describe("documents.publish", () => {
describe("revisions.create", () => {
test("should send a notification to other collaborators", async () => {
const document = await buildDocument();
const revision = await Revision.createFromDocument(document);
const collaborator = await buildUser({
teamId: document.teamId,
});
@@ -118,12 +119,14 @@ describe("revisions.create", () => {
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
modelId: revision.id,
});
expect(DocumentNotificationEmail.schedule).toHaveBeenCalled();
});
test("should not send a notification if viewed since update", async () => {
const document = await buildDocument();
const revision = await Revision.createFromDocument(document);
const collaborator = await buildUser({
teamId: document.teamId,
});
@@ -142,16 +145,18 @@ describe("revisions.create", () => {
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
modelId: revision.id,
});
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
});
test("should not send a notification to last editor", async () => {
test("should not send a notification to last user that modified", async () => {
const user = await buildUser();
const document = await buildDocument({
teamId: user.teamId,
lastModifiedById: user.id,
});
const revision = await Revision.createFromDocument(document);
await NotificationSetting.create({
userId: user.id,
teamId: user.teamId,
@@ -163,6 +168,7 @@ describe("revisions.create", () => {
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
modelId: revision.id,
});
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
});
@@ -9,6 +9,8 @@ import {
Collection,
User,
NotificationSetting,
Revision,
Attachment,
} from "@server/models";
import {
DocumentEvent,
@@ -16,8 +18,27 @@ import {
RevisionEvent,
Event,
} from "@server/types";
import markdownDiff from "@server/utils/markdownDiff";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import { getSignedUrl } from "@server/utils/s3";
import BaseProcessor from "./BaseProcessor";
async function replaceImageAttachments(text: string) {
const attachmentIds = parseAttachmentIds(text);
await Promise.all(
attachmentIds.map(async (id) => {
const attachment = await Attachment.findByPk(id);
if (attachment) {
const accessUrl = await getSignedUrl(attachment.key, 86400 * 4);
text = text.replace(attachment.redirectUrl, accessUrl);
}
})
);
return text;
}
export default class NotificationsProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = [
"documents.publish",
@@ -26,11 +47,17 @@ export default class NotificationsProcessor extends BaseProcessor {
];
async perform(event: Event) {
// never send notifications when batch importing documents
// @ts-expect-error More granular typing of events needed here
if (event.data?.source === "import") {
return;
}
switch (event.name) {
case "documents.publish":
return this.documentPublished(event);
case "revisions.create":
return this.documentUpdated(event);
return this.revisionCreated(event);
case "collections.create":
return this.collectionCreated(event);
@@ -38,30 +65,22 @@ export default class NotificationsProcessor extends BaseProcessor {
}
}
async documentUpdated(event: DocumentEvent | RevisionEvent) {
// never send notifications when batch importing documents
// @ts-expect-error ts-migrate(2339) FIXME: Property 'data' does not exist on type 'DocumentEv... Remove this comment to see the full error message
if (event.data?.source === "import") {
return;
}
const [document, team] = await Promise.all([
async documentPublished(event: DocumentEvent) {
const [collection, document, team] = await Promise.all([
Collection.findByPk(event.collectionId),
Document.findByPk(event.documentId),
Team.findByPk(event.teamId),
]);
if (!document || !team || !document.collection) {
if (!document || !team || !collection) {
return;
}
const { collection } = document;
const notificationSettings = await NotificationSetting.findAll({
where: {
userId: {
[Op.ne]: document.lastModifiedById,
},
teamId: document.teamId,
event:
event.name === "documents.publish"
? "documents.publish"
: "documents.update",
event: "documents.publish",
},
include: [
{
@@ -71,22 +90,10 @@ export default class NotificationsProcessor extends BaseProcessor {
},
],
});
const eventName =
event.name === "documents.publish" ? "published" : "updated";
for (const setting of notificationSettings) {
// Suppress notifications for suspended users
if (setting.user.isSuspended) {
continue;
}
// For document updates we only want to send notifications if
// the document has been edited by the user with this notification setting
// This could be replaced with ability to "follow" in the future
if (
eventName === "updated" &&
!document.collaboratorIds.includes(setting.userId)
) {
if (setting.user.isSuspended || !setting.user.email) {
continue;
}
@@ -118,18 +125,115 @@ export default class NotificationsProcessor extends BaseProcessor {
continue;
}
if (!setting.user.email) {
continue;
}
const content = await replaceImageAttachments(document.getSummary());
await DocumentNotificationEmail.schedule({
to: setting.user.email,
eventName,
eventName: "published",
documentId: document.id,
teamUrl: team.url,
actorName: document.updatedBy.name,
collectionName: collection.name,
unsubscribeUrl: setting.unsubscribeUrl,
content,
});
}
}
async revisionCreated(event: RevisionEvent) {
const [collection, document, team, revision] = await Promise.all([
Collection.findByPk(event.collectionId),
Document.findByPk(event.documentId),
Team.findByPk(event.teamId),
Revision.findByPk(event.modelId),
]);
if (!document || !revision || !team || !collection) {
return;
}
const notificationSettings = await NotificationSetting.findAll({
where: {
userId: {
[Op.ne]: revision.userId,
},
teamId: document.teamId,
event: "documents.update",
},
include: [
{
model: User,
required: true,
as: "user",
},
],
});
for (const setting of notificationSettings) {
// Suppress notifications for suspended users
if (setting.user.isSuspended || !setting.user.email) {
continue;
}
// For document updates we only want to send notifications if
// the document has been edited by the user with this notification setting
// This could be replaced with ability to "follow" in the future
if (!document.collaboratorIds.includes(setting.userId)) {
continue;
}
// Check the user has access to the collection this document is in. Just
// because they were a collaborator once doesn't mean they still are.
const collectionIds = await setting.user.collectionIds();
if (!collectionIds.includes(document.collectionId)) {
continue;
}
// If this user has viewed the document since the last update was made
// then we can avoid sending them a useless notification, yay.
const view = await View.findOne({
where: {
userId: setting.userId,
documentId: event.documentId,
updatedAt: {
[Op.gt]: revision.createdAt,
},
},
});
if (view) {
Logger.info(
"processor",
`suppressing notification to ${setting.userId} because update viewed`
);
continue;
}
const previous = await Revision.findOne({
where: {
documentId: document.id,
createdAt: {
[Op.lt]: revision.createdAt,
},
},
order: [["createdAt", "DESC"]],
});
let content = markdownDiff(previous ? previous.text : "", revision.text);
if (!content) {
continue;
}
content = await replaceImageAttachments(content);
await DocumentNotificationEmail.schedule({
to: setting.user.email,
eventName: "updated",
documentId: document.id,
teamUrl: team.url,
actorName: document.updatedBy.name,
collectionName: collection.name,
unsubscribeUrl: setting.unsubscribeUrl,
content,
});
}
}
+34
View File
@@ -0,0 +1,34 @@
# Heading 1
## Heading 2
This is a test paragraph
This is a second test paragraph. This is a second sentence.
This is a another test paragraph. This is a another sentence.
- list item 1
- list item 2
```
this is a codeblock
```
:::info
This is an info block
:::
!!This is a placeholder!!
==this is a highlight==
- [ ] checklist item 1
- [ ] checklist item 2
- [x] checklist item 3
same on both sides
same on both sides
same on both sides
+37
View File
@@ -0,0 +1,37 @@
# Heading 1
## Heading 2
This is a test paragraph
This is a second test paragraph. This is a second sentence.
This is a another test paragraph. This is a another sentence.
- list item 1
```
this is a codeblock
```
This is a new paragraph.
:::info
This is an info block
:::
!!This is a placeholder!!
==this is a highlight==
- [x] checklist item 1
- [x] checklist item 2
- [ ] checklist item 3
- [ ] checklist item 4
- [x] checklist item 5
same on both sides
same on both sides
same on both sides
+1
View File
@@ -102,6 +102,7 @@ export type RevisionEvent = {
name: "revisions.create";
documentId: string;
collectionId: string;
modelId: string;
teamId: string;
};
@@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should diff a complex document 1`] = `
"<p>This is a second test paragraph. This is a second sentence.</p>
<p>This is a another test paragraph. This is a another sentence.</p>
<ul>
<li>list item 1</li>
<li data-diff-node=\\"del\\" data-operation-index=\\"1\\"><del data-operation-index=\\"1\\">list item 2</del></li></ul>
<pre><code>this is a codeblock
</code></pre><p data-diff-node=\\"ins\\" data-operation-index=\\"3\\"><ins data-operation-index=\\"3\\">This is a new paragraph.</ins></p>
<div class=\\"notice notice-info\\">
<p>This is an info block</p>
</div>
<p><span class=\\"placeholder\\">This is a placeholder</span></p>
<p><span class=\\"highlight\\">this is a highlight</span></p>
<ul>
<li class=\\"checkbox-list-item\\"><span class=\\"checkbox checked\\">[<del data-operation-index=\\"5\\"> ]</del><ins data-operation-index=\\"5\\">x]</ins></span>checklist item 1</li><li class=\\"checkbox-list-item\\"><span class=\\"checkbox checked\\" data-diff-node=\\"ins\\" data-operation-index=\\"7\\"><ins data-operation-index=\\"7\\">[x]</ins></span><ins data-operation-index=\\"7\\">checklist item 2</ins></li>
<li class=\\"checkbox-list-item\\"><span class=\\"checkbox \\">[ ]</span>checklist item <del data-operation-index=\\"9\\">2</del><ins data-operation-index=\\"9\\">3</ins></li><li class=\\"checkbox-list-item\\"><span class=\\"checkbox \\" data-diff-node=\\"ins\\" data-operation-index=\\"9\\"><ins data-operation-index=\\"9\\">[ ]</ins></span><ins data-operation-index=\\"9\\">checklist item 4</ins></li>
<li class=\\"checkbox-list-item\\"><span class=\\"checkbox checked\\">[x]</span>checklist item <del data-operation-index=\\"11\\">3</del><ins data-operation-index=\\"11\\">5</ins></li>
</ul>"
`;
exports[`should return everything inserted when previously empty 1`] = `
"<h1 data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">Heading 1</ins></h1><h2 data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">Heading 2</ins></h2><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a test paragraph</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a second test paragraph. This is a second sentence.</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a another test paragraph. This is a another sentence.</ins></p><ul data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><li data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">list item 1</ins></li><li data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">list item 2</ins></li></ul><pre data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><code data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">this is a codeblock
</ins></code></pre><div class=\\"notice notice-info\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is an info block</ins></p></div><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"placeholder\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a placeholder</ins></span></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"highlight\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">this is a highlight</ins></span></p><ul data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><li class=\\"checkbox-list-item\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"checkbox \\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">[ ]</ins></span><ins data-operation-index=\\"0\\">checklist item 1</ins></li><li class=\\"checkbox-list-item\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"checkbox \\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">[ ]</ins></span><ins data-operation-index=\\"0\\">checklist item 2</ins></li><li class=\\"checkbox-list-item\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"checkbox checked\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">[x]</ins></span><ins data-operation-index=\\"0\\">checklist item 3</ins></li></ul><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">same on both sides</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">same on both sides</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">same on both sides</ins></p>"
`;
+54
View File
@@ -0,0 +1,54 @@
import fs from "fs";
import path from "path";
import markdownDiff from "./markdownDiff";
it("should diff a complex document", async () => {
const before = await fs.promises.readFile(
path.resolve(process.cwd(), "server", "test", "fixtures", "complex.md"),
"utf8"
);
const after = await fs.promises.readFile(
path.resolve(
process.cwd(),
"server",
"test",
"fixtures",
"complexModified.md"
),
"utf8"
);
const diff = markdownDiff(before, after);
expect(diff).toMatchSnapshot();
});
it("should return empty string when both sides are empty", () => {
const diff = markdownDiff("", "");
expect(diff).toEqual("");
});
it("should return everything inserted when previously empty", async () => {
const content = await fs.promises.readFile(
path.resolve(process.cwd(), "server", "test", "fixtures", "complex.md"),
"utf8"
);
const diff = markdownDiff("", content);
expect(diff).toMatchSnapshot();
});
it("should return empty for changed nodes", async () => {
// Note: This isn't ideal behavior, but it is current behavior. If the diffing
// library is improved then we could potentially render the old + new heading
// with ins/del tags as appropriate.
const diff = markdownDiff("# Heading", "## Heading");
expect(diff).toEqual("");
});
it("should return deleted nodes", async () => {
const diff = markdownDiff("![caption](/image.png)", "");
expect(diff).toEqual(
'<p><del data-operation-index="0"><img src="/image.png" alt="caption"></del></p>'
);
});
+56
View File
@@ -0,0 +1,56 @@
import { findIndex, findLastIndex } from "lodash";
import diff from "node-htmldiff";
import renderToHtml from "@server/editor/renderToHtml";
export default function markdownDiff(
before: string,
after: string,
fullDiff = false,
buffer = 1
) {
// The basic idea here is to first render the Markdown to HTML, then diff the
// HTML - both sides will have valid HTML so we should have a valid diff as well
const beforeHtml = renderToHtml(before);
const afterHtml = renderToHtml(after);
const diffHtml = diff(beforeHtml, afterHtml);
if (fullDiff) {
return diffHtml;
}
if (before === after) {
return "";
}
// Split diff at paragraphs and find the first and last changed tags
// so we can chop around paragraphs rather than return the entire document.
//
// In an ideal world we'd use an AST here and parse that rather than be doing
// operations on strings. I hope this can be revisted in the future with an
// improved diffing library.
const newParagraph = /(?:^|\n)<p>/;
let lines = diffHtml.split(newParagraph);
const firstChangedLineIndex = findIndex(
lines,
(value) => value.includes("<ins ") || value.includes("<del ")
);
const lastChangedLineIndex = findLastIndex(
lines,
(value) => value.includes("</ins>") || value.includes("</del>")
);
const start = Math.max(0, firstChangedLineIndex - buffer);
const end = Math.min(lines.length, lastChangedLineIndex + buffer);
lines = lines.slice(start, end);
if (!lines.length) {
return "";
}
return [start > 0 ? "" : undefined, ...lines]
.filter((x) => x !== undefined)
.join("\n<p>")
.trim();
}
+5
View File
@@ -10639,6 +10639,11 @@ node-gyp-build@^3.9.0:
resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-3.9.0.tgz#53a350187dd4d5276750da21605d1cb681d09e25"
integrity sha512-zLcTg6P4AbcHPq465ZMFNXx7XpKKJh+7kkN699NiQWisR2uWYOWNWqRHAmbnmKiL4e9aLSlmy5U7rEMUXV59+A==
node-htmldiff@^0.9.3:
version "0.9.3"
resolved "https://registry.yarnpkg.com/node-htmldiff/-/node-htmldiff-0.9.3.tgz#020704e381597e5e449a4708996edf23eebb7fcc"
integrity sha512-9n4pd+x4qL6zsq5tntW5Bscn4j+oo0qt0RiLsf88knFjkZkPKj1VFGUBRRHVUUBCE7d64VMluj0LjxfJlJ3hpw==
node-int64@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"