#!/usr/bin/env php XML Actions > Convert Schema) to Relax NG with an XML parser * 2. remove the obsolete widgets * 3. add new widgets from components.json file generated by our documentation build (and enhanced with attributes inherited from Shoelace ) * 4. apply overwrites below * 5. output it again as new eTemplate2 Relax NG schema * 6. convert it again via PHPStorm (Tools > XML Actions > Convert Schema) to etemplate2.0.dtd referenced in our xet files * (until we figure out how to use RELAX NG direct) * * Open problems: * - components.json has no information about element hierarchy: not all elements are allowed be contained by an arbitrary element * - Relax NG can define attribute types, need to check how with match our internal types to the xet attributes * * @link https://en.wikipedia.org/wiki/RELAX_NG RELAX NG (REgular LAnguage for XML Next Generation) * @link https://relaxng.org/tutorial-20011203.html RELAX NG Tutorial * @link https://github.com/EGroupware/etemplate/blob/6767672516524444847207d50b21ba59ff7f1540/js/widget_browser.js old widget-browser dtd generation * @link https://www.jetbrains.com/help/phpstorm/validating-web-content-files.html JetBrains DTD, XML-Schema or RelaxNG support */ if (!file_exists($file=__DIR__."/etemplate2/_data/components.json")) { die("Missing '$file file!"); } if (!($components=json_decode(file_get_contents($file), true))) { die("Could not load '$file'!"); } if (!file_exists(($file = __DIR__."/etemplate2/etemplate2.rng"))) { die("Missing file '$file', you need to generate it from 'etemplate2.dtd' e.g. with PHPStorm!"); } $grammar = new SimpleXMLElement(file_get_contents($file)); $widgets_choice = getByName($grammar, 'Widgets')->choice; /** * Manually overwriting problems / errors in what we automatically generate * Use class-name (e.g. Et2Button) so all descends inherit the fix, use-tag to fix only specific widget. * @todo fix in TS sources */ $overwrites = [ // RE to remove no longer used legacy widgets not matching "et2-" '.remove' => '/^(button|dropdown_button|int|float|menu|select|taglist|tree|passwd|date|time|ajax_select|vfs-(select|path))/', '*' => [ // all widgets, DOM attributes are NOT reported '.attrs' => [ 'id' => 'string', // commented out with some reasoning in Et2Widget 'width' => 'string', 'height' => 'string', 'slot' => 'string', // would be nice, if we could list parent slots ... 'style' => 'string', 'span' => "'all' | '2' | '3' | '4'", // eT2 grid span ], ], 'Et2InputWidget' => [ '.attrs' => [ 'tabindex' => 'int', // not reported, probably because DOM attributeq ], ], 'et2-textbox' => [ '.children' => ['.quantity' => 'optional', 'et2-image'], ], 'et2-date' => [ '.attrs' => [ 'yearRange' => 'string', 'dataFormat' => 'string', ], ], 'et2-hbox' => [ '.children' => 'Widgets', ], 'et2-vbox' => [ '.children' => 'Widgets', ], 'et2-box' => [ '.children' => 'Widgets', ], 'Et2Box' => [ // inherited by et2-(v|h)box too '.attrs' => [ 'overflow' => 'string', // DOM attributes ], ], 'et2-tabbox' => [ '.children' => ['tabs','tabpanels'], // add legacy children tabs and tabpanels '.attrs' => [ 'cfDisabled' => 'boolean', // implemented on server-side 'cfTypeFilter' => 'string', 'cfPrivateTab' => 'boolean', 'cfPrepend' => 'string', 'cfExclude' => 'string', ], ], 'et2-tab' => null, // remove/skip, as we currently use legacy tabs and tabpanels 'et2-tab-panel' => null, 'et2-details' => [ '.children' => 'Widgets', ], 'et2-split' => [ '.children' => 'Widgets', ], 'et2-url-email' => [ '.attrs' => [ 'emailDisplay' => 'string', // can't see that anywhere in the code, but in addressbook.index.xet ], ], 'et2-nextmatch-header-custom' => [ '.attrs' => [ 'emptyLabel' => 'string', ], ], 'Et2Button' => [ '.attrs' => [ 'image' => 'string', 'noSubmit' => 'boolean', 'hideOnReadonly' => 'boolean', ], ], 'Et2ButtonIcon' => 'Et2Button', // no inheritance from Et2Button, but Et2ButtonMixin, which is not recognised 'Et2ButtonScroll' => 'Et2Button', 'Et2Select' => [ '.attrs' => [ 'rows' => 'int', 'tabindex' => 'int', 'allowFreeEntries' => 'boolean', ], ], 'et2-select' => [ '.children' => ['.quantity' => 'zeroOrMore', 'option'], ], 'et2-email' => [ '.attrs' => [ 'onTagClick' => 'function', 'multiple' => 'boolean', ], ], ]; /** * Fixes on the existing legacy DTD */ // make overlay the only allowed start element removeNode($grammar->start->choice); $grammar->start->addChild('ref')->addAttribute('name', 'overlay'); // fix legacy widgets: attribute-name => (array of) widgets $missing_legacy_attributes = [ 'app' => 'customfields-types', 'callback' => 'vfs-upload', 'class' => ['nextmatch','nextmatch-header', 'nextmatch-customfields', 'nextmatch-sortheader', 'customfields-types'], 'disabled' => 'nextmatch', 'exclude' => 'customfields', 'id' => [ '.optional' => false, 'nextmatch-header', 'nextmatch-sortheader', 'nextmatch-customfields', 'nextmatch', 'customfields-types', ], 'header_left' => 'nextmatch', 'header_right' => 'nextmatch', 'header_row' => 'nextmatch', 'label' => [ '.optional' => false, 'nextmatch-header', 'nextmatch-sortheader', ], 'maxWidth' => 'column', 'minWidth' => 'column', 'onchange' => 'customfields-types', 'onselect' => 'nextmatch', 'value' => 'option', 'readonly' => 'customfields-types', 'sortmode' => [ '.values' => ['ASC', 'DESC'], '.default' => 'ASC', 'nextmatch-sortheader', ], 'span' => ['nextmatch', 'nextmatch-header', 'nextmatch-customfields', 'nextmatch-sortheader', 'customfields-types'], 'statustext' => ['tab', 'customfields-types', 'option'], 'template' => ['.optional' => false, 'nextmatch'], 'tab' => 'customfields', ]; foreach($missing_legacy_attributes as $attribute => $widgets) { foreach((array)$widgets as $key => $widget) { if (!is_int($key)) continue; // .(values|default) // widget not found add it plus it's attribute-list if (!getByName($grammar, $widget)) { $widgets_choice->addChild('ref')->addAttribute('name', $widget); ($define = $grammar->addChild('define'))->addAttribute('name', $widget); ($element = $define->addChild('element'))->addAttribute('name', $widget); $element->addChild('ref')->addAttribute('name', 'attlist.'.$widget); $element->addChild('empty'); // no children allowed $grammar->addChild('define')->addAttribute('name', 'attlist.'.$widget); } // add (optional) attribute if (!is_array($widgets) || (!isset($widgets['.optional']) || $widgets['.optional'] === true)) { $attr = getByName($grammar, 'attlist.'.$widget)->addChild('optional') ->addChild('attribute'); } else { $attr = getByName($grammar, 'attlist.'.$widget)->addChild('attribute'); } $attr->addAttribute('name', $attribute); // add values and/or default if (is_array($widgets) && isset($widgets['.values'])) { $choice = $attr->addChild('choice'); foreach($widgets['.values'] as $value) { $choice->addChild('value', $value); } } if (is_array($widgets) && isset($widgets['.default'])) { $attr->addAttribute('a:defaultValue', $widgets['.default'], 'http://relaxng.org/ns/compatibility/annotations/1.0'); } } } // build a hashed version of all classes, members and attributes to e.g. find ancestors $classes = []; foreach($components as $component) { foreach (['members', 'attributes', 'properties'] as $collection) { foreach ($component[$collection] ?? [] as $key => $element) { if (!empty($element['name'])) { $component[$collection][$element['name']] = $element; } unset($component[$collection][$key]); } } $classes[$component['name']] = $component; } // iterate of custom-elements to define in the schema foreach($components as $component) { if (empty($component['tagName']) || preg_match('/_(ro|mobile)$/', $component['tagName']) || array_key_exists($component['tagName'], $overwrites) && !isset($overwrites[$component['tagName']])) { continue; } // add the element $define = $grammar->addChild('define'); $define->addAttribute('name', $component['tagName']); $element = $define->addChild('element'); $element->addAttribute('name', $component['tagName']); $attrs = $element->addChild('ref'); $attrs->addAttribute('name', 'attlist.'.$component['tagName']); // add to widgets $widgets_choice->addChild('ref')->addAttribute('name', $component['tagName']); // add the element-attributes $attrs = $grammar->addChild('define'); $attrs->addAttribute('name', 'attlist.'.$component['tagName']); $attrs->addAttribute('combine', 'interleave'); attributes($component, $attrs); // add or disallow children depending on overwrites (not available from the TS sources) // ToDo: this ignores the use in slots! if (empty($overwrites[$component['tagName']]['.children'])) { // don't allow children $element->addChild('empty'); } else { $children = (array)$overwrites[$component['tagName']]['.children']; $list = $element->addChild($children['.quantity'] ?? 'oneOrMore'); // zeroOrMore for e.g. empty boxes? unset($children['.quantity']); // add allowed children foreach($children as $child) { $list->addChild('ref')->addAttribute('name', $child); } } // remove corresponding legacy widget removeWidget(str_replace('et2-', '', $component['tagName'])); } $remove = []; foreach($widgets_choice->children() as $widget) { if (preg_match($overwrites['.remove'], $name=(string)$widget->attributes()['name'])) { $remove[] = $name; // removing direct disturbs the foreach! } } foreach($remove as $name) { removeWidget($name); } $dom = new DOMDocument("1.0"); $dom->preserveWhiteSpace = false; $dom->formatOutput = true; $dom->loadXML($grammar->asXML()); if (php_sapi_name() !== "cli") { header('Content-Type: application/xml; charset=utf-8'); } // add 1 to legacy widget boolean attributes echo preg_replace('# (\s+)true (\s+)false (\s+)#', "\n\$1false\n\$1true\n\$11\n\$3", // update the header preg_replace('##s', '', $dom->saveXML())); /** * Remove (legacy-)widget with given name from schema * * @param string $name * @return void */ function removeWidget(string $name) { global $grammar, $widgets_choice; if (removeByName($widgets_choice, $name)) { removeByName($grammar, $name); removeByName($grammar, 'attlist.'.$name); } } function removeByName(SimpleXMLElement $parent, string $name) : bool { foreach($parent->children() as $child) { if ((string)$child->attributes()['name'] === $name) { removeNode($child); return true; } } return false; } function removeNode(SimpleXMLElement $node) { $dom=dom_import_simplexml($node); $dom->parentNode->removeChild($dom); } function getByName(SimpleXMLElement $parent, string $name) : ?SimpleXMLElement { foreach($parent as $element) { if ((string)$element->attributes()['name'] === $name) { return $element; } } return null; } /** * Overwrite attributes in given element / class * @param array& $element * @param string|null $name overwrites to use e.g. "*", default use $element['name'] * @return void */ function overwriteAttributes(array& $element, string $name=null) { global $overwrites; if (!isset($name)) $name = $element['tagName']; // forward just to another widget, class or mixin if (isset($overwrites[$name]) && is_string($overwrites[$name])) { $name = $overwrites[$name]; } if (empty($overwrites[$name]['.attrs'])) { return; // nothing to do } foreach($overwrites[$name]['.attrs'] as $attr => $type) { if (isset($type)) { // only add it, if not already there if (!array_filter($element['attributes']??[], static function($attribute) use ($attr) { return isset($attribute) && $attribute['name'] === $attr; })) { $element['attributes'][] = ['name' => $attr, 'type' => ['text' => $type]]; } } // remove attribute set to NULL in overwrites elseif (isset($element['attributes'])) { $element['attributes'] = array_filter($element['attributes'], static function($attribute) use($attr) { return isset($attribute) && $attribute['name'] !== $attr; }); } } } /** * Generate attribute list for an element * * @param array $component class defining the element * @param SimpleXMLElement|null $attrs attribute list element: * @return string[]|void */ function attributes(array $component, ?SimpleXMLElement $attrs=null) { overwriteAttributes($component, '*'); overwriteAttributes($component); // also apply overwrites of own class, direct parent and mixins foreach(getAncestors($component) as $parent) { if ($parent && !empty($parent['name']) && preg_match('/^Et2/', $parent['name'])) // can also be Lit or Sl* { overwriteAttributes($component, $parent['name']); } } $attributes = array_filter($component['attributes'] ?? [], static function ($attr) { return ($attr['name'] ?? null) && $attr['name'][0] !== '_'; // ignore attributes with empty name or name starting with underscore }); usort($attributes, static function ($a, $b) { return strcasecmp($a['name'], $b['name']); }); if (!isset($attrs)) { return array_map(static function($attr) use ($component) { return $attr['name'].'('.($attr['type']['text']??'any'). (isset($attr['fieldName']) && isset($component['members'][$attr['fieldName']]['default']) ? ':'.$component['members'][$attr['fieldName']]['default'] : '').')'; }, $attributes); } foreach($attributes as $attr) { // todo: are all attributes optional, probably $optional = $attrs->addChild('optional'); $attribute = $optional->addChild('attribute'); $attribute->addAttribute('name', $attr['name']); if (isset($attr['fieldName']) && isset($component['members'][$attr['fieldName']]['default'])) { $default = $component['members'][$attr['fieldName']]['default']; if (in_array($default[0], ['"', "'"]) && $default[0] === substr($default, -1)) { $default = substr($default, 1, -1); } if ($default !== 'undefined') // do NOT add undefined, it's the default anyway { $attribute->addAttribute('a:defaultValue', $default, 'http://relaxng.org/ns/compatibility/annotations/1.0'); } } switch ($attr['type']['text'] ?? 'any') { case 'boolean': $choice = $attribute->addChild('choice'); $choice->addChild('value', 'false'); $choice->addChild('value', 'true'); $choice->addChild('value', '1'); // often used in our templates // not understood by DTD :( //$choice->addChild('text'); // as we allow "@" or "$cont[name]" break; case 'any': break; // todo: other types are understood by RELAX NG, but not by DTD default: // distinct values: 'a' | 'b' | 'c' if (isset($attr['type']['text']) && $attr['type']['text'][0] === "'" && substr($attr['type']['text'], -1) === "'") { $choice = $attribute->addChild('choice'); foreach(preg_split("/'\s*\|\s*'/", substr($attr['type']['text'], 1, -1)) as $part) { $choice->addChild('value', $part); } } break; } } } /** * Get ancestors: superclass(es) and mixins of the current class/element * * @param array|null $class * @return array[] of array with name attribute */ function getAncestors(?array $class=null) { if (!isset($class) || empty($class['name']) || !preg_match('/^Et2/', $class['name'])) { return []; } if (!isset($class['kind'])) { global $classes; $class = $classes[$class['name']] ?? null; } return $class ? array_filter([$class, ...getAncestors($class['superclass']??null), ...$class['mixins']??[]]) : []; }