Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions source/FluidXml/FluidContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,27 @@ public function xml($strip = false): string
return FluidHelper::domnodesToString($this->nodes);
}

public function toArray(): array
{
$result = [];

foreach ($this->nodes as $node) {
if ($node instanceof \DOMElement) {
$result[] = [$node->nodeName => FluidHelper::domNodeToArray($node)];
}
}

return $result;
}

public function toObject(): array
{
return \array_map(
fn($item) => FluidHelper::arrayToObject($item),
$this->toArray()
);
}

public function html($strip = false): string
{
return FluidHelper::domnodesToString($this->nodes, true);
Expand Down
82 changes: 82 additions & 0 deletions source/FluidXml/FluidHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,88 @@

class FluidHelper
{
public static function domNodeToArray(\DOMElement $node): mixed
{
$result = [];

if ($node->hasAttributes()) {
foreach ($node->attributes as $attr) {
$result['@' . $attr->name] = $attr->value;
}
}

// Capture namespace declarations introduced on this element (not inherited).
$xpath = new \DOMXPath($node->ownerDocument);
$parentNs = [];

if ($node->parentNode instanceof \DOMElement) {
foreach ($xpath->query('namespace::*', $node->parentNode) as $ns) {
$parentNs[$ns->nodeName] = $ns->nodeValue;
}
}

foreach ($xpath->query('namespace::*', $node) as $ns) {
if ($ns->localName === 'xml') {
continue;
}
if (! isset($parentNs[$ns->nodeName]) || $parentNs[$ns->nodeName] !== $ns->nodeValue) {
$result['@' . $ns->nodeName] = $ns->nodeValue;
}
}

$childElements = [];

foreach ($node->childNodes as $child) {
if ($child instanceof \DOMCDATASection) {
$result['@:cdata'] = $child->nodeValue;
} elseif ($child instanceof \DOMText) {
$text = \trim($child->nodeValue);
if ($text !== '') {
$result['@'] = ($result['@'] ?? '') . $text;
}
} elseif ($child instanceof \DOMComment) {
$result['@:comment'] = $child->nodeValue;
} elseif ($child instanceof \DOMElement) {
$childElements[] = $child;
}
}

$names = \array_map(fn($el) => $el->nodeName, $childElements);
$hasRepeat = \count($names) !== \count(\array_unique($names));

if ($hasRepeat) {
foreach ($childElements as $child) {
$result[] = [$child->nodeName => static::domNodeToArray($child)];
}
} else {
foreach ($childElements as $child) {
$result[$child->nodeName] = static::domNodeToArray($child);
}
}

// Simplify text-only element to a plain string
if (\array_keys($result) === ['@']) {
return $result['@'];
}

return empty($result) ? null : $result;
}

public static function arrayToObject(array $array): \stdClass
{
$obj = new \stdClass();

foreach ($array as $k => $v) {
$obj->$k = \is_array($v)
? (\array_is_list($v)
? \array_map(fn($i) => \is_array($i) ? static::arrayToObject($i) : $i, $v)
: static::arrayToObject($v))
: $v;
}

return $obj;
}

public static function isAnXmlString($string): bool
{
// Removes any empty new line at the beginning,
Expand Down
2 changes: 2 additions & 0 deletions source/FluidXml/FluidInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public function __toString();
public function xml($strip = false);
public function html($strip = false);
public function save($file, $strip = false);
public function toArray(): array;
public function toObject(): \stdClass|array;
public function query(...$query);
public function __invoke(...$query);
public function times($times, callable $fn = null);
Expand Down
16 changes: 16 additions & 0 deletions source/FluidXml/FluidXml.php
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,22 @@ public function xml($strip = false)
return $this->document->dom->saveXML();
}

public function toArray(): array
{
$root = $this->document->dom->documentElement;

if ($root === null) {
return [];
}

return [$root->nodeName => FluidHelper::domNodeToArray($root)];
}

public function toObject(): \stdClass
{
return FluidHelper::arrayToObject($this->toArray());
}

public function html($strip = false): string
{
$header = "<!DOCTYPE html>\n";
Expand Down
129 changes: 129 additions & 0 deletions specs/FluidXml.php
Original file line number Diff line number Diff line change
Expand Up @@ -2249,6 +2249,135 @@ function addchild($parent, $i)
});
});

