diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index f2c0be5e7a..57a7ac2fc2 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -557,3 +557,159 @@ test('should keep query arguments when updating settings', async ({ await wordpress.locator('body').evaluate((body) => body.baseURI) ).toMatch('/wp-admin/'); }); + +test('should edit a file in the code editor and see changes in the viewport', async ({ + website, + wordpress, +}) => { + await website.goto('./'); + + // Open site manager + await website.ensureSiteManagerIsOpen(); + + // Navigate to File Browser tab + await website.page.getByRole('tab', { name: 'File Browser' }).click(); + + // Wait for file tree to load + await website.page.locator('[data-path="/wordpress"]').waitFor(); + + // Expand /wordpress folder + const wordpressFolder = website.page.locator( + 'button[data-path="/wordpress"]' + ); + if ((await wordpressFolder.getAttribute('data-expanded')) !== 'true') { + await wordpressFolder.click(); + } + + // Double-click index.php to open it in the editor + await website.page + .locator('button[data-path="/wordpress/index.php"]') + .dblclick(); + + // Wait for CodeMirror editor to load + const editor = website.page.locator('[class*="file-browser"] .cm-editor'); + await editor.waitFor({ timeout: 10000 }); + + // Click on the editor to focus it + await website.page.waitForTimeout(50); + + await editor.click(); + + await website.page.waitForTimeout(250); + + // Select all content in the editor (Cmd+A or Ctrl+A) + await website.page.keyboard.press( + process.platform === 'darwin' ? 'Meta+A' : 'Control+A' + ); + + await website.page.keyboard.press('Backspace'); + await website.page.waitForTimeout(200); + + // Type the new content with a delay between keystrokes + await website.page.keyboard.type('Edited file', { delay: 50 }); + + // Wait a moment for the change to be processed + await website.page.waitForTimeout(500); + + // Save the file (Cmd+S or Ctrl+S) + await website.page.keyboard.press( + process.platform === 'darwin' ? 'Meta+S' : 'Control+S' + ); + + // Wait for save to complete (look for save indicator if there is one) + await website.page.waitForTimeout(1000); + + // Close the site manager to see the viewport + await website.ensureSiteManagerIsClosed(); + + // Reload just the WordPress iframe to see the changes + const playgroundViewport = website.page.frameLocator( + '#playground-viewport:visible,.playground-viewport:visible' + ); + await playgroundViewport + .locator('#wp') + .evaluate((iframe: HTMLIFrameElement) => { + iframe.contentWindow?.location.reload(); + }); + + // Verify the page shows "Edited file" + await expect(wordpress.locator('body')).toContainText('Edited file', { + timeout: 10000, + }); +}); + +test('should edit a blueprint in the blueprint editor and recreate the playground', async ({ + website, + wordpress, +}) => { + await website.goto('./'); + + // Open site manager + await website.ensureSiteManagerIsOpen(); + + // Navigate to Blueprint tab + await website.page.getByRole('tab', { name: 'Blueprint' }).click(); + + // Wait for CodeMirror editor to load + const editor = website.page.locator( + '[class*="blueprint-editor"] .cm-editor' + ); + await editor.waitFor({ timeout: 10000 }); + + await editor.click(); + + // Delete all content in the editor (Cmd+A or Ctrl+A) + await website.page.keyboard.press( + process.platform === 'darwin' ? 'Meta+A' : 'Control+A' + ); + + await website.page.keyboard.press('Backspace'); + await website.page.waitForTimeout(200); + + // Create a simple blueprint that writes "Blueprint test" to index.php + const blueprint = JSON.stringify( + { + landingPage: '/index.php', + steps: [ + { + step: 'writeFile', + path: '/wordpress/index.php', + data: 'Blueprint test', + }, + ], + }, + null, + 2 + ); + + // Type the new blueprint with a delay between keystrokes + await website.page.keyboard.type(blueprint, { delay: 50 }); + + // Remove the autoinserted brackets until the end of the Blueprint + await website.page.keyboard.down('Shift'); + for (let i = 0; i < 4; i++) { + await website.page.keyboard.press('ArrowDown'); + } + + // Delete the selected lines + await website.page.keyboard.press('Backspace'); + + // Wait a moment for the change to be processed + await website.page.waitForTimeout(500); + + // Click the "Recreate Playground from this Blueprint" button + await website.page + .getByRole('button', { + name: 'Recreate Playground from this Blueprint', + }) + .click(); + + await website.page.waitForTimeout(1500); + // Wait for the playground to recreate + await website.waitForNestedIframes(); + + // Verify the page shows "Blueprint test" + await expect(wordpress.locator('body')).toContainText('Blueprint test', { + timeout: 10000, + }); +}); diff --git a/packages/playground/website/playwright/playwright.config.ts b/packages/playground/website/playwright/playwright.config.ts index 7491a56d8c..f28999f5fc 100644 --- a/packages/playground/website/playwright/playwright.config.ts +++ b/packages/playground/website/playwright/playwright.config.ts @@ -11,7 +11,7 @@ export const playwrightConfig: PlaywrightTestConfig = { fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, - retries: 3, + retries: 0, workers: 3, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: [['html'], ['list', { printSteps: true }]], @@ -23,6 +23,7 @@ export const playwrightConfig: PlaywrightTestConfig = { trace: 'on-first-retry', actionTimeout: 120000, navigationTimeout: 120000, + headless: false, }, timeout: 300000, @@ -40,10 +41,10 @@ export const playwrightConfig: PlaywrightTestConfig = { }, }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, // Safari runner is disabled in CI – it used to be enabled but the tests // failed randomly without any obvious reason. diff --git a/packages/playground/website/src/components/blueprint-editor/index.tsx b/packages/playground/website/src/components/blueprint-editor/index.tsx new file mode 100644 index 0000000000..bc0a6cca54 --- /dev/null +++ b/packages/playground/website/src/components/blueprint-editor/index.tsx @@ -0,0 +1,1136 @@ +import { + autocompletion, + startCompletion, + closeBrackets, + completionKeymap, + closeBracketsKeymap, + type CompletionContext, + type CompletionResult, +} from '@codemirror/autocomplete'; +import { + defaultKeymap, + history, + historyKeymap, + indentWithTab, +} from '@codemirror/commands'; +import { json } from '@codemirror/lang-json'; +import { + bracketMatching, + foldGutter, + foldKeymap, + indentOnInput, + indentUnit, + syntaxHighlighting, + defaultHighlightStyle, +} from '@codemirror/language'; +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import { EditorState, type Extension } from '@codemirror/state'; +import { + EditorView, + keymap, + type ViewUpdate, + lineNumbers, + highlightActiveLineGutter, + highlightActiveLine, + dropCursor, + rectangularSelection, + crosshairCursor, +} from '@codemirror/view'; +import { useEffect, useRef } from 'react'; +import { + filterSchemaByDiscriminator, + getCurrentContainerType, + getCurrentDiscriminatorValue, + getDiscriminatorValues, + getExistingKeysInCurrentObject, + getJsonPath, + getPropertyNameForValueCompletion, + mergeCompositeSchemas, + resolveSchemaRefs, +} from './schema-utils'; +import type { JSONSchema, JSONSchemaCompletionConfig } from './types'; + +interface JSONSchemaEditorProps { + config?: JSONSchemaCompletionConfig; + className?: string; +} + +const schemaCache = new Map(); + +export function clearSchemaCache(): void { + schemaCache.clear(); +} + +function schemaHasProperty( + schema: JSONSchema | undefined, + property: string, + rootSchema: JSONSchema, + seen = new WeakSet() +): boolean { + if (!schema || typeof schema !== 'object') { + return false; + } + + if (seen.has(schema)) { + return false; + } + + seen.add(schema); + + const resolved = resolveSchemaRefs(schema, rootSchema); + + if (resolved.properties && resolved.properties[property] !== undefined) { + return true; + } + + if ( + resolved.allOf && + resolved.allOf.some((sub) => + schemaHasProperty(sub, property, rootSchema, seen) + ) + ) { + return true; + } + + if ( + resolved.anyOf && + resolved.anyOf.some((sub) => + schemaHasProperty(sub, property, rootSchema, seen) + ) + ) { + return true; + } + + if ( + resolved.oneOf && + resolved.oneOf.some((sub) => + schemaHasProperty(sub, property, rootSchema, seen) + ) + ) { + return true; + } + + return false; +} + +function collectConstValuesFromSchema( + schema: JSONSchema | undefined, + rootSchema: JSONSchema, + values: Set, + seen = new WeakSet() +): void { + if (!schema || typeof schema !== 'object') { + return; + } + + if (seen.has(schema as object)) { + return; + } + seen.add(schema as object); + + const resolved = resolveSchemaRefs(schema, rootSchema); + if (resolved !== schema) { + collectConstValuesFromSchema(resolved, rootSchema, values, seen); + return; + } + + if (resolved.const !== undefined) { + values.add(String(resolved.const)); + } + + if (resolved.enum) { + for (const entry of resolved.enum) { + values.add(String(entry)); + } + } + + if (Array.isArray(resolved.allOf)) { + for (const subSchema of resolved.allOf) { + collectConstValuesFromSchema(subSchema, rootSchema, values, seen); + } + } + + if (Array.isArray(resolved.anyOf)) { + for (const subSchema of resolved.anyOf) { + collectConstValuesFromSchema(subSchema, rootSchema, values, seen); + } + } + + if (Array.isArray(resolved.oneOf)) { + for (const subSchema of resolved.oneOf) { + collectConstValuesFromSchema(subSchema, rootSchema, values, seen); + } + } +} + +function collectPropertyValuesFromSchema( + schema: JSONSchema | undefined, + property: string, + rootSchema: JSONSchema, + values: Set, + seen = new WeakSet() +): void { + if (!schema || typeof schema !== 'object') { + return; + } + + if (seen.has(schema as object)) { + return; + } + seen.add(schema as object); + + const resolved = resolveSchemaRefs(schema, rootSchema); + if (resolved !== schema) { + collectPropertyValuesFromSchema( + resolved, + property, + rootSchema, + values, + seen + ); + return; + } + + if (resolved.properties && resolved.properties[property]) { + collectConstValuesFromSchema( + resolved.properties[property], + rootSchema, + values + ); + } + + if (Array.isArray(resolved.allOf)) { + for (const subSchema of resolved.allOf) { + collectPropertyValuesFromSchema( + subSchema, + property, + rootSchema, + values, + seen + ); + } + } + + if (Array.isArray(resolved.anyOf)) { + for (const subSchema of resolved.anyOf) { + collectPropertyValuesFromSchema( + subSchema, + property, + rootSchema, + values, + seen + ); + } + } + + if (Array.isArray(resolved.oneOf)) { + for (const subSchema of resolved.oneOf) { + collectPropertyValuesFromSchema( + subSchema, + property, + rootSchema, + values, + seen + ); + } + } +} + +function filterSchemaByConstProperty( + schema: JSONSchema, + rootSchema: JSONSchema, + propertyName: string, + propertyValue: string, + seen = new WeakSet() +): JSONSchema | null { + if (!schema || typeof schema !== 'object') { + return null; + } + + if (seen.has(schema as object)) { + return null; + } + seen.add(schema as object); + + const resolved = resolveSchemaRefs(schema, rootSchema); + if (resolved !== schema) { + return filterSchemaByConstProperty( + resolved, + rootSchema, + propertyName, + propertyValue, + seen + ); + } + + const propertySchema = resolved.properties?.[propertyName]; + if (propertySchema) { + const constants = new Set(); + collectConstValuesFromSchema(propertySchema, rootSchema, constants); + if (constants.has(propertyValue)) { + return mergeCompositeSchemas(resolved, rootSchema); + } + } + + if (Array.isArray(resolved.anyOf)) { + for (const option of resolved.anyOf) { + const filtered = filterSchemaByConstProperty( + option, + rootSchema, + propertyName, + propertyValue, + seen + ); + if (filtered) { + return filtered; + } + } + } + + if (Array.isArray(resolved.oneOf)) { + for (const option of resolved.oneOf) { + const filtered = filterSchemaByConstProperty( + option, + rootSchema, + propertyName, + propertyValue, + seen + ); + if (filtered) { + return filtered; + } + } + } + + if (Array.isArray(resolved.allOf)) { + let matched = false; + for (const option of resolved.allOf) { + const filtered = filterSchemaByConstProperty( + option, + rootSchema, + propertyName, + propertyValue, + seen + ); + if (filtered) { + matched = true; + break; + } + } + if (matched) { + return mergeCompositeSchemas(resolved, rootSchema); + } + } + + return null; +} + +/** + * Format JSON document while preserving cursor position + * Returns the formatted text and new cursor position + */ +function formatJSON( + doc: EditorState['doc'], + cursorPos: number +): { formatted: string; newPos: number } { + const text = doc.toString(); + + try { + const parsed = JSON.parse(text); + const formatted = JSON.stringify(parsed, null, '\t'); + + let nonWhitespaceCount = 0; + for (let i = 0; i < cursorPos && i < text.length; i++) { + if (!/\s/.test(text[i])) { + nonWhitespaceCount++; + } + } + + let newPos = 0; + let count = 0; + for (let i = 0; i < formatted.length; i++) { + if (!/\s/.test(formatted[i])) { + count++; + if (count >= nonWhitespaceCount) { + newPos = i + 1; + break; + } + } + } + + return { formatted, newPos }; + } catch { + return { formatted: text, newPos: cursorPos }; + } +} + +/** + * Format the editor content and update cursor position + */ +export function formatEditor(view: EditorView): void { + const doc = view.state.doc; + const cursorPos = view.state.selection.main.head; + + const { formatted, newPos } = formatJSON(doc, cursorPos); + + if (formatted !== doc.toString()) { + view.dispatch({ + changes: { from: 0, to: doc.length, insert: formatted }, + selection: { anchor: newPos }, + }); + } +} + +/** + * Fetch a JSON schema from a URL + */ +async function fetchSchema(url: string): Promise { + if (schemaCache.has(url)) { + return schemaCache.get(url)!; + } + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch schema: ${response.statusText}`); + } + const schema = await response.json(); + schemaCache.set(url, schema); + return schema; + } catch { + return null; + } +} + +/** + * Extract the $schema URL from the JSON document + */ +function getSchemaUrl(doc: string): string | null { + try { + const parsed = JSON.parse(doc); + return parsed.$schema || null; + } catch { + const match = doc.match(/"?\$schema"?\s*:\s*"([^"]+)"/); + return match ? match[1] : null; + } +} + +/** + * Check if cursor is in a position to suggest property keys + */ +function isInPropertyKeyPosition( + doc: EditorState['doc'], + pos: number +): boolean { + const textBefore = doc.sliceString(0, pos); + const trimmed = textBefore.trim(); + + if (trimmed.endsWith('{') || trimmed.endsWith(',')) { + return true; + } + + const lastChars = textBefore.slice(-20); + if (lastChars.match(/[{,]\s*"\w*$/)) { + return true; + } + + if (lastChars.match(/[{,]\s*"$/)) { + return true; + } + + return false; +} + +/** + * JSON Schema-based autocompletion source + */ +export async function jsonSchemaCompletion( + context: CompletionContext +): Promise { + const doc = context.state.doc; + const pos = context.pos; + const docText = doc.toString(); + const currentContainerType = getCurrentContainerType(doc, pos); + + const schemaUrl = getSchemaUrl(docText); + if (!schemaUrl) { + return null; + } + + const schema = await fetchSchema(schemaUrl); + if (!schema) { + return null; + } + + const valuePropertyName = getPropertyNameForValueCompletion(doc, pos); + + let path = getJsonPath(doc, pos); + const contextPath = path.slice(); + + if ( + valuePropertyName && + path.length > 0 && + path[path.length - 1].type === 'key' + ) { + path = path.slice(0, -1); + } + + // Navigate the path while applying discriminator filtering at each level + let currentSchema = schema; + currentSchema = resolveSchemaRefs(currentSchema, schema); + + for (const segment of path) { + if (segment.type === 'key' && currentSchema.properties) { + const next = currentSchema.properties[segment.key!]; + if (!next) return null; + currentSchema = resolveSchemaRefs(next, schema); + } else if (segment.type === 'array') { + if (currentSchema.type === 'array' && currentSchema.items) { + currentSchema = resolveSchemaRefs(currentSchema.items, schema); + + // Check if this schema has a discriminator defined at the top level + const hasTopLevelDiscriminator = + currentSchema.discriminator?.propertyName; + + // Check if this array items has a discriminator + if ( + hasTopLevelDiscriminator || + currentSchema.anyOf || + currentSchema.oneOf + ) { + // Look for discriminator at this level + const discriminator = + hasTopLevelDiscriminator || + (() => { + const firstOption = + currentSchema.anyOf?.[0] || + currentSchema.oneOf?.[0]; + if (firstOption) { + const resolved = resolveSchemaRefs( + firstOption, + schema + ); + return resolved.discriminator?.propertyName; + } + return undefined; + })(); + + if (discriminator) { + // Only try to find discriminator value if we're navigating THROUGH the array + // to a nested property. If we're AT the array level (completing properties + // of the array item itself), we'll handle discriminator logic later. + const isNavigatingDeeper = + path.length > path.indexOf(segment) + 1; + + if (isNavigatingDeeper) { + // Find the discriminator value at the ARRAY ITEM level, not at the current cursor position + // We need to find the opening brace of the array item, not the nested object + const textBefore = doc.sliceString(0, pos); + let braceDepth = 0; + let arrayItemStart = -1; + + // Walk backward from cursor, tracking brace depth + // We want to find the opening brace where we entered the array item (depth becomes -1) + for (let i = textBefore.length - 1; i >= 0; i--) { + const char = textBefore[i]; + if (char === '}') { + braceDepth++; + } else if (char === '{') { + if (braceDepth === -1) { + // This is the opening brace of the array item + arrayItemStart = i; + break; + } + braceDepth--; + } else if (char === '[' && braceDepth === 0) { + // We've gone back past the array opening without finding an item start + break; + } + } + + let discriminatorValue: string | null = null; + if (arrayItemStart !== -1) { + // Look for the discriminator within this array item object + const arrayItemText = + textBefore.substring(arrayItemStart); + const regex = new RegExp( + `"${discriminator}"\\s*:\\s*"([^"]+)"` + ); + const match = arrayItemText.match(regex); + discriminatorValue = match ? match[1] : null; + } + + if (discriminatorValue) { + // Filter to the matching schema + currentSchema = filterSchemaByDiscriminator( + currentSchema, + schema, + discriminator, + discriminatorValue + ); + } + } + } + } + } else if (currentSchema.items) { + currentSchema = resolveSchemaRefs(currentSchema.items, schema); + } + } else if (segment.type === 'object') { + continue; + } + } + + if (!currentSchema) { + return null; + } + + // Check for discriminator ONLY in the current schema level + // A valid discriminator must be present in the DIRECT anyOf/oneOf at this level + let discriminatorProp = currentSchema.discriminator?.propertyName; + if (!discriminatorProp && (currentSchema.anyOf || currentSchema.oneOf)) { + // Check if there's a discriminator defined at this level + const options = currentSchema.anyOf || currentSchema.oneOf; + if (options && options.length > 0) { + const firstOption = options[0]; + const resolved = resolveSchemaRefs(firstOption, schema); + const candidateDiscriminator = resolved.discriminator?.propertyName; + + // Verify this discriminator actually belongs to THIS level by checking + // that it's defined as a property in the anyOf/oneOf options themselves + if (candidateDiscriminator) { + const discriminatorPresent = + schemaHasProperty( + resolved, + candidateDiscriminator, + schema + ) || + options.some((opt) => + schemaHasProperty(opt, candidateDiscriminator, schema) + ); + + if (discriminatorPresent) { + discriminatorProp = candidateDiscriminator; + } + } + } + } + + if (valuePropertyName) { + if (valuePropertyName === discriminatorProp) { + let schemaWithDiscriminator = currentSchema; + if (currentSchema.anyOf || currentSchema.oneOf) { + const firstOption = + currentSchema.anyOf?.[0] || currentSchema.oneOf?.[0]; + if (firstOption) { + schemaWithDiscriminator = resolveSchemaRefs( + firstOption, + schema + ); + } + } + + const discriminatorValues = getDiscriminatorValues( + schemaWithDiscriminator, + discriminatorProp + ); + + if (discriminatorValues.length === 0) { + return null; + } + + const word = context.matchBefore(/"[^"]*$/); + const from = word ? word.from + 1 : pos; + const to = pos; + + const textBefore = doc.sliceString(0, pos); + const valueMatch = textBefore.match(/"[^"]*$/); + const typedText = valueMatch + ? valueMatch[0].substring(1).toLowerCase() + : ''; + + let filteredValues = discriminatorValues; + if (typedText) { + filteredValues = discriminatorValues.filter((value) => + value.toLowerCase().startsWith(typedText) + ); + } + + const options = filteredValues.map((value) => ({ + label: value, + type: 'constant', + apply: value, + boost: 10, + })); + + return { + from, + to, + options, + filter: false, + }; + } + + const candidateValues = new Set(); + collectPropertyValuesFromSchema( + currentSchema, + valuePropertyName, + schema, + candidateValues + ); + + if (candidateValues.size > 0) { + const word = context.matchBefore(/"[^"\\]*$/); + const from = word ? word.from + 1 : pos; + const to = pos; + const typedText = word ? word.text.substring(1).toLowerCase() : ''; + + const sortedValues = Array.from(candidateValues).sort((a, b) => + a.localeCompare(b) + ); + const filteredValues = typedText + ? sortedValues.filter((value) => + value.toLowerCase().startsWith(typedText) + ) + : sortedValues; + + const options = filteredValues.map((value) => ({ + label: value, + type: 'constant', + apply: value, + })); + + if (options.length > 0) { + return { + from, + to, + options, + filter: false, + }; + } + } + + return null; + } + + const inObjectContainer = currentContainerType === 'object'; + const inKeyPosition = + inObjectContainer && isInPropertyKeyPosition(doc, pos); + if (!inKeyPosition) { + return null; + } + + const word = context.matchBefore(/"\w*/); + if (!word && !context.explicit) { + return null; + } + + const from = word ? word.from : pos; + + let to = pos; + const textAfterCursor = doc.sliceString(pos, pos + 50); + const quoteMatch = textAfterCursor.match(/^(\w*)"/); + + if (quoteMatch) { + to = pos + quoteMatch[0].length; + } + + const isPluginDataContext = contextPath.some( + (segment) => segment.type === 'key' && segment.key === 'pluginData' + ); + const resourceValue = isPluginDataContext + ? getCurrentDiscriminatorValue(doc, pos, 'resource') + : null; + + let schemaCandidate = currentSchema; + let schemaAlreadyMerged = false; + + if (isPluginDataContext && resourceValue) { + const filtered = filterSchemaByConstProperty( + schemaCandidate, + schema, + 'resource', + resourceValue + ); + if (filtered) { + schemaCandidate = filtered; + schemaAlreadyMerged = true; + } + } + + const currentDiscriminatorValue = getCurrentDiscriminatorValue( + doc, + pos, + discriminatorProp + ); + + if (currentDiscriminatorValue && discriminatorProp) { + schemaCandidate = filterSchemaByDiscriminator( + schemaCandidate, + schema, + discriminatorProp, + currentDiscriminatorValue + ); + schemaAlreadyMerged = true; + } else if (discriminatorProp) { + const mergedSchema = mergeCompositeSchemas(schemaCandidate, schema); + const discriminatorProperty = + mergedSchema.properties?.[discriminatorProp]; + + if (discriminatorProperty) { + schemaCandidate = { + ...schemaCandidate, + properties: { + [discriminatorProp]: discriminatorProperty, + }, + required: schemaCandidate.required?.includes(discriminatorProp) + ? [discriminatorProp] + : [], + }; + } + } else if (!schemaAlreadyMerged) { + schemaCandidate = mergeCompositeSchemas(schemaCandidate, schema); + schemaAlreadyMerged = true; + } + + currentSchema = schemaCandidate; + + if (!currentSchema || !currentSchema.properties) { + return null; + } + + const existingKeys = getExistingKeysInCurrentObject(doc, pos); + const currentlyTypingKey = + word && word.text.length > 1 ? word.text.substring(1) : null; + const discriminatorMissing = Boolean( + discriminatorProp && !existingKeys.has(discriminatorProp) + ); + + const calculatedTo = to; + + let propertyKeys = Object.keys(currentSchema.properties).filter((key) => { + if (existingKeys.has(key) && key !== currentlyTypingKey) { + return false; + } + return true; + }); + + if ( + discriminatorProp && + discriminatorMissing && + currentSchema.properties[discriminatorProp] + ) { + propertyKeys = [discriminatorProp]; + } + + const options = propertyKeys.map((key) => { + const prop = currentSchema.properties![key]; + const resolvedProp = resolveSchemaRefs(prop, schema); + const effectiveProp = resolvedProp; + const required = + currentSchema.required && currentSchema.required.includes(key); + const isDiscriminator = key === discriminatorProp; + const isPluginData = key === 'pluginData'; + + let valueToInsert = ''; + const propTypes = Array.isArray(effectiveProp.type) + ? effectiveProp.type + : effectiveProp.type + ? [effectiveProp.type] + : []; + + const hasObjectShape = + propTypes.includes('object') || + !!effectiveProp.properties || + !!effectiveProp.allOf || + !!effectiveProp.anyOf || + !!effectiveProp.oneOf; + const hasArrayShape = + propTypes.includes('array') || !!effectiveProp.items; + const hasStringShape = propTypes.includes('string'); + const hasNumberShape = + propTypes.includes('number') || propTypes.includes('integer'); + const hasBooleanShape = propTypes.includes('boolean'); + const hasEnumValues = + Array.isArray(effectiveProp.enum) && effectiveProp.enum.length > 0; + + if (hasArrayShape) { + valueToInsert = '[]'; + } else if (hasObjectShape) { + valueToInsert = '{}'; + } else if (hasStringShape) { + valueToInsert = '""'; + } else if (hasNumberShape) { + valueToInsert = '0'; + } else if (hasBooleanShape) { + valueToInsert = 'false'; + } else if (effectiveProp.const !== undefined) { + valueToInsert = JSON.stringify(effectiveProp.const); + } else if (effectiveProp.enum && effectiveProp.enum.length > 0) { + valueToInsert = JSON.stringify(effectiveProp.enum[0]); + } else { + valueToInsert = '""'; + } + + if (isPluginData) { + valueToInsert = '{ "resource": "" }'; + } + + const keepCursorInString = valueToInsert === '""' && !isPluginData; + const keepCursorInObject = !isPluginData && hasObjectShape; + + let boost = 0; + if (isDiscriminator) { + boost = 10; + } else if (required) { + boost = 1; + } + + return { + label: key, + type: 'property', + detail: (prop.type as string) || (prop.enum ? 'enum' : 'any'), + info: prop.description || '', + apply: (view: EditorView) => { + const insertText = `"${key}": ${valueToInsert}`; + + let cursorOffset; + if (isPluginData) { + const resourcePrefix = '"pluginData": { "resource": "'; + const prefixIndex = insertText.indexOf(resourcePrefix); + cursorOffset = prefixIndex + resourcePrefix.length; + } else if (hasObjectShape) { + cursorOffset = insertText.length - 1; + } else if (hasArrayShape) { + cursorOffset = insertText.length - 1; + } else if (hasStringShape) { + cursorOffset = insertText.length - 1; + } else { + cursorOffset = insertText.length; + } + + view.dispatch({ + changes: { from, to: calculatedTo, insert: insertText }, + selection: { anchor: from + cursorOffset }, + }); + + setTimeout(() => { + formatEditor(view); + + if (isPluginData) { + const resourcePrefix = '"resource": "'; + const docText = view.state.doc.toString(); + const searchStart = from; + const resourceIndex = docText.indexOf( + resourcePrefix, + Math.max(0, searchStart - 10) + ); + if (resourceIndex !== -1) { + const anchor = + resourceIndex + resourcePrefix.length; + view.dispatch({ selection: { anchor } }); + startCompletion(view); + } + return; + } + + if (keepCursorInObject) { + const docText = view.state.doc.toString(); + const searchStart = Math.max(0, from - 20); + const pattern = `"${key}": {`; + const propertyIndex = docText.indexOf( + pattern, + searchStart + ); + if (propertyIndex !== -1) { + const anchor = propertyIndex + pattern.length; + view.dispatch({ selection: { anchor } }); + startCompletion(view); + return; + } + } + + if (keepCursorInString) { + const anchorAfterFormat = + view.state.selection.main.anchor; + if (anchorAfterFormat >= 2) { + const docText = view.state.doc.toString(); + const lastChar = docText[anchorAfterFormat - 1]; + const prevChar = docText[anchorAfterFormat - 2]; + if (lastChar === '"' && prevChar === '"') { + view.dispatch({ + selection: { + anchor: anchorAfterFormat - 1, + }, + }); + } + } + + if (hasEnumValues) { + startCompletion(view); + } + } + + if (isDiscriminator && prop.type === 'string') { + startCompletion(view); + } + }, 0); + }, + boost, + }; + }); + + const enforceDiscriminator = discriminatorMissing; + + let filteredOptions = options; + if (!enforceDiscriminator && word && word.text.length > 1) { + const typed = word.text.substring(1).toLowerCase(); + filteredOptions = options.filter((opt) => + opt.label.toLowerCase().startsWith(typed) + ); + } + + return { + from, + to, + options: filteredOptions, + filter: false, + }; +} + +const DEFAULT_DOC = `{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json" +}`; + +export function JSONSchemaEditor({ + config = {}, + className = '', +}: JSONSchemaEditorProps) { + const editorRef = useRef(null); + const viewRef = useRef(null); + + useEffect(() => { + if (!editorRef.current) return; + + const initialDoc = config.initialDoc || DEFAULT_DOC; + const autofocus = config.autofocus ?? true; + + const extensions: Extension[] = [ + // Line numbers and highlighting + lineNumbers(), + highlightActiveLineGutter(), + highlightActiveLine(), + // Folding + foldGutter(), + // Selection features + dropCursor(), + rectangularSelection(), + crosshairCursor(), + // Language support + json(), + syntaxHighlighting(defaultHighlightStyle), + // Indentation + indentUnit.of('\t'), + indentOnInput(), + // Bracket features + bracketMatching(), + closeBrackets(), + // History + history(), + // Selection highlighting + highlightSelectionMatches(), + // Keymaps + keymap.of([ + ...defaultKeymap, + ...historyKeymap, + ...foldKeymap, + ...searchKeymap, + ...completionKeymap, + ...closeBracketsKeymap, + indentWithTab, + ]), + // Autocompletion with JSON schema + autocompletion({ + override: [jsonSchemaCompletion], + activateOnTyping: true, + closeOnBlur: false, + }), + ]; + + // Add readOnly extension if specified + if (config.readOnly) { + extensions.push(EditorState.readOnly.of(true)); + } + + // Add onChange listener if provided + if (config.onChange) { + extensions.push( + EditorView.updateListener.of((update: ViewUpdate) => { + if (update.docChanged) { + config.onChange!(update.state.doc.toString()); + } + }) + ); + } + + const view = new EditorView({ + doc: initialDoc, + extensions, + parent: editorRef.current, + }); + + viewRef.current = view; + + formatEditor(view); + + // Position cursor after the first key/value pair if it's the default schema + const doc = view.state.doc.toString(); + const schemaUrl = + '"https://playground.wordpress.net/blueprint-schema.json"'; + const schemaLineEnd = doc.indexOf(schemaUrl); + if (schemaLineEnd > 0) { + const cursorPos = schemaLineEnd + schemaUrl.length; + if (cursorPos <= view.state.doc.length) { + view.dispatch({ + selection: { anchor: cursorPos }, + }); + } + } + + if (autofocus) { + view.focus(); + } + + return () => { + view.destroy(); + viewRef.current = null; + }; + // Only create the editor once, don't recreate on prop changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Handle document updates from parent without recreating the editor + useEffect(() => { + const view = viewRef.current; + if (!view || !config.initialDoc) { + return; + } + + const currentDoc = view.state.doc.toString(); + if (config.initialDoc === currentDoc) { + return; + } + + // Only update if the change came from outside (not from user typing) + view.dispatch({ + changes: { + from: 0, + to: view.state.doc.length, + insert: config.initialDoc, + }, + }); + }, [config.initialDoc]); + + return
; +} + +export default JSONSchemaEditor; diff --git a/packages/playground/website/src/components/blueprint-editor/schema-utils.ts b/packages/playground/website/src/components/blueprint-editor/schema-utils.ts new file mode 100644 index 0000000000..f911e84938 --- /dev/null +++ b/packages/playground/website/src/components/blueprint-editor/schema-utils.ts @@ -0,0 +1,516 @@ +import { + findNodeAtLocation, + findNodeAtOffset, + getLocation, + parseTree, + type Location, + type Node as JsonNode, +} from 'jsonc-parser'; +import type { CodeMirrorDoc, JSONSchema, PathSegment } from './types'; + +interface ParsedJsonDocument { + text: string; + tree: JsonNode | undefined; +} + +interface DocumentContext { + parsed: ParsedJsonDocument; + location: Location; + node?: JsonNode; + containerNode?: JsonNode; + objectNode?: JsonNode; +} + +let lastParsedDocument: ParsedJsonDocument | null = null; +let lastParsedText = ''; + +function getParsedJsonDocument(doc: CodeMirrorDoc): ParsedJsonDocument { + const text = doc.toString(); + + if (lastParsedDocument && lastParsedText === text) { + return lastParsedDocument; + } + + const tree = parseTree(text); + lastParsedDocument = { text, tree }; + lastParsedText = text; + + return lastParsedDocument; +} + +function clampOffset(text: string, pos: number): number { + return Math.max(0, Math.min(pos, text.length)); +} + +function getContainerType(containerNode?: JsonNode): 'object' | 'array' | null { + if (!containerNode) { + return null; + } + if (containerNode.type === 'object') { + return 'object'; + } + if (containerNode.type === 'array') { + return 'array'; + } + return null; +} + +function buildPathSegments(path: Array): PathSegment[] { + const segments: PathSegment[] = []; + + for (const segment of path) { + if (typeof segment === 'string') { + if (segment === '') { + continue; + } + segments.push({ + type: 'key', + key: segment, + depth: segments.length + 1, + }); + } else { + segments.push({ type: 'array', depth: segments.length + 1 }); + } + } + + return segments; +} + +function collectObjectKeys(objectNode?: JsonNode): string[] { + if (!objectNode || objectNode.type !== 'object' || !objectNode.children) { + return []; + } + + const keys: string[] = []; + + for (const child of objectNode.children) { + if ( + child.type !== 'property' || + !child.children || + child.children.length === 0 + ) { + continue; + } + + const keyNode = child.children[0]; + if (keyNode.type === 'string' && typeof keyNode.value === 'string') { + keys.push(keyNode.value); + } + } + + return keys; +} + +function getPropertyValueNode( + objectNode: JsonNode | undefined, + propertyName: string +): JsonNode | undefined { + if (!objectNode || objectNode.type !== 'object' || !objectNode.children) { + return undefined; + } + + for (const child of objectNode.children) { + if ( + child.type !== 'property' || + !child.children || + child.children.length < 2 + ) { + continue; + } + + const keyNode = child.children[0]; + if (keyNode.type === 'string' && keyNode.value === propertyName) { + return child.children[1]; + } + } + + return undefined; +} + +function getDocumentContext(doc: CodeMirrorDoc, pos: number): DocumentContext { + const parsed = getParsedJsonDocument(doc); + const text = parsed.text; + const tree = parsed.tree; + + const offset = clampOffset(text, pos); + const location = getLocation(text, offset); + const node = tree ? findNodeAtOffset(tree, offset, true) : undefined; + + let containerNode: JsonNode | undefined; + let objectNode: JsonNode | undefined; + + if (tree) { + if (location.isAtPropertyKey) { + const pathNode = findNodeAtLocation(tree, location.path); + if (pathNode?.type === 'object') { + containerNode = pathNode; + objectNode = pathNode; + } + } + + if (!containerNode) { + let current = node; + while (current) { + if (current.type === 'object') { + containerNode = current; + objectNode = current; + break; + } + if (current.type === 'array') { + containerNode = current; + break; + } + current = current.parent; + } + } + + if (!containerNode && location.path.length > 0) { + const pathNode = findNodeAtLocation(tree, location.path); + let current = pathNode; + while (current) { + if (current.type === 'object' || current.type === 'array') { + containerNode = current; + if (current.type === 'object') { + objectNode = current; + } + break; + } + current = current.parent; + } + } + + if (!objectNode && containerNode?.type !== 'object') { + let current = node; + while (current) { + if (current.type === 'object') { + objectNode = current; + break; + } + current = current.parent; + } + } + + if (!containerNode && tree.type === 'object') { + containerNode = tree; + } + if (!objectNode && containerNode?.type === 'object') { + objectNode = containerNode; + } + } + + return { parsed, location, node, containerNode, objectNode }; +} + +/** + * Resolve a $ref reference in a schema + */ +export function resolveRef(schema: JSONSchema, ref: string): JSONSchema | null { + if (!ref || !ref.startsWith('#/')) { + return null; + } + + const path = ref.substring(2).split('/'); + let current: unknown = schema; + + for (const segment of path) { + if (!current || typeof current !== 'object') { + return null; + } + current = (current as Record)[segment]; + } + + return current as JSONSchema; +} + +/** + * Resolve all $ref in a schema object (non-recursive, one level) + */ +export function resolveSchemaRefs( + schema: JSONSchema, + rootSchema: JSONSchema +): JSONSchema { + if (!schema || typeof schema !== 'object') { + return schema; + } + + if (schema.$ref) { + const resolved = resolveRef(rootSchema, schema.$ref); + if (resolved) { + return { ...resolved, ...schema, $ref: undefined }; + } + } + + return schema; +} + +/** + * Merge all schemas from anyOf/oneOf/allOf into a single schema with combined properties + * This function recursively merges nested composite schemas + */ +export function mergeCompositeSchemas( + schema: JSONSchema, + rootSchema: JSONSchema +): JSONSchema { + if (!schema || typeof schema !== 'object') { + return schema; + } + + const merged: JSONSchema = { ...schema }; + + if (schema.allOf && Array.isArray(schema.allOf)) { + merged.properties = merged.properties || {}; + merged.required = merged.required || []; + + for (const subSchema of schema.allOf) { + let resolved = resolveSchemaRefs(subSchema, rootSchema); + resolved = mergeCompositeSchemas(resolved, rootSchema); + if (resolved.properties) { + merged.properties = { + ...merged.properties, + ...resolved.properties, + }; + } + if (resolved.required) { + merged.required = [...merged.required, ...resolved.required]; + } + } + } + + if (schema.anyOf && Array.isArray(schema.anyOf)) { + merged.properties = merged.properties || {}; + + for (const subSchema of schema.anyOf) { + let resolved = resolveSchemaRefs(subSchema, rootSchema); + resolved = mergeCompositeSchemas(resolved, rootSchema); + if (resolved.properties) { + merged.properties = { + ...merged.properties, + ...resolved.properties, + }; + } + } + } + + if (schema.oneOf && Array.isArray(schema.oneOf)) { + merged.properties = merged.properties || {}; + + for (const subSchema of schema.oneOf) { + let resolved = resolveSchemaRefs(subSchema, rootSchema); + resolved = mergeCompositeSchemas(resolved, rootSchema); + if (resolved.properties) { + merged.properties = { + ...merged.properties, + ...resolved.properties, + }; + } + } + } + + return merged; +} + +/** + * Parse the current JSON path from the cursor position + */ +export function getJsonPath(doc: CodeMirrorDoc, pos: number): PathSegment[] { + const context = getDocumentContext(doc, pos); + const path = + context.location.isAtPropertyKey && context.location.path.length > 0 + ? context.location.path.slice(0, -1) + : context.location.path; + return buildPathSegments(path); +} + +/** + * Return the container type (object or array) at the cursor position + */ +export function getCurrentContainerType( + doc: CodeMirrorDoc, + pos: number +): 'object' | 'array' | null { + const context = getDocumentContext(doc, pos); + return getContainerType(context.containerNode); +} + +/** + * Extract all possible discriminator values from a schema with oneOf/anyOf + */ +export function getDiscriminatorValues( + schema: JSONSchema, + discriminatorProp: string +): string[] { + if (!schema || !discriminatorProp) { + return []; + } + + const values: string[] = []; + const checkSchema = (subSchema: JSONSchema): void => { + if (subSchema.properties && subSchema.properties[discriminatorProp]) { + const prop = subSchema.properties[discriminatorProp]; + if (prop.const !== undefined) { + values.push(String(prop.const)); + } else if (prop.enum) { + values.push(...prop.enum.map(String)); + } + } + }; + + if (schema.oneOf) { + schema.oneOf.forEach(checkSchema); + } + if (schema.anyOf) { + schema.anyOf.forEach(checkSchema); + } + + return [...new Set(values)]; +} + +/** + * Find the discriminator value in the current object being edited + */ +export function getCurrentDiscriminatorValue( + doc: CodeMirrorDoc, + pos: number, + discriminatorProp: string | undefined +): string | null { + if (!discriminatorProp) { + return null; + } + + const context = getDocumentContext(doc, pos); + const valueNode = getPropertyValueNode( + context.objectNode, + discriminatorProp + ); + + if ( + valueNode && + valueNode.type === 'string' && + typeof valueNode.value === 'string' + ) { + return valueNode.value; + } + + return null; +} + +/** + * Get all existing keys in the current object being edited + * Returns a Set of key names that are already present + */ +export function getExistingKeysInCurrentObject( + doc: CodeMirrorDoc, + pos: number +): Set { + const context = getDocumentContext(doc, pos); + return new Set(collectObjectKeys(context.objectNode)); +} + +/** + * Determine the property name for which a value is being completed + */ +export function getPropertyNameForValueCompletion( + doc: CodeMirrorDoc, + pos: number +): string | null { + const context = getDocumentContext(doc, pos); + + if (context.location.isAtPropertyKey) { + return null; + } + + const path = context.location.path; + if (path.length === 0) { + return null; + } + + const lastSegment = path[path.length - 1]; + return typeof lastSegment === 'string' ? lastSegment : null; +} + +/** + * Filter a schema to only include properties valid for a specific discriminator value + */ +export function filterSchemaByDiscriminator( + schema: JSONSchema, + rootSchema: JSONSchema, + discriminatorProp: string, + discriminatorValue: string +): JSONSchema { + if (!schema || !discriminatorProp || !discriminatorValue) { + return schema; + } + + const resolved = resolveSchemaRefs(schema, rootSchema); + + const findMatchingSchema = ( + schemas: JSONSchema[] | undefined + ): JSONSchema | null => { + if (!schemas) return null; + + for (const subSchema of schemas) { + const subResolved = resolveSchemaRefs(subSchema, rootSchema); + const prop = subResolved.properties?.[discriminatorProp]; + + if ( + prop?.const === discriminatorValue || + prop?.enum?.includes(discriminatorValue) + ) { + return subResolved; + } + + if (subResolved.oneOf || subResolved.anyOf) { + const nested = filterSchemaByDiscriminator( + subResolved, + rootSchema, + discriminatorProp, + discriminatorValue + ); + if ( + nested && + nested.properties && + Object.keys(nested.properties).length > 0 + ) { + return nested; + } + } + } + return null; + }; + + const matchingSchema = + findMatchingSchema(resolved.oneOf) || + findMatchingSchema(resolved.anyOf); + + return matchingSchema || resolved; +} + +/** + * Get schema properties for the current JSON path + */ +export function getSchemaForPath( + schema: JSONSchema, + path: PathSegment[] +): JSONSchema | null { + let current: JSONSchema = schema; + + current = resolveSchemaRefs(current, schema); + + for (const segment of path) { + if (segment.type === 'key' && current.properties) { + const next = current.properties[segment.key!]; + if (!next) return null; + current = resolveSchemaRefs(next, schema); + } else if (segment.type === 'array') { + if (current.type === 'array' && current.items) { + current = resolveSchemaRefs(current.items, schema); + } else if (current.items) { + current = resolveSchemaRefs(current.items, schema); + } + } else if (segment.type === 'object') { + continue; + } + } + + return current; +} diff --git a/packages/playground/website/src/components/blueprint-editor/types.ts b/packages/playground/website/src/components/blueprint-editor/types.ts new file mode 100644 index 0000000000..d977aa3d84 --- /dev/null +++ b/packages/playground/website/src/components/blueprint-editor/types.ts @@ -0,0 +1,36 @@ +import type { Text } from '@codemirror/state'; + +export interface JSONSchema { + $schema?: string; + $ref?: string; + type?: string | string[]; + properties?: Record; + required?: string[]; + items?: JSONSchema; + anyOf?: JSONSchema[]; + oneOf?: JSONSchema[]; + allOf?: JSONSchema[]; + const?: unknown; + enum?: unknown[]; + description?: string; + discriminator?: { + propertyName: string; + mapping?: Record; + }; + [key: string]: unknown; +} + +export interface PathSegment { + type: 'key' | 'array' | 'object'; + key?: string; + depth: number; +} + +export interface JSONSchemaCompletionConfig { + autofocus?: boolean; + initialDoc?: string; + onChange?: (doc: string) => void; + readOnly?: boolean; +} + +export type { Text as CodeMirrorDoc }; diff --git a/packages/playground/website/src/components/playground-viewport/index.tsx b/packages/playground/website/src/components/playground-viewport/index.tsx index 8d3eabca10..c5d964f376 100644 --- a/packages/playground/website/src/components/playground-viewport/index.tsx +++ b/packages/playground/website/src/components/playground-viewport/index.tsx @@ -67,6 +67,15 @@ export const KeepAliveTemporarySitesViewport = () => { } return sites.map((site) => site.slug); }, [temporarySites, activeSite]); + + // Create a map of slug to site for easy lookup + const sitesBySlug = useMemo(() => { + const sites = [...temporarySites]; + if (activeSite) { + sites.push(activeSite); + } + return new Map(sites.map((site) => [site.slug, site])); + }, [temporarySites, activeSite]); /** * ## Critical data loss prevention mechanism * @@ -151,18 +160,24 @@ export const KeepAliveTemporarySitesViewport = () => {
)} - {slugsSeenSoFar.map((slug) => ( -
- {siteSlugsToRender.includes(slug) ? ( - - ) : null} -
- ))} + {slugsSeenSoFar.map((slug) => { + const site = sitesBySlug.get(slug); + const viewportKey = site + ? `${slug}-${site.metadata.whenCreated}` + : slug; + return ( +
+ {siteSlugsToRender.includes(slug) ? ( + + ) : null} +
+ ); + })} ); }; @@ -213,7 +228,7 @@ export const JustViewport = function JustViewport({ return (