diff --git a/.changeset/content-validate.md b/.changeset/content-validate.md new file mode 100644 index 00000000..abee4a37 --- /dev/null +++ b/.changeset/content-validate.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +--- + +Add `content validate` command to validate Page Designer metadefinition JSON files against bundled schemas. Supports auto-detection of schema types from file paths and content, or explicit `--type` flag. Includes glob pattern support for validating multiple files. diff --git a/docs/cli/content.md b/docs/cli/content.md index a0503ac5..e43de421 100644 --- a/docs/cli/content.md +++ b/docs/cli/content.md @@ -1,26 +1,27 @@ --- -description: Commands for exporting and listing Page Designer content from B2C Commerce content libraries. +description: Commands for exporting, listing, and validating Page Designer content from B2C Commerce content libraries. --- # Content Commands -Commands for working with Page Designer content libraries on B2C Commerce instances. +Commands for working with Page Designer content libraries and metadefinitions. ## Authentication -Content commands require OAuth authentication: +The `content export` and `content list` commands require OAuth authentication: | Operation | Auth Required | |-----------|--------------| | `content export` | OAuth (OCAPI for export job + WebDAV for assets) | | `content list` | OAuth (OCAPI for export job) | +| `content validate` | None (local file validation) | ```bash export SFCC_CLIENT_ID=your-client-id export SFCC_CLIENT_SECRET=your-client-secret ``` -Both commands also support a `--library-file` flag for offline use with a local XML file, which skips authentication entirely. +The `content export` and `content list` commands also support a `--library-file` flag for offline use with a local XML file, which skips authentication entirely. For complete setup instructions, see the [Authentication Guide](/guide/authentication). @@ -201,3 +202,96 @@ footer-content (CONTENT ASSET) Pages show `id (typeId: type)`, components show `typeId (id)`, content assets show `id (CONTENT ASSET)`, and static assets show `path (STATIC ASSET)`. The tree uses color when output to a terminal: page names are bold, component type IDs are cyan, asset paths are green, and tree connectors are dim. With `--json`, returns `{ data: [...] }` with each item containing `id`, `type`, `typeId`, and `children` count. + +--- + +## b2c content validate + +Validate Page Designer metadefinition JSON files against bundled JSON schemas. This is a local-only command — no instance connection or authentication is required. + +The command auto-detects the schema type using file path conventions (`experience/pages/` → pagetype, `experience/components/` → componenttype) and falls back to inspecting the JSON properties. You can also specify the type explicitly with `--type`. + +### Usage + +```bash +b2c content validate +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `files` | One or more file paths, directories, or glob patterns | Yes | + +When a directory is passed, all `*.json` files within it are validated recursively. + +### Flags + +In addition to [global flags](./index#global-flags): + +| Flag | Description | Default | +|------|-------------|---------| +| `--type`, `-t` | Schema type to validate against (skips auto-detection) | Auto-detected | + +Available schema types: `pagetype`, `componenttype`, `aspecttype`, `cmsrecord`, `customeditortype`, `contentassetpageconfig`, `contentassetcomponentconfig`, `contentassetstructuredcontentdata`, `image`. + +### Examples + +```bash +# Validate a single page type definition +b2c content validate cartridge/experience/pages/storePage.json + +# Validate all metadefinitions in a directory recursively +b2c content validate cartridge/experience/ + +# Validate with a glob pattern +b2c content validate 'cartridge/experience/**/*.json' + +# Explicitly specify the schema type +b2c content validate --type componenttype mycomponent.json + +# Validate multiple files +b2c content validate pages/home.json pages/about.json components/hero.json + +# JSON output for CI/scripting +b2c content validate cartridge/experience/ --json +``` + +### Output + +The command prints a color-coded result for each file: + +``` +PASS: experience/pages/storePage.json (pagetype) +PASS: experience/components/hero.json (componenttype) +FAIL: experience/components/broken.json (componenttype) + ERROR at .attribute_definition_groups[0]: is required + +2/3 file(s) valid, 1 error(s) +``` + +The command exits with a non-zero status when any file fails validation. + +With `--json`, returns a structured result: + +```json +{ + "results": [ + { + "valid": true, + "schemaType": "pagetype", + "errors": [], + "filePath": "/path/to/storePage.json" + } + ], + "totalFiles": 1, + "validFiles": 1, + "totalErrors": 0 +} +``` + +### Notes + +- Auto-detection uses file path conventions first: files under `experience/pages/` are validated as `pagetype`, files under `experience/components/` as `componenttype`. For other schema types, use `--type`. +- Schemas are bundled with the SDK and follow the [Page Designer metadefinition specification](https://developer.salesforce.com/docs/commerce/b2c-commerce/guide/b2c-page-designer.html). +- Use `b2c content validate` in CI pipelines to catch metadefinition errors before deployment. diff --git a/docs/typedoc.json b/docs/typedoc.json index 6a6a4158..9b762070 100644 --- a/docs/typedoc.json +++ b/docs/typedoc.json @@ -7,6 +7,7 @@ "../packages/b2c-tooling-sdk/src/instance/index.ts", "../packages/b2c-tooling-sdk/src/logging/index.ts", "../packages/b2c-tooling-sdk/src/operations/code/index.ts", + "../packages/b2c-tooling-sdk/src/operations/content/index.ts", "../packages/b2c-tooling-sdk/src/operations/cip/index.ts", "../packages/b2c-tooling-sdk/src/operations/jobs/index.ts", "../packages/b2c-tooling-sdk/src/operations/logs/index.ts", diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index 761454bc..178f97d4 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -105,7 +105,7 @@ "description": "Deploy and manage code versions on instances\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/code.html" }, "content": { - "description": "Export and manage Page Designer content libraries" + "description": "Export, list, and validate Page Designer content" }, "cip": { "description": "Run CIP analytics SQL and curated reports\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/cip.html" diff --git a/packages/b2c-cli/src/commands/content/validate.ts b/packages/b2c-cli/src/commands/content/validate.ts new file mode 100644 index 00000000..ba0343d4 --- /dev/null +++ b/packages/b2c-cli/src/commands/content/validate.ts @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import {Args, Flags, ux} from '@oclif/core'; +import {glob} from 'glob'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import { + CONTENT_SCHEMA_TYPES, + MetaDefinitionDetectionError, + validateMetaDefinitionFile, + type ContentSchemaType, + type MetaDefinitionValidationResult, +} from '@salesforce/b2c-tooling-sdk/operations/content'; + +interface ContentValidateResponse { + results: MetaDefinitionValidationResult[]; + totalErrors: number; + totalFiles: number; + validFiles: number; +} + +export default class ContentValidate extends BaseCommand { + static args = { + files: Args.string({ + description: 'File(s), directory, or glob pattern(s) for JSON metadefinition files to validate', + required: true, + }), + }; + + static description = 'Validate Page Designer metadefinition JSON files against schemas'; + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> cartridge/experience/pages/storePage.json', + '<%= config.bin %> <%= command.id %> --type componenttype mycomponent.json', + "<%= config.bin %> <%= command.id %> 'cartridge/experience/**/*.json'", + '<%= config.bin %> <%= command.id %> cartridge/experience/', + '<%= config.bin %> <%= command.id %> storePage.json --json', + ]; + + static flags = { + ...BaseCommand.baseFlags, + type: Flags.string({ + char: 't', + description: 'Schema type (auto-detected if not specified)', + options: [...CONTENT_SCHEMA_TYPES], + }), + }; + + // Allow multiple file arguments + static strict = false; + + protected operations: { + glob: (pattern: string, options?: {nodir?: boolean}) => Promise; + validateMetaDefinitionFile: typeof validateMetaDefinitionFile; + } = { + glob, + validateMetaDefinitionFile, + }; + + async run(): Promise { + const {argv, flags} = await this.parse(ContentValidate); + const patterns = argv as string[]; + + if (patterns.length === 0) { + this.error('At least one file path, directory, or glob pattern is required.'); + } + + // Expand directories to recursive JSON globs, then resolve all patterns in parallel + const resolvedPatterns = patterns.map((pattern) => + fs.existsSync(pattern) && fs.statSync(pattern).isDirectory() ? path.join(pattern, '**/*.json') : pattern, + ); + const allMatches = await Promise.all( + resolvedPatterns.map((resolved) => this.operations.glob(resolved, {nodir: true})), + ); + const filePaths: string[] = []; + for (const [i, matches] of allMatches.entries()) { + if (matches.length === 0) { + this.warn(`No files matched: ${patterns[i]}`); + } + filePaths.push(...matches); + } + + if (filePaths.length === 0) { + this.error('No files found matching the provided patterns.'); + } + + const results: MetaDefinitionValidationResult[] = []; + + for (const filePath of filePaths) { + try { + const result = this.operations.validateMetaDefinitionFile(filePath, { + type: flags.type as ContentSchemaType | undefined, + }); + results.push(result); + } catch (error) { + if (error instanceof MetaDefinitionDetectionError) { + const relativePath = path.relative(process.cwd(), path.resolve(filePath)); + this.error(`${relativePath}: ${error.message}`); + } + throw error; + } + } + + const totalErrors = results.reduce((sum, r) => sum + r.errors.length, 0); + const validFiles = results.filter((r) => r.valid).length; + + const response: ContentValidateResponse = { + results, + totalErrors, + totalFiles: results.length, + validFiles, + }; + + if (this.jsonEnabled()) { + return response; + } + + for (const result of results) { + const relativePath = path.relative(process.cwd(), result.filePath ?? ''); + const typeInfo = result.schemaType ? ` (${result.schemaType})` : ''; + + if (result.valid) { + ux.stdout(`${ux.colorize('green', 'PASS')}: ${relativePath}${typeInfo}`); + } else { + ux.stdout(`${ux.colorize('red', 'FAIL')}: ${relativePath}${typeInfo}`); + for (const error of result.errors) { + const location = error.path && error.path !== '/' ? ` at ${error.path}` : ''; + ux.stdout(` ${ux.colorize('red', 'ERROR')}${location}: ${error.message}`); + } + } + } + + ux.stdout(''); + ux.stdout(`${validFiles}/${results.length} file(s) valid, ${totalErrors} error(s)`); + + if (totalErrors > 0) { + this.error('Validation failed', {exit: 1}); + } + + return response; + } +} diff --git a/packages/b2c-cli/test/commands/content/validate.test.ts b/packages/b2c-cli/test/commands/content/validate.test.ts new file mode 100644 index 00000000..baa8a5a9 --- /dev/null +++ b/packages/b2c-cli/test/commands/content/validate.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {ux} from '@oclif/core'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import type {MetaDefinitionValidationResult} from '@salesforce/b2c-tooling-sdk/operations/content'; +import ContentValidate from '../../../src/commands/content/validate.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +function makeResult(overrides: Partial = {}): MetaDefinitionValidationResult { + return { + valid: true, + schemaType: 'pagetype', + errors: [], + filePath: '/tmp/test.json', + ...overrides, + }; +} + +describe('content validate', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, argv: string[] = ['test.json']) { + return createTestCommand(ContentValidate, hooks.getConfig(), flags, {}, argv); + } + + function stubGlob(command: any, mapping: Record) { + sinon.stub(command.operations, 'glob').callsFake(async (...args: unknown[]) => mapping[args[0] as string] ?? []); + } + + it('writes PASS to stdout for valid file', async () => { + const command: any = await createCommand({}, ['test.json']); + stubGlob(command, {'test.json': ['test.json']}); + sinon.stub(command, 'jsonEnabled').returns(false); + sinon.stub(command.operations, 'validateMetaDefinitionFile').returns(makeResult({filePath: '/cwd/test.json'})); + const stdoutStub = sinon.stub(ux, 'stdout'); + + const result = await command.run(); + + expect(result.validFiles).to.equal(1); + expect(result.totalErrors).to.equal(0); + expect(stdoutStub.calledWithMatch(sinon.match(/PASS/))).to.equal(true); + }); + + it('writes FAIL to stdout and calls error for invalid file', async () => { + const command: any = await createCommand({}, ['bad.json']); + stubGlob(command, {'bad.json': ['bad.json']}); + sinon.stub(command, 'jsonEnabled').returns(false); + sinon.stub(command.operations, 'validateMetaDefinitionFile').returns( + makeResult({ + valid: false, + filePath: '/cwd/bad.json', + errors: [{path: '/region_definitions', message: 'is required', property: 'instance.region_definitions'}], + }), + ); + const stdoutStub = sinon.stub(ux, 'stdout'); + const errorStub = sinon.stub(command, 'error').throws(new Error('Validation failed')); + + try { + await command.run(); + } catch { + // expected + } + + expect(stdoutStub.calledWithMatch(sinon.match(/FAIL/))).to.equal(true); + expect(stdoutStub.calledWithMatch(sinon.match(/ERROR/))).to.equal(true); + expect(errorStub.calledWithMatch(sinon.match('Validation failed'))).to.equal(true); + }); + + it('returns JSON result with data', async () => { + const command: any = await createCommand({}, ['test.json']); + stubGlob(command, {'test.json': ['test.json']}); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command.operations, 'validateMetaDefinitionFile').returns(makeResult()); + + const result = await command.run(); + + expect(result).to.have.property('results'); + expect(result).to.have.property('totalFiles', 1); + expect(result).to.have.property('validFiles', 1); + expect(result).to.have.property('totalErrors', 0); + }); + + it('passes --type flag to SDK', async () => { + const command: any = await createCommand({type: 'componenttype'}, ['test.json']); + stubGlob(command, {'test.json': ['test.json']}); + sinon.stub(command, 'jsonEnabled').returns(true); + const validateStub = sinon + .stub(command.operations, 'validateMetaDefinitionFile') + .returns(makeResult({schemaType: 'componenttype'})); + + await command.run(); + + expect(validateStub.calledOnce).to.equal(true); + expect(validateStub.firstCall.args[1]).to.deep.equal({type: 'componenttype'}); + }); + + it('processes multiple files', async () => { + const command: any = await createCommand({}, ['a.json', 'b.json']); + stubGlob(command, {'a.json': ['a.json'], 'b.json': ['b.json']}); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon + .stub(command.operations, 'validateMetaDefinitionFile') + .onFirstCall() + .returns(makeResult({filePath: '/cwd/a.json'})) + .onSecondCall() + .returns(makeResult({filePath: '/cwd/b.json'})); + + const result = await command.run(); + + expect(result.totalFiles).to.equal(2); + expect(result.validFiles).to.equal(2); + }); + + it('expands glob patterns to multiple files', async () => { + const command: any = await createCommand({}, ['experience/**/*.json']); + stubGlob(command, {'experience/**/*.json': ['experience/pages/a.json', 'experience/components/b.json']}); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command.operations, 'validateMetaDefinitionFile').returns(makeResult()); + + const result = await command.run(); + + expect(result.totalFiles).to.equal(2); + }); +}); diff --git a/packages/b2c-tooling-sdk/data/content-schemas/aspecttype.json b/packages/b2c-tooling-sdk/data/content-schemas/aspecttype.json new file mode 100644 index 00000000..f79c4891 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/aspecttype.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "name": {"$ref":"common.json#/definitions/name"}, + "description": {"$ref":"common.json#/definitions/description"}, + "arch_type": { + "$ref": "common.json#/definitions/arch_type" + }, + "attribute_definitions": { + "type": "array", + "items": { + "oneOf": [ + { "$ref": "attributedefinition.json#/definitions/string" }, + { "$ref": "attributedefinition.json#/definitions/integer" }, + { "$ref": "attributedefinition.json#/definitions/product" }, + { "$ref": "attributedefinition.json#/definitions/category" }, + { "$ref": "attributedefinition.json#/definitions/page" }, + { "$ref": "attributedefinition.json#/definitions/enum_of_integer" }, + { "$ref": "attributedefinition.json#/definitions/enum_of_string" } + ] + } + }, + "supported_object_types": { + "type": "array", + "items": { + "type": "string", + "enum": ["category", "product"] + }, + "uniqueItems": true + } + }, + "required": [ + "name", + "attribute_definitions", + "supported_object_types" + ], + "additionalProperties": false +} diff --git a/packages/b2c-tooling-sdk/data/content-schemas/attributedefinition.json b/packages/b2c-tooling-sdk/data/content-schemas/attributedefinition.json new file mode 100644 index 00000000..ba6ee493 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/attributedefinition.json @@ -0,0 +1,283 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object", + + "definitions": { + + "attribute_base": { + "properties": { + "id": {"$ref":"common.json#/definitions/id"}, + "name": {"$ref":"common.json#/definitions/name"}, + "description": {"$ref":"common.json#/definitions/description"}, + "type": {}, + "editor_definition":{}, + "values":{}, + "required": {"type": "boolean","default": true}, + "default_value":{}, + "dynamic_lookup": { + "type": "object", + "properties": { + "aspect_attribute_alias": {"$ref":"common.json#/definitions/id"} + }, + "required": [ + "aspect_attribute_alias" + ], + "additionalProperties": false + }, + "searching": { + "properties": { + "searchable": {"type": "boolean"}, + "refinable": {"type": "boolean"}, + "boost_factor": { + "type": "number", + "minimum": 0.01, + "maximum": 100 + }, + "sortable": {"type": "boolean","default": false} + }, + "required": ["searchable", "refinable"], + "additionalProperties":false + } + }, + "required": ["id","type"], + "additionalProperties":false + }, + + "string": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["string"] }, + "editor_definition":{"not":{}}, + "values":{"not":{}}, + "default_value":{"type":"string"} + }, + "required": ["type"] + } + ] + }, + "text": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["text"] }, + "editor_definition":{"not":{}}, + "values":{"not":{}}, + "default_value":{"type":"string"} + }, + "required": ["type"] + } + ] + }, + "markup": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["markup"] }, + "editor_definition":{"not":{}}, + "values":{"not":{}}, + "default_value":{"type":"string"}, + "searching": { + "properties": { + "searchable": {"type": "boolean"}, + "refinable": {"type": "boolean"}, + "sortable": {"enum": [false]} + } + } + }, + "required": ["type"] + } + ] + }, + "integer": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["integer"] }, + "editor_definition":{"not":{}}, + "values":{"not":{}}, + "default_value":{"type":"integer"}, + "searching":{"not":{}} + }, + "required": ["type"] + } + ] + }, + "boolean": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["boolean"] }, + "editor_definition":{"not":{}}, + "values":{"not":{}}, + "default_value":{"type":"boolean"}, + "searching":{"not":{}} + }, + "required": ["type"] + } + ] + }, + "product": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["product"] }, + "editor_definition":{"not":{}}, + "values":{"not":{}}, + "default_value":{"type":"string"} + }, + "required": ["type"] + } + ] + }, + "category": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["category"] }, + "editor_definition":{"not":{}}, + "values":{"not":{}}, + "default_value":{"type":"string"} + }, + "required": ["type"] + } + ] + }, + "file": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["file"] }, + "editor_definition":{"not":{}}, + "values":{"not":{}}, + "default_value":{"type":"string"}, + "searching":{"not":{}} + }, + "required": ["type"] + } + ] + }, + "page": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["page"] }, + "editor_definition":{"not":{}}, + "values":{"not":{}}, + "default_value":{"type":"string"}, + "searching":{"not":{}} + }, + "required": ["type"] + } + ] + }, + "image": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["image"] }, + "editor_definition":{"not":{}}, + "values":{"not":{}}, + "default_value":{"not":{}}, + "searching":{"not":{}} + }, + "required": ["type"] + } + ] + }, + "url": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["url"] }, + "editor_definition":{"not":{}}, + "values":{"not":{}}, + "default_value":{"type":"string"}, + "searching":{"not":{}} + }, + "required": ["type"] + } + ] + }, + "enum_of_integer": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["enum"] }, + "editor_definition":{"not":{}}, + "values": { + "type": "array", + "minItems":1, + "items": {"type": "integer"} + }, + "default_value":{"type":"integer"}, + "searching":{"not":{}} + }, + "required": ["type", "values"] + } + ] + }, + "enum_of_string": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["enum"] }, + "editor_definition":{"not":{}}, + "values": { + "type": "array", + "minItems":1, + "items": {"type": "string"} + }, + "default_value":{"type":"string"} + }, + "required": ["type", "values"] + } + ] + }, + "custom": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["custom"] }, + "editor_definition": {"$ref":"editordefinition.json#/definitions/custom"}, + "values":{"not":{}}, + "default_value":{"not":{}}, + "searching": { + "properties": { + "searchable": {"type": "boolean"}, + "refinable": { "enum": [ false ] }, + "sortable": { "enum": [ false ] } + }, + "required": ["searchable", "refinable"], + "additionalProperties":false + } + }, + "required": ["type"] + } + ] + }, + "cms_record": { + "allOf": [ + { "$ref": "#/definitions/attribute_base" }, + { "properties": { + "type": { "enum":["cms_record"] }, + "editor_definition": {"$ref":"editordefinition.json#/definitions/cms_record"}, + "values":{"not":{}}, + "default_value":{"not":{}}, + "searching": { + "properties": { + "searchable": {"type": "boolean"}, + "refinable": { "enum": [ false ] }, + "sortable": { "enum": [ false ] } + }, + "required": ["searchable", "refinable"], + "additionalProperties":false + } + }, + "required": ["type"] + } + ] + } + } +} diff --git a/packages/b2c-tooling-sdk/data/content-schemas/attributedefinitiongroup.json b/packages/b2c-tooling-sdk/data/content-schemas/attributedefinitiongroup.json new file mode 100644 index 00000000..c4fb8772 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/attributedefinitiongroup.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object", + + "properties": { + "id": {"$ref":"common.json#/definitions/id"}, + "name": {"$ref":"common.json#/definitions/name"}, + "description": {"$ref":"common.json#/definitions/description"}, + "attribute_definitions": { + "type": "array", + "items": { + "oneOf": [ + { "$ref": "attributedefinition.json#/definitions/string" }, + { "$ref": "attributedefinition.json#/definitions/text" }, + { "$ref": "attributedefinition.json#/definitions/markup" }, + { "$ref": "attributedefinition.json#/definitions/integer" }, + { "$ref": "attributedefinition.json#/definitions/boolean" }, + { "$ref": "attributedefinition.json#/definitions/product" }, + { "$ref": "attributedefinition.json#/definitions/category" }, + { "$ref": "attributedefinition.json#/definitions/file" }, + { "$ref": "attributedefinition.json#/definitions/page" }, + { "$ref": "attributedefinition.json#/definitions/image" }, + { "$ref": "attributedefinition.json#/definitions/url" }, + { "$ref": "attributedefinition.json#/definitions/enum_of_integer" }, + { "$ref": "attributedefinition.json#/definitions/enum_of_string" }, + { "$ref": "attributedefinition.json#/definitions/custom" }, + { "$ref": "attributedefinition.json#/definitions/cms_record" } + ] + } + } + }, + "required": ["id","attribute_definitions"], + "additionalProperties": false +} \ No newline at end of file diff --git a/packages/b2c-tooling-sdk/data/content-schemas/cmsrecord.json b/packages/b2c-tooling-sdk/data/content-schemas/cmsrecord.json new file mode 100644 index 00000000..a72f605d --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/cmsrecord.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object", + + "definitions": { + "cms_content_type": { + "type": "object", + "properties": { + "id" : { + "type": "string" + }, + "name" : { + "type": "string" + }, + "attribute_definitions": { + "type": "array", + "items": {"$ref": "attributedefinition.json"} + } + }, + "required": ["id", "attribute_definitions"], + "additionalProperties": false + } + }, + + "properties": { + "type": {"$ref": "#/definitions/cms_content_type"}, + "id" : { + "type": "string" + }, + "name" : { + "type": "string" + }, + "publish_date" : { + "type": "string", + "format": "date-time" + }, + "attributes": { + "type": "object" + } + }, + + "required": ["id", "type", "attributes"], + "additionalProperties": false +} \ No newline at end of file diff --git a/packages/b2c-tooling-sdk/data/content-schemas/common.json b/packages/b2c-tooling-sdk/data/content-schemas/common.json new file mode 100644 index 00000000..368c4afc --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/common.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "definitions": { + "id": { + "type": "string", + "pattern": "^[\\w]+$" + }, + "full_qualified_id": { + "type": "string", + "pattern": "^[\\w\\.]+$" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "arch_type": { + "type": "string", + "enum": ["controller", "headless"] + }, + "route": { + "type": "string" + } + } +} diff --git a/packages/b2c-tooling-sdk/data/content-schemas/componentconstructor.json b/packages/b2c-tooling-sdk/data/content-schemas/componentconstructor.json new file mode 100644 index 00000000..0282f8b8 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/componentconstructor.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object", + + "definitions": { + "region_component_constructor": { + "type": "object", + "properties": { + "region_id": {"$ref":"common.json#/definitions/id"}, + "component_constructors": { + "type": "array", + "items": {"$ref": "componentconstructor.json"} + } + }, + "required": ["region_id", "component_constructors"], + "additionalProperties": false + } + }, + + "properties": { + "type_id": {"$ref":"common.json#/definitions/full_qualified_id"}, + "name": {"$ref":"common.json#/definitions/name"}, + "region_component_constructors": { + "type": "array", + "items": { "$ref": "#/definitions/region_component_constructor" } + } + }, + "required": ["type_id"], + "additionalProperties": false +} diff --git a/packages/b2c-tooling-sdk/data/content-schemas/componenttype.json b/packages/b2c-tooling-sdk/data/content-schemas/componenttype.json new file mode 100644 index 00000000..d80406c3 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/componenttype.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object", + + "properties": { + "name": {"$ref":"common.json#/definitions/name"}, + "description": {"$ref":"common.json#/definitions/description"}, + "arch_type": { + "$ref": "common.json#/definitions/arch_type" + }, + "group": {"$ref":"common.json#/definitions/id"}, + "region_definitions": { + "type": "array", + "items": {"$ref": "regiondefinition.json"} + }, + "attribute_definition_groups": { + "type": "array", + "items": {"$ref": "attributedefinitiongroup.json"} + } + }, + "required": ["region_definitions","attribute_definition_groups", "group"], + "additionalProperties": false +} diff --git a/packages/b2c-tooling-sdk/data/content-schemas/componenttypeexclusion.json b/packages/b2c-tooling-sdk/data/content-schemas/componenttypeexclusion.json new file mode 100644 index 00000000..305399b1 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/componenttypeexclusion.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object", + + "properties": { + "type_id": {"$ref":"common.json#/definitions/full_qualified_id"} + }, + "required": ["type_id"], + "additionalProperties": false +} diff --git a/packages/b2c-tooling-sdk/data/content-schemas/componenttypeinclusion.json b/packages/b2c-tooling-sdk/data/content-schemas/componenttypeinclusion.json new file mode 100644 index 00000000..305399b1 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/componenttypeinclusion.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object", + + "properties": { + "type_id": {"$ref":"common.json#/definitions/full_qualified_id"} + }, + "required": ["type_id"], + "additionalProperties": false +} diff --git a/packages/b2c-tooling-sdk/data/content-schemas/contentassetcomponentconfig.json b/packages/b2c-tooling-sdk/data/content-schemas/contentassetcomponentconfig.json new file mode 100644 index 00000000..0b89e502 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/contentassetcomponentconfig.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object", + + "properties": { + "visibility":{ + "type":"array", + "maxItems":1, + "items":{"$ref":"visibilityrule.json"} + }, + "data_binding": { + "type": "object", + "properties": { + "expressions": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "context": { + "type": "array", + "maxItems": 1, + "items": { + "$ref": "databindingcontext.json" + } + } + } + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/packages/b2c-tooling-sdk/data/content-schemas/contentassetpageconfig.json b/packages/b2c-tooling-sdk/data/content-schemas/contentassetpageconfig.json new file mode 100644 index 00000000..f3111554 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/contentassetpageconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object", + + "properties": { + "visibility":{ + "type":"array", + "maxItems":1, + "items":{"$ref":"visibilityrule.json"} + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/packages/b2c-tooling-sdk/data/content-schemas/contentassetstructuredcontentdata.json b/packages/b2c-tooling-sdk/data/content-schemas/contentassetstructuredcontentdata.json new file mode 100644 index 00000000..2ecc5890 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/contentassetstructuredcontentdata.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object" +} \ No newline at end of file diff --git a/packages/b2c-tooling-sdk/data/content-schemas/customeditortype.json b/packages/b2c-tooling-sdk/data/content-schemas/customeditortype.json new file mode 100644 index 00000000..6dc1b480 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/customeditortype.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object", + + "definitions": { + "resources": { + "type": "array", + "items": { + "type": "string" + } + } + }, + + "properties": { + "name": {"$ref": "common.json#/definitions/name"}, + "description": {"$ref": "common.json#/definitions/description"}, + "resources": { + "type": "object", + "properties": { + "styles": {"$ref":"#/definitions/resources"}, + "scripts": {"$ref":"#/definitions/resources"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/packages/b2c-tooling-sdk/data/content-schemas/databindingcontext.json b/packages/b2c-tooling-sdk/data/content-schemas/databindingcontext.json new file mode 100644 index 00000000..7ddab1fb --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/databindingcontext.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[\\w\\-~:\\[\\]@!'*,;=]+$" + }, + "type": { + "type": "string", + "enum": [ + "content_asset" + ] + } + }, + "additionalProperties": false +} diff --git a/packages/b2c-tooling-sdk/data/content-schemas/editordefinition.json b/packages/b2c-tooling-sdk/data/content-schemas/editordefinition.json new file mode 100644 index 00000000..465d4bf2 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/editordefinition.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "definitions": { + "custom": { + "type": "object", + "properties": { + "type": {"$ref":"common.json#/definitions/full_qualified_id"}, + "configuration": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": false + }, + "cms_record": { + "type": "object", + "properties": { + "content_type": { + "type": "string" + } + }, + "required": ["content_type"], + "additionalProperties": false + } + } +} diff --git a/packages/b2c-tooling-sdk/data/content-schemas/image.json b/packages/b2c-tooling-sdk/data/content-schemas/image.json new file mode 100644 index 00000000..bd086753 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/image.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object", + + "definitions": { + "fraction" : { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "focal_point": { + "type": "object", + "properties": { + "x" : { "$ref": "#/definitions/fraction" }, + "y" : { "$ref": "#/definitions/fraction" } + }, + "required": ["x","y"] + }, + "meta_data": { + "type": "object", + "properties": { + "height" : { "type": "integer" }, + "width" : { "type": "integer" } + }, + "required": ["height","width"] + } + }, + + "properties": { + "path": {"type": "string"}, + "focal_point": {"$ref":"#/definitions/focal_point"}, + "meta_data": {"$ref":"#/definitions/meta_data"} + }, + "required": ["path"], + "additionalProperties": false +} \ No newline at end of file diff --git a/packages/b2c-tooling-sdk/data/content-schemas/pagetype.json b/packages/b2c-tooling-sdk/data/content-schemas/pagetype.json new file mode 100644 index 00000000..07271a04 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/pagetype.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object", + + "properties": { + "name": { + "$ref": "common.json#/definitions/name" + }, + "description": { + "$ref": "common.json#/definitions/description" + }, + "arch_type": { + "$ref": "common.json#/definitions/arch_type" + }, + "route": { + "$ref": "common.json#/definitions/route" + }, + "region_definitions": { + "type": "array", + "items": {"$ref": "regiondefinition.json"} + }, + "supported_aspect_types": { + "type": "array", + "items": {"$ref":"common.json#/definitions/full_qualified_id"} + }, + "attribute_definition_groups": { + "type": "array", + "items": { + "$ref": "attributedefinitiongroup.json" + } + } + }, + "required": [ + "region_definitions" + ], + "additionalProperties": false +} diff --git a/packages/b2c-tooling-sdk/data/content-schemas/regiondefinition.json b/packages/b2c-tooling-sdk/data/content-schemas/regiondefinition.json new file mode 100644 index 00000000..a3e9ff45 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/regiondefinition.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object", + + "properties": { + "id": {"$ref":"common.json#/definitions/id"}, + "name": {"$ref":"common.json#/definitions/name"}, + "max_components": { + "type": "integer", + "minimum": 1 + }, + "component_type_exclusions": { + "type": "array", + "items": {"$ref": "componenttypeexclusion.json"} + }, + "component_type_inclusions": { + "type": "array", + "items": {"$ref": "componenttypeinclusion.json"} + }, + "default_component_constructors": { + "type": "array", + "items": {"$ref": "componentconstructor.json"} + } + }, + "required": ["id"], + "additionalProperties": false, + "anyOf": [ + {"required": ["component_type_exclusions"], "not": {"required": ["component_type_inclusions"]}}, + {"required": ["component_type_inclusions"], "not": {"required": ["component_type_exclusions"]}}, + {"not": {"required": ["component_type_exclusions", "component_type_inclusions"]}} + ] +} \ No newline at end of file diff --git a/packages/b2c-tooling-sdk/data/content-schemas/visibilityrule.json b/packages/b2c-tooling-sdk/data/content-schemas/visibilityrule.json new file mode 100644 index 00000000..fc192be9 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/content-schemas/visibilityrule.json @@ -0,0 +1,70 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type":"object", + + "definitions": { + "time_period": { + "type": "object", + "properties": { + "valid_from": { + "format": "date-time" + }, + "valid_to": { + "format": "date-time" + } + }, + "additionalProperties": false + }, + "customer_group_id_listing": { + "type": "array", + "items": { + "type": "string" + } + }, + "locale_id_listing": { + "type": "array", + "items": { + "type": "string" + } + }, + "campaign_qualifier": { + "type": "object", + "properties": { + "campaign_id": { + "type": "string" + }, + "promotion_id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "aspect_attribute_qualifiers": { + "type": "array", + "items": { "$ref": "#/definitions/aspect_attribute_qualifier" } + }, + "aspect_attribute_qualifier": { + "type": "object", + "properties": { + "id": {"$ref":"common.json#/definitions/id"}, + "values": { + "type": "array", + "maxItems": 20, + "items": {} + } + }, + "required": ["id", "values"], + "additionalProperties": false + } + }, + + "properties": { + "schedule": {"$ref": "#/definitions/time_period"}, + "customer_group_ids": {"$ref": "#/definitions/customer_group_id_listing"}, + "locale_ids": {"$ref": "#/definitions/locale_id_listing"}, + "campaign_qualifier": {"$ref": "#/definitions/campaign_qualifier"}, + "aspect_attribute_qualifiers" : {"$ref": "#/definitions/aspect_attribute_qualifiers"} + }, + "additionalProperties": false +} diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index b942f145..75ee88aa 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -391,6 +391,7 @@ "fuse.js": "7.1.0", "glob": "catalog:", "i18next": "25.7.4", + "jsonschema": "catalog:", "jszip": "3.10.1", "minimatch": "10.1.1", "open": "catalog:", diff --git a/packages/b2c-tooling-sdk/src/operations/content/index.ts b/packages/b2c-tooling-sdk/src/operations/content/index.ts index 6b17daa9..96d53a50 100644 --- a/packages/b2c-tooling-sdk/src/operations/content/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/content/index.ts @@ -6,8 +6,8 @@ /** * Content library operations for B2C Commerce. * - * This module provides functions for exporting and manipulating - * Page Designer content libraries including pages, components, and assets. + * This module provides functions for exporting, manipulating, and validating + * Page Designer content libraries and metadefinitions. * * ## Core Classes * @@ -19,6 +19,12 @@ * - {@link fetchContentLibrary} - Fetch and parse a content library * - {@link exportContent} - Export filtered pages with assets to disk * + * ## Validation + * + * - {@link validateMetaDefinition} - Validate a parsed JSON object against a metadefinition schema + * - {@link validateMetaDefinitionFile} - Validate a JSON file against a metadefinition schema + * - {@link detectMetaDefinitionType} - Auto-detect the schema type from file path or data + * * ## Utilities * * - {@link extractAssetPaths} - Extract asset paths from component JSON data @@ -30,6 +36,7 @@ * Library, * fetchContentLibrary, * exportContent, + * validateMetaDefinitionFile, * } from '@salesforce/b2c-tooling-sdk/operations/content'; * * // Fetch and filter a library @@ -38,6 +45,10 @@ * * // Or use the high-level export * const result = await exportContent(instance, ['homepage'], 'SharedLibrary', './export'); + * + * // Validate a metadefinition file + * const validation = validateMetaDefinitionFile('experience/pages/home.json'); + * console.log(validation.valid, validation.errors); * ``` * * @module operations/content @@ -62,3 +73,20 @@ export type { TraverseOptions, TreeStringOptions, } from './types.js'; + +export { + CONTENT_SCHEMA_TYPES, + MetaDefinitionDetectionError, + detectMetaDefinitionType, + detectTypeFromData, + detectTypeFromPath, + validateMetaDefinition, + validateMetaDefinitionFile, +} from './validate.js'; + +export type { + ContentSchemaType, + MetaDefinitionValidationError, + MetaDefinitionValidationResult, + ValidateMetaDefinitionOptions, +} from './validate.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/content/validate.ts b/packages/b2c-tooling-sdk/src/operations/content/validate.ts new file mode 100644 index 00000000..c8eb54bd --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/content/validate.ts @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +import {Validator, type ValidatorResult, type Schema} from 'jsonschema'; + +// Resolve the content-schemas data directory from the package root +const require = createRequire(import.meta.url); +const packageRoot = path.dirname(require.resolve('@salesforce/b2c-tooling-sdk/package.json')); +export const CONTENT_SCHEMAS_DIR = path.join(packageRoot, 'data/content-schemas'); + +/** Top-level schema types that users validate files against. */ +export const CONTENT_SCHEMA_TYPES = [ + 'pagetype', + 'componenttype', + 'aspecttype', + 'cmsrecord', + 'customeditortype', + 'contentassetpageconfig', + 'contentassetcomponentconfig', + 'contentassetstructuredcontentdata', + 'image', +] as const; + +export type ContentSchemaType = (typeof CONTENT_SCHEMA_TYPES)[number]; + +export interface MetaDefinitionValidationError { + /** JSON pointer path to the error (e.g., "/region_definitions/0/id") */ + path: string; + /** Human-readable error message */ + message: string; + /** The name of the failing property or keyword */ + property: string; +} + +export interface MetaDefinitionValidationResult { + /** Whether the data is valid against the schema */ + valid: boolean; + /** Detected or specified schema type */ + schemaType: ContentSchemaType; + /** Validation errors (empty if valid) */ + errors: MetaDefinitionValidationError[]; + /** Source file path (if validated from file) */ + filePath?: string; +} + +export interface ValidateMetaDefinitionOptions { + /** Explicit schema type (overrides auto-detection) */ + type?: ContentSchemaType; +} + +/** + * Error thrown when the schema type cannot be determined from the file path or data. + */ +export class MetaDefinitionDetectionError extends Error { + constructor(message?: string) { + super( + message ?? + `Unable to detect meta definition type. Use --type to specify one of: ${CONTENT_SCHEMA_TYPES.join(', ')}`, + ); + this.name = 'MetaDefinitionDetectionError'; + } +} + +// Lazy-loaded validator singleton +let validatorInstance: Validator | null = null; + +function getValidator(): Validator { + if (validatorInstance) return validatorInstance; + + validatorInstance = new Validator(); + + // Load all schemas so $ref resolution works across files + for (const file of fs.readdirSync(CONTENT_SCHEMAS_DIR)) { + if (file.endsWith('.json')) { + const schemaPath = path.join(CONTENT_SCHEMAS_DIR, file); + const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8')) as Schema; + // Register with / URI so relative $ref like "common.json#/definitions/name" resolves + // (jsonschema resolves relative refs against the parent schema's URI) + validatorInstance.addSchema(schema, `/${file}`); + } + } + + return validatorInstance; +} + +/** + * Detect the metadefinition schema type from a file path using Page Designer conventions. + * + * - `experience/pages/` → `pagetype` + * - `experience/components/` → `componenttype` + */ +export function detectTypeFromPath(filePath: string): ContentSchemaType | null { + const normalized = filePath.replace(/\\/g, '/'); + if (/experience\/pages\//.test(normalized)) return 'pagetype'; + if (/experience\/components\//.test(normalized)) return 'componenttype'; + return null; +} + +/** + * Detect the metadefinition schema type from the data's top-level properties. + */ +export function detectTypeFromData(data: Record): ContentSchemaType | null { + const keys = new Set(Object.keys(data)); + + if (keys.has('region_definitions')) { + return keys.has('group') ? 'componenttype' : 'pagetype'; + } + if (keys.has('supported_object_types')) return 'aspecttype'; + if (keys.has('path')) return 'image'; + if (keys.has('resources')) return 'customeditortype'; + if (keys.has('data_binding')) return 'contentassetcomponentconfig'; + if (keys.has('visibility') && !keys.has('data_binding')) return 'contentassetpageconfig'; + if (keys.has('attributes') && keys.has('type')) return 'cmsrecord'; + return null; +} + +/** + * Detect the metadefinition schema type using a 3-tier strategy: + * 1. File path convention (if filePath provided) + * 2. Property heuristics from data + * 3. Returns null if neither works (caller should use explicit --type or throw) + */ +export function detectMetaDefinitionType(data: Record, filePath?: string): ContentSchemaType | null { + if (filePath) { + const fromPath = detectTypeFromPath(filePath); + if (fromPath) return fromPath; + } + return detectTypeFromData(data); +} + +function mapErrors(result: ValidatorResult): MetaDefinitionValidationError[] { + return result.errors.map((err) => ({ + path: err.property.replace(/^instance/, '') || '/', + message: err.message, + property: err.property, + })); +} + +/** + * Validate a parsed JSON object against a content metadefinition schema. + * + * @throws {MetaDefinitionDetectionError} When the schema type cannot be detected and no explicit type is provided. + */ +export function validateMetaDefinition( + data: Record, + options?: ValidateMetaDefinitionOptions, +): MetaDefinitionValidationResult { + const schemaType = options?.type ?? detectMetaDefinitionType(data); + + if (!schemaType) { + throw new MetaDefinitionDetectionError(); + } + + const validator = getValidator(); + const result = validator.validate(data, {$ref: `/${schemaType}.json`}); + + return { + valid: result.valid, + schemaType, + errors: mapErrors(result), + }; +} + +/** + * Validate a JSON metadefinition file against a content schema. + * + * Uses file path conventions for type detection before falling back to property heuristics. + * + * @throws {MetaDefinitionDetectionError} When the schema type cannot be detected and no explicit type is provided. + */ +export function validateMetaDefinitionFile( + filePath: string, + options?: ValidateMetaDefinitionOptions, +): MetaDefinitionValidationResult { + const absolutePath = path.resolve(filePath); + const content = fs.readFileSync(absolutePath, 'utf-8'); + + let data: Record; + try { + data = JSON.parse(content) as Record; + } catch (err) { + return { + valid: false, + schemaType: options?.type ?? ('unknown' as ContentSchemaType), + errors: [ + { + path: '/', + message: `Invalid JSON: ${(err as Error).message}`, + property: 'instance', + }, + ], + filePath: absolutePath, + }; + } + + const schemaType = options?.type ?? detectMetaDefinitionType(data, absolutePath); + + if (!schemaType) { + throw new MetaDefinitionDetectionError(); + } + + const validator = getValidator(); + const result = validator.validate(data, {$ref: `${schemaType}.json`}); + + return { + valid: result.valid, + schemaType, + errors: mapErrors(result), + filePath: absolutePath, + }; +} diff --git a/packages/b2c-tooling-sdk/test/operations/content/validate.test.ts b/packages/b2c-tooling-sdk/test/operations/content/validate.test.ts new file mode 100644 index 00000000..aef7a5f2 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/content/validate.test.ts @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {expect} from 'chai'; +import { + CONTENT_SCHEMA_TYPES, + MetaDefinitionDetectionError, + detectMetaDefinitionType, + detectTypeFromData, + detectTypeFromPath, + validateMetaDefinition, + validateMetaDefinitionFile, +} from '@salesforce/b2c-tooling-sdk/operations/content'; + +function writeTempJson(data: unknown, filename = 'test.json'): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-test-')); + const filePath = path.join(dir, filename); + fs.writeFileSync(filePath, JSON.stringify(data), 'utf-8'); + return filePath; +} + +describe('content metadefinition validation', () => { + describe('detectTypeFromPath', () => { + it('detects pagetype from experience/pages/ path', () => { + expect(detectTypeFromPath('cartridge/experience/pages/storePage.json')).to.equal('pagetype'); + }); + + it('detects componenttype from experience/components/ path', () => { + expect(detectTypeFromPath('cartridge/experience/components/banner.json')).to.equal('componenttype'); + }); + + it('detects componenttype from nested component path', () => { + expect(detectTypeFromPath('cartridge/experience/components/assets/hero.json')).to.equal('componenttype'); + }); + + it('returns null for unknown path', () => { + expect(detectTypeFromPath('some/other/path.json')).to.equal(null); + }); + + it('handles Windows-style paths', () => { + expect(detectTypeFromPath('cartridge\\experience\\pages\\storePage.json')).to.equal('pagetype'); + }); + }); + + describe('detectTypeFromData', () => { + it('detects pagetype from region_definitions', () => { + expect(detectTypeFromData({region_definitions: []})).to.equal('pagetype'); + }); + + it('detects componenttype from region_definitions + group', () => { + expect(detectTypeFromData({region_definitions: [], group: 'mygroup', attribute_definition_groups: []})).to.equal( + 'componenttype', + ); + }); + + it('detects aspecttype from supported_object_types', () => { + expect(detectTypeFromData({supported_object_types: ['product']})).to.equal('aspecttype'); + }); + + it('detects image from path property', () => { + expect(detectTypeFromData({path: '/images/hero.jpg'})).to.equal('image'); + }); + + it('detects customeditortype from resources', () => { + expect(detectTypeFromData({resources: {}})).to.equal('customeditortype'); + }); + + it('detects contentassetcomponentconfig from data_binding', () => { + expect(detectTypeFromData({data_binding: {}, visibility: []})).to.equal('contentassetcomponentconfig'); + }); + + it('detects contentassetpageconfig from visibility alone', () => { + expect(detectTypeFromData({visibility: []})).to.equal('contentassetpageconfig'); + }); + + it('detects cmsrecord from attributes + type', () => { + expect(detectTypeFromData({attributes: {}, type: 'cms_content_type'})).to.equal('cmsrecord'); + }); + + it('returns null for empty object', () => { + expect(detectTypeFromData({})).to.equal(null); + }); + }); + + describe('detectMetaDefinitionType', () => { + it('prefers file path over data heuristics', () => { + // Data looks like pagetype, but path says componenttype + const data = {region_definitions: []}; + expect(detectMetaDefinitionType(data, 'cartridge/experience/components/foo.json')).to.equal('componenttype'); + }); + + it('falls back to data when path is unknown', () => { + expect(detectMetaDefinitionType({region_definitions: []}, 'some/path.json')).to.equal('pagetype'); + }); + + it('falls back to data when no path provided', () => { + expect(detectMetaDefinitionType({region_definitions: []})).to.equal('pagetype'); + }); + }); + + describe('validateMetaDefinition', () => { + it('validates a valid pagetype', () => { + const data = { + name: 'Home Page', + description: 'A landing page', + region_definitions: [ + { + id: 'hero', + name: 'Hero Section', + }, + ], + }; + const result = validateMetaDefinition(data, {type: 'pagetype'}); + expect(result.valid).to.equal(true); + expect(result.schemaType).to.equal('pagetype'); + expect(result.errors).to.have.lengthOf(0); + }); + + it('validates a valid componenttype', () => { + const data = { + name: 'Banner', + group: 'content', + region_definitions: [], + attribute_definition_groups: [ + { + id: 'main', + attribute_definitions: [ + { + id: 'heading', + name: 'Heading', + type: 'string', + }, + ], + }, + ], + }; + const result = validateMetaDefinition(data, {type: 'componenttype'}); + expect(result.valid).to.equal(true); + expect(result.schemaType).to.equal('componenttype'); + }); + + it('validates a valid aspecttype', () => { + const data = { + name: 'Color', + attribute_definitions: [ + { + id: 'color', + name: 'Color', + type: 'string', + }, + ], + supported_object_types: ['product'], + }; + const result = validateMetaDefinition(data, {type: 'aspecttype'}); + expect(result.valid).to.equal(true); + }); + + it('validates a valid image', () => { + const data = { + path: '/images/hero.jpg', + focal_point: {x: 0.5, y: 0.5}, + }; + const result = validateMetaDefinition(data, {type: 'image'}); + expect(result.valid).to.equal(true); + }); + + it('reports errors for invalid pagetype (missing region_definitions)', () => { + const data = {name: 'Bad Page'}; + const result = validateMetaDefinition(data, {type: 'pagetype'}); + expect(result.valid).to.equal(false); + expect(result.errors.length).to.be.greaterThan(0); + expect(result.errors.some((e) => e.message.includes('region_definitions'))).to.equal(true); + }); + + it('reports errors for additional properties', () => { + const data = { + region_definitions: [], + unknown_property: 'should fail', + }; + const result = validateMetaDefinition(data, {type: 'pagetype'}); + expect(result.valid).to.equal(false); + expect(result.errors.some((e) => e.message.includes('additional property'))).to.equal(true); + }); + + it('auto-detects schema type when not specified', () => { + const data = { + region_definitions: [{id: 'main', name: 'Main'}], + }; + const result = validateMetaDefinition(data); + expect(result.valid).to.equal(true); + expect(result.schemaType).to.equal('pagetype'); + }); + + it('validates $ref resolution across schemas', () => { + // arch_type is defined in common.json and referenced by pagetype.json + const data = { + arch_type: 'headless', + region_definitions: [{id: 'main', name: 'Main'}], + }; + const result = validateMetaDefinition(data, {type: 'pagetype'}); + expect(result.valid).to.equal(true); + }); + + it('reports error for invalid $ref value', () => { + const data = { + arch_type: 'invalid_value', + region_definitions: [{id: 'main', name: 'Main'}], + }; + const result = validateMetaDefinition(data, {type: 'pagetype'}); + expect(result.valid).to.equal(false); + }); + + it('throws MetaDefinitionDetectionError for undetectable data', () => { + expect(() => validateMetaDefinition({foo: 'bar'})).to.throw(MetaDefinitionDetectionError); + }); + }); + + describe('validateMetaDefinitionFile', () => { + it('validates a valid file', () => { + const filePath = writeTempJson({region_definitions: [{id: 'main', name: 'Main'}]}); + const result = validateMetaDefinitionFile(filePath, {type: 'pagetype'}); + expect(result.valid).to.equal(true); + expect(result.filePath).to.equal(path.resolve(filePath)); + }); + + it('returns parse error for invalid JSON', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-test-')); + const filePath = path.join(dir, 'bad.json'); + fs.writeFileSync(filePath, '{ invalid json }', 'utf-8'); + + const result = validateMetaDefinitionFile(filePath, {type: 'pagetype'}); + expect(result.valid).to.equal(false); + expect(result.errors[0].message).to.include('Invalid JSON'); + }); + + it('detects type from file path convention', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-test-')); + const pagesDir = path.join(dir, 'experience', 'pages'); + fs.mkdirSync(pagesDir, {recursive: true}); + const filePath = path.join(pagesDir, 'home.json'); + fs.writeFileSync(filePath, JSON.stringify({region_definitions: [{id: 'main', name: 'Main'}]}), 'utf-8'); + + const result = validateMetaDefinitionFile(filePath); + expect(result.valid).to.equal(true); + expect(result.schemaType).to.equal('pagetype'); + }); + + it('throws MetaDefinitionDetectionError for undetectable file', () => { + const filePath = writeTempJson({foo: 'bar'}); + expect(() => validateMetaDefinitionFile(filePath)).to.throw(MetaDefinitionDetectionError); + }); + }); + + describe('CONTENT_SCHEMA_TYPES', () => { + it('contains expected types', () => { + expect(CONTENT_SCHEMA_TYPES).to.include('pagetype'); + expect(CONTENT_SCHEMA_TYPES).to.include('componenttype'); + expect(CONTENT_SCHEMA_TYPES).to.include('aspecttype'); + expect(CONTENT_SCHEMA_TYPES).to.include('image'); + expect(CONTENT_SCHEMA_TYPES).to.have.lengthOf(9); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dd4526e..211bc596 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ catalogs: glob: specifier: 13.0.0 version: 13.0.0 + jsonschema: + specifier: 1.5.0 + version: 1.5.0 mocha: specifier: ^10 version: 10.8.2 @@ -422,6 +425,9 @@ importers: i18next: specifier: 25.7.4 version: 25.7.4(typescript@5.9.3) + jsonschema: + specifier: 'catalog:' + version: 1.5.0 jszip: specifier: 3.10.1 version: 3.10.1 @@ -5245,12 +5251,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} @@ -5817,6 +5823,9 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonschema@1.5.0: + resolution: {integrity: sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==} + jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -14551,6 +14560,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonschema@1.5.0: {} + jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7f7f8bba..77111e5d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ catalog: '@oclif/core': 4.8.0 cliui: 9.0.1 glob: 13.0.0 + jsonschema: 1.5.0 open: 11.0.0 # Dev dependencies (shared across packages) diff --git a/skills/b2c-cli/skills/b2c-content/SKILL.md b/skills/b2c-cli/skills/b2c-content/SKILL.md index 18cb273a..87021452 100644 --- a/skills/b2c-cli/skills/b2c-content/SKILL.md +++ b/skills/b2c-cli/skills/b2c-content/SKILL.md @@ -1,11 +1,11 @@ --- name: b2c-content -description: Export and list Page Designer pages from B2C Commerce content libraries. Always reference when using the CLI to export or list Page Designer content, discover page IDs, or work with content library assets. +description: Export, list, and validate Page Designer pages and metadefinitions from B2C Commerce content libraries. Always reference when using the CLI to export, list, or validate Page Designer content, discover page IDs, or work with content library assets. --- # B2C Content Skill -Use the `b2c` CLI to export and list Page Designer content from Salesforce B2C Commerce content libraries. +Use the `b2c` CLI to export, list, and validate Page Designer content from Salesforce B2C Commerce content libraries. > **Tip:** If `b2c` is not installed globally, use `npx @salesforce/b2c-cli` instead (e.g., `npx @salesforce/b2c-cli content export homepage`). @@ -104,6 +104,27 @@ b2c content export homepage b2c content list --type page ``` +### Validate Metadefinitions + +```bash +# validate a single metadefinition file +b2c content validate cartridge/experience/pages/storePage.json + +# validate all metadefinitions in a directory recursively +b2c content validate cartridge/experience/ + +# validate with a glob pattern +b2c content validate 'cartridge/experience/**/*.json' + +# explicitly specify the schema type +b2c content validate --type componenttype mycomponent.json + +# JSON output for CI/scripting +b2c content validate cartridge/experience/ --json +``` + +Schema types are auto-detected from file paths (`experience/pages/` → pagetype, `experience/components/` → componenttype) and from JSON content. Use `--type` to override. + ### More Commands See `b2c content --help` for a full list of available commands and options in the `content` topic.