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