Diffs: add viewer payload validation and presentation defaults

This commit is contained in:
Gustavo Madeira Santana
2026-03-01 22:06:50 -05:00
parent 0202d79df4
commit 6532757cdf
13 changed files with 345 additions and 40 deletions

View File

@@ -92,7 +92,10 @@ Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
defaults: {
fontFamily: "Fira Code",
fontSize: 15,
lineSpacing: 1.6,
layout: "unified",
showLineNumbers: true,
diffIndicators: "bars",
wordWrap: true,
background: true,
theme: "dark",
@@ -109,7 +112,10 @@ Supported defaults:
- `fontFamily`
- `fontSize`
- `lineSpacing`
- `layout`
- `showLineNumbers`
- `diffIndicators`
- `wordWrap`
- `background`
- `theme`

View File

@@ -68,7 +68,10 @@ Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
defaults: {
fontFamily: "Fira Code",
fontSize: 15,
lineSpacing: 1.6,
layout: "unified",
showLineNumbers: true,
diffIndicators: "bars",
wordWrap: true,
background: true,
theme: "dark",

File diff suppressed because one or more lines are too long

View File

@@ -70,6 +70,9 @@ describe("diffs plugin registration", () => {
theme: "light",
background: false,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
lineSpacing: 2,
},
},
runtime: {} as never,
@@ -119,5 +122,8 @@ describe("diffs plugin registration", () => {
expect(String(res.body)).toContain('body data-theme="light"');
expect(String(res.body)).toContain('"backgroundEnabled":false');
expect(String(res.body)).toContain('"diffStyle":"split"');
expect(String(res.body)).toContain('"disableLineNumbers":true');
expect(String(res.body)).toContain('"diffIndicators":"classic"');
expect(String(res.body)).toContain("--diffs-line-height: 30px;");
});
});

View File

