diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000..036f3ee
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,16 @@
+FROM ubuntu:22.04
+
+# prepare to install php 8.2
+RUN apt update && apt install -y software-properties-common
+RUN add-apt-repository ppa:ondrej/php
+RUN apt update
+
+# install php 8.2 and other fundamental packages
+RUN export DEBIAN_FRONTEND=noninteractive; apt install -y --no-install-recommends php8.2 php-curl git openssl unzip
+
+# install composer and its CA certificates
+COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
+COPY --from=composer:latest /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
+
+# install the PHP extensions that basically all PHP projects should need
+RUN export DEBIAN_FRONTEND=noninteractive; apt install -y php8.2-opcache php-xdebug php-mbstring php-zip php-gd php-xml
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..9a605e2
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,34 @@
+{
+ // build from dockerfile
+ "build": {
+ "dockerfile": "Dockerfile",
+ "context": ".."
+ },
+ // specify run arguments
+ "runArgs": [
+ "--dns=8.8.8.8" // for some reason DNS doesn't work right unless we explicitly name a DNS server
+ ],
+ // mount entire sites_v2 directory, so we can access global config and shared DB
+ "workspaceMount": "source=${localWorkspaceFolder},target=/workspace/${localWorkspaceFolderBasename},type=bind,consistency=cached",
+ "workspaceFolder": "/workspace/${localWorkspaceFolderBasename}",
+ // specify extensions that we want
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "DEVSENSE.intelli-php-vscode",
+ "DEVSENSE.phptools-vscode",
+ "DEVSENSE.profiler-php-vscode",
+ "DEVSENSE.composer-php-vscode",
+ "SanderRonde.phpstan-vscode",
+ "mrmlnc.vscode-scss",
+ "Gruntfuggly.todo-tree",
+ "ecmel.vscode-html-css",
+ "yzhang.markdown-all-in-one",
+ "DavidAnson.vscode-markdownlint",
+ "helixquar.randomeverything",
+ "neilbrayfield.php-docblocker",
+ "ms-vscode.test-adapter-converter"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
deleted file mode 100644
index fe461b4..0000000
--- a/.github/workflows/dependency-review.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-# Dependency Review Action
-#
-# This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging.
-#
-# Source repository: https://github.com/actions/dependency-review-action
-# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement
-name: 'Dependency Review'
-on: [pull_request]
-
-permissions:
- contents: read
-
-jobs:
- dependency-review:
- runs-on: ubuntu-latest
- steps:
- - name: 'Checkout Repository'
- uses: actions/checkout@v3
- - name: 'Dependency Review'
- uses: actions/dependency-review-action@v2
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index d97c609..d79847e 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -10,6 +10,8 @@ jobs:
dev: yes
- uses: php-actions/phpstan@v3
with:
+ path: src
+ level: max
memory_limit: 1G
args: --memory-limit 1G
- - uses: php-actions/phpunit@v3
\ No newline at end of file
+ - uses: php-actions/phpunit@v3
diff --git a/LICENSE b/LICENSE
index 6d682eb..0bbff52 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-MIT License
+# MIT License
Copyright (c) 2019 Joby Elliott
diff --git a/composer.json b/composer.json
index f5a1fbb..39b485b 100644
--- a/composer.json
+++ b/composer.json
@@ -27,12 +27,11 @@
},
"scripts": {
"test": "phpunit",
- "stan": "phpstan",
- "sniff": "phpcs"
+ "stan": "phpstan"
},
"require-dev": {
"phpstan/phpstan": "^1.9",
"phpunit/phpunit": "^9.5",
- "squizlabs/php_codesniffer": "^3.7"
+ "mustangostang/spyc": "^0.6.3"
}
-}
\ No newline at end of file
+}
diff --git a/phpcs.xml b/phpcs.xml
deleted file mode 100644
index 5064e75..0000000
--- a/phpcs.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
- Coding Standard
-
- src
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/AbstractParser.php b/src/AbstractParser.php
index 73eef23..28cea59 100644
--- a/src/AbstractParser.php
+++ b/src/AbstractParser.php
@@ -19,12 +19,34 @@ use DOMElement;
use DOMNode;
use DOMText;
+/**
+ * Extensions of this class to create new parsers are primarily meant to be
+ * controlled by adjusting the default properties below
+ */
abstract class AbstractParser
{
- /** @var array */
+ /**
+ * A list of namespaces in which to match tags. To match in this way the
+ * tag classes must implement TagInterface and be named following the
+ * convention of the tag name in CamelCase followed by "Tag" i.e. a class
+ * implementing a tag would need to be named MyTagTag.
+ *
+ * They are searched in order and the first match is used.
+ *
+ * @var array
+ */
protected $tag_namespaces = [];
- /** @var array> */
+ /**
+ * A list of defined tag classes, matching a tag string (in lower case) to
+ * a fully-qualified TagInterface-implementing class. This array is also
+ * used at runtime to cache the pairings found and verified from
+ * $tag_namespaces.
+ *
+ * They are searched in order and the first match is used.
+ *
+ * @var array>
+ */
protected $tag_classes = [];
/** @var class-string */
@@ -97,11 +119,13 @@ abstract class AbstractParser
} elseif ($node instanceof DOMComment) {
return new ($this->comment_class)($node->textContent);
} elseif ($node instanceof DOMText) {
- return new ($this->text_class)($node->textContent);
+ $content = trim($node->textContent);
+ if ($content) {
+ return new ($this->text_class)($content);
+ }
}
- // This line shouldn't be reached, but if it is it's philosophically
- // consistent to simply ignore unknown node types
- return null; // @codeCoverageIgnore
+ // It's philosophically consistent to simply ignore unknown node types
+ return null;
}
protected function convertNodeToTag(DOMElement $node): null|NodeInterface
@@ -112,7 +136,7 @@ abstract class AbstractParser
return null;
}
$tag = new $class();
- // tool for settin gup content tags
+ // tool for setting up content tags
if ($tag instanceof ContentTagInterface) {
$tag->setContent($node->textContent);
}
diff --git a/src/Helpers/Attributes.php b/src/Helpers/Attributes.php
index 405fbcd..17ce3d5 100644
--- a/src/Helpers/Attributes.php
+++ b/src/Helpers/Attributes.php
@@ -4,6 +4,7 @@ namespace ByJoby\HTML\Helpers;
use ArrayAccess;
use ArrayIterator;
+use BackedEnum;
use Exception;
use IteratorAggregate;
use Stringable;
@@ -12,12 +13,12 @@ use Traversable;
/**
* Holds and validates a set of HTML attribute name/value pairs for use in tags.
*
- * @implements ArrayAccess
- * @implements IteratorAggregate
+ * @implements ArrayAccess
+ * @implements IteratorAggregate
*/
class Attributes implements IteratorAggregate, ArrayAccess
{
- /** @var array */
+ /** @var array */
protected $array = [];
/** @var bool */
protected $sorted = true;
@@ -25,7 +26,7 @@ class Attributes implements IteratorAggregate, ArrayAccess
protected $disallowed = [];
/**
- * @param null|array $array
+ * @param null|array $array
* @param array $disallowed
* @return void
*/
@@ -67,16 +68,147 @@ class Attributes implements IteratorAggregate, ArrayAccess
$this->array[$offset] = $value;
}
- public function string(string $offset): null|string
+ /**
+ * Set a value as a stringable enum array, automatically converting from a single enum or normal array of enums.
+ *
+ * @template T of BackedEnum
+ * @param string $offset
+ * @param null|BackedEnum|StringableEnumArray|array $value
+ * @param class-string $enum_class
+ * @param string $separator
+ * @return static
+ */
+ public function setEnumArray(string $offset, null|BackedEnum|StringableEnumArray|array $value, string $enum_class, string $separator): static
+ {
+ if (is_null($value)) {
+ $value = [];
+ }
+ if ($value instanceof BackedEnum) {
+ $value = [$value];
+ }
+ if (is_array($value)) {
+ $value = new StringableEnumArray($value, ' ');
+ }
+ $this->offsetSet($offset, $value);
+ return $this;
+ }
+
+ /**
+ * Returns a given offset's value as an array of enums.
+ *
+ * @template T of BackedEnum
+ * @param string $offset
+ * @param class-string $enum_class
+ * @param non-empty-string $separator
+ * @return array
+ */
+ public function asEnumArray(string $offset, string $enum_class, string $separator): array
+ {
+ $value = strval($this->offsetGet($offset));
+ $value = explode($separator, $value);
+ $value = array_map(
+ $enum_class::tryFrom(...),
+ $value
+ );
+ $value = array_filter(
+ $value,
+ fn($e) => !empty($e)
+ );
+ return $value;
+ }
+
+ /**
+ * Returns a given offset's value as a string, if possible.
+ *
+ * @param string $offset
+ * @return null|string|Stringable
+ */
+ public function asString(string $offset): null|string|Stringable
{
$value = $this->offsetGet($offset);
- if (is_string($value)) {
+ if ($value instanceof Stringable || is_string($value)) {
return $value;
} else {
return null;
}
}
+ /**
+ * Returns a given offset's value as an integer, if possible.
+ *
+ * @param string $offset
+ * @return null|int
+ */
+ public function asInt(string $offset): null|int
+ {
+ $value = $this->asNumber($offset);
+ if (is_int($value)) {
+ return $value;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Returns a given offset's value as a float, if possible.
+ *
+ * @param string $offset
+ * @return null|float
+ */
+ public function asFloat(string $offset): null|float
+ {
+ $value = $this->asNumber($offset);
+ if (!is_null($value)) {
+ return floatval($value);
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Returns a given offset's value as a numeric type, if possible.
+ *
+ * @param string $offset
+ * @return null|number
+ */
+ public function asNumber(string $offset): null|int|float
+ {
+ $value = $this->offsetGet($offset);
+ if (is_numeric($value)) {
+ if (is_string($value)) {
+ if ($value == intval($value)) {
+ $value = intval($value);
+ } else {
+ $value = floatval($value);
+ }
+ }
+ return $value;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Return a given offset's value as an enum of the given class, if possible.
+ *
+ * @template T of BackedEnum
+ * @param string $offset
+ * @param class-string $enum_class
+ * @return null|T
+ */
+ public function asEnum(string $offset, string $enum_class): null|BackedEnum
+ {
+ $value = $this->offsetGet($offset);
+ if ($value instanceof Stringable) {
+ $value = $value->__toString();
+ }
+ if (is_string($value) || is_int($value)) {
+ return $enum_class::tryFrom($value);
+ } else {
+ return null;
+ }
+ }
+
public function offsetUnset(mixed $offset): void
{
$offset = static::sanitizeOffset($offset);
@@ -84,7 +216,7 @@ class Attributes implements IteratorAggregate, ArrayAccess
}
/**
- * @return array
+ * @return array
*/
public function getArray(): array
{
@@ -109,4 +241,4 @@ class Attributes implements IteratorAggregate, ArrayAccess
}
return $offset;
}
-}
+}
\ No newline at end of file
diff --git a/src/Helpers/StringableEnumArray.php b/src/Helpers/StringableEnumArray.php
new file mode 100644
index 0000000..9ef8931
--- /dev/null
+++ b/src/Helpers/StringableEnumArray.php
@@ -0,0 +1,49 @@
+
+ */
+class StringableEnumArray extends ArrayIterator implements Stringable
+{
+ /**
+ * @param array $array
+ */
+ public function __construct(
+ $array = [],
+ protected string $separator = ', '
+ ) {
+ parent::__construct($array);
+ }
+
+ public function __toString()
+ {
+ return implode(
+ $this->separator,
+ array_filter(
+ $this->stringValues(),
+ fn($e) => !empty($e)
+ )
+ );
+ }
+
+ /**
+ * @return array
+ */
+ protected function stringValues(): array
+ {
+ return array_map(
+ function ($e) {
+ if ($e instanceof BackedEnum) $e = $e->value;
+ return strval($e);
+ },
+ $this->getArrayCopy()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/Html5/ContentSectioningTags/AddressTag.php b/src/Html5/ContentSectioningTags/AddressTag.php
index ade6185..64a4d05 100644
--- a/src/Html5/ContentSectioningTags/AddressTag.php
+++ b/src/Html5/ContentSectioningTags/AddressTag.php
@@ -6,6 +6,20 @@ use ByJoby\HTML\ContentCategories\FlowContent;
use ByJoby\HTML\DisplayTypes\DisplayBlock;
use ByJoby\HTML\Tags\AbstractContainerTag;
+/**
+ * The HTML element indicates that the enclosed HTML provides contact
+ * information for a person or people, or for an organization.
+ *
+ * The contact information provided by an element's contents can take
+ * whatever form is appropriate for the context, and may include any type of
+ * contact information that is needed, such as a physical address, URL, email
+ * address, phone number, social media handle, geographic coordinates, and so
+ * forth. The element should include the name of the person, people,
+ * or organization to which the contact information refers.
+ *
+ * Tag description by Mozilla Contributors licensed under CC-BY-SA 2.5
+ * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/address
+ */
class AddressTag extends AbstractContainerTag implements DisplayBlock, FlowContent
{
const TAG = 'address';
diff --git a/src/Html5/ContentSectioningTags/ArticleTag.php b/src/Html5/ContentSectioningTags/ArticleTag.php
index 8f552d7..6eddf5f 100644
--- a/src/Html5/ContentSectioningTags/ArticleTag.php
+++ b/src/Html5/ContentSectioningTags/ArticleTag.php
@@ -7,6 +7,17 @@ use ByJoby\HTML\ContentCategories\SectioningContent;
use ByJoby\HTML\DisplayTypes\DisplayBlock;
use ByJoby\HTML\Tags\AbstractContainerTag;
+/**
+ * The HTML element represents a self-contained composition in a
+ * document, page, application, or site, which is intended to be independently
+ * distributable or reusable (e.g., in syndication). Examples include: a forum
+ * post, a magazine or newspaper article, or a blog entry, a product card, a
+ * user-submitted comment, an interactive widget or gadget, or any other
+ * independent item of content.
+ *
+ * Tag description by Mozilla Contributors licensed under CC-BY-SA 2.5
+ * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/article
+ */
class ArticleTag extends AbstractContainerTag implements DisplayBlock, FlowContent, SectioningContent
{
const TAG = 'article';
diff --git a/src/Html5/ContentSectioningTags/AsideTag.php b/src/Html5/ContentSectioningTags/AsideTag.php
index 5fced0a..9f0cd1b 100644
--- a/src/Html5/ContentSectioningTags/AsideTag.php
+++ b/src/Html5/ContentSectioningTags/AsideTag.php
@@ -7,6 +7,14 @@ use ByJoby\HTML\ContentCategories\SectioningContent;
use ByJoby\HTML\DisplayTypes\DisplayBlock;
use ByJoby\HTML\Tags\AbstractContainerTag;
+/**
+ * The