From 01821bea9af8ceb2f7b33726fcede7994dad2df1 Mon Sep 17 00:00:00 2001 From: Herve Tribouilloy Date: Fri, 9 Oct 2026 16:54:07 +0100 Subject: [PATCH 01/28] Added new widget capability to deliver CMS block editing capability in ReactEdge --- widgets/cmsblock/api/index.ts | 6 ++ widgets/cmsblock/api/widget.tsx | 33 +++++++++ widgets/cmsblock/capability.json | 15 ++++ widgets/cmsblock/index.html | 23 ++++++ widgets/cmsblock/package.json | 39 ++++++++++ widgets/cmsblock/postcss.config.cjs | 15 ++++ widgets/cmsblock/public/default.json | 8 ++ widgets/cmsblock/src/Config.ts | 56 ++++++++++++++ widgets/cmsblock/src/ConfigSchema.ts | 23 ++++++ widgets/cmsblock/src/Widget.tsx | 20 +++++ widgets/cmsblock/src/WidgetView.tsx | 15 ++++ .../src/activity/Context/ActivityContext.tsx | 4 + .../Context/ActivityContextProvider.tsx | 31 ++++++++ .../activity/Context/useActivityContext.ts | 11 +++ .../cmsblock/src/bootstrap/WidgetWrapper.tsx | 18 +++++ .../cmsblock/src/bootstrap/widget-root.tsx | 24 ++++++ .../src/bootstrap/widget-ssr-component.tsx | 16 ++++ .../src/components/WidgetCmsblock.tsx | 18 +++++ widgets/cmsblock/src/entrypoints/npm.tsx | 41 +++++++++++ widgets/cmsblock/src/entrypoints/ssr.tsx | 12 +++ widgets/cmsblock/src/styles/main.css | 73 +++++++++++++++++++ widgets/cmsblock/src/styles/widget.css | 1 + widgets/cmsblock/src/types/global.d.ts | 9 +++ widgets/cmsblock/tests/cmsblock.spec.ts | 33 +++++++++ widgets/cmsblock/tsconfig.app.json | 7 ++ widgets/cmsblock/tsconfig.json | 8 ++ widgets/cmsblock/tsconfig.node.json | 10 +++ widgets/cmsblock/vite.config.ts | 49 +++++++++++++ widgets/cmsblock/vite.npm.config.ts | 31 ++++++++ 29 files changed, 649 insertions(+) create mode 100644 widgets/cmsblock/api/index.ts create mode 100644 widgets/cmsblock/api/widget.tsx create mode 100644 widgets/cmsblock/capability.json create mode 100644 widgets/cmsblock/index.html create mode 100644 widgets/cmsblock/package.json create mode 100644 widgets/cmsblock/postcss.config.cjs create mode 100644 widgets/cmsblock/public/default.json create mode 100644 widgets/cmsblock/src/Config.ts create mode 100644 widgets/cmsblock/src/ConfigSchema.ts create mode 100644 widgets/cmsblock/src/Widget.tsx create mode 100644 widgets/cmsblock/src/WidgetView.tsx create mode 100644 widgets/cmsblock/src/activity/Context/ActivityContext.tsx create mode 100644 widgets/cmsblock/src/activity/Context/ActivityContextProvider.tsx create mode 100644 widgets/cmsblock/src/activity/Context/useActivityContext.ts create mode 100644 widgets/cmsblock/src/bootstrap/WidgetWrapper.tsx create mode 100644 widgets/cmsblock/src/bootstrap/widget-root.tsx create mode 100644 widgets/cmsblock/src/bootstrap/widget-ssr-component.tsx create mode 100644 widgets/cmsblock/src/components/WidgetCmsblock.tsx create mode 100644 widgets/cmsblock/src/entrypoints/npm.tsx create mode 100644 widgets/cmsblock/src/entrypoints/ssr.tsx create mode 100644 widgets/cmsblock/src/styles/main.css create mode 100644 widgets/cmsblock/src/styles/widget.css create mode 100644 widgets/cmsblock/src/types/global.d.ts create mode 100644 widgets/cmsblock/tests/cmsblock.spec.ts create mode 100644 widgets/cmsblock/tsconfig.app.json create mode 100644 widgets/cmsblock/tsconfig.json create mode 100644 widgets/cmsblock/tsconfig.node.json create mode 100644 widgets/cmsblock/vite.config.ts create mode 100644 widgets/cmsblock/vite.npm.config.ts diff --git a/widgets/cmsblock/api/index.ts b/widgets/cmsblock/api/index.ts new file mode 100644 index 00000000..254ab32b --- /dev/null +++ b/widgets/cmsblock/api/index.ts @@ -0,0 +1,6 @@ +/** + * Public npm entry point. + */ +export { Widget } from "../src/Widget"; + +export { WidgetComponent } from "../src/bootstrap/widget-ssr-component.tsx" \ No newline at end of file diff --git a/widgets/cmsblock/api/widget.tsx b/widgets/cmsblock/api/widget.tsx new file mode 100644 index 00000000..12b89196 --- /dev/null +++ b/widgets/cmsblock/api/widget.tsx @@ -0,0 +1,33 @@ +import "../src/styles/widget.css"; +import {WIDGET_ID} from "../src/Config.ts"; +import {ElementHostProvider} from "@reactedge/framework/host.ts"; +import type {WidgetApi} from "@reactedge/public-api/widget.ts"; +import {createRoot} from "react-dom/client"; +import {WidgetRoot} from "../src/bootstrap/widget-root.tsx"; + + +const mount = ( + el: HTMLElement, + contract: unknown, + bootstrap: unknown +) => { + const hostProvider = new ElementHostProvider(WIDGET_ID) + + createRoot(hostProvider.getMountedHost(el)).render( + + ) +}; + +const api: WidgetApi = { + mount, +}; + +if (typeof window !== "undefined") { + window[`ReactEdge_${WIDGET_ID}`] = api; +} + +export { mount }; \ No newline at end of file diff --git a/widgets/cmsblock/capability.json b/widgets/cmsblock/capability.json new file mode 100644 index 00000000..8baaa437 --- /dev/null +++ b/widgets/cmsblock/capability.json @@ -0,0 +1,15 @@ +{ + "id": "cmsblock", + "widget": "cmsblock", + "description": "", + "signals": { + "consumes": [], + "emits": [] + }, + "runtime": { + "rendering": [ + "client", + "ssr" + ] + } +} \ No newline at end of file diff --git a/widgets/cmsblock/index.html b/widgets/cmsblock/index.html new file mode 100644 index 00000000..5ef3c211 --- /dev/null +++ b/widgets/cmsblock/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/widgets/cmsblock/package.json b/widgets/cmsblock/package.json new file mode 100644 index 00000000..260493ed --- /dev/null +++ b/widgets/cmsblock/package.json @@ -0,0 +1,39 @@ +{ + "name": "widget-cmsblock", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "build:ssr": "vite build -c vite.npm.config.ts", + "analyze": "ANALYZE=true vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react-swc": "^4.2.2", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "postcss": "^8.5.6", + "postcss-prefix-selector": "^2.1.1", + "rollup-plugin-visualizer": "^7.0.1", + "tsx": "^4.21.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^8.3.0" + }, + "peerDependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + } +} \ No newline at end of file diff --git a/widgets/cmsblock/postcss.config.cjs b/widgets/cmsblock/postcss.config.cjs new file mode 100644 index 00000000..a6076193 --- /dev/null +++ b/widgets/cmsblock/postcss.config.cjs @@ -0,0 +1,15 @@ +const prefixSelector = require('postcss-prefix-selector'); + +module.exports = { + plugins: [ + prefixSelector({ + prefix: '.reactedge-cmsblock', + transform(prefix, selector, prefixedSelector) { + if (selector.startsWith('html') || selector.startsWith('body')) { + return selector; + } + return prefixedSelector; + }, + }), + ], +}; diff --git a/widgets/cmsblock/public/default.json b/widgets/cmsblock/public/default.json new file mode 100644 index 00000000..a9972ee8 --- /dev/null +++ b/widgets/cmsblock/public/default.json @@ -0,0 +1,8 @@ +{ + "data": { + "title": "I am standard cmsblock ready to work" + }, + "settings": { + "colour": "red" + } +} \ No newline at end of file diff --git a/widgets/cmsblock/src/Config.ts b/widgets/cmsblock/src/Config.ts new file mode 100644 index 00000000..67a2514c --- /dev/null +++ b/widgets/cmsblock/src/Config.ts @@ -0,0 +1,56 @@ +import {parseConfig} from "./ConfigSchema.ts"; +import type {WidgetActivity} from "@reactedge/framework/activity"; + +export interface WidgetConfig { + readonly data: { + title: string; + } + + readonly settings: { + colour: string; + }; +} + +export const WIDGET_ID = 'cmsblock'; + +/** + * Validates the widget contract and returns an immutable configuration. + * + * The contract is treated as untrusted input and is validated before + * being exposed to the React application. + * + * This function represents the trust boundary between the ReactEdge + * runtime and the widget implementation for widgets that do not require + * runtime integrations. + * + * @param rawContract - Widget contract supplied by the host platform. + * @param activity - Optional activity logger used during bootstrap. + * @returns An immutable widget configuration. + * @throws When the widget contract is invalid. + */ +export function readWidgetConfig( + rawContract: unknown, + activity?: WidgetActivity +): WidgetConfig { + try { + const contract = parseConfig(rawContract); + + activity?.log( + 'bootstrap', + 'Config resolved', + contract + ); + + return Object.freeze(contract); + + } catch (e) { + activity?.log( + 'bootstrap', + 'Invalid widget contract', + e instanceof Error? e.message: e, + 'error' + ); + + throw e; + } +} \ No newline at end of file diff --git a/widgets/cmsblock/src/ConfigSchema.ts b/widgets/cmsblock/src/ConfigSchema.ts new file mode 100644 index 00000000..2cd27c6c --- /dev/null +++ b/widgets/cmsblock/src/ConfigSchema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +const WidgetDataSchema = z.object({ + title: z.string(), +}); + +const WidgetSettingsSchema = z.object({ + colour: z.string(), +}); + +export const WidgetConfigSchema = z.object({ + data: WidgetDataSchema, + settings: WidgetSettingsSchema, +}).strict(); + +export type WidgetConfig = + z.infer; + +export function parseConfig( + input: unknown +): WidgetConfig { + return WidgetConfigSchema.parse(input); +} \ No newline at end of file diff --git a/widgets/cmsblock/src/Widget.tsx b/widgets/cmsblock/src/Widget.tsx new file mode 100644 index 00000000..175b7104 --- /dev/null +++ b/widgets/cmsblock/src/Widget.tsx @@ -0,0 +1,20 @@ +import {WidgetRoot} from "./bootstrap/widget-root.tsx"; +import {createRoot, hydrateRoot} from "react-dom/client"; +import type {StaticWidgetOptions} from "@reactedge/public-api/WidgetOptions.ts"; + +export function Widget({ + container, + contract, + bootstrap, + hydrate = false, + }: StaticWidgetOptions) { + const element = ( + + ); + + if (hydrate) { + hydrateRoot(container, element); + } else { + createRoot(container).render(element); + } +} \ No newline at end of file diff --git a/widgets/cmsblock/src/WidgetView.tsx b/widgets/cmsblock/src/WidgetView.tsx new file mode 100644 index 00000000..5662304c --- /dev/null +++ b/widgets/cmsblock/src/WidgetView.tsx @@ -0,0 +1,15 @@ +import {readWidgetConfig} from "./Config.ts"; +import {WidgetCmsblock} from "./components/WidgetCmsblock.tsx"; + +type Props = { + contract?: unknown; +}; + +export const WidgetView = ({ contract }: Props) => { + const config = readWidgetConfig(contract); + + if (!config) return null; + + return +}; + diff --git a/widgets/cmsblock/src/activity/Context/ActivityContext.tsx b/widgets/cmsblock/src/activity/Context/ActivityContext.tsx new file mode 100644 index 00000000..81be6cc3 --- /dev/null +++ b/widgets/cmsblock/src/activity/Context/ActivityContext.tsx @@ -0,0 +1,4 @@ +import {createContext} from "react"; +import type {WidgetActivity} from "@reactedge/framework/activity"; + +export const LocalInstanceStateContext = createContext(undefined); \ No newline at end of file diff --git a/widgets/cmsblock/src/activity/Context/ActivityContextProvider.tsx b/widgets/cmsblock/src/activity/Context/ActivityContextProvider.tsx new file mode 100644 index 00000000..e1afaf74 --- /dev/null +++ b/widgets/cmsblock/src/activity/Context/ActivityContextProvider.tsx @@ -0,0 +1,31 @@ +import {type ReactNode} from "react"; +import {WIDGET_ID} from "../../Config.ts"; +import {LocalInstanceStateContext} from "./ActivityContext.tsx"; +import {WidgetActivity} from "@reactedge/framework/activity"; + +interface InstanceStateProviderProps { + children: ReactNode; + hostElement?: HTMLElement; +} + +const LocalStateProvider = LocalInstanceStateContext.Provider; + +export const ActivityContextProvider: React.FC = ({ + children, + hostElement + }) => { + + const host = hostElement ?? document.documentElement; + + const activity = new WidgetActivity(WIDGET_ID, host.dataset.instance) + + return ( + + {children} + + ); +}; diff --git a/widgets/cmsblock/src/activity/Context/useActivityContext.ts b/widgets/cmsblock/src/activity/Context/useActivityContext.ts new file mode 100644 index 00000000..7a61a77d --- /dev/null +++ b/widgets/cmsblock/src/activity/Context/useActivityContext.ts @@ -0,0 +1,11 @@ +import {useContext} from "react"; +import {LocalInstanceStateContext} from "./ActivityContext.tsx"; +import type {WidgetActivity} from "@reactedge/framework/activity"; + +export function useActivityContext(): WidgetActivity { + const context = useContext(LocalInstanceStateContext); + if (!context) { + throw new Error("useInstanceState must be used within InstanceStateProvider"); + } + return context; +} \ No newline at end of file diff --git a/widgets/cmsblock/src/bootstrap/WidgetWrapper.tsx b/widgets/cmsblock/src/bootstrap/WidgetWrapper.tsx new file mode 100644 index 00000000..2e095ceb --- /dev/null +++ b/widgets/cmsblock/src/bootstrap/WidgetWrapper.tsx @@ -0,0 +1,18 @@ +import {WidgetCmsblock} from "../components/WidgetCmsblock.tsx"; +import {readWidgetConfig} from "../Config.ts"; +import {useActivityContext} from "../activity/Context/useActivityContext.ts"; + +type Props = { + contract?: unknown + bootstrap?: unknown +}; + +export const WidgetWrapper = ({ contract }: Props) => { + const activity = useActivityContext() + const config = readWidgetConfig(contract, activity); + + if (!config) return null; + + return +}; + diff --git a/widgets/cmsblock/src/bootstrap/widget-root.tsx b/widgets/cmsblock/src/bootstrap/widget-root.tsx new file mode 100644 index 00000000..b0c91cd3 --- /dev/null +++ b/widgets/cmsblock/src/bootstrap/widget-root.tsx @@ -0,0 +1,24 @@ +import {WIDGET_ID} from "../Config.ts"; +import {ActivityContextProvider} from "../activity/Context/ActivityContextProvider.tsx"; +import {WidgetWrapper} from "./WidgetWrapper.tsx"; +interface WidgetRootProps { + contract: unknown; + bootstrap: unknown; + hostElement?: HTMLElement; +} + +export function WidgetRoot({ + contract, + bootstrap, + hostElement, + }: WidgetRootProps) { + return ( +
+ + + +
+ ); +} \ No newline at end of file diff --git a/widgets/cmsblock/src/bootstrap/widget-ssr-component.tsx b/widgets/cmsblock/src/bootstrap/widget-ssr-component.tsx new file mode 100644 index 00000000..bf7328f6 --- /dev/null +++ b/widgets/cmsblock/src/bootstrap/widget-ssr-component.tsx @@ -0,0 +1,16 @@ +import {WIDGET_ID} from "../Config.ts"; +import {WidgetView} from "../WidgetView.tsx"; +interface WidgetRootProps { + contract: unknown; + hostElement?: HTMLElement; +} + +export function WidgetComponent({ + contract + }: WidgetRootProps) { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/widgets/cmsblock/src/components/WidgetCmsblock.tsx b/widgets/cmsblock/src/components/WidgetCmsblock.tsx new file mode 100644 index 00000000..ce4b37f3 --- /dev/null +++ b/widgets/cmsblock/src/components/WidgetCmsblock.tsx @@ -0,0 +1,18 @@ +import type { WidgetConfig } from "../Config"; + +type Props = { + config: WidgetConfig; +}; + +export const WidgetCmsblock = ({ + config, + }: Props) => { + return ( +

+ {config.data.title} +

+ ); +}; \ No newline at end of file diff --git a/widgets/cmsblock/src/entrypoints/npm.tsx b/widgets/cmsblock/src/entrypoints/npm.tsx new file mode 100644 index 00000000..35763c83 --- /dev/null +++ b/widgets/cmsblock/src/entrypoints/npm.tsx @@ -0,0 +1,41 @@ +import { Widget } from "../Widget.tsx"; +import { ResourceLoader } from "@reactedge/framework/contract"; +import { WidgetActivity } from "@reactedge/framework/activity"; +import { WIDGET_ID } from "../Config.ts"; + +async function main() { + const loader = new ResourceLoader(); + const activity = new WidgetActivity(WIDGET_ID) + const contract = await loader.loadContract("default.json"); + const bootstrap = await loader.loadContract("data.json"); + const container = document.getElementById("root")!; + + const mode = __REACTEDGE_MODE__; + + if (mode === "hydrate") { + activity.debug("Hydrating existing HTML", { + contract, + html: container.innerHTML + }); + } else { + activity.debug("Rendering from scratch"); + } + + if (mode === "hydrate") { + Widget({ + container, + contract, + bootstrap, + hydrate: true + }); + } else { + Widget({ + container, + contract, + bootstrap, + hydrate: false + }); + } +} + +main(); \ No newline at end of file diff --git a/widgets/cmsblock/src/entrypoints/ssr.tsx b/widgets/cmsblock/src/entrypoints/ssr.tsx new file mode 100644 index 00000000..85faaff8 --- /dev/null +++ b/widgets/cmsblock/src/entrypoints/ssr.tsx @@ -0,0 +1,12 @@ +import { renderToString } from 'react-dom/server'; +import type {WidgetConfig} from "../Config.ts"; +import {WidgetView} from "../WidgetView.tsx"; +import {WIDGET_ID} from "../Config.ts"; + +export const renderHtml = (config: WidgetConfig): string => { + return renderToString( +
+ +
+ ); +}; \ No newline at end of file diff --git a/widgets/cmsblock/src/styles/main.css b/widgets/cmsblock/src/styles/main.css new file mode 100644 index 00000000..cd844aa9 --- /dev/null +++ b/widgets/cmsblock/src/styles/main.css @@ -0,0 +1,73 @@ +.cmsblock-slider-bar.dark, .cmsblock-static-bar.dark { + --cmsblock-bg: #1f2937; /* slate-800 */ + --cmsblock-color: #e5e7eb; /* soft white */ + + --arrow-bg: #374151; /* subtle contrast */ + --arrow-color: #f9fafb; +} + +.cmsblock-slider-bar.light, .cmsblock-static-bar.light { + --cmsblock-bg: #f8fafc; + --cmsblock-color: #1e3a8a; + + --arrow-bg: #e5e7eb; /* light gray */ + --arrow-color: #374151; +} + +.cmsblock-slider-bar.promo, .cmsblock-static-bar.promo { + --cmsblock-bg: linear-gradient(60deg, #f97316, #f5a623); /* orange gradient */ + --cmsblock-color: #ffffff; + + --arrow-bg: rgba(255,255,255,0.2); + --arrow-color: #ffffff; +} + +.cmsblock-slider-bar, .cmsblock-static-bar { + background: var(--cmsblock-bg); + color: var(--cmsblock-color); +} + +.arrow-btn { + background: var(--arrow-bg); + color: var(--arrow-color); + + border-radius: 9999px; + width: 28px; + height: 28px; + + display: flex; + align-items: center; + justify-content: center; + + transition: all 0.2s ease; +} + +.cmsblock-static-bar { + display: flex; + width: 100%; + padding: 2rem; + + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + box-shadow: + 0 1px 2px 0 rgb(0 0 0 / 0.05); + + text-align: center; + + .cmsblock-slide { + flex: 1; + } + + .cmsblock-slide__heading { + margin: 0; + font-weight: 600; + } + + .cmsblock-slide__text { + margin: 0.5rem 0 0; + font-size: 1rem; + line-height: 1.25rem; + color: #4b5563; + } +} \ No newline at end of file diff --git a/widgets/cmsblock/src/styles/widget.css b/widgets/cmsblock/src/styles/widget.css new file mode 100644 index 00000000..520f553b --- /dev/null +++ b/widgets/cmsblock/src/styles/widget.css @@ -0,0 +1 @@ +@import "main.css"; diff --git a/widgets/cmsblock/src/types/global.d.ts b/widgets/cmsblock/src/types/global.d.ts new file mode 100644 index 00000000..99c9f6c7 --- /dev/null +++ b/widgets/cmsblock/src/types/global.d.ts @@ -0,0 +1,9 @@ +export {}; + +declare global { + interface Window { + __REACTEDGE_DEBUG__?: boolean; + [key: `ReactEdge_${string}`]: WidgetApi; + } + const __REACTEDGE_MODE__: "render" | "hydrate"; +} diff --git a/widgets/cmsblock/tests/cmsblock.spec.ts b/widgets/cmsblock/tests/cmsblock.spec.ts new file mode 100644 index 00000000..cb13dc9b --- /dev/null +++ b/widgets/cmsblock/tests/cmsblock.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from "@playwright/test"; +import type { Locator } from "@playwright/test"; + +test.describe('Cmsblock Widget', () => { + let cmsblock: Locator; + + test.beforeEach(async ({ page }) => { + await page.goto('/?reactedge_debug=eager'); + cmsblock = page.locator('cmsblock-widget'); + await expect(cmsblock).toBeVisible(); + }); + + test('Cmsblock widget renders its configured title', async () => { + const title = cmsblock.locator( + '[data-cmsblock-title]' + ); + + await expect(title).toBeVisible(); + }); + + test('Cmsblock widget renders the title colour', async () => { + const title = cmsblock.locator( + '[data-cmsblock-title]' + ); + + const colour = await title.evaluate( + (element: HTMLElement) => + getComputedStyle(element).color + ); + + expect(colour).not.toBe('rgb(0, 0, 0)'); + }); +}); \ No newline at end of file diff --git a/widgets/cmsblock/tsconfig.app.json b/widgets/cmsblock/tsconfig.app.json new file mode 100644 index 00000000..705ab148 --- /dev/null +++ b/widgets/cmsblock/tsconfig.app.json @@ -0,0 +1,7 @@ +{ + "extends": "../../packages/widget-build/shared-resources/tsconfig.app.widget.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo" + }, + "include": ["src", "api"] +} \ No newline at end of file diff --git a/widgets/cmsblock/tsconfig.json b/widgets/cmsblock/tsconfig.json new file mode 100644 index 00000000..4ebbbc80 --- /dev/null +++ b/widgets/cmsblock/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "extends": "../../tsconfig.json" +} diff --git a/widgets/cmsblock/tsconfig.node.json b/widgets/cmsblock/tsconfig.node.json new file mode 100644 index 00000000..620f3ec4 --- /dev/null +++ b/widgets/cmsblock/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "extends": "../../packages/widget-build/shared-resources/tsconfig.node.widget.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" + }, + "include": [ + "vite.config.ts", + "vite.npm.config.ts" + ] +} diff --git a/widgets/cmsblock/vite.config.ts b/widgets/cmsblock/vite.config.ts new file mode 100644 index 00000000..a6ef5d77 --- /dev/null +++ b/widgets/cmsblock/vite.config.ts @@ -0,0 +1,49 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import pkg from "./package.json" with { type: "json" }; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +import { manifestPlugin } from "../../packages/widget-build/shared-resources/widget-preset/manifestPlugin.ts"; +import { createWidgetBuildDefaults } from "../../packages/widget-build/shared-resources/widget-preset/createReactEdgeConfig.ts"; +import { reactEdgeVisualizer } from "../../packages/widget-build/shared-resources/widget-preset/reactEdgeVisualizer.ts"; + +const isAnalyze = process.env.ANALYZE === "true"; +const widgetName = pkg.name.replace(/^widget-/, ""); +const widgetDir = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "@reactedge": resolve( + widgetDir, + "../../packages/widget-build/shared-resources" + ), + }, + }, + + plugins: [ + react(), + reactEdgeVisualizer(isAnalyze), + manifestPlugin({ + widgetName, + version: pkg.version, + }), + ], + + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, + + build: createWidgetBuildDefaults({ + widgetName, + version: pkg.version, + entry: resolve(widgetDir, "api/widget.tsx"), + outDir: resolve( + widgetDir, + `../../workspace/release/source/${widgetName}` + ), + emitCss: true, + sourcemap: false + }), +}); \ No newline at end of file diff --git a/widgets/cmsblock/vite.npm.config.ts b/widgets/cmsblock/vite.npm.config.ts new file mode 100644 index 00000000..71f83cc3 --- /dev/null +++ b/widgets/cmsblock/vite.npm.config.ts @@ -0,0 +1,31 @@ +import { defineConfig} from "vite"; +import type { BuildOptions } from "vite" +import react from "@vitejs/plugin-react-swc"; +import pkg from "./package.json" with { type: "json" }; +import {dirname, resolve} from "node:path"; +import {createNpmBuildDefaults} from "../../packages/widget-build/shared-resources/widget-preset/createReactEdgeConfig.ts"; +import {fileURLToPath} from "node:url"; + +const widgetName = pkg.name.replace(/^widget-/, ""); +const widgetDir = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "@reactedge": resolve( + widgetDir, + "../../packages/widget-build/shared-resources" + ), + }, + }, + publicDir: resolve(import.meta.dirname, "public"), + plugins: [react(), ], + define: { + __REACTEDGE_MODE__: JSON.stringify( + process.env.REACTEDGE_MODE ?? "render" + ), + }, + build: createNpmBuildDefaults({ + widgetName + }), +}); \ No newline at end of file From 6e3290f680fa728c325e38c2f2b03ef0e7a3f622 Mon Sep 17 00:00:00 2001 From: Herve Tribouilloy <166377039+digitalrisedorset@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:01:14 +0200 Subject: [PATCH 02/28] feat(cmsblock): scaffold editable content draft and template selection --- widgets/cmsblock/public/default.json | 11 +- widgets/cmsblock/src/Config.ts | 52 ++---- widgets/cmsblock/src/ConfigSchema.ts | 24 +-- widgets/cmsblock/src/Model/CmsBlock.ts | 18 ++ .../src/components/CmsBlockEditor.tsx | 52 ++++++ .../src/components/CmsBlockSourcePreview.tsx | 11 ++ .../src/components/WidgetCmsblock.tsx | 54 ++++-- .../src/controller/useCmsBlockController.ts | 36 ++++ widgets/cmsblock/src/styles/main.css | 155 ++++++++++++------ widgets/cmsblock/tests/cmsblock.spec.ts | 52 +++--- 10 files changed, 323 insertions(+), 142 deletions(-) create mode 100644 widgets/cmsblock/src/Model/CmsBlock.ts create mode 100644 widgets/cmsblock/src/components/CmsBlockEditor.tsx create mode 100644 widgets/cmsblock/src/components/CmsBlockSourcePreview.tsx create mode 100644 widgets/cmsblock/src/controller/useCmsBlockController.ts diff --git a/widgets/cmsblock/public/default.json b/widgets/cmsblock/public/default.json index a9972ee8..9c7f4455 100644 --- a/widgets/cmsblock/public/default.json +++ b/widgets/cmsblock/public/default.json @@ -1,8 +1,13 @@ { "data": { - "title": "I am standard cmsblock ready to work" + "title": "CMS Block", + "source": { + "format": "text", + "content": "Add your content here. Select a template to prepare it for AI-assisted styling." + }, + "templateId": "editorial" }, "settings": { - "colour": "red" + "colour": "#1f2937" } -} \ No newline at end of file +} diff --git a/widgets/cmsblock/src/Config.ts b/widgets/cmsblock/src/Config.ts index 67a2514c..1c4c56d0 100644 --- a/widgets/cmsblock/src/Config.ts +++ b/widgets/cmsblock/src/Config.ts @@ -1,56 +1,26 @@ -import {parseConfig} from "./ConfigSchema.ts"; -import type {WidgetActivity} from "@reactedge/framework/activity"; +import {parseConfig, type WidgetConfig as ParsedWidgetConfig} from './ConfigSchema.ts'; +import type {WidgetActivity} from '@reactedge/framework/activity'; -export interface WidgetConfig { - readonly data: { - title: string; - } - - readonly settings: { - colour: string; - }; -} +export type WidgetConfig = ParsedWidgetConfig; export const WIDGET_ID = 'cmsblock'; -/** - * Validates the widget contract and returns an immutable configuration. - * - * The contract is treated as untrusted input and is validated before - * being exposed to the React application. - * - * This function represents the trust boundary between the ReactEdge - * runtime and the widget implementation for widgets that do not require - * runtime integrations. - * - * @param rawContract - Widget contract supplied by the host platform. - * @param activity - Optional activity logger used during bootstrap. - * @returns An immutable widget configuration. - * @throws When the widget contract is invalid. - */ +/** CMSBlock is a static widget; editing will use a separate backend service. */ export function readWidgetConfig( rawContract: unknown, activity?: WidgetActivity ): WidgetConfig { try { - const contract = parseConfig(rawContract); - - activity?.log( - 'bootstrap', - 'Config resolved', - contract - ); - - return Object.freeze(contract); - - } catch (e) { + const config = parseConfig(rawContract); + activity?.log('bootstrap', 'Config resolved', config); + return Object.freeze(config); + } catch (error) { activity?.log( 'bootstrap', 'Invalid widget contract', - e instanceof Error? e.message: e, + error instanceof Error ? error.message : error, 'error' ); - - throw e; + throw error; } -} \ No newline at end of file +} diff --git a/widgets/cmsblock/src/ConfigSchema.ts b/widgets/cmsblock/src/ConfigSchema.ts index 2cd27c6c..6f874c20 100644 --- a/widgets/cmsblock/src/ConfigSchema.ts +++ b/widgets/cmsblock/src/ConfigSchema.ts @@ -1,23 +1,27 @@ -import { z } from 'zod'; +import {z} from 'zod'; + +const WidgetSourceSchema = z.object({ + format: z.enum(['text', 'html']), + content: z.string().max(100_000), +}).strict(); const WidgetDataSchema = z.object({ - title: z.string(), -}); + title: z.string().min(1), + source: WidgetSourceSchema.default({format: 'text', content: ''}), + templateId: z.enum(['editorial', 'feature', 'promotion']).default('editorial'), +}).strict(); const WidgetSettingsSchema = z.object({ colour: z.string(), -}); +}).strict(); export const WidgetConfigSchema = z.object({ data: WidgetDataSchema, settings: WidgetSettingsSchema, }).strict(); -export type WidgetConfig = - z.infer; +export type WidgetConfig = z.infer; -export function parseConfig( - input: unknown -): WidgetConfig { +export function parseConfig(input: unknown): WidgetConfig { return WidgetConfigSchema.parse(input); -} \ No newline at end of file +} diff --git a/widgets/cmsblock/src/Model/CmsBlock.ts b/widgets/cmsblock/src/Model/CmsBlock.ts new file mode 100644 index 00000000..00e7b5ff --- /dev/null +++ b/widgets/cmsblock/src/Model/CmsBlock.ts @@ -0,0 +1,18 @@ +/** Source is always preserved as authored; it is not executable markup. */ +export type CmsBlockSource = { + format: 'text' | 'html'; + content: string; +}; + +export const CMS_BLOCK_TEMPLATES = [ + {id: 'editorial', label: 'Editorial'}, + {id: 'feature', label: 'Feature'}, + {id: 'promotion', label: 'Promotional'}, +] as const; + +export type CmsBlockTemplateId = (typeof CMS_BLOCK_TEMPLATES)[number]['id']; + +export type CmsBlockDraft = { + source: CmsBlockSource; + templateId: CmsBlockTemplateId; +}; diff --git a/widgets/cmsblock/src/components/CmsBlockEditor.tsx b/widgets/cmsblock/src/components/CmsBlockEditor.tsx new file mode 100644 index 00000000..1c76aeca --- /dev/null +++ b/widgets/cmsblock/src/components/CmsBlockEditor.tsx @@ -0,0 +1,52 @@ +import {useId} from 'react'; +import {CMS_BLOCK_TEMPLATES, type CmsBlockDraft, type CmsBlockTemplateId} from '../Model/CmsBlock.ts'; +import type {CmsBlockController} from '../controller/useCmsBlockController.ts'; + +type Props = { + controller: CmsBlockController; +}; + +export const CmsBlockEditor = ({controller}: Props) => { + const contentId = useId(); + const formatId = useId(); + const templateId = useId(); + const {draft} = controller; + + return ( +
+ +