diff --git a/e2e/code.e2e.ts b/e2e/code.e2e.ts index 285054d7..4d56dc9f 100644 --- a/e2e/code.e2e.ts +++ b/e2e/code.e2e.ts @@ -175,7 +175,10 @@ test("an invalid diagram leaves its error inside the fence", async ({ join, seed await seed("```mermaid\nflowchart LR\nA[raw MemEntry[]]\n```\n"); let page = await join("ana"); - await expect(content(page).locator("[data-plan-error]")).toBeVisible(); + let error = content(page).locator("[data-plan-error]"); + await expect(error).toBeVisible(); + await expect(error.getByText("This diagram could not be drawn")).toBeVisible(); + await expect(error.locator(".plan-error-message")).not.toBeEmpty(); await expect( page.locator("body > div").filter({ hasText: "Syntax error in text" }), ).toHaveCount(0); diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index a833320e..f0870903 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -1650,6 +1650,69 @@ padding-inline: 0.75rem; } +/* Diagram error callout. */ +.plan-content .planCode [data-plan-preview] [data-plan-error]:has(.plan-error-badge) { + display: flex; + align-items: flex-start; + gap: 0.625rem; + margin: 0.75rem; + padding: 0.625rem 0.75rem; + border-radius: var(--radius-md); + background: var(--color-ruby-2); +} + +.plan-content .plan-error-badge { + display: grid; + flex-shrink: 0; + place-items: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + background: var(--color-danger-surface); + color: var(--color-destructive); +} + +.plan-content .plan-error-badge [data-nucleo-icon] { + color: inherit; +} + +.plan-content .plan-error-text { + min-width: 0; +} + +.plan-content .plan-error-title { + display: block; + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-destructive-ink); +} + +.plan-content .plan-error-message { + margin: 0.125rem 0 0; + font-size: var(--text-sm); + color: var(--color-destructive-ink); + overflow-wrap: anywhere; + line-height: var(--text-sm--line-height); +} + +.plan-content .plan-error-detail { + margin: 0.5rem 0 0; + padding: 0; + overflow-x: auto; + background: transparent; + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); + color: var(--color-destructive-ink); +} + +.plan-content .plan-error-expected { + margin: 0.375rem 0 0; + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); + color: var(--color-destructive-ink); + overflow-wrap: anywhere; +} + .plan-content .plan-code-title { min-width: 0; overflow: hidden; diff --git a/packages/editor/src/widgets/code.test.ts b/packages/editor/src/widgets/code.test.ts index 0b2a0487..81f9c02c 100644 --- a/packages/editor/src/widgets/code.test.ts +++ b/packages/editor/src/widgets/code.test.ts @@ -9,7 +9,16 @@ import { describe, expect, it } from "bun:test"; import { DIFF_LANGUAGE, MERMAID_LANGUAGE } from "@chopin/dialect"; -import { fileNameOf, kindOf, languageOptions, LANGUAGES, repaired, titled, titleOf } from "./code"; +import { + describeDiagramError, + fileNameOf, + kindOf, + languageOptions, + LANGUAGES, + repaired, + titled, + titleOf, +} from "./code"; describe("what a fence is", () => { it("tells the two rendered languages apart from ordinary code", () => { @@ -216,3 +225,55 @@ describe("languageOptions", () => { expect(options).toHaveLength(LANGUAGES.length + 2); }); }); + +describe("describeDiagramError", () => { + it("drops the echoed source for an unknown diagram type", () => { + let result = describeDiagramError( + "No diagram type detected matching given configuration for text: nope\nmore", + ); + expect(result).toEqual({ + summary: "The diagram type is not recognised.", + excerpt: "", + expected: "", + }); + }); + + it("splits a parse error into summary, excerpt and expectation", () => { + let result = describeDiagramError( + "Parse error on line 3:\n...a -> b\n-----^\nExpecting '+', '-', 'ACTOR', got 'NEWLINE'", + ); + expect(result.summary).toBe("Parse error on line 3"); + expect(result.excerpt).toBe("...a -> b\n-----^"); + expect(result.expected).toBe("Expecting '+', '-', 'ACTOR', got 'NEWLINE'"); + }); + + it("handles a parse error without a caret line", () => { + let result = describeDiagramError("Parse error on line 2:\nExpecting 'X'\nfoo"); + expect(result).toEqual({ + summary: "Parse error on line 2", + excerpt: "", + expected: "Expecting 'X' foo", + }); + }); + + it("keeps a single-line message as the summary", () => { + expect(describeDiagramError("Something odd")).toEqual({ + summary: "Something odd", + excerpt: "", + expected: "", + }); + }); + + it("handles Windows line endings and a caret with no dashes", () => { + let result = describeDiagramError( + "Lexical error on line 2. Unrecognized text.\r\n...\r\nA -->\r\n^\r\nExpecting 'X', got 'Y'", + ); + expect(result.summary).toBe("Lexical error on line 2. Unrecognized text."); + expect(result.excerpt).toBe("A -->\n^"); + expect(result.expected).toBe("Expecting 'X', got 'Y'"); + }); + + it("never renders an empty summary", () => { + expect(describeDiagramError("").summary).toBe("The diagram could not be parsed."); + }); +}); diff --git a/packages/editor/src/widgets/code.ts b/packages/editor/src/widgets/code.ts index e9c41815..b8be8fee 100644 --- a/packages/editor/src/widgets/code.ts +++ b/packages/editor/src/widgets/code.ts @@ -251,3 +251,25 @@ export function languageOptions(language: string): (readonly [string, string])[] ...LANGUAGES, ]; } + +/** + * Mermaid's failure message, split into the parts worth showing. Mermaid + * quotes the whole source back after "for text:" on an unknown type; the + * source is already on screen, so only the sentence is kept. Anything that + * matches no known shape falls back to the raw first line. + */ +export function describeDiagramError( + message: string, +): { summary: string; excerpt: string; expected: string } { + if (message.startsWith("No diagram type detected")) { + return { summary: "The diagram type is not recognised.", excerpt: "", expected: "" }; + } + let lines = message.split(/\r?\n/); + // An error at the very start of the source has no dashes before its caret. + let caret = lines.findIndex((line, index) => index > 1 && /^-*\^$/.test(line)); + return { + summary: (lines[0] || "The diagram could not be parsed.").replace(/:\s*$/, ""), + excerpt: caret > 0 ? lines.slice(caret - 1, caret + 1).join("\n") : "", + expected: lines.slice(caret > 0 ? caret + 1 : 1).join(" ").trim(), + }; +} diff --git a/packages/editor/src/widgets/render-blocks.tsx b/packages/editor/src/widgets/render-blocks.tsx index a0856ab0..27e646c8 100644 --- a/packages/editor/src/widgets/render-blocks.tsx +++ b/packages/editor/src/widgets/render-blocks.tsx @@ -36,10 +36,10 @@ import { import { $isCodeBlockNode, $isMathNode } from "@chopin/dialect"; import { enclosing, remember } from "../collapse"; -import { kindOf, languageOptions, titleOf } from "./code"; +import { describeDiagramError, kindOf, languageOptions, titleOf } from "./code"; import { CodeView } from "./code-view"; import { LanguageMenu } from "./language-menu"; -import { CodeIcon } from "@chopin/icons"; +import { CodeIcon, WarningIcon } from "@chopin/icons"; import type { ElementNode, LexicalEditor } from "lexical"; import type { Kind } from "./code"; @@ -335,9 +335,31 @@ function Preview( ); } +/** + * Not a live region: the diagram re-renders on every keystroke, and each + * intermediate error would be announced. + */ +function DiagramError({ message }: { message: string }) { + let { summary, excerpt, expected } = describeDiagramError(message); + return ( +
+ +
+ This diagram could not be drawn +

{summary}

+ {excerpt &&
{excerpt}
} + {expected &&

{expected}

} +
+
+ ); +} + function Rendered( { block, html, error }: { block: Block; html: string | undefined; error: string | undefined }, ) { + if (error && block.kind === "mermaid") return ; if (error) return
{error}
; if (!block.source.trim()) return null;