├── phpstan.neon ├── ecs.php ├── src ├── config.php ├── helperFunctions.php └── schemagen.php ├── Dockerfile ├── composer.json ├── Makefile └── README.md /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 5 3 | paths: 4 | - src 5 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | paths([ 8 | __DIR__ . '/src', 9 | __FILE__, 10 | ]); 11 | $ecsConfig->parallel(); 12 | $ecsConfig->sets([SetList::CRAFT_CMS_4]); 13 | }; 14 | -------------------------------------------------------------------------------- /src/config.php: -------------------------------------------------------------------------------- 1 | isDir() ? rmdir($file) : unlink($file); 13 | } 14 | } 15 | } 16 | 17 | /** 18 | * Ensure a folder exists or throw an exception 19 | * 20 | * @param string $dir 21 | * @throws RuntimeException if unable to ensure that a directory exists 22 | */ 23 | function ensureDir(string $dir): void 24 | { 25 | if (!@mkdir($dir, 0777, true) && !is_dir($dir)) { 26 | throw new RuntimeException(sprintf('Directory "%s" was not created', $dir)); 27 | } 28 | } 29 | 30 | /** 31 | * Get the current schema release version from the GitHub API 32 | * 33 | * @param string $schemaReleases 34 | * @return string 35 | */ 36 | function getSchemaVersion(string $schemaReleases): string 37 | { 38 | $schemaRelease = 'latest'; 39 | // Per: https://stackoverflow.com/questions/37141315/file-get-contents-gets-403-from-api-github-com-every-time 40 | $opts = [ 41 | 'http' => [ 42 | 'method' => 'GET', 43 | 'header' => [ 44 | 'User-Agent: PHP', 45 | ], 46 | ], 47 | ]; 48 | $context = stream_context_create($opts); 49 | $data = file_get_contents($schemaReleases, false, $context); 50 | if ($data) { 51 | $data = json_decode($data); 52 | if (is_array($data)) { 53 | $schemaRelease = $data[0]->tag_name ?? 'latest'; 54 | } 55 | } 56 | 57 | return $schemaRelease; 58 | } 59 | 60 | /** 61 | * Given a schema name, generate a class name for the schema. 62 | * 63 | * @param string $schemaName 64 | * @return string 65 | */ 66 | function getSchemaClassName(string $schemaName): string 67 | { 68 | $invalidClassnames = ['class', 'false', 'true', 'float']; 69 | if (!preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $schemaName) || in_array(strtolower($schemaName), $invalidClassnames, true)) { 70 | return 'Schema' . $schemaName; 71 | } 72 | 73 | return $schemaName; 74 | } 75 | 76 | /** 77 | * Compile a field's data from property definition array. 78 | * 79 | * @param array $propertyDef 80 | * @return array{propertyDescription: string, propertyType: string, propertyTypesAsArray: array, propertyHandle: string} 81 | */ 82 | function compileFieldData(array $propertyDef): array 83 | { 84 | $propertyDescription = getTextValue($propertyDef['rdfs:comment']); 85 | 86 | $propertyTypes = empty($propertyDef['schema:rangeIncludes']['@id']) ? $propertyDef['schema:rangeIncludes'] : [$propertyDef['schema:rangeIncludes']]; 87 | $propertyHandle = getTextValue($propertyDef['rdfs:label']); 88 | 89 | $propertyTypesAsArray = []; 90 | $propertyPhpTypesAsArray = []; 91 | 92 | foreach ($propertyTypes as &$schemaType) { 93 | $schemaType = substr($schemaType['@id'], 7); 94 | switch ($schemaType) { 95 | case 'Text': 96 | case 'Url': 97 | $phpType = 'string'; 98 | break; 99 | case 'Integer': 100 | $phpType = 'int'; 101 | break; 102 | case 'Number': 103 | case 'Float': 104 | $phpType = 'float'; 105 | break; 106 | case 'Boolean': 107 | $phpType = 'bool'; 108 | break; 109 | default: 110 | $phpType = ''; 111 | break; 112 | } 113 | $schemaClassName = getSchemaClassName($schemaType);; 114 | $propertyTypesAsArray[] = 'array'; 115 | $propertyTypesAsArray[] = $schemaClassName; 116 | $propertyTypesAsArray[] = $schemaClassName . '[]'; 117 | $propertyPhpTypesAsArray[] = $phpType; 118 | } 119 | 120 | $propertyPhpTypesAsArray = array_merge( 121 | array_filter($propertyPhpTypesAsArray), 122 | $propertyTypesAsArray, 123 | ); 124 | 125 | $propertyType = implode('|', $propertyPhpTypesAsArray); 126 | 127 | return compact( 128 | 'propertyDescription', 129 | 'propertyType', 130 | 'propertyTypesAsArray', 131 | 'propertyHandle' 132 | ); 133 | } 134 | 135 | function printInterfaceFile(string $schemaName, string $schemaRelease): string 136 | { 137 | $schemaInterfaceName = $schemaName . 'Interface'; 138 | $schemaScope = getScope($schemaName); 139 | 140 | $file = createFileWithHeader(); 141 | $interface = $file->addInterface(MODEL_NAMESPACE . '\\' . $schemaInterfaceName); 142 | $interface->addComment("schema.org version: $schemaRelease") 143 | ->addComment("Interface for $schemaName.\n"); 144 | decorateWithPackageInfo($interface, $schemaScope); 145 | 146 | return (new Nette\PhpGenerator\PsrPrinter())->printFile($file); 147 | } 148 | 149 | /** 150 | * Make the trait. 151 | * 152 | * @param string $schemaName 153 | * @param array $properties 154 | * @return string 155 | */ 156 | function printTraitFile(string $schemaName, array $properties, string $schemaRelease): string 157 | { 158 | foreach ($properties as &$fieldDef) { 159 | $fieldDef = compileFieldData($fieldDef); 160 | $fieldDef['propertyDescription'] = wordwrap($fieldDef['propertyDescription']); 161 | } 162 | unset($fieldDef); 163 | 164 | $schemaScope = getScope($schemaName); 165 | $schemaTraitName = $schemaName . 'Trait'; 166 | 167 | $file = createFileWithHeader(); 168 | 169 | $trait = $file->addTrait(MODEL_NAMESPACE . '\\' . $schemaTraitName); 170 | $trait->addComment("schema.org version: $schemaRelease") 171 | ->addComment("Trait for $schemaName.\n"); 172 | 173 | decorateWithPackageInfo($trait, $schemaScope); 174 | 175 | foreach ($properties as $fieldDef) { 176 | $trait->addProperty($fieldDef['propertyHandle']) 177 | ->setPublic() 178 | ->addComment($fieldDef['propertyDescription'] . "\n") 179 | ->addComment("@var {$fieldDef['propertyType']}"); 180 | } 181 | 182 | return (new Nette\PhpGenerator\PsrPrinter())->printFile($file); 183 | } 184 | 185 | /** 186 | * Get the scope URL for a given schema name. 187 | * 188 | * @param string $schemaName 189 | * @return string 190 | */ 191 | function getScope(string $schemaName): string 192 | { 193 | return 'https://schema.org/' . $schemaName; 194 | } 195 | 196 | /** 197 | * Save a generated file. 198 | * 199 | * @param string $path 200 | * @param string $content 201 | */ 202 | function saveGeneratedFile(string $path, string $content): void 203 | { 204 | file_put_contents($path, $content); 205 | } 206 | 207 | /** 208 | * Get google fields based on an array of schemas (inherited and current) 209 | * 210 | * @param array $schemas 211 | * @return array{required: array, recommended: array} 212 | */ 213 | function getGoogleFields(array $schemas): array 214 | { 215 | $required = []; 216 | $recommended = []; 217 | 218 | foreach ($schemas as $schema) { 219 | switch ($schema) { 220 | case 'Thing': 221 | $required = array_merge($required, ['name', 'description']); 222 | $recommended = array_merge($recommended, ['url', 'image']); 223 | break; 224 | 225 | case 'Article': 226 | case 'NewsArticle': 227 | case 'BlogPosting': 228 | $required = array_merge($required, ['author', 'datePublished', 'headline', 'image', 'publisher']); 229 | $recommended = array_merge($recommended, ['dateModified', 'mainEntityOfPage']); 230 | break; 231 | 232 | case 'SocialMediaPosting': 233 | $required = array_merge($required, ['datePublished', 'headline', 'image']); 234 | break; 235 | 236 | case 'LiveBlogPosting': 237 | $recommended = array_merge($recommended, ['coverageEndTime', 'coverageStartTime']); 238 | break; 239 | } 240 | } 241 | 242 | return compact('required', 'recommended'); 243 | } 244 | 245 | function cleanArray(array $array): array 246 | { 247 | $array = array_unique($array); 248 | sort($array); 249 | 250 | return $array; 251 | } 252 | 253 | /** 254 | * If the field value is a localized node, just get the first value and be done with it. 255 | * 256 | * @param mixed $fieldValue 257 | * @param bool $removeBreaks 258 | * @return string 259 | */ 260 | function getTextValue(mixed $fieldValue, bool $removeBreaks = true): string 261 | { 262 | if (is_array($fieldValue)) { 263 | $fieldValue = $fieldValue['@value']; 264 | } 265 | $fieldValue = html_entity_decode($fieldValue); 266 | if ($removeBreaks) { 267 | $fieldValue = str_replace(['
', '\n', "\n"], ' ', $fieldValue); 268 | } 269 | 270 | return $fieldValue; 271 | } 272 | 273 | function loadAllAncestors(array &$ancestors, array $entityTree, string $className): void 274 | { 275 | foreach ($entityTree[$className] as $parentClassName) { 276 | $ancestors[] = $parentClassName; 277 | loadAllAncestors($ancestors, $entityTree, $parentClassName); 278 | } 279 | } 280 | 281 | /** 282 | * Wrap an array's values in single quotes. 283 | * 284 | * @param array $array 285 | * @return array 286 | */ 287 | function wrapValuesInSingleQuotes(array $array): array 288 | { 289 | array_walk($array, function(&$value) { 290 | $value = "'$value'"; 291 | }); 292 | 293 | return $array; 294 | } 295 | 296 | /** 297 | * @return PhpFile 298 | */ 299 | function createFileWithHeader(): PhpFile 300 | { 301 | $file = new Nette\PhpGenerator\PhpFile(); 302 | $file->addComment("SEOmatic plugin for Craft CMS\n") 303 | ->addComment("A turnkey SEO implementation for Craft CMS that is comprehensive, powerful, and flexible\n") 304 | ->addComment('@link https://nystudio107.com') 305 | ->addComment("@copyright Copyright (c) nystudio107"); 306 | return $file; 307 | } 308 | 309 | /** 310 | * @param ClassLike $classLike 311 | * @param string $schemaScope 312 | */ 313 | function decorateWithPackageInfo(ClassLike $classLike, string $schemaScope) 314 | { 315 | $classLike 316 | ->addComment('@author nystudio107') 317 | ->addComment('@package Seomatic') 318 | ->addComment("@see $schemaScope"); 319 | } 320 | -------------------------------------------------------------------------------- /src/schemagen.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | setName('Download Schema.org data and generate models') 25 | ->setVersion('1.0.0') 26 | ->addArgument('source', InputArgument::OPTIONAL, 'The data source URL or file location') 27 | ->addArgument('outputDir', InputArgument::OPTIONAL, 'The output directory') 28 | ->addOption('skipSuperseded', 's', InputOption::VALUE_OPTIONAL, 'Whether superseded entities should be skipped', false) 29 | ->addOption('craft-version', 'c', InputOption::VALUE_OPTIONAL, 'Craft version to generate the models for. Defaults to 3.', 3) 30 | ->setCode(function(InputInterface $input, OutputInterface $output) { 31 | $source = $input->getArgument('source') ?? SCHEMA_SOURCE; 32 | $outputDir = $input->getArgument('outputDir') ?? OUTPUT_DIR; 33 | $craftVersion = ($input->getOption('craft-version') ?? 3); 34 | 35 | // ensure output folders exist 36 | try { 37 | nukeDir($outputDir); 38 | ensureDir($outputDir); 39 | } catch (RuntimeException $exception) { 40 | $output->writeln("{$exception->getMessage()}"); 41 | return Command::FAILURE; 42 | } 43 | 44 | // Fetch the latest schema release name 45 | $schemaReleases = SCHEMA_RELEASES; 46 | $output->writeln("Fetching schema releases data - $schemaReleases"); 47 | $schemaRelease = getSchemaVersion($schemaReleases); 48 | 49 | // Fetch the tree.jsonld ref: https://schema.org/docs/developers.html 50 | $schemaTree = SCHEMA_TREE; 51 | $treeDest = dirname($outputDir) . '/' . TREE_FILE_NAME; 52 | $output->writeln("Fetching schema tree - $schemaTree"); 53 | $tree = file_get_contents($schemaTree); 54 | if ($tree) { 55 | file_put_contents($treeDest, $tree); 56 | } 57 | 58 | $output->writeln("Fetching data source - $source"); 59 | $data = file_get_contents($source); 60 | 61 | $encoders = [new XmlEncoder(), new JsonEncoder()]; 62 | $normalizers = [new ObjectNormalizer()]; 63 | 64 | $serializer = new Serializer($normalizers, $encoders); 65 | $json = $serializer->decode($data, 'json'); 66 | 67 | if (!is_array($json) || !array_key_exists('@graph', $json)) { 68 | $output->writeln('Unrecognized data structure'); 69 | return Command::FAILURE; 70 | } 71 | 72 | $properties = []; 73 | $classes = []; 74 | $enums = []; 75 | 76 | $output->writeln('Parsing the received data ...'); 77 | 78 | // Parse the JSON into entity groups 79 | foreach ($json['@graph'] as $entity) { 80 | if (!empty($entity['schema:supersededBy'])) { 81 | $output->write("A deprecated entity encountered - {$entity['@id']}"); 82 | 83 | // Skip entities if required 84 | if ($input->getOption('skipSuperseded')) { 85 | $output->writeln(" ... skipping"); 86 | continue; 87 | } 88 | $output->writeln(''); 89 | } 90 | 91 | $id = $entity['@id']; 92 | $type = $entity['@type']; 93 | 94 | $types = (array)$type; 95 | 96 | // Some entities are enums AND classes 97 | foreach ($types as $type) { 98 | switch ($type) { 99 | case 'rdf:Property': 100 | if (empty($entity['schema:domainIncludes'])) { 101 | break; 102 | } 103 | 104 | // Normalize to an array 105 | if (is_array($entity['schema:domainIncludes']) && !is_array(reset($entity['schema:domainIncludes']))) { 106 | $entity['schema:domainIncludes'] = [$entity['schema:domainIncludes']]; 107 | } 108 | 109 | foreach ($entity['schema:domainIncludes'] ?? [] as $domainIncludes) { 110 | foreach ($domainIncludes as $key => $containingClass) { 111 | $properties[$containingClass][$id] = $entity; 112 | } 113 | } 114 | break; 115 | case 'rdfs:Class': 116 | $classes[$id] = $entity; 117 | break; 118 | default: 119 | if (substr($type, 0, 7) === 'schema:') { 120 | // Enums should be treated as classes, too 121 | $classes[$id] = $entity; 122 | } else { 123 | $output->writeln("Cannot handle type $type"); 124 | return Command::FAILURE; 125 | } 126 | } 127 | } 128 | } 129 | 130 | $output->writeln('Generating traits and building the hierarchy tree ...'); 131 | // First pass to generate traits and create hierarchy tree 132 | $entityTree = []; 133 | $propertiesBySchemaName = []; 134 | 135 | foreach ($classes as $id => $classDef) { 136 | if (str_starts_with($id, 'schema:')) { 137 | $schemaName = getTextValue($classDef['rdfs:label']); 138 | $schemaClass = getSchemaClassName($schemaName); 139 | $schemaTraitName = $schemaClass . 'Trait'; 140 | $schemaInterfaceName = $schemaClass . 'Interface'; 141 | $propertiesBySchemaName[$schemaName] = $properties[$id] ?? []; 142 | 143 | $trait = printTraitFile($schemaClass, $properties[$id] ?? [], $schemaRelease); 144 | saveGeneratedFile($outputDir . $schemaTraitName . '.php', $trait); 145 | $interface = printInterfaceFile($schemaClass, $schemaRelease); 146 | saveGeneratedFile($outputDir . $schemaInterfaceName . '.php', $interface); 147 | 148 | $entityTree[$schemaName] = []; 149 | 150 | if (!empty($classDef['rdfs:subClassOf'])) { 151 | // Ensure normalized form 152 | if (is_array($classDef['rdfs:subClassOf']) && !is_array(reset($classDef['rdfs:subClassOf']))) { 153 | $classDef['rdfs:subClassOf'] = [$classDef['rdfs:subClassOf']]; 154 | } 155 | 156 | // Build a list of parent entities 157 | foreach ($classDef['rdfs:subClassOf'] as $subclassDef) { 158 | // Interested only in schema types 159 | if (substr($subclassDef['@id'], 0, 7) === 'schema:') { 160 | $subClassOf = substr($subclassDef['@id'], 7); 161 | $entityTree[$schemaName][] = $subClassOf; 162 | } 163 | } 164 | } 165 | 166 | if (!empty($classDef['@type']) && is_string($classDef['@type']) && str_starts_with($classDef['@type'], 'schema:')) { 167 | $subClassOf = substr($classDef['@type'], 7); 168 | $entityTree[$schemaName][] = $subClassOf; 169 | } 170 | } 171 | } 172 | 173 | $output->writeln('Generating models and including traits ...'); 174 | 175 | // Loop again, now with all the traits generated and relations known. 176 | foreach ($classes as $id => $classDef) { 177 | if (str_starts_with($id, 'schema:')) { 178 | $schemaTraits = []; 179 | $schemaInterfaces = []; 180 | 181 | $schemaName = getTextValue($classDef['rdfs:label']); 182 | $schemaClass = getSchemaClassName($schemaName); 183 | $schemaDescriptionRaw = rtrim(getTextValue($classDef['rdfs:comment'], false), "\n"); 184 | $schemaDescription = wordwrap(getTextValue($classDef['rdfs:comment'])); 185 | $schemaScope = getScope($schemaName); 186 | 187 | // Add the schemaName itself as an ancestor so its properties, Trait, and Interface are included 188 | $ancestors = []; 189 | $ancestors[] = $schemaName; 190 | loadAllAncestors($ancestors, $entityTree, $schemaName); 191 | $ancestors = array_unique($ancestors); 192 | 193 | $schemaExtends = $ancestors[1] ?? 'Thing'; 194 | 195 | // Include all ancestor traits 196 | foreach ($ancestors as $ancestor) { 197 | $schemaTraits[] = getSchemaClassName($ancestor) . 'Trait'; 198 | $schemaInterfaces[] = getSchemaClassName($ancestor) . 'Interface'; 199 | } 200 | 201 | // Load google field information 202 | $googleFields = getGoogleFields(array_merge([$schemaName], $ancestors)); 203 | $required = wrapValuesInSingleQuotes(cleanArray($googleFields['required'])); 204 | $recommended = wrapValuesInSingleQuotes(cleanArray($googleFields['recommended'])); 205 | 206 | $googleRequiredSchemaAsArray = '[' . implode(", ", $required) . ']'; 207 | $googleRecommendedSchemaAsArray = '[' . implode(", ", $recommended) . ']'; 208 | 209 | $schemaPropertyTypes = []; 210 | $schemaPropertyDescriptions = []; 211 | 212 | $schemaPropertyExpectedTypesAsArray = "[\n"; 213 | $schemaPropertyDescriptionsAsArray = "[\n"; 214 | 215 | // Load property information 216 | $fieldsToParse = []; 217 | foreach ($ancestors as $ancestor) { 218 | $fields = $propertiesBySchemaName[$ancestor]; 219 | foreach ($fields as $fieldDef) { 220 | $handle = getTextValue($fieldDef['rdfs:label']); 221 | $fieldsToParse[$handle] = $fieldDef; 222 | } 223 | } 224 | 225 | ksort($fieldsToParse); 226 | 227 | foreach ($fieldsToParse as $fieldDef) { 228 | $fieldData = compileFieldData($fieldDef); 229 | $types = wrapValuesInSingleQuotes($fieldData['propertyTypesAsArray']); 230 | $description = str_replace("'", '\\\'', $fieldData['propertyDescription']); 231 | 232 | $schemaPropertyTypes[] = " '" . $fieldData['propertyHandle'] . "' => [" . implode(', ', $types) . "]"; 233 | $schemaPropertyDescriptions[] = " '" . $fieldData['propertyHandle'] . "' => '" . $description . "'"; 234 | } 235 | 236 | $schemaPropertyExpectedTypesAsArray .= implode(",\n", $schemaPropertyTypes) . "\n]"; 237 | $schemaPropertyDescriptionsAsArray .= implode(",\n", $schemaPropertyDescriptions) . "\n]"; 238 | 239 | $file = createFileWithHeader(); 240 | $file->addNamespace(MODEL_NAMESPACE) 241 | ->addUse(PARENT_MODEL); 242 | 243 | $class = $file->addClass(MODEL_NAMESPACE . '\\' . $schemaClass); 244 | $class->addComment("schema.org version: $schemaRelease") 245 | ->addComment("$schemaName - $schemaDescription\n"); 246 | decorateWithPackageInfo($class, $schemaScope); 247 | 248 | $class->setExtends(PARENT_MODEL); 249 | 250 | foreach ($schemaInterfaces as $schemaInterface) { 251 | $class->addImplement(MODEL_NAMESPACE . '\\' . $schemaInterface); 252 | } 253 | 254 | $properties = []; 255 | $properties[] = $class->addProperty('schemaTypeName', $schemaName) 256 | ->setStatic() 257 | ->setPublic() 258 | ->addComment("The Schema.org Type Name\n") 259 | ->addComment('@var string'); 260 | 261 | $properties[] = $class->addProperty('schemaTypeScope', $schemaScope) 262 | ->setStatic() 263 | ->setPublic() 264 | ->addComment("The Schema.org Type Scope\n") 265 | ->addComment('@var string'); 266 | 267 | $properties[] = $class->addProperty('schemaTypeExtends', $schemaExtends) 268 | ->setStatic() 269 | ->setPublic() 270 | ->addComment("The Schema.org Type Extends\n") 271 | ->addComment('@var string'); 272 | 273 | $properties[] = $class->addProperty('schemaTypeDescription', $schemaDescriptionRaw) 274 | ->setStatic() 275 | ->setPublic() 276 | ->addComment("The Schema.org Type Description\n") 277 | ->addComment('@var string'); 278 | 279 | if ($craftVersion !== 3) { 280 | foreach ($properties as $property) { 281 | $property->setType('string'); 282 | } 283 | } 284 | 285 | foreach ($schemaTraits as $schemaTrait) { 286 | $class->addTrait(MODEL_NAMESPACE . '\\' . $schemaTrait); 287 | } 288 | 289 | $class->addMethod('getSchemaPropertyNames') 290 | ->addComment('@inheritdoc') 291 | ->setPublic() 292 | ->setReturnType('array') 293 | ->setBody('return array_keys($this->getSchemaPropertyExpectedTypes());'); 294 | 295 | $class->addMethod('getSchemaPropertyExpectedTypes') 296 | ->addComment('@inheritdoc') 297 | ->setPublic() 298 | ->setReturnType('array') 299 | ->setBody("return $schemaPropertyExpectedTypesAsArray;"); 300 | 301 | $class->addMethod('getSchemaPropertyDescriptions') 302 | ->addComment('@inheritdoc') 303 | ->setPublic() 304 | ->setReturnType('array') 305 | ->setBody("return $schemaPropertyDescriptionsAsArray;"); 306 | 307 | $class->addMethod('getGoogleRequiredSchema') 308 | ->addComment('@inheritdoc') 309 | ->setPublic() 310 | ->setReturnType('array') 311 | ->setBody("return $googleRequiredSchemaAsArray;"); 312 | 313 | $class->addMethod('getGoogleRecommendedSchema') 314 | ->addComment('@inheritdoc') 315 | ->setPublic() 316 | ->setReturnType('array') 317 | ->setBody("return $googleRecommendedSchemaAsArray;"); 318 | 319 | $class->addMethod('defineRules') 320 | ->addComment('@inheritdoc') 321 | ->setPublic() 322 | ->setReturnType('array') 323 | ->setBody(<<<'METHOD' 324 | $rules = parent::defineRules(); 325 | $rules = array_merge($rules, [ 326 | [$this->getSchemaPropertyNames(), 'validateJsonSchema'], 327 | [$this->getGoogleRequiredSchema(), 'required', 'on' => ['google'], 'message' => 'This property is required by Google.'], 328 | [$this->getGoogleRecommendedSchema(), 'required', 'on' => ['google'], 'message' => 'This property is recommended by Google.'] 329 | ]); 330 | 331 | return $rules; 332 | METHOD 333 | ); 334 | 335 | $model = (new Printer())->printFile($file); 336 | 337 | saveGeneratedFile($outputDir . $schemaClass . '.php', $model); 338 | } 339 | } 340 | 341 | $output->writeln('All done!'); 342 | 343 | return Command::SUCCESS; 344 | }) 345 | ->run(); 346 | 347 | $application->run(); 348 | --------------------------------------------------------------------------------