mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
Diffs: add viewer payload validation and presentation defaults
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
@@ -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;");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
55
extensions/diffs/src/viewer-payload.test.ts
Normal file
55
extensions/diffs/src/viewer-payload.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
94
extensions/diffs/src/viewer-payload.ts
Normal file
94
extensions/diffs/src/viewer-payload.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user