describe('.toArray()', function () {
it('should export a simple element with text content', function () {
$xml = new FluidXml();
$xml->addChild(['type' => 'Herbivore']);

$actual = $xml->toArray();
$expected = ['doc' => ['type' => 'Herbivore']];
\assert($actual === $expected, __($actual, $expected));
});

it('should export attributes using the @ prefix', function () {
$xml = new FluidXml();
$xml->addChild(['animal' => ['@operation' => 'create', '@' => 'data']]);

$actual = $xml->toArray();
$expected = ['doc' => ['animal' => ['@operation' => 'create', '@' => 'data']]];
\assert($actual === $expected, __($actual, $expected));
});

it('should export nested elements as nested arrays', function () {
$xml = new FluidXml();
$xml->addChild(['animal' => [
'@operation' => 'create',
'type' => 'Herbivore',
'attribute' => ['legs' => '4', 'head' => '1'],
]]);

$actual = $xml->toArray();
$expected = ['doc' => [
'animal' => [
'@operation' => 'create',
'type' => 'Herbivore',
'attribute' => ['legs' => '4', 'head' => '1'],
],
]];
\assert($actual === $expected, __($actual, $expected));
});

it('should export repeated sibling elements as indexed arrays', function () {
$xml = new FluidXml();
$xml->addChild(['chapters' => [
['chapter' => 'One'],
['chapter' => 'Two'],
]]);

$actual = $xml->toArray();
$expected = ['doc' => [
'chapters' => [
['chapter' => 'One'],
['chapter' => 'Two'],
],
]];
\assert($actual === $expected, __($actual, $expected));
});

it('should export an empty element as null', function () {
$xml = new FluidXml();
$xml->addChild('empty');

$actual = $xml->toArray();
$expected = ['doc' => ['empty' => null]];
\assert($actual === $expected, __($actual, $expected));
});

it('should export queried nodes via FluidContext', function () {
$xml = new FluidXml();
$xml->addChild(['animal' => ['type' => 'Herbivore', 'legs' => '4']]);

$actual = $xml->query('//type')->toArray();
$expected = [['type' => 'Herbivore']];
\assert($actual === $expected, __($actual, $expected));
});

it('should export namespace declarations using the @xmlns prefix', function () {
$xml = new FluidXml('animal');
$xml->namespace(new FluidNamespace('foo', 'http://foo.com'));
$xml->addChild('foo:legs', '4');

$actual = $xml->toArray();
// The xmlns:foo declaration appears on foo:legs (where it is first used).
// Text content is under '@' when co-present with namespace metadata.
$expected = ['animal' => [
'foo:legs' => [
'@xmlns:foo' => 'http://foo.com',
'@' => '4',
],
]];
\assert($actual === $expected, __($actual, $expected));
});
});

describe('.toObject()', function () {
it('should return a stdClass mirroring toArray()', function () {
$xml = new FluidXml();
$xml->addChild(['animal' => ['@operation' => 'create', 'type' => 'Herbivore']]);

$obj = $xml->toObject();
\assert($obj instanceof \stdClass, __($obj, 'stdClass'));
\assert($obj->doc instanceof \stdClass, __($obj->doc, 'stdClass'));
\assert($obj->doc->animal instanceof \stdClass, __($obj->doc->animal, 'stdClass'));
\assert($obj->doc->animal->{'@operation'} === 'create', __($obj->doc->animal->{'@operation'}, 'create'));
\assert($obj->doc->animal->type === 'Herbivore', __($obj->doc->animal->type, 'Herbivore'));
});

it('should preserve indexed arrays for repeated siblings', function () {
$xml = new FluidXml();
$xml->addChild(['chapters' => [
['chapter' => 'One'],
['chapter' => 'Two'],
]]);

$obj = $xml->toObject();
\assert(\is_array($obj->doc->chapters), __($obj->doc->chapters, 'array'));
\assert($obj->doc->chapters[0] instanceof \stdClass, __($obj->doc->chapters[0], 'stdClass'));
\assert($obj->doc->chapters[0]->chapter === 'One', __($obj->doc->chapters[0]->chapter, 'One'));
});

it('should return an array of stdClass for FluidContext', function () {
$xml = new FluidXml();
$xml->addChild(['animal' => ['type' => 'Herbivore', 'legs' => '4']]);

$result = $xml->query('//animal')->toObject();
\assert(\is_array($result), __($result, 'array'));
\assert($result[0] instanceof \stdClass, __($result[0], 'stdClass'));
\assert($result[0]->animal instanceof \stdClass, __($result[0]->animal, 'stdClass'));
\assert($result[0]->animal->type === 'Herbivore', __($result[0]->animal->type, 'Herbivore'));
});
});

describe('.__toString()', function () {
it('should behave like .xml()', function () {
$xml = new FluidXml();
Expand Down