Skip to content

Commit 38c4f7b

Browse files
authored
[Blueprint editor] Blueprint Validation (#2967)
## Motivation for the change, related issues Adds validation to the Blueprint editor to surface errors as they come up. Before this PR, the user would have to try running the Blueprint first: <img width="2174" height="1632" alt="CleanShot 2025-11-30 at 01 10 23@2x" src="https://github.com/user-attachments/assets/dcc83e81-89c1-4fe5-8eb5-0eaa70c85759" />
1 parent 0e86fed commit 38c4f7b

File tree

5 files changed

+546
-32
lines changed

5 files changed

+546
-32
lines changed

packages/playground/blueprints/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export {
2121
runBlueprintV1Steps,
2222
InvalidBlueprintError,
2323
BlueprintStepExecutionError,
24+
validateBlueprint,
2425

2526
// BC:
2627
compileBlueprintV1 as compileBlueprint,
@@ -32,6 +33,7 @@ export type {
3233
CompiledBlueprintV1,
3334
CompiledV1Step,
3435
OnStepCompleted,
36+
BlueprintValidationResult,
3537
} from './lib/v1/compile';
3638
export type {
3739
CachedResource,

packages/playground/blueprints/src/lib/v1/compile.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -352,15 +352,14 @@ function compileBlueprintJson(
352352
});
353353
}
354354

355-
const { valid, errors } = validateBlueprint(blueprint);
356-
if (!valid) {
357-
const formattedErrors = formatValidationErrors(blueprint, errors ?? []);
355+
const validationResult = validateBlueprint(blueprint);
356+
if (!validationResult.valid) {
357+
const { errors } = validationResult;
358+
const formattedErrors = formatValidationErrors(blueprint, errors);
358359

359360
throw new InvalidBlueprintError(
360361
`Invalid Blueprint: The Blueprint does not conform to the schema.\n\n` +
361-
`Found ${
362-
errors!.length
363-
} validation error(s):\n\n${formattedErrors}\n\n` +
362+
`Found ${errors.length} validation error(s):\n\n${formattedErrors}\n\n` +
364363
`Please review your Blueprint and fix these issues. ` +
365364
`Learn more about the Blueprint format: https://wordpress.github.io/wordpress-playground/blueprints/data-format`,
366365
errors
@@ -543,7 +542,13 @@ function formatValidationErrors(
543542
.join('\n\n');
544543
}
545544

546-
export function validateBlueprint(blueprintMaybe: object) {
545+
export type BlueprintValidationResult =
546+
| { valid: true }
547+
| { valid: false; errors: ErrorObject[] };
548+
549+
export function validateBlueprint(
550+
blueprintMaybe: object
551+
): BlueprintValidationResult {
547552
const valid = blueprintValidator(blueprintMaybe);
548553
if (valid) {
549554
return { valid };
@@ -568,16 +573,18 @@ export function validateBlueprint(blueprintMaybe: object) {
568573
hasErrorsDifferentThanAnyOf.add(error.instancePath);
569574
}
570575
}
571-
const errors = blueprintValidator.errors?.filter(
572-
(error) =>
573-
!(
574-
error.schemaPath.startsWith('#/properties/steps/items/anyOf') &&
575-
hasErrorsDifferentThanAnyOf.has(error.instancePath)
576-
)
577-
);
576+
const errors =
577+
blueprintValidator.errors?.filter(
578+
(error) =>
579+
!(
580+
error.schemaPath.startsWith(
581+
'#/properties/steps/items/anyOf'
582+
) && hasErrorsDifferentThanAnyOf.has(error.instancePath)
583+
)
584+
) ?? [];
578585

579586
return {
580-
valid,
587+
valid: false,
581588
errors,
582589
};
583590
}

packages/playground/website/src/components/blueprint-editor/BlueprintBundleEditor.tsx

Lines changed: 152 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import {
99
import { logger } from '@php-wasm/logger';
1010
import { Button, Icon, Notice } from '@wordpress/components';
1111
import { download } from '@wordpress/icons';
12-
import { resolveRuntimeConfiguration } from '@wp-playground/blueprints';
12+
import {
13+
resolveRuntimeConfiguration,
14+
type BlueprintValidationResult,
15+
} from '@wp-playground/blueprints';
1316
import type { AsyncWritableFilesystem } from '@wp-playground/storage';
1417
import { BlobWriter, Uint8ArrayReader, ZipWriter } from '@zip.js/zip.js';
1518
import classNames from 'classnames';
@@ -32,6 +35,7 @@ import {
3235
getStringNodeAtPosition,
3336
jsonSchemaCompletion,
3437
} from './json-schema-editor/jsonSchemaCompletion';
38+
import { createBlueprintLinter } from './json-schema-editor/blueprint-linter';
3539
import {
3640
inferLanguageFromBlueprint,
3741
type SupportedLanguage,
@@ -45,10 +49,42 @@ import { sitesSlice } from '../../lib/state/redux/slice-sites';
4549
import { useAppDispatch } from '../../lib/state/redux/store';
4650
import styles from '../site-manager/site-file-browser/style.module.css';
4751
import hideRootStyles from './hide-root.module.css';
52+
import validationStyles from './validation-panel.module.css';
4853
import type { EventedFilesystem } from '@wp-playground/storage';
4954

5055
const BLUEPRINT_JSON_PATH = '/blueprint.json';
5156

57+
/**
58+
* Format a validation error into a human-readable message for the error panel
59+
*/
60+
function formatValidationError(error: {
61+
keyword: string;
62+
message?: string;
63+
params?: Record<string, unknown>;
64+
instancePath: string;
65+
}): string {
66+
// Provide better messages based on error type
67+
if (error.keyword === 'additionalProperties' && error.params) {
68+
const prop = error.params.additionalProperty;
69+
return `Unknown property "${prop}"`;
70+
}
71+
if (error.keyword === 'required' && error.params) {
72+
const prop = error.params.missingProperty;
73+
return `Missing required property "${prop}"`;
74+
}
75+
if (error.keyword === 'enum' && error.params) {
76+
const allowed = error.params.allowedValues;
77+
if (Array.isArray(allowed)) {
78+
return `Value must be one of: ${allowed.join(', ')}`;
79+
}
80+
}
81+
if (error.keyword === 'type' && error.params) {
82+
const expected = error.params.type;
83+
return `Expected ${expected}`;
84+
}
85+
return error.message || 'Validation error';
86+
}
87+
5288
interface StringEditorState {
5389
isOpen: boolean;
5490
initialValue: string;
@@ -248,6 +284,8 @@ export const BlueprintBundleEditor = forwardRef<
248284
>(null);
249285
const [displayPath, setDisplayPath] = useState<string | null>(null);
250286
const [isRecreating, setIsRecreating] = useState(false);
287+
const [validationResult, setValidationResult] =
288+
useState<BlueprintValidationResult | null>(null);
251289
const [stringEditorState, setStringEditorState] =
252290
useState<StringEditorState>({
253291
isOpen: false,
@@ -467,13 +505,21 @@ export const BlueprintBundleEditor = forwardRef<
467505
[]
468506
);
469507

508+
const handleValidationChange = useCallback(
509+
(result: BlueprintValidationResult | null) => {
510+
setValidationResult(result);
511+
},
512+
[]
513+
);
514+
470515
const blueprintSchemaExtensions = useMemo(
471516
() => [
472517
autocompletion({
473518
override: [jsonSchemaCompletion],
474519
activateOnTyping: true,
475520
closeOnBlur: false,
476521
}),
522+
createBlueprintLinter(handleValidationChange),
477523
// Capture the EditorView reference for string editor operations
478524
EditorView.updateListener.of((update) => {
479525
cmViewRef.current = update.view;
@@ -489,9 +535,12 @@ export const BlueprintBundleEditor = forwardRef<
489535
// String editor toolbar tooltip
490536
createStringEditorTooltip(openStringEditor),
491537
],
492-
[openStringEditor]
538+
[handleValidationChange, openStringEditor]
493539
);
494540

541+
const hasValidationErrors =
542+
validationResult !== null && !validationResult.valid;
543+
495544
const handleDownloadBundle = useCallback(async () => {
496545
try {
497546
const zipWriter = new ZipWriter(new BlobWriter('application/zip'));
@@ -542,7 +591,7 @@ export const BlueprintBundleEditor = forwardRef<
542591
[handleDownloadBundle, filesystem, handleRecreateFromBlueprint]
543592
);
544593

545-
const disableRunButton = isRecreating || !site;
594+
const disableRunButton = isRecreating || !site || hasValidationErrors;
546595
return (
547596
<>
548597
<div className={classNames(styles.container, className)}>
@@ -612,10 +661,21 @@ export const BlueprintBundleEditor = forwardRef<
612661
{!readOnly && (
613662
<Button
614663
variant="primary"
615-
className={styles.editorToolbarButton}
664+
className={classNames(
665+
styles.editorToolbarButton,
666+
{
667+
[validationStyles.runButtonDisabled]:
668+
hasValidationErrors,
669+
}
670+
)}
616671
onClick={handleRecreateFromBlueprint}
617672
isBusy={isRecreating}
618673
disabled={disableRunButton}
674+
title={
675+
hasValidationErrors
676+
? 'Fix validation errors before running'
677+
: undefined
678+
}
619679
>
620680
<PlayIcon
621681
className={
@@ -640,19 +700,94 @@ export const BlueprintBundleEditor = forwardRef<
640700
{messageContent}
641701
</div>
642702
) : (
643-
<CodeEditor
644-
ref={editorRef}
645-
code={code}
646-
onChange={handleCodeChange}
647-
currentPath={currentPath}
648-
className={styles.editor}
649-
readOnly={readOnly}
650-
additionalExtensions={
651-
currentPath === BLUEPRINT_JSON_PATH
652-
? blueprintSchemaExtensions
653-
: undefined
654-
}
655-
/>
703+
<>
704+
<CodeEditor
705+
ref={editorRef}
706+
code={code}
707+
onChange={handleCodeChange}
708+
currentPath={currentPath}
709+
className={styles.editor}
710+
readOnly={readOnly}
711+
additionalExtensions={
712+
currentPath === BLUEPRINT_JSON_PATH
713+
? blueprintSchemaExtensions
714+
: undefined
715+
}
716+
/>
717+
{currentPath === BLUEPRINT_JSON_PATH &&
718+
hasValidationErrors &&
719+
!validationResult.valid && (
720+
<div
721+
className={
722+
validationStyles.validationPanel
723+
}
724+
>
725+
<div
726+
className={
727+
validationStyles.validationHeader
728+
}
729+
>
730+
<span
731+
className={
732+
validationStyles.validationIcon
733+
}
734+
>
735+
<svg
736+
viewBox="0 0 20 20"
737+
fill="currentColor"
738+
>
739+
<path
740+
fillRule="evenodd"
741+
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
742+
clipRule="evenodd"
743+
/>
744+
</svg>
745+
</span>
746+
{validationResult.errors
747+
.length === 1
748+
? '1 validation error'
749+
: `${validationResult.errors.length} validation errors`}
750+
</div>
751+
<ul
752+
className={
753+
validationStyles.validationErrors
754+
}
755+
>
756+
{validationResult.errors.map(
757+
(error, index) => (
758+
<li
759+
key={index}
760+
className={
761+
validationStyles.validationError
762+
}
763+
>
764+
{error.instancePath && (
765+
<span
766+
className={
767+
validationStyles.errorPath
768+
}
769+
>
770+
{
771+
error.instancePath
772+
}
773+
</span>
774+
)}
775+
<span
776+
className={
777+
validationStyles.errorMessage
778+
}
779+
>
780+
{formatValidationError(
781+
error
782+
)}
783+
</span>
784+
</li>
785+
)
786+
)}
787+
</ul>
788+
</div>
789+
)}
790+
</>
656791
)
657792
) : (
658793
<div className={styles.placeholder}>

0 commit comments

Comments
 (0)