fix(ui): improve knowledge reading and add isolated formatting examples
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { MarkdownView } from "./MarkdownView";
|
||||
|
||||
test("readable paragraphs preserve text, inline code, links and authored source", () => {
|
||||
const source = `${"Una regola dimostrativa conserva sempre i dati originali. ".repeat(5)}Si usa \`flag = FALSE; valore >= 0.4\` senza cambiare il codice; ${"Questa parte spiega il criterio senza aggiungere nuove informazioni. ".repeat(5)}Leggere **tutto** e il [documento](https://example.com/a.b).`;
|
||||
const normal = render(<MarkdownView source={source} />);
|
||||
const expected = normal.container.textContent;
|
||||
normal.unmount();
|
||||
const view = render(<MarkdownView source={source} readable />);
|
||||
expect(view.container.querySelectorAll(".markdown-view > p").length).toBeGreaterThan(2);
|
||||
// HTML paragraph boundaries add whitespace, but never change the wording.
|
||||
expect(view.container.textContent?.replace(/\s+/g, " ").trim()).toBe(expected?.replace(/\s+/g, " ").trim());
|
||||
expect(view.container.querySelector("code")).toHaveTextContent("flag = FALSE; valore >= 0.4");
|
||||
expect(view.container.querySelector("strong")).toHaveTextContent("tutto");
|
||||
expect(view.container.querySelector("a")).toHaveAttribute("href", "https://example.com/a.b");
|
||||
expect(view.container.querySelector(".markdown-view")).toHaveClass("thot-knowledge-prose");
|
||||
});
|
||||
|
||||
test("leaves existing paragraphs, lists and fenced SQL unchanged", () => {
|
||||
const sql = `SELECT '${"valore; con punteggiatura. ".repeat(20)}' AS esempio;`;
|
||||
const source = `Primo paragrafo.\n\nSecondo paragrafo.\n\n- Una voce\n- Altra voce\n\n\`\`\`sql\n${sql}\n\`\`\``;
|
||||
const view = render(<MarkdownView source={source} readable />);
|
||||
expect(view.container.querySelectorAll(".markdown-view > p")).toHaveLength(2);
|
||||
expect(view.container.querySelectorAll("li")).toHaveLength(2);
|
||||
expect(view.container.querySelector("pre code")?.textContent).toBe(sql + "\n");
|
||||
});
|
||||
|
||||
test("does not enable long-paragraph reflow for ordinary document viewers", () => {
|
||||
const view = render(<MarkdownView source={"A normal document keeps its authored paragraph. ".repeat(20)} />);
|
||||
expect(view.container.querySelectorAll("p")).toHaveLength(1);
|
||||
expect(view.container.querySelector(".thot-knowledge-prose")).toBeNull();
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
import { renderMermaid } from "./mermaid";
|
||||
import { useShell } from "../shell/host/ShellProvider";
|
||||
import { readableParagraphs } from "./readableParagraphs";
|
||||
|
||||
function MermaidBlock({ code }: { code: string }) {
|
||||
const { theme } = useShell();
|
||||
@@ -45,10 +46,10 @@ const components: Components = {
|
||||
},
|
||||
};
|
||||
|
||||
export function MarkdownView({ source }: { source: string }) {
|
||||
export function MarkdownView({ source, readable = false }: { source: string; readable?: boolean }) {
|
||||
return (
|
||||
<div className="markdown-view thot-prose">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
||||
<div className={`markdown-view thot-prose${readable ? " thot-knowledge-prose" : ""}`}>
|
||||
<ReactMarkdown remarkPlugins={readable ? [remarkGfm, readableParagraphs] : [remarkGfm]} components={components}>
|
||||
{source}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Paragraph, PhrasingContent, Root, RootContent } from "mdast";
|
||||
|
||||
// Display-only: preserve every inline node and character. Never rewrite source
|
||||
// files, SQL, links, existing lists or short authored paragraphs.
|
||||
export function readableParagraphs() {
|
||||
return (tree: Root) => {
|
||||
tree.children = tree.children.flatMap<RootContent>(node => {
|
||||
if (node.type !== "paragraph") return [node];
|
||||
const length = node.children.reduce((total, child) => total + ("value" in child ? String(child.value).length : 0), 0);
|
||||
if (length < 360) return [node];
|
||||
const paragraphs: Paragraph[] = [];
|
||||
let children: PhrasingContent[] = [];
|
||||
let size = 0;
|
||||
for (const child of node.children) {
|
||||
if (child.type !== "text") {
|
||||
children.push(child);
|
||||
if ("value" in child) size += String(child.value).length;
|
||||
continue;
|
||||
}
|
||||
// Boundaries must be outside inline code/emphasis/links. Decimal points
|
||||
// and identifiers cannot match because they have no following space.
|
||||
let start = 0;
|
||||
for (const match of child.value.matchAll(/;\s+|[.!?]\s+(?=[A-ZÀ-Ý])/g)) {
|
||||
const end = match.index! + match[0].length;
|
||||
if (size + end - start < 180) continue;
|
||||
children.push({ type: "text", value: child.value.slice(start, end) });
|
||||
paragraphs.push({ type: "paragraph", children });
|
||||
children = [];
|
||||
size = 0;
|
||||
start = end;
|
||||
}
|
||||
if (start < child.value.length) {
|
||||
children.push({ type: "text", value: child.value.slice(start) });
|
||||
size += child.value.length - start;
|
||||
}
|
||||
}
|
||||
if (children.length) paragraphs.push({ type: "paragraph", children });
|
||||
return paragraphs;
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user