@@ -11,10 +11,22 @@
"label": "Default Font Size",
"help": "Base diff font size in pixels."
},
"defaults.lineSpacing": {
"label": "Default Line Spacing",
"help": "Line-height multiplier applied to diff rows."
},
"defaults.layout": {
"label": "Default Layout",
"help": "Initial diff layout shown in the viewer."
},
"defaults.showLineNumbers": {
"label": "Show Line Numbers",
"help": "Show line numbers by default."
},
"defaults.diffIndicators": {
"label": "Diff Indicator Style",
"help": "Choose added/removed indicators style."
},
"defaults.wordWrap": {
"label": "Default Word Wrap",
"help": "Wrap long lines by default."
@@ -50,11 +62,26 @@
"maximum": 24,
"default": 15
},
"lineSpacing": {
"type": "number",
"minimum": 1,
"maximum": 3,
"default": 1.6
},
"layout": {
"type": "string",
"enum": ["unified", "split"],
"default": "unified"
},
"showLineNumbers": {
"type": "boolean",
"default": true
},
"diffIndicators": {
"type": "string",
"enum": ["bars", "classic", "none"],
"default": "bars"
},
"wordWrap": {
"type": "boolean",
"default": true

View File

@@ -12,7 +12,10 @@ describe("resolveDiffsPluginDefaults", () => {
defaults: {
fontFamily: "JetBrains Mono",
fontSize: 17,
lineSpacing: 1.8,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
wordWrap: false,
background: false,
theme: "light",
@@ -22,11 +25,48 @@ describe("resolveDiffsPluginDefaults", () => {
).toEqual({
fontFamily: "JetBrains Mono",
fontSize: 17,
lineSpacing: 1.8,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
wordWrap: false,
background: false,
theme: "light",
mode: "view",
});
});
it("clamps and falls back for invalid line spacing and indicators", () => {
expect(
resolveDiffsPluginDefaults({
defaults: {
lineSpacing: -5,
diffIndicators: "unknown",
},
}),
).toMatchObject({
lineSpacing: 1,
diffIndicators: "bars",
});
expect(
resolveDiffsPluginDefaults({
defaults: {
lineSpacing: 9,
},
}),
).toMatchObject({
lineSpacing: 3,
});
expect(
resolveDiffsPluginDefaults({
defaults: {
lineSpacing: Number.NaN,
},
}),
).toMatchObject({
lineSpacing: DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing,
});
});
});

View File

@@ -1,8 +1,10 @@
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
import {
DIFF_INDICATORS,
DIFF_LAYOUTS,
DIFF_MODES,
DIFF_THEMES,
type DiffIndicators,
type DiffLayout,
type DiffMode,
type DiffPresentationDefaults,
@@ -14,7 +16,10 @@ type DiffsPluginConfig = {
defaults?: {
fontFamily?: string;
fontSize?: number;
lineSpacing?: number;
layout?: DiffLayout;
showLineNumbers?: boolean;
diffIndicators?: DiffIndicators;
wordWrap?: boolean;
background?: boolean;
theme?: DiffTheme;
@@ -25,7 +30,10 @@ type DiffsPluginConfig = {
export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
fontFamily: "Fira Code",
fontSize: 15,
lineSpacing: 1.6,
layout: "unified",
showLineNumbers: true,
diffIndicators: "bars",
wordWrap: true,
background: true,
theme: "dark",
@@ -47,11 +55,26 @@ const DIFFS_PLUGIN_CONFIG_JSON_SCHEMA = {
maximum: 24,
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fontSize,
},
lineSpacing: {
type: "number",
minimum: 1,
maximum: 3,
default: DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing,
},
layout: {
type: "string",
enum: [...DIFF_LAYOUTS],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.layout,
},
showLineNumbers: {
type: "boolean",
default: DEFAULT_DIFFS_TOOL_DEFAULTS.showLineNumbers,
},
diffIndicators: {
type: "string",
enum: [...DIFF_INDICATORS],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.diffIndicators,
},
wordWrap: { type: "boolean", default: DEFAULT_DIFFS_TOOL_DEFAULTS.wordWrap },
background: { type: "boolean", default: DEFAULT_DIFFS_TOOL_DEFAULTS.background },
theme: {
@@ -101,7 +124,10 @@ export function resolveDiffsPluginDefaults(config: unknown): DiffToolDefaults {
return {
fontFamily: normalizeFontFamily(defaults.fontFamily),
fontSize: normalizeFontSize(defaults.fontSize),
lineSpacing: normalizeLineSpacing(defaults.lineSpacing),
layout: normalizeLayout(defaults.layout),
showLineNumbers: defaults.showLineNumbers !== false,
diffIndicators: normalizeDiffIndicators(defaults.diffIndicators),
wordWrap: defaults.wordWrap !== false,
background: defaults.background !== false,
theme: normalizeTheme(defaults.theme),
@@ -110,11 +136,24 @@ export function resolveDiffsPluginDefaults(config: unknown): DiffToolDefaults {
}
export function toPresentationDefaults(defaults: DiffToolDefaults): DiffPresentationDefaults {
const { fontFamily, fontSize, layout, wordWrap, background, theme } = defaults;
const {
fontFamily,
fontSize,
lineSpacing,
layout,
showLineNumbers,
diffIndicators,
wordWrap,
background,
theme,
} = defaults;
return {
fontFamily,
fontSize,
lineSpacing,
layout,
showLineNumbers,
diffIndicators,
wordWrap,
background,
theme,
@@ -134,10 +173,23 @@ function normalizeFontSize(fontSize?: number): number {
return Math.min(Math.max(rounded, 10), 24);
}
function normalizeLineSpacing(lineSpacing?: number): number {
if (lineSpacing === undefined || !Number.isFinite(lineSpacing)) {
return DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing;
}
return Math.min(Math.max(lineSpacing, 1), 3);
}
function normalizeLayout(layout?: DiffLayout): DiffLayout {
return layout && DIFF_LAYOUTS.includes(layout) ? layout : DEFAULT_DIFFS_TOOL_DEFAULTS.layout;
}
function normalizeDiffIndicators(diffIndicators?: DiffIndicators): DiffIndicators {
return diffIndicators && DIFF_INDICATORS.includes(diffIndicators)
? diffIndicators
: DEFAULT_DIFFS_TOOL_DEFAULTS.diffIndicators;
}
function normalizeTheme(theme?: DiffTheme): DiffTheme {
return theme && DIFF_THEMES.includes(theme) ? theme : DEFAULT_DIFFS_TOOL_DEFAULTS.theme;
}

View File

@@ -26,6 +26,9 @@ describe("renderDiffDocument", () => {
expect(rendered.imageHtml).toContain('data-openclaw-diffs-ready="true"');
expect(rendered.imageHtml).toContain("max-width: 960px;");
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
expect(rendered.html).toContain('"diffIndicators":"bars"');
expect(rendered.html).toContain('"disableLineNumbers":false');
expect(rendered.html).toContain("--diffs-line-height: 24px;");
expect(rendered.html).toContain("--diffs-font-size: 15px;");
expect(rendered.html).not.toContain("fonts.googleapis.com");
});

View File

@@ -52,13 +52,15 @@ function resolveBeforeAfterFileName(input: Extract<DiffInput, { kind: "before_af
function buildDiffOptions(options: DiffRenderOptions): DiffViewerOptions {
const fontFamily = escapeCssString(options.presentation.fontFamily);
const fontSize = Math.max(10, Math.floor(options.presentation.fontSize));
const lineHeight = Math.max(20, Math.round(fontSize * 1.6));
const lineHeight = Math.max(20, Math.round(fontSize * options.presentation.lineSpacing));
return {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: options.presentation.layout,
diffIndicators: options.presentation.diffIndicators,
disableLineNumbers: !options.presentation.showLineNumbers,
expandUnchanged: options.expandUnchanged,
themeType: options.presentation.theme,
backgroundEnabled: options.presentation.background,

View File

@@ -3,15 +3,20 @@ import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre
export const DIFF_LAYOUTS = ["unified", "split"] as const;
export const DIFF_MODES = ["view", "image", "both"] as const;
export const DIFF_THEMES = ["light", "dark"] as const;
export const DIFF_INDICATORS = ["bars", "classic", "none"] as const;
export type DiffLayout = (typeof DIFF_LAYOUTS)[number];
export type DiffMode = (typeof DIFF_MODES)[number];
export type DiffTheme = (typeof DIFF_THEMES)[number];
export type DiffIndicators = (typeof DIFF_INDICATORS)[number];
export type DiffPresentationDefaults = {
fontFamily: string;
fontSize: number;
lineSpacing: number;
layout: DiffLayout;
showLineNumbers: boolean;
diffIndicators: DiffIndicators;
wordWrap: boolean;
background: boolean;
theme: DiffTheme;
@@ -49,6 +54,8 @@ export type DiffViewerOptions = {
dark: "pierre-dark";
};
diffStyle: DiffLayout;
diffIndicators: DiffIndicators;
disableLineNumbers: boolean;
expandUnchanged: boolean;
themeType: DiffTheme;
backgroundEnabled: boolean;

View File

@@ -6,6 +6,7 @@ import type {
SupportedLanguages,
} from "@pierre/diffs";
import type { DiffViewerPayload, DiffLayout, DiffTheme } from "./types.js";
import { parseViewerPayloadJson } from "./viewer-payload.js";
type ViewerState = {
theme: DiffTheme;
@@ -33,18 +34,25 @@ function parsePayload(element: HTMLScriptElement): DiffViewerPayload {
if (!raw) {
throw new Error("Diff payload was empty.");
}
return JSON.parse(raw) as DiffViewerPayload;
return parseViewerPayloadJson(raw);
}
function getCards(): Array<{ host: HTMLElement; payload: DiffViewerPayload }> {
return [...document.querySelectorAll<HTMLElement>(".oc-diff-card")].flatMap((card) => {
const cards: Array<{ host: HTMLElement; payload: DiffViewerPayload }> = [];
for (const card of document.querySelectorAll<HTMLElement>(".oc-diff-card")) {
const host = card.querySelector<HTMLElement>("[data-openclaw-diff-host]");
const payloadNode = card.querySelector<HTMLScriptElement>("[data-openclaw-diff-payload]");
if (!host || !payloadNode) {
return [];
continue;
}
return [{ host, payload: parsePayload(payloadNode) }];
});
try {
cards.push({ host, payload: parsePayload(payloadNode) });
} catch (error) {
console.warn("Skipping invalid diff payload", error);
}
}
return cards;
}
function ensureShadowRoot(host: HTMLElement): void {
@@ -249,8 +257,10 @@ function createRenderOptions(payload: DiffViewerPayload): FileDiffOptions<undefi
theme: payload.options.theme,
themeType: viewerState.theme,
diffStyle: viewerState.layout,
diffIndicators: payload.options.diffIndicators,
expandUnchanged: payload.options.expandUnchanged,
overflow: viewerState.wrapEnabled ? "wrap" : "scroll",
disableLineNumbers: payload.options.disableLineNumbers,
disableBackground: !viewerState.backgroundEnabled,
unsafeCSS: payload.options.unsafeCSS,
renderHeaderMetadata: () => createToolbar(),

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { parseViewerPayloadJson } from "./viewer-payload.js";
function buildValidPayload(): Record<string, unknown> {
return {
prerenderedHTML: "<div>ok</div>",
langs: ["text"],
oldFile: {
name: "README.md",
contents: "before",
},
newFile: {
name: "README.md",
contents: "after",
},
options: {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: "unified",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "dark",
backgroundEnabled: true,
overflow: "wrap",
unsafeCSS: ":host{}",
},
};
}
describe("parseViewerPayloadJson", () => {
it("accepts valid payload JSON", () => {
const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload()));
expect(parsed.options.diffStyle).toBe("unified");
expect(parsed.options.diffIndicators).toBe("bars");
});
it("rejects payloads with invalid shape", () => {
const broken = buildValidPayload();
broken.options = {
...(broken.options as Record<string, unknown>),
diffIndicators: "invalid",
};
expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow(
"Diff payload has invalid shape.",
);
});
it("rejects invalid JSON", () => {
expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON.");
});
});

View File

@@ -0,0 +1,94 @@
import { DIFF_INDICATORS, DIFF_LAYOUTS, DIFF_THEMES } from "./types.js";
import type { DiffViewerPayload } from "./types.js";
const OVERFLOW_VALUES = ["scroll", "wrap"] as const;
export function parseViewerPayloadJson(raw: string): DiffViewerPayload {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("Diff payload is not valid JSON.");
}
if (!isDiffViewerPayload(parsed)) {
throw new Error("Diff payload has invalid shape.");
}
return parsed;
}
function isDiffViewerPayload(value: unknown): value is DiffViewerPayload {
if (!isRecord(value)) {
return false;
}
if (typeof value.prerenderedHTML !== "string") {
return false;
}
if (!Array.isArray(value.langs) || !value.langs.every((lang) => typeof lang === "string")) {
return false;
}
if (!isViewerOptions(value.options)) {
return false;
}
const hasFileDiff = isRecord(value.fileDiff);
const hasBeforeAfterFiles = isRecord(value.oldFile) && isRecord(value.newFile);
if (!hasFileDiff && !hasBeforeAfterFiles) {
return false;
}
return true;
}
function isViewerOptions(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
if (!isRecord(value.theme)) {
return false;
}
if (value.theme.light !== "pierre-light" || value.theme.dark !== "pierre-dark") {
return false;
}
if (!includesValue(DIFF_LAYOUTS, value.diffStyle)) {
return false;
}
if (!includesValue(DIFF_INDICATORS, value.diffIndicators)) {
return false;
}
if (!includesValue(DIFF_THEMES, value.themeType)) {
return false;
}
if (!includesValue(OVERFLOW_VALUES, value.overflow)) {
return false;
}
if (typeof value.disableLineNumbers !== "boolean") {
return false;
}
if (typeof value.expandUnchanged !== "boolean") {
return false;
}
if (typeof value.backgroundEnabled !== "boolean") {
return false;
}
if (typeof value.unsafeCSS !== "string") {
return false;
}
return true;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function includesValue<T extends readonly string[]>(values: T, value: unknown): value is T[number] {
return typeof value === "string" && values.includes(value as T[number]);
}