mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
64062af9a1
A Guided Review can now leave Plannotator: as a single self-contained HTML file that renders exactly like the in-app guide, as an encrypted-by-default share link on guides.show, or authored by any agent through the new guide CLI. Highlights: packages/guide-viewer extracted from review-editor at the injection seam (read-only host, no third renderer); guides.show Worker with R2-backed share storage, per-IP rate limiting on creation, delete tokens hashed at rest, and 128-bit ids; portable exports pin the viewer by SRI hash with budget and manifest gates in PR CI and at deploy; two-runtime parity across Bun and Pi verified; v0.27.x saved guides load unchanged. Retention is indefinite by explicit decision, to revisit with the lean sharing refactor. Decision record: adr/decisions/007-portable-guided-reviews-20260815.md
67 lines
2.9 KiB
TypeScript
67 lines
2.9 KiB
TypeScript
import { describe, it, expect } from "bun:test";
|
|
import { renderMarkdownProse } from "./renderMarkdownProse";
|
|
|
|
type El = { type: unknown; props: { children?: unknown } };
|
|
|
|
const isElement = (node: unknown): node is El =>
|
|
typeof node === "object" && node !== null && "type" in node && "props" in node;
|
|
|
|
/** Flattens a React node tree (as returned by renderMarkdownProse, never
|
|
* actually mounted) down to its plain text content. */
|
|
function textOf(node: unknown): string {
|
|
if (node === null || node === undefined || typeof node === "boolean") return "";
|
|
if (typeof node === "string" || typeof node === "number") return String(node);
|
|
if (Array.isArray(node)) return node.map(textOf).join("");
|
|
if (isElement(node)) return textOf(node.props.children);
|
|
return "";
|
|
}
|
|
|
|
describe("renderMarkdownProse", () => {
|
|
it("keeps the single-line callout opener's own text (regression: used to render empty)", () => {
|
|
const nodes = renderMarkdownProse("> [!IMPORTANT] message here");
|
|
expect(nodes).toHaveLength(1);
|
|
const callout = nodes[0];
|
|
expect(isElement(callout) && callout.type).toBe("div");
|
|
expect(textOf(callout)).toContain("message here");
|
|
|
|
// The label span and the content span are the two children — the content
|
|
// span specifically (not just the label) must carry the opener's text.
|
|
const children = isElement(callout) ? callout.props.children : null;
|
|
expect(Array.isArray(children)).toBe(true);
|
|
const contentSpan = (children as unknown[])[1];
|
|
expect(textOf(contentSpan)).toBe("message here");
|
|
});
|
|
|
|
it("still renders the multi-line callout form (opener alone, then > body lines) unchanged", () => {
|
|
const nodes = renderMarkdownProse("> [!NOTE]\n> multi-line body\n> more body");
|
|
expect(nodes).toHaveLength(1);
|
|
const callout = nodes[0];
|
|
expect(isElement(callout) && callout.type).toBe("div");
|
|
expect(textOf(callout)).toContain("multi-line body more body");
|
|
});
|
|
|
|
it("combines a single-line opener's text with any following > lines", () => {
|
|
const nodes = renderMarkdownProse("> [!WARNING] heads up\n> more detail");
|
|
expect(nodes).toHaveLength(1);
|
|
expect(textOf(nodes[0])).toContain("heads up more detail");
|
|
});
|
|
|
|
it("leaves a plain paragraph unchanged", () => {
|
|
const nodes = renderMarkdownProse("Just a paragraph.");
|
|
expect(nodes).toHaveLength(1);
|
|
expect(isElement(nodes[0]) && nodes[0].type).toBe("p");
|
|
expect(textOf(nodes[0])).toBe("Just a paragraph.");
|
|
});
|
|
|
|
it("applies inline markdown inside ### headings (regression: rendered raw tokens)", () => {
|
|
const nodes = renderMarkdownProse("### The `useGuideData` hook");
|
|
expect(nodes).toHaveLength(1);
|
|
const heading = nodes[0];
|
|
expect(isElement(heading) && heading.type).toBe("h3");
|
|
// Inline rendering strips the backticks into a <code> element, so the
|
|
// flattened text must NOT contain the raw markdown tokens.
|
|
expect(textOf(heading)).toBe("The useGuideData hook");
|
|
expect(textOf(heading)).not.toContain("`");
|
|
});
|
|
});
|