Build a complete resource-panel widget for Znode 10 Page Builder
Recipe 03 / Znode 10
Build a complete resource-panel widget for Znode 10 Page Builder
Create the typed widget, register it once for Page Builder and Webstore, include its local resource page, and verify editing, saved content, safe links, and both application builds.
- ContractTyped widget fields
- EditingPuck configuration
- RegistrationShared base registry
- ExperienceSaved storefront panel
The complete implementation
What you are building
A product team needs a small block that points buyers to installation information. Editors should be able to change its heading, description, link label, and local path without asking a developer to rebuild the page. The storefront must render exactly the fields that Page Builder saves.
This recipe supplies every custom source file, the precise registration edits, a sample local resource, focused tests, and paired build commands. The result is a responsive gradient panel with an encoded text heading and an ordinary internal link. It makes no API calls and requires no additional credentials.
Znode 10 WebStoreSDK with @measured/puck 0.17.4
Before you start
- A configured Znode 10 WebStoreSDK checkout with apps/webstore, apps/page-builder, and packages/page-builder. Start with an existing working test Store and editable page using the base configuration.
- The inspected dependency baseline is Next.js 14.2.20, React 18.3.1, TypeScript 5.5.4, Nx 19.7.0, and @measured/puck 0.17.4. Use the project's package-lock.json to restore that baseline.
- Use the supported Node.js version for this SDK and hosting environment. Znode's current system-requirements page specifies Node.js 20 or later. The widget runs in the Next.js/React layer; it does not run inside the ASP.NET Core Custom API.
- This import path targets the 0.17.4 SDK. On a branch already migrated to @puckeditor/core 0.21.1, use that package name consistently and complete the documented Puck migration for both applications. Do not install a second Puck package just for this widget.
- All filenames below are relative to WebStoreSDK. Retain existing configuration and theme registrations when adding the listed entries.
Step 01
Restore the SDK baseline
From the WebStoreSDK root, restore the checked-in dependency lock and inspect the selected packages. The widget adds no runtime dependency. Keep the working Store, API, authentication, and Page Builder launch configuration already used by this checkout.
Terminal at WebStoreSDKpowershell
npm ci
npm ls --depth=0 @measured/puck next react typescript nx
Step 02
Name the saved content contract
Create the resource-panel directory and add these interfaces. ResourcePanelProps contains the four fields saved in page content. ResourcePanelRenderProps adds the unique id supplied by Puck so the rendered heading can name its section.
packages/page-builder/src/configs/base-config/widgets/ui-widgets/resource-panel/ResourcePanel.types.tstypescript
export interface ResourcePanelProps {
heading: string;
description: string;
linkLabel: string;
linkPath: string;
}
export interface ResourcePanelRenderProps extends ResourcePanelProps {
id: string;
}
Step 03
Accept only a local resource path
The link editor accepts root-relative paths, such as /resources/installation-checklist.html. The helper trims surrounding whitespace, normalizes dot segments, and rejects external origins, protocol-relative links, backslashes, control characters, and internal whitespace. Its reference origin is used only for parsing and causes no network request.
packages/page-builder/src/configs/base-config/widgets/ui-widgets/resource-panel/resource-path.tstypescript
const referenceOrigin = "https://resource.example.test";
export function localResourcePath(value: string): string | null {
const path = value.trim();
if (path.length === 0 || path.length > 1024 ||
!path.startsWith("/") || path.startsWith("//") ||
/[\\\s\u0000-\u001f\u007f]/.test(path)) {
return null;
}
try {
const parsed = new URL(path, referenceOrigin);
if (parsed.origin !== referenceOrigin) {
return null;
}
return parsed.pathname + parsed.search + parsed.hash;
} catch {
return null;
}
}
Step 04
Render the complete panel
Use ordinary React text interpolation so saved content is HTML-encoded. The renderer does not replace intentionally empty fields with defaults. The section and link wrap on narrow layouts, and the action has a visible keyboard focus outline. Both icons use SVG paths, which prevents mobile emoji substitution.
packages/page-builder/src/configs/base-config/widgets/ui-widgets/resource-panel/ResourcePanelRender.tsxtsx
import type { ResourcePanelRenderProps } from "./ResourcePanel.types";
import { localResourcePath } from "./resource-path";
export function ResourcePanelRender({
id,
heading,
description,
linkLabel,
linkPath,
}: ResourcePanelRenderProps) {
const headingId = `resource-panel-${id}`;
const safePath = localResourcePath(linkPath);
const showLink = safePath !== null && linkLabel.trim().length > 0;
return (
<section
aria-labelledby={heading.trim() ? headingId : undefined}
className="mx-auto my-6 w-full max-w-5xl overflow-hidden rounded-2xl border border-slate-200 bg-gradient-to-br from-white via-slate-50 to-indigo-50 p-6 text-slate-950 shadow-sm sm:p-10"
data-test-selector="resource-panel"
>
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
<div
aria-hidden="true"
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border border-white bg-white/80 text-indigo-700 shadow-sm"
>
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M7 3h7l4 4v14H7z" />
<path d="M14 3v5h4M10 12h5M10 16h5" />
</svg>
</div>
<div className="min-w-0 flex-1">
{heading.trim() && (
<h2 id={headingId} className="break-words text-2xl font-semibold leading-tight sm:text-3xl">
{heading}
</h2>
)}
{description.trim() && (
<p className="mt-3 max-w-3xl whitespace-pre-line break-words text-base leading-7 text-slate-700">
{description}
</p>
)}
{showLink && (
<a
href={safePath}
className="mt-5 inline-flex min-h-11 max-w-full items-center gap-3 rounded-lg bg-indigo-700 px-5 py-3 text-sm font-semibold text-white transition hover:bg-indigo-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-indigo-700"
>
<span className="min-w-0 break-words">{linkLabel}</span>
<svg aria-hidden="true" viewBox="0 0 20 20" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" className="shrink-0">
<path d="M4 10h12m-5-5 5 5-5 5" />
</svg>
</a>
)}
</div>
</div>
</section>
);
}
Step 05
Connect editor fields to the renderer
ComponentConfig connects the four saved fields to text and textarea controls supported by Puck 0.17.4. The named resourcePanelDefaults object supplies values when a new instance is inserted. Rendering uses the saved props, so later edits and deliberately cleared labels remain intact.
packages/page-builder/src/configs/base-config/widgets/ui-widgets/resource-panel/ResourcePanelConfig.tsxtsx
import type { ComponentConfig } from "@measured/puck";
import type { ResourcePanelProps } from "./ResourcePanel.types";
import { ResourcePanelRender } from "./ResourcePanelRender";
export type { ResourcePanelProps } from "./ResourcePanel.types";
export const resourcePanelDefaults: ResourcePanelProps = {
heading: "Installation resources",
description: "Prepare your team with a short checklist before installing an example product.",
linkLabel: "Read the installation checklist",
linkPath: "/resources/installation-checklist.html",
};
export const ResourcePanelConfig: ComponentConfig<ResourcePanelProps> = {
label: "Resource panel",
fields: {
heading: { type: "text", label: "Heading" },
description: { type: "textarea", label: "Description" },
linkLabel: { type: "text", label: "Link label" },
linkPath: { type: "text", label: "Local resource path" },
},
defaultProps: { ...resourcePanelDefaults },
render: (props) => <ResourcePanelRender {...props} />,
};
Step 06
Register the same component for both applications
Apply each edit below to the existing file. The shared base registry reaches both PageEditor and PageRender through getConfig in this SDK, so there is one component registration, not separate copies of the widget in apps/page-builder and apps/webstore.
Use the exact ResourcePanel key for the props interface, component map, and palette list. Select a base-config test page for this recipe. A theme that replaces or removes base entries needs its own explicit inclusion; changing a global DEFAULT_THEME value is not required to add this widget.
Add this export alongside the existing widget exports.
packages/page-builder/src/configs/base-config/widgets/ui-widgets/index.tstypescript
export * from "./resource-panel/ResourcePanelConfig";
Add this type import with the other imports.
packages/page-builder/src/types/page-builder.tstypescript
import type { ResourcePanelProps } from "../configs/base-config/widgets/ui-widgets/resource-panel/ResourcePanel.types";
Add this property inside the existing IComponentProps interface, retaining its other properties.
packages/page-builder/src/types/page-builder.tstypescript
ResourcePanel: ResourcePanelProps;
Add this import with the existing imports.
packages/page-builder/src/configs/base-config/config/base-components-config.tstypescript
import { ResourcePanelConfig } from "../widgets/ui-widgets/resource-panel/ResourcePanelConfig";
Add this property inside baseComponentsConfig.components.
packages/page-builder/src/configs/base-config/config/base-components-config.tstypescript
ResourcePanel: ResourcePanelConfig,
Append this string once to baseComponentsConfig.categories.uiWidgets.components, retaining its existing entries.
packages/page-builder/src/configs/base-config/config/base-components-config.tstypescript
"ResourcePanel"
Step 07
Include a real local resource
Add this fictional resource page to the Webstore public directory. Save the identical file at apps/page-builder/public/resources/installation-checklist.html so an internal link also resolves when opened from the local editor origin. No client document, logo, or product specification is used.
apps/webstore/public/resources/installation-checklist.htmlhtml
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Example installation checklist</title>
<style>
body { margin: 0; padding: 2rem 1.25rem; color: #172033; background: #f5f7fb; font: 1rem/1.7 system-ui, sans-serif; }
main { max-width: 46rem; margin: auto; padding: 2rem; background: white; border: 1px solid #dfe4ee; border-radius: 1rem; }
h1 { line-height: 1.15; letter-spacing: -.035em; }
a { color: #3730a3; } a:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }
</style>
</head>
<body>
<main>
<p>Example resource</p>
<h1>Installation checklist</h1>
<p>This fictional checklist demonstrates a local resource linked from a Page Builder widget.</p>
<ol>
<li>Confirm that the sample product reference matches the selected item.</li>
<li>Gather the documentation provided for that item.</li>
<li>Record any questions for the product support team before beginning work.</li>
</ol>
<p><a href="/">Return to the storefront</a></p>
</main>
</body>
</html>
Step 08
Add focused contract and render tests
These tests check path normalization, rejected URLs, editor fields, visible markup, encoded content, cleared labels, and rendering after a saved page object is serialized and restored. They import this widget directly rather than loading the entire application's server-dependent registry.
packages/page-builder/src/configs/base-config/widgets/ui-widgets/resource-panel/ResourcePanel.spec.tsxtsx
import { renderToStaticMarkup } from "react-dom/server";
import { Render, type Config, type Data } from "@measured/puck";
import { ResourcePanelConfig, resourcePanelDefaults } from "./ResourcePanelConfig";
import type { ResourcePanelProps } from "./ResourcePanel.types";
import { ResourcePanelRender } from "./ResourcePanelRender";
import { localResourcePath } from "./resource-path";
describe("localResourcePath", () => {
test.each([
["/resources/installation-checklist.html", "/resources/installation-checklist.html"],
[" /resources/manual.html?format=print#tools ", "/resources/manual.html?format=print#tools"],
["/resources/../resources/manual.html", "/resources/manual.html"],
])("normalizes an internal path: %s", (input, expected) => {
expect(localResourcePath(input)).toBe(expected);
});
test.each([
"", " ", "https://other.example/manual", "//other.example/manual",
"javascript:alert(1)", "data:text/html,hello", "resources/manual.html",
"/\\other.example/manual", "/resources/white space.html", "/resources/\u0000manual",
"/" + "a".repeat(1024),
])("rejects an unsupported path: %s", (input) => {
expect(localResourcePath(input)).toBeNull();
});
});
describe("Resource panel", () => {
test("publishes all four editor fields and named defaults", () => {
expect(ResourcePanelConfig.label).toBe("Resource panel");
expect(Object.keys(ResourcePanelConfig.fields ?? {})).toEqual([
"heading", "description", "linkLabel", "linkPath",
]);
expect(ResourcePanelConfig.defaultProps).toEqual(resourcePanelDefaults);
});
test("renders its heading, descriptive text, and local resource action", () => {
const html = renderToStaticMarkup(<ResourcePanelRender id="sample-1" {...resourcePanelDefaults} />);
expect(html).toContain('aria-labelledby="resource-panel-sample-1"');
expect(html).toContain('href="/resources/installation-checklist.html"');
expect(html).toContain("Installation resources");
expect(html).toContain("Read the installation checklist");
expect(html).not.toContain('target="_blank"');
});
test("encodes editor text and hides an invalid link", () => {
const html = renderToStaticMarkup(
<ResourcePanelRender id="sample-2" {...resourcePanelDefaults}
heading="<script>alert(1)</script>" linkPath="//other.example" />
);
expect(html).toContain("<script>");
expect(html).not.toContain("<script>");
expect(html).not.toContain("<a ");
});
test("preserves intentionally empty labels instead of reapplying defaults", () => {
const html = renderToStaticMarkup(
<ResourcePanelRender id="sample-3" {...resourcePanelDefaults} heading="" linkLabel="" />
);
expect(html).not.toContain("<h2");
expect(html).not.toContain("aria-labelledby");
expect(html).not.toContain("<a ");
});
test("renders serialized page data using the same stable component key", () => {
type Components = { ResourcePanel: ResourcePanelProps };
const config: Config<Components> = {
components: { ResourcePanel: ResourcePanelConfig },
};
const data: Data<Components> = {
root: { props: {} },
content: [{ type: "ResourcePanel", props: { id: "saved-1", ...resourcePanelDefaults } }],
};
const restored = JSON.parse(JSON.stringify(data)) as Data<Components>;
const html = renderToStaticMarkup(<Render config={config} data={restored} />);
expect(html).toContain('data-test-selector="resource-panel"');
expect(html).toContain("Installation resources");
});
});
Step 09
Run type, registration, and behavior checks
Copy tools/resource-panel/tsconfig.json, jest.config.cjs, and check-registration.cjs from the source bundle into the same paths in WebStoreSDK. The type-check includes the real installed Puck types. The registration check parses the actual source files and requires the key in exactly one palette category.
tools/resource-panel/tsconfig.jsonjson
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noEmit": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"types": ["node", "jest"]
},
"include": [
"../../packages/page-builder/src/configs/base-config/widgets/ui-widgets/resource-panel/*.ts",
"../../packages/page-builder/src/configs/base-config/widgets/ui-widgets/resource-panel/*.tsx"
],
"exclude": ["../../node_modules"]
}
tools/resource-panel/jest.config.cjsjavascript
const path = require("node:path");
module.exports = {
displayName: "resource-panel",
rootDir: path.resolve(__dirname, "../.."),
testEnvironment: "node",
testMatch: ["<rootDir>/packages/page-builder/src/configs/base-config/widgets/ui-widgets/resource-panel/ResourcePanel.spec.tsx"],
transform: {
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "<rootDir>/tools/resource-panel/tsconfig.json" }],
},
cacheDirectory: "<rootDir>/.cache/resource-panel-jest",
};
tools/resource-panel/check-registration.cjsjavascript
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const ts = require("typescript");
const root = path.resolve(__dirname, "../..");
const read = (file) => ts.createSourceFile(file,
fs.readFileSync(path.join(root, file), "utf8"), ts.ScriptTarget.Latest, true);
const nameOf = (node) => node && (ts.isIdentifier(node) || ts.isStringLiteral(node)) ? node.text : null;
const child = (object, name) => object.properties.find((node) => nameOf(node.name) === name);
const initializerOf = (object, name) => {
const node = child(object, name);
assert(node && ts.isPropertyAssignment(node), `Missing property: ${name}`);
return node.initializer;
};
const barrel = read("packages/page-builder/src/configs/base-config/widgets/ui-widgets/index.ts");
assert(barrel.statements.some((node) => ts.isExportDeclaration(node) &&
node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) &&
node.moduleSpecifier.text === "./resource-panel/ResourcePanelConfig"), "Missing widget export");
const types = read("packages/page-builder/src/types/page-builder.ts");
const props = types.statements.find((node) => ts.isInterfaceDeclaration(node) && node.name.text === "IComponentProps");
assert(props, "Missing IComponentProps");
const member = props.members.find((node) => nameOf(node.name) === "ResourcePanel");
assert(member && member.type && member.type.getText(types) === "ResourcePanelProps", "Missing typed ResourcePanel property");
const config = read("packages/page-builder/src/configs/base-config/config/base-components-config.ts");
let registry;
for (const statement of config.statements) {
if (!ts.isVariableStatement(statement)) continue;
for (const declaration of statement.declarationList.declarations) {
if (nameOf(declaration.name) === "baseComponentsConfig") registry = declaration.initializer;
}
}
assert(registry && ts.isObjectLiteralExpression(registry), "Missing baseComponentsConfig object");
const components = initializerOf(registry, "components");
assert(ts.isObjectLiteralExpression(components), "Expected component map");
assert.equal(initializerOf(components, "ResourcePanel").getText(config), "ResourcePanelConfig");
const categories = initializerOf(registry, "categories");
assert(ts.isObjectLiteralExpression(categories), "Expected categories");
let matches = 0;
for (const category of categories.properties) {
if (!ts.isPropertyAssignment(category) || !ts.isObjectLiteralExpression(category.initializer)) continue;
const members = initializerOf(category.initializer, "components");
assert(ts.isArrayLiteralExpression(members), "Expected category component list");
const count = members.elements.filter((node) => ts.isStringLiteral(node) && node.text === "ResourcePanel").length;
if (count > 0) assert.equal(nameOf(category.name), "uiWidgets", "ResourcePanel belongs in uiWidgets");
matches += count;
}
assert.equal(matches, 1, "ResourcePanel must appear once in the palette");
console.log("ResourcePanel export, props type, registry, and palette checks passed.");
Terminal at WebStoreSDKpowershell
npx tsc -p tools/resource-panel/tsconfig.json
node tools/resource-panel/check-registration.cjs
npx jest --config tools/resource-panel/jest.config.cjs --runInBand
Step 10
Build and run both applications from the same source
Stop this checkout's development servers before production builds so a dev process does not share its .next output with the build. Build the two applications sequentially, then restart them in separate terminals with explicit ports. Deploy both application artifacts together when releasing the component.
Terminal at WebStoreSDKpowershell
npx nx build webstore --skip-nx-cache
npx nx build page-builder --skip-nx-cache
# Terminal 1
npx nx dev webstore --port=3000
# Terminal 2
npx nx dev page-builder --port=3001
Step 11
Exercise the editor-to-storefront journey
Launch Page Builder for the existing test Store through its configured Admin link and choose a page using the base configuration. Add Resource panel from UI Widgets to the canvas. Confirm the rendered heading and action before editing the fields.
Change the heading to Product preparation, the description to Review the checklist before scheduling work, and the action label to Open checklist. Save, leave the page, and reopen it. Confirm that those exact values remain. Use the test environment's normal preview or publish workflow, then inspect the corresponding storefront page and follow the local link.
Also clear the link label and enter //other.example as its path. The action should disappear while the remaining content stays visible. Restore the intended local path and label before accepting the page.
Verify the complete result
| Check | Action | Expected result |
|---|---|---|
| Check 1 | Insert a new Resource panel instance | One palette entry, four editable fields, default heading and checklist action visible on the canvas. |
| Check 2 | Edit all four fields, save, leave, and reopen | The saved values are restored without defaults overwriting them. |
| Check 3 | Preview the saved page in Webstore | The same heading, description, and local link render under the stable ResourcePanel key. |
| Check 4 | Open the local checklist | The included fictional resource loads with its three-item list on the current origin. |
| Check 5 | Use a blank label or unsupported external path | The action is omitted; the remaining panel content stays available. |
| Check 6 | Enter markup-like text in the heading | Angle brackets display as text, and no script or raw HTML is executed. |
| Check 7 | Use keyboard navigation at 375 px and 1280 px | The link has visible focus, labels wrap, and the panel does not overflow its content area. |
Troubleshooting
Cannot find @measured/puck
Run npm ls for the installed Puck package. This recipe targets 0.17.4. If the branch uses @puckeditor/core, align the config and test imports with that completed SDK migration; do not mix two editor packages.
Resource panel is missing from UI Widgets
Run check-registration.cjs, then inspect the selected page/theme configuration. Confirm the export, props member, component map, and one palette membership. A theme or page removeComponentKeys list can remove an otherwise valid base registration.
Page Builder renders the panel but Webstore cannot resolve it
Compare the deployed artifacts and saved component key. Rebuild and release Webstore with the same registry as Page Builder. Keep ResourcePanel stable after content has been saved.
The link is hidden
Supply a nonblank link label and a root-relative local path. Paths beginning with //, external URLs, backslashes, and whitespace inside the path are rejected by design.
The link opens a 404
Confirm installation-checklist.html is under the correct app's public/resources directory and the deployment includes public files. A local URL is not evidence that its resource exists.
Panel markup appears without its intended styling
Confirm the app's Tailwind content includes the page-builder library through Nx dependency discovery. The inspected Webstore and Page Builder configurations use createGlobPatternsForDependencies; rebuild the affected app after registry changes.
Next.js reports locked or inconsistent .next output
Stop only the dev processes belonging to this checkout, reset its Nx state if needed, and rebuild the applications sequentially. Keep the application's cache recovery process separate from widget content changes.
References and further reading
Original examples and independent guidance from GCG. The sample data is fictional. Znode and Microsoft are trademarks of their respective owners. Discuss your implementation with GCG.
