diff --git a/CHANGELOG.md b/CHANGELOG.md
index 478969c..face872 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,17 @@
All notable changes are documented here. The format is based on [Keep a Changelog][keep_a_changelog] and this project adheres to [Semantic Versioning][semantic_versioning].
+## [0.3.0](https://github.com/phalcon/quill/releases/tag/v0.3.0) (2026-08-03)
+
+### Added
+
+- The Markdown markup now lives in twenty template files under `resources/templates/markdown`, overridable one file at a time. Output is unchanged.
+- The optional `templates` configuration key, naming a directory whose templates are consulted before the shipped ones. A `.tpl` that no lookup can reach - a name outside the shipped set, or a file above the format directory - is reported with the nearest real name and ignored.
+
+### Changed
+
+- Rendering one class moved out of `MarkdownFormatter` into `Formatter\Markdown\ClassPage`.
+
## [0.2.3](https://github.com/phalcon/quill/releases/tag/v0.2.3) (2026-08-03)
### Fixed
diff --git a/README.md b/README.md
index 41de567..2eac8bb 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,7 @@ return [
'prefix' => 'phalcon',
'extension' => 'zep',
'namespace' => 'Phalcon',
+ 'templates' => 'output/docs/templates',
];
```
@@ -87,11 +88,47 @@ return [
| `repository`, `branch`, `prefix` | build the "Source on GitHub" link: `https://github.com//blob///` |
| `extension` | file extension the reader collects |
| `namespace` | root namespace; headings drop it and page names carry it lowercased |
+| `templates` | directory holding your own templates; each is looked up there first and falls back to the shipped one. Optional |
-`source`, `output` and `assets` are relative to `quill.php` unless they start with a slash. Every key except `assets` is required and must be a non-empty string; anything missing raises `MissingConfigurationKey` naming the key.
+`source`, `output`, `assets` and `templates` are relative to `quill.php` unless they start with a slash. Every key except `assets` and `templates` is required and must be a non-empty string; anything missing raises `MissingConfigurationKey` naming the key.
Splitting `output` from `assets` lets the destination mirror the layout of whatever consumes it. With the values above, `cp -r nikos/docs/* /docs/` lands the pages and the stylesheet where each belongs.
+## Templates
+
+The Markdown formatter emits no markup of its own. Every fragment comes from a file in `resources/templates/markdown`, and `templates` points at a directory of your own that is consulted first, per name. Overriding one template is not vendoring the other nineteen.
+
+Files go under a directory named for the format, so `templates` set to `docs/templates` means `docs/templates/markdown/class.tpl`. A `.tpl` whose name is not in the shipped set, or one sitting above the format directory, is ignored with a warning naming it and the nearest real name - both would otherwise produce a successful run that applied no override.
+
+Slots are `{{name}}`, substituted in a single pass: a value that happens to contain `{{title}}` is text, not an instruction. A placeholder a template does not use is ignored, so a template may take fewer slots than it is handed; one it invents is fatal, and `UnknownPlaceholder` names every unsupplied token at once. Loops, ordering and conditionals stay in PHP - a section that renders nothing is handed an empty string rather than asked to decide.
+
+A template's trailing newline is stripped, exactly one. A fragment whose output must end in a newline is therefore written with a blank final line, which is also what an editor leaves behind.
+
+| Template | Renders | Placeholders |
+|---|---|---|
+| `index` | the index page | `lines` |
+| `index-line` | one entry on it | `namespace`, `label`, `page` |
+| `page` | one page's frontmatter and notice | `namespace`, `classes` |
+| `class` | one class's whole section | `title`, `structure`, `badge`, `sourceUrl`, `description`, `tree`, `uses`, `usedBy`, `summary`, `constants`, `properties`, `methods` |
+| `class-description` | its prose, when it has any | `description` |
+| `tree` | the inheritance block | `lines` |
+| `uses` | the import list | `entries` |
+| `used-by` | the classes pulling a trait in | `entries` |
+| `summary` | the method summary section | `rows` |
+| `summary-row` | one summary row | `anchor`, `visibility`, `returnType`, `signature`, `description` |
+| `summary-return-type` | its type chip, when the method declares one | `type` |
+| `constants` | the constants section | `rows` |
+| `constant-row` | one constant | `type`, `name`, `default`, `description` |
+| `properties` | the properties section | `rows` |
+| `property-row` | one property | `visibility`, `type`, `name`, `default`, `description` |
+| `row-description` | the description cell shared by all three row shapes | `description` |
+| `methods` | the method detail section | `groups` |
+| `method-group` | one visibility group's header and body | `label`, `count`, `methods` |
+| `method` | one method's heading and signature block | `name`, `anchor`, `signature`, `description` |
+| `method-description` | its prose, when it has any | `description` |
+
+The class names the templates emit are declared in `Formatter\Markdown\Classes` and styled by `resources/api.css`; a test binds all three, so a name cannot drift out of one of them unnoticed.
+
## What `generate` writes
- one page per top-level namespace segment, named `_.`
@@ -149,6 +186,34 @@ Add `--namespace=` to both sides to compare one subsystem at a time, which keeps
`quill-8.1` is the floor and where the byte-for-byte comparison runs; `quill-8.5` covers deprecations. The suite must pass on both.
+### The full-corpus gate
+
+The suite proves the rendering over fixtures. The gate proves it over the whole cphalcon tree - roughly 2,600 declarations, which is where a signature shape that appears once and in no fixture turns up. Two directories, both gitignored because committing them buries every source change under a few thousand generated lines:
+
+| Directory | Role |
+|---|---|
+| `tests/_baseline` | the expectation - a snapshot of known-good output |
+| `tests/_output/gate` | the candidate - where a fresh run writes |
+
+Run it. `tests/Fixtures/config/cphalcon.php` already points `output` at `gate`, so no `--output` is needed; `phalcon.php` beside it does the same for the PHP implementation:
+
+ docker exec -w /srv quill-8.1 rm -rf tests/_output/gate
+ docker exec -w /srv quill-8.1 php bin/quill generate --config=tests/Fixtures/config/cphalcon.php
+ diff -r tests/_baseline tests/_output/gate
+
+**Silence is the pass.** Any output is a real change to what quill emits and belongs in the CHANGELOG. Delete `gate` first or a page that should have disappeared survives from the previous run and the diff stays quiet about it.
+
+Regenerating the baseline is the same binary with the destination redirected:
+
+ docker exec -w /srv quill-8.1 php bin/quill generate \
+ --config=tests/Fixtures/config/cphalcon.php --output=/srv/tests/_baseline
+
+Read the diff before you do, and move the old snapshot aside rather than deleting it - it is gitignored, so there is no `git checkout` to undo an `rm`. Regenerating is how an accepted change is recorded; doing it to make the diff go away is how the next one goes unnoticed.
+
+Two things that catch people out. `bin/quill`, not `vendor/bin/quill` - a package in its own tree has no `vendor/bin` shim. And a redirected run keeps `templates` while dropping `assets`, so the stylesheet follows the pages into the baseline and `api.css` is compared too, while an override still applies - a run that quietly fell back to the shipped templates would produce a clean diff having compared the wrong thing.
+
+Nothing enforces this. The baseline is refreshed by hand, so it goes stale silently; when the gate reports a difference, check its age against the commits since it was written before assuming the working tree is at fault.
+
## License
BSD-3-Clause. See [LICENSE](LICENSE).
diff --git a/bin/quill b/bin/quill
index b9156ce..bff2334 100755
--- a/bin/quill
+++ b/bin/quill
@@ -186,7 +186,8 @@ try {
$command = new GenerateCommand(
new ReaderFactory(),
- (new FormatterFactory())->create($format)
+ (new FormatterFactory())->create($format),
+ $format
);
exit($command->execute($config, Selection::of($filter, $namespace, $config)));
diff --git a/resources/templates/markdown/class-description.tpl b/resources/templates/markdown/class-description.tpl
new file mode 100644
index 0000000..76831a4
--- /dev/null
+++ b/resources/templates/markdown/class-description.tpl
@@ -0,0 +1,3 @@
+
+{{description}}
+
diff --git a/resources/templates/markdown/class.tpl b/resources/templates/markdown/class.tpl
new file mode 100644
index 0000000..15b0c4f
--- /dev/null
+++ b/resources/templates/markdown/class.tpl
@@ -0,0 +1,7 @@
+
+
+## {{title}}
+
+{{badge}}
+[:material-github: Source on GitHub]({{sourceUrl}}){ .src-btn }
+{{description}}{{tree}}{{uses}}{{usedBy}}{{summary}}{{constants}}{{properties}}{{methods}}
diff --git a/resources/templates/markdown/constant-row.tpl b/resources/templates/markdown/constant-row.tpl
new file mode 100644
index 0000000..7806c12
--- /dev/null
+++ b/resources/templates/markdown/constant-row.tpl
@@ -0,0 +1,5 @@
+
+{{type}}
+{{name}}{{default}}
+{{description}}
+
diff --git a/resources/templates/markdown/constants.tpl b/resources/templates/markdown/constants.tpl
new file mode 100644
index 0000000..7783be4
--- /dev/null
+++ b/resources/templates/markdown/constants.tpl
@@ -0,0 +1,6 @@
+
+### Constants
+
+
+{{rows}}
+
diff --git a/resources/templates/markdown/index-line.tpl b/resources/templates/markdown/index-line.tpl
new file mode 100644
index 0000000..dfe77d5
--- /dev/null
+++ b/resources/templates/markdown/index-line.tpl
@@ -0,0 +1,2 @@
+- [{{namespace}} {{label}}]({{page}}.md)
+
diff --git a/resources/templates/markdown/index.tpl b/resources/templates/markdown/index.tpl
new file mode 100644
index 0000000..d5b1681
--- /dev/null
+++ b/resources/templates/markdown/index.tpl
@@ -0,0 +1,9 @@
+---
+hide:
+ - toc
+---
+
+# API Index
+- - -
+
+{{lines}}
diff --git a/resources/templates/markdown/method-description.tpl b/resources/templates/markdown/method-description.tpl
new file mode 100644
index 0000000..76831a4
--- /dev/null
+++ b/resources/templates/markdown/method-description.tpl
@@ -0,0 +1,3 @@
+
+{{description}}
+
diff --git a/resources/templates/markdown/method-group.tpl b/resources/templates/markdown/method-group.tpl
new file mode 100644
index 0000000..d56db68
--- /dev/null
+++ b/resources/templates/markdown/method-group.tpl
@@ -0,0 +1,3 @@
+
+{{label}} · {{count}}
+{{methods}}
diff --git a/resources/templates/markdown/method.tpl b/resources/templates/markdown/method.tpl
new file mode 100644
index 0000000..45555de
--- /dev/null
+++ b/resources/templates/markdown/method.tpl
@@ -0,0 +1,7 @@
+
+#### `{{name}}()` { #{{anchor}} }
+
+```php
+{{signature}}
+```
+{{description}}
diff --git a/resources/templates/markdown/methods.tpl b/resources/templates/markdown/methods.tpl
new file mode 100644
index 0000000..72d95a2
--- /dev/null
+++ b/resources/templates/markdown/methods.tpl
@@ -0,0 +1,3 @@
+
+### Methods
+{{groups}}
diff --git a/resources/templates/markdown/page.tpl b/resources/templates/markdown/page.tpl
new file mode 100644
index 0000000..87c8737
--- /dev/null
+++ b/resources/templates/markdown/page.tpl
@@ -0,0 +1,9 @@
+---
+hide:
+ - navigation
+---
+
+!!! info "NOTE"
+
+ All classes are prefixed with `{{namespace}}`
+{{classes}}
diff --git a/resources/templates/markdown/properties.tpl b/resources/templates/markdown/properties.tpl
new file mode 100644
index 0000000..3e10b80
--- /dev/null
+++ b/resources/templates/markdown/properties.tpl
@@ -0,0 +1,6 @@
+
+### Properties
+
+
+{{rows}}
+
diff --git a/resources/templates/markdown/property-row.tpl b/resources/templates/markdown/property-row.tpl
new file mode 100644
index 0000000..454f348
--- /dev/null
+++ b/resources/templates/markdown/property-row.tpl
@@ -0,0 +1,6 @@
+
+{{visibility}}
+{{type}}
+${{name}}{{default}}
+{{description}}
+
diff --git a/resources/templates/markdown/row-description.tpl b/resources/templates/markdown/row-description.tpl
new file mode 100644
index 0000000..b06aa30
--- /dev/null
+++ b/resources/templates/markdown/row-description.tpl
@@ -0,0 +1,2 @@
+{{description}}
+
diff --git a/resources/templates/markdown/summary-return-type.tpl b/resources/templates/markdown/summary-return-type.tpl
new file mode 100644
index 0000000..376ab6a
--- /dev/null
+++ b/resources/templates/markdown/summary-return-type.tpl
@@ -0,0 +1,2 @@
+{{type}}
+
diff --git a/resources/templates/markdown/summary-row.tpl b/resources/templates/markdown/summary-row.tpl
new file mode 100644
index 0000000..fadbf21
--- /dev/null
+++ b/resources/templates/markdown/summary-row.tpl
@@ -0,0 +1,5 @@
+
+{{visibility}}
+{{returnType}}{{signature}}
+{{description}}
+
diff --git a/resources/templates/markdown/summary.tpl b/resources/templates/markdown/summary.tpl
new file mode 100644
index 0000000..9d237a9
--- /dev/null
+++ b/resources/templates/markdown/summary.tpl
@@ -0,0 +1,6 @@
+
+### Method Summary
+
+
+{{rows}}
+
diff --git a/resources/templates/markdown/tree.tpl b/resources/templates/markdown/tree.tpl
new file mode 100644
index 0000000..cb3542c
--- /dev/null
+++ b/resources/templates/markdown/tree.tpl
@@ -0,0 +1,7 @@
+
+
+
+{{lines}}
+
+
+
diff --git a/resources/templates/markdown/used-by.tpl b/resources/templates/markdown/used-by.tpl
new file mode 100644
index 0000000..72d12f1
--- /dev/null
+++ b/resources/templates/markdown/used-by.tpl
@@ -0,0 +1,4 @@
+
+__Used by__ {{entries}}
+{ .api-used-by }
+
diff --git a/resources/templates/markdown/uses.tpl b/resources/templates/markdown/uses.tpl
new file mode 100644
index 0000000..ada5f43
--- /dev/null
+++ b/resources/templates/markdown/uses.tpl
@@ -0,0 +1,4 @@
+
+__Uses__ {{entries}}
+{ .api-uses }
+
diff --git a/src/Cli/GenerateCommand.php b/src/Cli/GenerateCommand.php
index 08609f7..c0b9eee 100644
--- a/src/Cli/GenerateCommand.php
+++ b/src/Cli/GenerateCommand.php
@@ -20,6 +20,7 @@
use Phalcon\Quill\Model\Registry;
use Phalcon\Quill\Reader\ReaderFactory;
use Phalcon\Quill\Selection;
+use Phalcon\Quill\Template\Templates;
use function array_keys;
use function basename;
@@ -46,6 +47,7 @@ final class GenerateCommand
public function __construct(
private readonly ReaderFactory $factory,
private readonly Formatter $formatter,
+ private readonly string $format,
private $stdout = STDOUT,
) {
}
@@ -56,6 +58,15 @@ public function __construct(
*/
public function execute(Config $config, Selection $selection): int
{
+ // First, before anything can throw and before the per-page lines. A
+ // consumer with both a misnamed override and a bad token would
+ // otherwise get the exception and never see the warning that explains
+ // it, and a warning printed after the pages is line 31 of a 45-line
+ // successful run, which nobody reads.
+ foreach (Templates::unrecognized($this->format, $config->templatesDir()) as $warning) {
+ fwrite($this->stdout, 'Warning: ' . $warning . PHP_EOL);
+ }
+
$reader = $this->factory->create($config->language());
$registry = $reader->read($config);
diff --git a/src/Config.php b/src/Config.php
index 2d9567e..1029db5 100644
--- a/src/Config.php
+++ b/src/Config.php
@@ -51,6 +51,7 @@ final class Config
private readonly string $assetsDir;
private readonly string $outputDir;
private readonly string $sourceRoot;
+ private readonly string $templatesDir;
/**
* `$assetsDir` defaults to the output directory, which puts a formatter's
@@ -68,10 +69,12 @@ public function __construct(
private readonly string $extension,
private readonly string $rootNamespace,
string $assetsDir = '',
+ string $templatesDir = '',
) {
- $this->sourceRoot = rtrim($sourceRoot, '/');
- $this->outputDir = rtrim($outputDir, '/');
- $this->assetsDir = $assetsDir === '' ? $this->outputDir : rtrim($assetsDir, '/');
+ $this->sourceRoot = rtrim($sourceRoot, '/');
+ $this->outputDir = rtrim($outputDir, '/');
+ $this->assetsDir = $assetsDir === '' ? $this->outputDir : rtrim($assetsDir, '/');
+ $this->templatesDir = rtrim($templatesDir, '/');
}
/**
@@ -95,6 +98,11 @@ public static function fromArray(array $config, string $root): self
/** @var mixed $assets */
$assets = $config['assets'] ?? null;
+ // Absent from KEYS on purpose: a project using the shipped templates
+ // says nothing and takes the constructor's default.
+ /** @var mixed $templates */
+ $templates = $config['templates'] ?? null;
+
return new self(
$values['language'],
self::absolute($values['source'], $root),
@@ -105,6 +113,7 @@ public static function fromArray(array $config, string $root): self
$values['extension'],
trim($values['namespace'], '\\'),
is_string($assets) && $assets !== '' ? self::absolute($assets, $root) : '',
+ is_string($templates) && $templates !== '' ? self::absolute($templates, $root) : '',
);
}
@@ -196,15 +205,29 @@ public function sourceUrl(string $relativePath): string
. '/' . str_replace('\\', '/', $relativePath);
}
+ /**
+ * Where a consumer's own templates live, or '' when they use the shipped
+ * set. The formatter resolves each template name here first.
+ */
+ public function templatesDir(): string
+ {
+ return $this->templatesDir;
+ }
+
/**
* A copy writing somewhere else, for one-off runs that must not touch the
* configured destination. Normal use never needs it.
*
* The assets directory is not carried over - a redirected run wants
- * everything it produces in the one place.
+ * everything it produces in the one place. The templates directory is,
+ * because templates are input: a redirected run that fell back to the
+ * shipped set would not be the same run, and a diff against it would come
+ * back clean having proved nothing.
*/
public function withOutputDir(string $outputDir): self
{
+ // Assets are passed empty explicitly in order to reach templates
+ // positionally - dropping the one and carrying the other in one call.
return new self(
$this->language,
$this->sourceRoot,
@@ -214,6 +237,8 @@ public function withOutputDir(string $outputDir): self
$this->sourcePrefix,
$this->extension,
$this->rootNamespace,
+ '',
+ $this->templatesDir,
);
}
}
diff --git a/src/Exceptions/MissingTemplate.php b/src/Exceptions/MissingTemplate.php
new file mode 100644
index 0000000..385c04f
--- /dev/null
+++ b/src/Exceptions/MissingTemplate.php
@@ -0,0 +1,32 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Quill\Exceptions;
+
+use function implode;
+
+class MissingTemplate extends Exception
+{
+ /**
+ * @param list $searched
+ */
+ public function __construct(string $name, array $searched)
+ {
+ parent::__construct(
+ "The template '" . $name . "' was not found. Looked in: "
+ . implode(', ', $searched) . '.'
+ . ' The shipped set ships with quill, so a copy that cannot find'
+ . ' it is incomplete rather than misconfigured.'
+ );
+ }
+}
diff --git a/src/Exceptions/MissingTemplateDirectory.php b/src/Exceptions/MissingTemplateDirectory.php
new file mode 100644
index 0000000..0ecde05
--- /dev/null
+++ b/src/Exceptions/MissingTemplateDirectory.php
@@ -0,0 +1,27 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Quill\Exceptions;
+
+class MissingTemplateDirectory extends Exception
+{
+ public function __construct(string $path)
+ {
+ parent::__construct(
+ "The configured templates directory '" . $path . "' is not a"
+ . ' directory. Remove the `templates` key to use the shipped'
+ . ' templates, or correct the path - a run that silently ignored'
+ . ' it would apply no override and still report success.'
+ );
+ }
+}
diff --git a/src/Exceptions/UnknownPlaceholder.php b/src/Exceptions/UnknownPlaceholder.php
new file mode 100644
index 0000000..7e94c24
--- /dev/null
+++ b/src/Exceptions/UnknownPlaceholder.php
@@ -0,0 +1,32 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Quill\Exceptions;
+
+use function implode;
+
+class UnknownPlaceholder extends Exception
+{
+ /**
+ * @param list $tokens
+ */
+ public function __construct(string $template, array $tokens)
+ {
+ parent::__construct(
+ "The template '" . $template . "' uses {{"
+ . implode('}}, {{', $tokens) . '}}, which nothing supplies.'
+ . ' Placeholders a template does not use are ignored; ones it'
+ . ' invents are not.'
+ );
+ }
+}
diff --git a/src/Formatter/Markdown/ClassPage.php b/src/Formatter/Markdown/ClassPage.php
new file mode 100644
index 0000000..320c2f1
--- /dev/null
+++ b/src/Formatter/Markdown/ClassPage.php
@@ -0,0 +1,396 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Quill\Formatter\Markdown;
+
+use Phalcon\Quill\Model\ClassDefinition;
+use Phalcon\Quill\Model\Keyword;
+use Phalcon\Quill\Model\MethodDefinitionCollection;
+use Phalcon\Quill\Model\Registry;
+use Phalcon\Quill\Template\Templates;
+
+use function array_map;
+use function count;
+use function explode;
+use function implode;
+use function sort;
+use function str_repeat;
+use function str_starts_with;
+use function trim;
+use function ucfirst;
+
+/**
+ * One class's section of a page: the heading, the badge, the inheritance tree
+ * and every member section under it.
+ *
+ * Which templates are rendered, in what order, and what a section is skipped
+ * for stays here - the templates carry the markup and nothing else. Everything
+ * a section needs that does not vary per class is constructor state, so the
+ * methods below take a definition and nothing more.
+ *
+ * Built fresh per format() call, because the registry and the presentation it
+ * closes over belong to that run.
+ */
+final class ClassPage
+{
+ public function __construct(
+ private readonly Templates $templates,
+ private readonly Presentation $view,
+ private readonly Registry $registry,
+ private readonly Naming $naming,
+ private readonly Signature $signature,
+ private readonly Html $html,
+ ) {
+ }
+
+ public function render(ClassDefinition $class): string
+ {
+ $badge = 'Class';
+ $structure = 'class';
+
+ if ($class->structure->keyword === Keyword::Interface) {
+ $badge = 'Interface';
+ $structure = 'interface';
+ } elseif ($class->structure->keyword === Keyword::Trait) {
+ $badge = 'Trait';
+ $structure = 'trait';
+ } elseif ($class->structure->isAbstract === true) {
+ $badge = 'Abstract';
+ $structure = 'abstract';
+ } elseif ($class->structure->isFinal === true) {
+ $badge = 'Final';
+ $structure = 'final';
+ }
+
+ $description = '';
+ if ($class->description !== '') {
+ $description = $this->templates->render('class-description', [
+ 'description' => $class->description,
+ ]);
+ }
+
+ return $this->templates->render('class', [
+ 'badge' => $badge,
+ 'constants' => $this->constants($class),
+ 'description' => $description,
+ 'methods' => $this->methodDetails($class),
+ 'properties' => $this->properties($class),
+ 'sourceUrl' => $this->view->sourceUrl($class->location->relPath),
+ 'structure' => $structure,
+ 'summary' => $this->summary($class),
+ 'title' => $this->naming->title($class, $this->view),
+ 'tree' => $this->tree($class),
+ 'usedBy' => $this->usedBy($class),
+ 'uses' => $this->uses($class),
+ ]);
+ }
+
+ private function constants(ClassDefinition $class): string
+ {
+ if ($class->members->constants->isEmpty()) {
+ return '';
+ }
+
+ $rows = '';
+ foreach ($class->members->constants as $constant) {
+ $rows .= $this->templates->render('constant-row', [
+ 'default' => $this->html->default($constant->default),
+ 'description' => $this->rowDescription($constant->description),
+ 'name' => $this->html->escape($constant->name),
+ 'type' => $this->html->escape($constant->varType),
+ ]);
+ }
+
+ return $this->templates->render('constants', ['rows' => $rows]);
+ }
+
+ /**
+ * Markdown link to a class when it is in the registry, plain code when not.
+ */
+ private function fqcnLink(string $display, ?string $fqcn, string $currentPage): string
+ {
+ $target = $fqcn === null ? null : $this->registry->get($fqcn);
+ if ($target === null) {
+ return "`{$display}`";
+ }
+
+ $href = '#' . $this->naming->anchor($target, $this->view);
+ $targetPage = $this->naming->pageKey($target, $this->view);
+ if ($targetPage !== $currentPage) {
+ $href = $targetPage . '.md' . $href;
+ }
+
+ return "[`{$display}`]({$href})";
+ }
+
+ private function methodDetails(ClassDefinition $class): string
+ {
+ $groups = $this->orderMethods($class->members->methods);
+ if ($groups === null) {
+ return '';
+ }
+
+ $rendered = '';
+
+ foreach (['public', 'protected'] as $group) {
+ if ($groups[$group]->isEmpty()) {
+ continue;
+ }
+
+ $methods = '';
+ foreach ($groups[$group] as $method) {
+ $description = '';
+ if ($method->description !== '') {
+ $description = $this->templates->render('method-description', [
+ 'description' => $method->description,
+ ]);
+ }
+
+ $methods .= $this->templates->render('method', [
+ 'anchor' => $this->naming->methodAnchor($class, $method->name, $this->view),
+ 'description' => $description,
+ 'name' => $method->name,
+ 'signature' => implode("\n", $this->signature->lines($method)),
+ ]);
+ }
+
+ $rendered .= $this->templates->render('method-group', [
+ 'count' => (string) $groups[$group]->count(),
+ 'label' => ucfirst($group),
+ 'methods' => $methods,
+ ]);
+ }
+
+ return $this->templates->render('methods', ['groups' => $rendered]);
+ }
+
+ /**
+ * Private methods dropped, reserved (__*) first, then alphabetical, split
+ * by visibility. Null when nothing survives.
+ *
+ * The emptiness guard is on the result rather than the incoming list: the
+ * model keeps private members, so a class whose methods are all private
+ * would otherwise emit a heading with nothing under it. Both sections that
+ * render methods open with this question, so both ask it here.
+ *
+ * @return array{public: MethodDefinitionCollection, protected: MethodDefinitionCollection}|null
+ */
+ private function orderMethods(MethodDefinitionCollection $methods): ?array
+ {
+ $visible = $methods->withoutPrivate()->ordered();
+
+ $groups = [
+ 'public' => $visible->withVisibility('public'),
+ 'protected' => $visible->withVisibility('protected'),
+ ];
+
+ if ($groups['public']->isEmpty() && $groups['protected']->isEmpty()) {
+ return null;
+ }
+
+ return $groups;
+ }
+
+ private function properties(ClassDefinition $class): string
+ {
+ $visible = $class->members->properties->withoutPrivate();
+ if ($visible->isEmpty()) {
+ return '';
+ }
+
+ $rows = '';
+ foreach ($visible as $property) {
+ $rows .= $this->templates->render('property-row', [
+ 'default' => $this->html->default($property->default),
+ 'description' => $this->rowDescription($property->description),
+ 'name' => $this->html->escape($property->name),
+ 'type' => $this->html->escape($property->varType),
+ 'visibility' => $property->visibility,
+ ]);
+ }
+
+ return $this->templates->render('properties', ['rows' => $rows]);
+ }
+
+ /**
+ * One relation's names as a comma-separated list of links, for the
+ * `extends`/`implements` annotation under a class in the tree. A name the
+ * registry does not hold falls back to plain code, which fqcnLink handles.
+ *
+ * @param list $names
+ */
+ private function relationLinks(array $names, ClassDefinition $class, string $currentPage): string
+ {
+ $links = [];
+ foreach ($names as $name) {
+ $fqcn = $this->registry->resolve($name, $class);
+ $links[] = $this->fqcnLink($fqcn ?? $name, $fqcn, $currentPage);
+ }
+
+ return implode(', ', $links);
+ }
+
+ /**
+ * The description cell a member row carries, or empty when it has none.
+ *
+ * Shared by all three row shapes: the markup is identical, and only the
+ * question of whether there is anything to say differs.
+ */
+ private function rowDescription(string $description): string
+ {
+ if ($description === '') {
+ return '';
+ }
+
+ return $this->templates->render('row-description', [
+ 'description' => $this->html->inlineCode($description),
+ ]);
+ }
+
+ private function summary(ClassDefinition $class): string
+ {
+ $groups = $this->orderMethods($class->members->methods);
+ if ($groups === null) {
+ return '';
+ }
+
+ $rows = '';
+
+ foreach (['public', 'protected'] as $group) {
+ foreach ($groups[$group] as $method) {
+ $returnType = '';
+ if ($method->returnType !== null) {
+ $returnType = $this->templates->render('summary-return-type', [
+ 'type' => $this->html->escape($method->returnType),
+ ]);
+ }
+
+ $rows .= $this->templates->render('summary-row', [
+ 'anchor' => $this->naming->methodAnchor($class, $method->name, $this->view),
+ 'description' => $this->rowDescription($this->summaryLine($method->description)),
+ 'returnType' => $returnType,
+ 'signature' => $this->signature->inline($method),
+ 'visibility' => $group,
+ ]);
+ }
+ }
+
+ return $this->templates->render('summary', ['rows' => $rows]);
+ }
+
+ /**
+ * First prose line of a description, used in the summary rows.
+ */
+ private function summaryLine(string $description): string
+ {
+ foreach (explode("\n", $description) as $line) {
+ $line = trim($line);
+ if ($line === '') {
+ continue;
+ }
+ if (str_starts_with($line, '```')) {
+ return '';
+ }
+
+ return $line;
+ }
+
+ return '';
+ }
+
+ private function tree(ClassDefinition $class): string
+ {
+ $currentPage = $this->naming->pageKey($class, $this->view);
+ $level = 0;
+ $lines = [];
+
+ foreach ($this->registry->ancestorsOf($class) as $ancestor) {
+ $lines[] = str_repeat(' ', $level * 4) . '- '
+ . $this->fqcnLink($ancestor['display'], $ancestor['fqcn'], $currentPage);
+ $level++;
+ }
+
+ $current = str_repeat(' ', $level * 4) . "- **`{$class->location->fqcn}`**";
+
+ $annotations = [];
+ if (
+ $class->structure->keyword === Keyword::Interface
+ && count($class->relations->extends) > 1
+ ) {
+ $annotations[] = 'extends '
+ . $this->relationLinks($class->relations->extends, $class, $currentPage);
+ }
+
+ if ($class->relations->implements !== []) {
+ $annotations[] = 'implements '
+ . $this->relationLinks($class->relations->implements, $class, $currentPage);
+ }
+
+ if ($annotations !== []) {
+ $current .= ' - ' . implode('; ', $annotations);
+ }
+
+ $lines[] = $current;
+ $level++;
+
+ $children = $this->registry->childrenOf($class);
+ sort($children);
+ foreach ($children as $child) {
+ $lines[] = str_repeat(' ', $level * 4) . '- '
+ . $this->fqcnLink($child, $child, $currentPage);
+ }
+
+ return $this->templates->render('tree', ['lines' => implode("\n", $lines)]);
+ }
+
+ /**
+ * The inverse of a trait's usage: which classes pull it in. Rendered as
+ * links because, unlike the import list, every target is by construction
+ * in the registry.
+ */
+ private function usedBy(ClassDefinition $class): string
+ {
+ $users = $this->registry->usedBy($class);
+ if ($users === []) {
+ return '';
+ }
+
+ sort($users);
+
+ $currentPage = $this->naming->pageKey($class, $this->view);
+
+ $links = array_map(
+ fn (string $fqcn): string => $this->fqcnLink($fqcn, $fqcn, $currentPage),
+ $users
+ );
+
+ return $this->templates->render('used-by', ['entries' => implode(' · ', $links)]);
+ }
+
+ private function uses(ClassDefinition $class): string
+ {
+ if ($class->imports->uses === []) {
+ return '';
+ }
+
+ $uses = $class->imports->uses;
+ sort($uses);
+
+ $codes = array_map(
+ static fn (string $use): string => "`{$use}`",
+ $uses
+ );
+
+ return $this->templates->render('uses', ['entries' => implode(' · ', $codes)]);
+ }
+}
diff --git a/src/Formatter/Markdown/Classes.php b/src/Formatter/Markdown/Classes.php
index 6f7b9d2..b3f4089 100644
--- a/src/Formatter/Markdown/Classes.php
+++ b/src/Formatter/Markdown/Classes.php
@@ -22,6 +22,12 @@
* rename was invisible here and surfaced as an unstyled page on a published
* site; gathered here, the set is something you can read, diff and version.
*
+ * Five are still read at render time - PARAMETER, TOKEN_FUNCTION, TOKEN_MUTED,
+ * TOKEN_TYPE and TOKEN_VARIABLE, by Signature and Html, which build their
+ * markup in PHP. The rest are declarations: the markup that carries them now
+ * lives in `resources/templates/markdown`, and MarkupContractTest binds the
+ * two together so a name can no longer drift out of either side unnoticed.
+ *
* Changing a value is a breaking change for the stylesheet. Adding one means
* adding a rule to api.css in the same commit.
*/
diff --git a/src/Formatter/MarkdownFormatter.php b/src/Formatter/MarkdownFormatter.php
index ce004df..24982b3 100644
--- a/src/Formatter/MarkdownFormatter.php
+++ b/src/Formatter/MarkdownFormatter.php
@@ -16,45 +16,36 @@
use Phalcon\Quill\Config;
use Phalcon\Quill\Contracts\Formatter;
use Phalcon\Quill\Exceptions\MissingAsset;
-use Phalcon\Quill\Formatter\Markdown\Classes;
+use Phalcon\Quill\Formatter\Markdown\ClassPage;
use Phalcon\Quill\Formatter\Markdown\Html;
use Phalcon\Quill\Formatter\Markdown\Naming;
use Phalcon\Quill\Formatter\Markdown\Presentation;
use Phalcon\Quill\Formatter\Markdown\Signature;
-use Phalcon\Quill\Model\ClassDefinition;
-use Phalcon\Quill\Model\Keyword;
-use Phalcon\Quill\Model\MethodDefinitionCollection;
use Phalcon\Quill\Model\Registry;
use Phalcon\Quill\Selection;
+use Phalcon\Quill\Template\Templates;
use function array_keys;
-use function array_map;
-use function count;
use function dirname;
-use function explode;
use function file_get_contents;
-use function implode;
use function ksort;
use function sort;
-use function str_repeat;
use function str_replace;
-use function str_starts_with;
use function stripos;
-use function trim;
use function ucfirst;
-use const PHP_EOL;
-
/**
* Emits the mkdocs Markdown the documentation repositories consume.
*
- * The exact whitespace and markup here is load-bearing. These documents are
- * diffed between the two Phalcon implementations, so an incidental formatting
- * change reads as an API change. Alter the rendering deliberately, never for
+ * The exact whitespace and markup is load-bearing. These documents are diffed
+ * between the two Phalcon implementations, so an incidental formatting change
+ * reads as an API change. It lives in `resources/templates/markdown`, which a
+ * project can override a file at a time; alter it deliberately, never for
* tidiness.
*
- * Naming, escaping and signature rendering live in the Markdown namespace;
- * what stays here is page assembly and the section layout.
+ * Naming, escaping and signature rendering live in the Markdown namespace, and
+ * a class's own section is ClassPage's. What stays here is which pages exist,
+ * what goes on each, and the index.
*/
final class MarkdownFormatter implements Formatter
{
@@ -100,9 +91,10 @@ public function extension(): string
*/
public function format(Registry $registry, Config $config, Selection $selection): array
{
- $view = Presentation::from($config);
- $pages = $this->pages($registry, $view, $selection);
- $output = [];
+ $templates = Templates::for('markdown', $config->templatesDir());
+ $view = Presentation::from($config);
+ $pages = $this->pages($registry, $view, $selection);
+ $output = [];
foreach (array_keys($pages) as $page) {
if ($selection->filter !== '' && stripos($page, $selection->filter) === false) {
@@ -110,230 +102,47 @@ public function format(Registry $registry, Config $config, Selection $selection)
}
}
- $index = <<indexLine($page, $view);
+ $lines .= $this->indexLine($page, $view, $templates);
}
- $output['index'] = $index;
+ $output['index'] = $templates->render('index', ['lines' => $lines]);
- foreach ($pages as $page => $fqcns) {
- $document = <<rootNamespace}`
-
- EOT;
+ $classPage = new ClassPage(
+ $templates,
+ $view,
+ $registry,
+ $this->naming,
+ $this->signature,
+ $this->html
+ );
+ foreach ($pages as $page => $fqcns) {
+ $classes = '';
foreach ($fqcns as $fqcn) {
$class = $registry->get($fqcn);
if ($class !== null) {
- $document .= $this->classDoc($class, $registry, $view);
+ $classes .= $classPage->render($class);
}
}
- $output[$page] = $document;
- }
-
- return $output;
- }
-
- private function classDoc(ClassDefinition $class, Registry $registry, Presentation $view): string
- {
- $badge = 'Class';
- $css = 'class';
-
- if ($class->structure->keyword === Keyword::Interface) {
- $badge = 'Interface';
- $css = 'interface';
- } elseif ($class->structure->keyword === Keyword::Trait) {
- $badge = 'Trait';
- $css = 'trait';
- } elseif ($class->structure->isAbstract === true) {
- $badge = 'Abstract';
- $css = 'abstract';
- } elseif ($class->structure->isFinal === true) {
- $badge = 'Final';
- $css = 'final';
- }
-
- $badgeClass = Classes::BADGE . ' ' . Classes::BADGE_PREFIX . $css;
-
- $output = "\n\n## " . $this->naming->title($class, $view) . "\n\n"
- . "{$badge}\n"
- . "[:material-github: Source on GitHub]"
- . '(' . $view->sourceUrl($class->location->relPath) . ')'
- . '{ .' . Classes::SOURCE_BUTTON . " }\n";
-
- if ($class->description !== '') {
- $output .= "\n" . $class->description . "\n";
- }
-
- $output .= $this->tree($class, $registry, $view);
- $output .= $this->uses($class);
- $output .= $this->usedBy($class, $registry, $view);
- $output .= $this->summary($class, $view);
- $output .= $this->constants($class);
- $output .= $this->properties($class);
- $output .= $this->methodDetails($class, $view);
-
- return $output;
- }
-
- private function constants(ClassDefinition $class): string
- {
- if ($class->members->constants->isEmpty()) {
- return '';
- }
-
- $output = "\n### Constants\n\n" . $this->openList();
- foreach ($class->members->constants as $constant) {
- $output .= $this->row(
- ''
- . $this->html->escape($constant->varType)
- . "\n"
- . '' . $this->html->escape($constant->name)
- . '' . $this->html->default($constant->default)
- . "\n",
- $constant->description
- );
- }
-
- return $output . "\n";
- }
-
- /**
- * Markdown link to a class when it is in the registry, plain code when not.
- */
- private function fqcnLink(
- string $display,
- ?string $fqcn,
- Registry $registry,
- Presentation $view,
- string $currentPage
- ): string {
- $target = $fqcn === null ? null : $registry->get($fqcn);
- if ($target === null) {
- return "`{$display}`";
- }
-
- $href = '#' . $this->naming->anchor($target, $view);
- $targetPage = $this->naming->pageKey($target, $view);
- if ($targetPage !== $currentPage) {
- $href = $targetPage . '.md' . $href;
- }
-
- return "[`{$display}`]({$href})";
- }
-
- private function indexLine(string $page, Presentation $view): string
- {
- $label = ucfirst(str_replace($view->pagePrefix, '', $page));
-
- return '- [' . $view->rootNamespace . ' ' . $label . ']'
- . '(' . $page . '.md)' . PHP_EOL;
- }
-
- /**
- * A labelled run of inline entries under a class - the shape both the
- * import list and the trait's users are rendered in. Only the envelope is
- * shared; what the entries are is the caller's business.
- *
- * @param list $entries already rendered, in display order
- */
- private function inlineList(string $label, array $entries, string $cssClass): string
- {
- return "\n__" . $label . '__ ' . implode(' · ', $entries)
- . "\n{ ." . $cssClass . " }\n";
- }
-
- private function methodDetails(ClassDefinition $class, Presentation $view): string
- {
- $groups = $this->orderMethods($class->members->methods);
- if ($groups === null) {
- return '';
- }
-
- $output = "\n### Methods\n";
-
- foreach (['public', 'protected'] as $group) {
- if ($groups[$group]->isEmpty()) {
- continue;
- }
-
- $count = $groups[$group]->count();
- $label = ucfirst($group);
- $groupClass = Classes::GROUP;
- $output .= "\n{$label} · {$count}
\n";
-
- foreach ($groups[$group] as $method) {
- $anchor = $this->naming->methodAnchor($class, $method->name, $view);
- $signature = implode("\n", $this->signature->lines($method));
-
- $output .= "\n#### `{$method->name}()` { #{$anchor} }\n\n"
- . "```php\n{$signature}\n```\n";
-
- if ($method->description !== '') {
- $output .= "\n" . $method->description . "\n";
- }
- }
+ $output[$page] = $templates->render('page', [
+ 'classes' => $classes,
+ 'namespace' => $view->rootNamespace,
+ ]);
}
return $output;
}
- /**
- * The opening tag of a member list. Three sections emit one, and the class
- * name on it is part of the contract with the stylesheet.
- */
- private function openList(): string
- {
- return '\n";
- }
-
- /**
- * Private methods dropped, reserved (__*) first, then alphabetical, split
- * by visibility. Null when nothing survives.
- *
- * The emptiness guard is on the result rather than the incoming list: the
- * model keeps private members, so a class whose methods are all private
- * would otherwise emit a heading with nothing under it. Both sections that
- * render methods open with this question, so both ask it here.
- *
- * @return array{public: MethodDefinitionCollection, protected: MethodDefinitionCollection}|null
- */
- private function orderMethods(MethodDefinitionCollection $methods): ?array
+ private function indexLine(string $page, Presentation $view, Templates $templates): string
{
- $visible = $methods->withoutPrivate()->ordered();
-
- $groups = [
- 'public' => $visible->withVisibility('public'),
- 'protected' => $visible->withVisibility('protected'),
- ];
-
- if ($groups['public']->isEmpty() && $groups['protected']->isEmpty()) {
- return null;
- }
-
- return $groups;
+ return $templates->render('index-line', [
+ 'label' => ucfirst(str_replace($view->pagePrefix, '', $page)),
+ 'namespace' => $view->rootNamespace,
+ 'page' => $page,
+ ]);
}
/**
@@ -364,243 +173,4 @@ private function pages(Registry $registry, Presentation $view, Selection $select
return $pages;
}
-
- private function properties(ClassDefinition $class): string
- {
- $visible = $class->members->properties->withoutPrivate();
- if ($visible->isEmpty()) {
- return '';
- }
-
- $output = "\n### Properties\n\n" . $this->openList();
- foreach ($visible as $property) {
- $visibility = $property->visibility;
- $visClass = Classes::VISIBILITY . ' ' . Classes::VISIBILITY_PREFIX . $visibility;
-
- $output .= $this->row(
- "{$visibility}\n"
- . ''
- . $this->html->escape($property->varType) . "\n"
- . '$' . $this->html->escape($property->name)
- . '' . $this->html->default($property->default)
- . "\n",
- $property->description
- );
- }
-
- return $output . "
\n";
- }
-
- /**
- * One relation's names as a comma-separated list of links, for the
- * `extends`/`implements` annotation under a class in the tree. A name the
- * registry does not hold falls back to plain code, which fqcnLink handles.
- *
- * @param list $names
- */
- private function relationLinks(
- array $names,
- ClassDefinition $class,
- Registry $registry,
- Presentation $view,
- string $currentPage
- ): string {
- $links = [];
- foreach ($names as $name) {
- $fqcn = $registry->resolve($name, $class);
- $links[] = $this->fqcnLink($fqcn ?? $name, $fqcn, $registry, $view, $currentPage);
- }
-
- return implode(', ', $links);
- }
-
- /**
- * One row of an `api-list`: the wrapper, whatever the caller puts inside,
- * and the description when there is one.
- *
- * Only the envelope is shared - a constant row and a property row differ in
- * the middle, and forcing those through one signature would take more
- * parameters than it saves lines.
- */
- private function row(string $body, string $description): string
- {
- $output = '\n" . $body;
-
- if ($description !== '') {
- $output .= ''
- . $this->html->inlineCode($description) . "\n";
- }
-
- return $output . "
\n";
- }
-
- private function summary(ClassDefinition $class, Presentation $view): string
- {
- $groups = $this->orderMethods($class->members->methods);
- if ($groups === null) {
- return '';
- }
-
- $output = "\n### Method Summary\n\n" . $this->openList();
-
- foreach (['public', 'protected'] as $group) {
- foreach ($groups[$group] as $method) {
- $anchor = $this->naming->methodAnchor($class, $method->name, $view);
- $itemClass = Classes::ITEM;
- $visClass = Classes::VISIBILITY . ' ' . Classes::VISIBILITY_PREFIX . $group;
-
- $output .= "\n"
- . "{$group}\n";
-
- if ($method->returnType !== null) {
- $output .= ''
- . $this->html->escape($method->returnType) . "\n";
- }
-
- $output .= ''
- . $this->signature->inline($method) . "\n";
-
- $line = $this->summaryLine($method->description);
- if ($line !== '') {
- $output .= ''
- . $this->html->inlineCode($line) . "\n";
- }
-
- $output .= "\n";
- }
- }
-
- return $output . "\n";
- }
-
- /**
- * First prose line of a description, used in the summary rows.
- */
- private function summaryLine(string $description): string
- {
- foreach (explode("\n", $description) as $line) {
- $line = trim($line);
- if ($line === '') {
- continue;
- }
- if (str_starts_with($line, '```')) {
- return '';
- }
-
- return $line;
- }
-
- return '';
- }
-
- private function tree(
- ClassDefinition $class,
- Registry $registry,
- Presentation $view
- ): string {
- $currentPage = $this->naming->pageKey($class, $view);
- $level = 0;
- $lines = [];
-
- foreach ($registry->ancestorsOf($class) as $ancestor) {
- $lines[] = str_repeat(' ', $level * 4) . '- '
- . $this->fqcnLink(
- $ancestor['display'],
- $ancestor['fqcn'],
- $registry,
- $view,
- $currentPage
- );
- $level++;
- }
-
- $current = str_repeat(' ', $level * 4) . "- **`{$class->location->fqcn}`**";
-
- $annotations = [];
- if (
- $class->structure->keyword === Keyword::Interface
- && count($class->relations->extends) > 1
- ) {
- $annotations[] = 'extends '
- . $this->relationLinks(
- $class->relations->extends,
- $class,
- $registry,
- $view,
- $currentPage
- );
- }
-
- if ($class->relations->implements !== []) {
- $annotations[] = 'implements '
- . $this->relationLinks(
- $class->relations->implements,
- $class,
- $registry,
- $view,
- $currentPage
- );
- }
-
- if ($annotations !== []) {
- $current .= ' - ' . implode('; ', $annotations);
- }
-
- $lines[] = $current;
- $level++;
-
- $children = $registry->childrenOf($class);
- sort($children);
- foreach ($children as $child) {
- $lines[] = str_repeat(' ', $level * 4) . '- '
- . $this->fqcnLink($child, $child, $registry, $view, $currentPage);
- }
-
- return "\n\n\n"
- . implode("\n", $lines)
- . "\n\n
\n";
- }
-
- /**
- * The inverse of a trait's usage: which classes pull it in. Rendered as
- * links because, unlike the import list, every target is by construction
- * in the registry.
- */
- private function usedBy(ClassDefinition $class, Registry $registry, Presentation $view): string
- {
- $users = $registry->usedBy($class);
- if ($users === []) {
- return '';
- }
-
- sort($users);
-
- $currentPage = $this->naming->pageKey($class, $view);
-
- $links = array_map(
- fn (string $fqcn): string => $this->fqcnLink($fqcn, $fqcn, $registry, $view, $currentPage),
- $users
- );
-
- return $this->inlineList('Used by', $links, Classes::USED_BY);
- }
-
- private function uses(ClassDefinition $class): string
- {
- if ($class->imports->uses === []) {
- return '';
- }
-
- $uses = $class->imports->uses;
- sort($uses);
-
- $codes = array_map(
- static fn (string $use): string => "`{$use}`",
- $uses
- );
-
- return $this->inlineList('Uses', $codes, Classes::USES);
- }
}
diff --git a/src/Template/Templates.php b/src/Template/Templates.php
new file mode 100644
index 0000000..4daa2c0
--- /dev/null
+++ b/src/Template/Templates.php
@@ -0,0 +1,217 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Quill\Template;
+
+use Phalcon\Quill\Exceptions\MissingTemplate;
+use Phalcon\Quill\Exceptions\MissingTemplateDirectory;
+use Phalcon\Quill\Exceptions\UnknownPlaceholder;
+
+use function array_diff;
+use function array_keys;
+use function array_unique;
+use function array_values;
+use function basename;
+use function dirname;
+use function file_get_contents;
+use function glob;
+use function in_array;
+use function is_dir;
+use function is_file;
+use function levenshtein;
+use function preg_match_all;
+use function str_ends_with;
+use function strtr;
+use function substr;
+
+use const PHP_EOL;
+
+/**
+ * A template name resolved to a file and its `{{name}}` placeholders filled.
+ *
+ * A consumer's own directory is consulted before the shipped one, per name, so
+ * overriding one template does not mean vendoring the other nineteen.
+ *
+ * Substitution is a single pass: a value that happens to contain `{{title}}`
+ * is text, not an instruction. Loops, ordering and conditionals stay in PHP -
+ * this deliberately is not a template language.
+ */
+final class Templates
+{
+ private const EXTENSION = '.tpl';
+ private const PLACEHOLDER = '/\{\{([a-zA-Z][a-zA-Z0-9]*)\}\}/';
+
+ /** @var array}> */
+ private array $cache = [];
+
+ private function __construct(
+ private readonly string $format,
+ private readonly string $customDir,
+ private readonly string $shippedDir,
+ ) {
+ }
+
+ public static function for(string $format, string $templatesDir): self
+ {
+ self::guard($templatesDir);
+
+ return new self(
+ $format,
+ $templatesDir === '' ? '' : $templatesDir . '/' . $format,
+ self::shipped($format)
+ );
+ }
+
+ /**
+ * @param array $values
+ */
+ public function render(string $name, array $values): string
+ {
+ $template = $this->load($name);
+
+ $missing = array_values(array_diff($template['tokens'], array_keys($values)));
+ if ($missing !== []) {
+ throw new UnknownPlaceholder($this->format . '/' . $name, $missing);
+ }
+
+ $pairs = [];
+ foreach ($values as $key => $value) {
+ $pairs['{{' . $key . '}}'] = $value;
+ }
+
+ return strtr($template['body'], $pairs);
+ }
+
+ /**
+ * Files in the custom directory that no lookup will ever reach: a name
+ * outside the shipped set, or a template sitting above the format
+ * directory. Both otherwise produce a successful run that applied no
+ * override, which reads as the feature working.
+ *
+ * @return list
+ */
+ public static function unrecognized(string $format, string $templatesDir): array
+ {
+ self::guard($templatesDir);
+
+ if ($templatesDir === '') {
+ return [];
+ }
+
+ $known = [];
+ foreach (glob(self::shipped($format) . '/*' . self::EXTENSION) ?: [] as $path) {
+ $known[] = basename($path, self::EXTENSION);
+ }
+
+ // A format with no shipped templates has no set to be outside of, so a
+ // json run with `templates` configured warns about nothing.
+ if ($known === []) {
+ return [];
+ }
+
+ $warnings = [];
+
+ foreach (glob($templatesDir . '/' . $format . '/*' . self::EXTENSION) ?: [] as $path) {
+ $name = basename($path, self::EXTENSION);
+ if (in_array($name, $known, true)) {
+ continue;
+ }
+
+ $warnings[] = "'" . basename($path) . "' matches no known template and was ignored."
+ . PHP_EOL . ' Did you mean ' . "'" . self::nearest($name, $known) . ".tpl'?";
+ }
+
+ foreach (glob($templatesDir . '/*' . self::EXTENSION) ?: [] as $path) {
+ $warnings[] = "'" . basename($path) . "' sits outside '" . $format . "/' and was ignored."
+ . PHP_EOL . ' Move it to ' . $templatesDir . '/' . $format . '/' . basename($path);
+ }
+
+ return $warnings;
+ }
+
+ private static function guard(string $templatesDir): void
+ {
+ if ($templatesDir !== '' && !is_dir($templatesDir)) {
+ throw new MissingTemplateDirectory($templatesDir);
+ }
+ }
+
+ /**
+ * @param list $known
+ */
+ private static function nearest(string $name, array $known): string
+ {
+ $best = $known[0];
+ $distance = levenshtein($name, $best);
+
+ foreach ($known as $candidate) {
+ $current = levenshtein($name, $candidate);
+ if ($current < $distance) {
+ $best = $candidate;
+ $distance = $current;
+ }
+ }
+
+ return $best;
+ }
+
+ private static function shipped(string $format): string
+ {
+ return dirname(__DIR__, 2) . '/resources/templates/' . $format;
+ }
+
+ /**
+ * @return array{body: string, tokens: list}
+ */
+ private function load(string $name): array
+ {
+ if (isset($this->cache[$name])) {
+ return $this->cache[$name];
+ }
+
+ $searched = [];
+ $body = null;
+
+ foreach ([$this->customDir, $this->shippedDir] as $directory) {
+ if ($directory === '') {
+ continue;
+ }
+
+ $path = $directory . '/' . $name . self::EXTENSION;
+ $searched[] = $path;
+
+ if (is_file($path)) {
+ $body = (string) file_get_contents($path);
+
+ break;
+ }
+ }
+
+ if ($body === null) {
+ throw new MissingTemplate($name, $searched);
+ }
+
+ // Exactly one. Editors and .editorconfig add a trailing newline, so a
+ // template whose output must end in one is written with a blank line.
+ if (str_ends_with($body, "\n")) {
+ $body = substr($body, 0, -1);
+ }
+
+ preg_match_all(self::PLACEHOLDER, $body, $matches);
+
+ return $this->cache[$name] = [
+ 'body' => $body,
+ 'tokens' => array_values(array_unique($matches[1])),
+ ];
+ }
+}
diff --git a/tests/Fixtures/golden/phalcon_sample.md b/tests/Fixtures/golden/phalcon_sample.md
new file mode 100644
index 0000000..f39c012
--- /dev/null
+++ b/tests/Fixtures/golden/phalcon_sample.md
@@ -0,0 +1,175 @@
+---
+hide:
+ - navigation
+---
+
+!!! info "NOTE"
+
+ All classes are prefixed with `Phalcon`
+
+
+## Sample\Base
+
+Abstract
+[:material-github: Source on GitHub](https://github.com/phalcon/cphalcon/blob/5.0.x/phalcon/Sample/Base.zep){ .src-btn }
+
+The abstract root.
+
+
+
+- **`Phalcon\Sample\Base`**
+ - [`Phalcon\Sample\Child`](#samplechild)
+
+
+
+### Method Summary
+
+
+
+### Constants
+
+
+
+int
+LIMIT = 10
+How many.
+
+
+string
+MARKER
+
+
+
+### Properties
+
+
+
+protected
+string|null
+$label = null
+The label.
+
+
+public
+array
+$store
+
+
+
+### Methods
+
+Public · 1
+
+#### `describe()` { #samplebase-describe }
+
+```php
+public function describe(
+ string $text = 'none',
+ int $depth = 0
+): string;
+```
+
+Describes the subject.
+
+Protected · 1
+
+#### `conceal()` { #samplebase-conceal }
+
+```php
+protected function conceal( bool $flag );
+```
+
+
+## Sample\Child
+
+Final
+[:material-github: Source on GitHub](https://github.com/phalcon/cphalcon/blob/5.0.x/phalcon/Sample/Child.zep){ .src-btn }
+
+The concrete leaf.
+
+
+
+- [`Phalcon\Sample\Base`](#samplebase)
+ - **`Phalcon\Sample\Child`** - implements [`Phalcon\Sample\Contract`](#samplecontract)
+
+
+
+__Uses__ `Phalcon\Sample\Base` · `Phalcon\Sample\Contract`
+{ .api-uses }
+
+
+## Sample\Contract
+
+Interface
+[:material-github: Source on GitHub](https://github.com/phalcon/cphalcon/blob/5.0.x/phalcon/Sample/Contract.zep){ .src-btn }
+
+What the leaf promises.
+
+
+
+- **`Phalcon\Sample\Contract`**
+
+
+
+### Method Summary
+
+
+
+### Methods
+
+Public · 1
+
+#### `describe()` { #samplecontract-describe }
+
+```php
+public function describe( string $text ): string;
+```
+
+Describes the subject.
+
+
+## Sample\Helper
+
+Trait
+[:material-github: Source on GitHub](https://github.com/phalcon/cphalcon/blob/5.0.x/phalcon/Sample/Helper.zep){ .src-btn }
+
+Shared behavior.
+
+
+
+- **`Phalcon\Sample\Helper`**
+
+
+
+__Used by__ [`Phalcon\Sample\Child`](#samplechild)
+{ .api-used-by }
+
+
+## Sample\Plain
+
+Class
+[:material-github: Source on GitHub](https://github.com/phalcon/cphalcon/blob/5.0.x/phalcon/Sample/Plain.zep){ .src-btn }
+
+
+
+- **`Phalcon\Sample\Plain`**
+
+
diff --git a/tests/Fixtures/templates/markdown/class.tpl b/tests/Fixtures/templates/markdown/class.tpl
new file mode 100644
index 0000000..ba53e73
--- /dev/null
+++ b/tests/Fixtures/templates/markdown/class.tpl
@@ -0,0 +1 @@
+CUSTOM {{title}}
diff --git a/tests/Fixtures/templates/markdown/constants-row.tpl b/tests/Fixtures/templates/markdown/constants-row.tpl
new file mode 100644
index 0000000..ba53e73
--- /dev/null
+++ b/tests/Fixtures/templates/markdown/constants-row.tpl
@@ -0,0 +1 @@
+CUSTOM {{title}}
diff --git a/tests/Fixtures/templates/markdown/echo.tpl b/tests/Fixtures/templates/markdown/echo.tpl
new file mode 100644
index 0000000..2e0ede7
--- /dev/null
+++ b/tests/Fixtures/templates/markdown/echo.tpl
@@ -0,0 +1 @@
+literal {{value}} here
diff --git a/tests/Fixtures/templates/markdown/pair.tpl b/tests/Fixtures/templates/markdown/pair.tpl
new file mode 100644
index 0000000..69e85fd
--- /dev/null
+++ b/tests/Fixtures/templates/markdown/pair.tpl
@@ -0,0 +1 @@
+{{alpha}} {{beta}}
diff --git a/tests/Fixtures/templates/markdown/repeat.tpl b/tests/Fixtures/templates/markdown/repeat.tpl
new file mode 100644
index 0000000..1e816f7
--- /dev/null
+++ b/tests/Fixtures/templates/markdown/repeat.tpl
@@ -0,0 +1 @@
+{{word}} and {{word}} again
diff --git a/tests/Fixtures/templates/markdown/trailing.tpl b/tests/Fixtures/templates/markdown/trailing.tpl
new file mode 100644
index 0000000..1806d48
--- /dev/null
+++ b/tests/Fixtures/templates/markdown/trailing.tpl
@@ -0,0 +1,2 @@
+x
+
diff --git a/tests/Fixtures/templates/markdown/verbatim.tpl b/tests/Fixtures/templates/markdown/verbatim.tpl
new file mode 100644
index 0000000..a43a42f
--- /dev/null
+++ b/tests/Fixtures/templates/markdown/verbatim.tpl
@@ -0,0 +1 @@
+nothing to substitute
diff --git a/tests/Fixtures/templates/misplaced.tpl b/tests/Fixtures/templates/misplaced.tpl
new file mode 100644
index 0000000..cbba15e
--- /dev/null
+++ b/tests/Fixtures/templates/misplaced.tpl
@@ -0,0 +1 @@
+wrong place
diff --git a/tests/Fixtures/zep/Shapes.zep b/tests/Fixtures/zep/Shapes.zep
index e9a69c6..b4c2309 100644
--- a/tests/Fixtures/zep/Shapes.zep
+++ b/tests/Fixtures/zep/Shapes.zep
@@ -36,6 +36,10 @@ abstract class Shapes extends Consumer implements Countable, Stringable
// only thing that can name the type.
protected array registry;
+ // A declared type with a null default, which is the only union Zephir can
+ // express. The PHP twin spells the same thing `?string`.
+ protected string title = null;
+
public function withCast( item) ->
{
return item;
diff --git a/tests/Unit/Cli/GenerateCommandTest.php b/tests/Unit/Cli/GenerateCommandTest.php
index a072b86..bad1fff 100644
--- a/tests/Unit/Cli/GenerateCommandTest.php
+++ b/tests/Unit/Cli/GenerateCommandTest.php
@@ -33,13 +33,19 @@
use function glob;
use function is_dir;
use function is_file;
+use function rewind;
use function rmdir;
+use function stream_get_contents;
+use function strpos;
use function unlink;
final class GenerateCommandTest extends TestCase
{
private string $outputDir = '';
+ /** @var resource|null */
+ private $stdout = null;
+
protected function setUp(): void
{
parent::setUp();
@@ -125,6 +131,28 @@ public function testANamespaceMatchingNothingFailsBeforeAnythingIsWritten(): voi
}
}
+ /**
+ * The position is load-bearing: format() can throw on a bad placeholder,
+ * and a warning printed after the per-page lines is buried in a successful
+ * run.
+ */
+ public function testAnUnrecognizedOverrideWarnsBeforeAnyPage(): void
+ {
+ $this->command()->execute(
+ $this->config('', dirname(__DIR__, 2) . '/Fixtures/templates'),
+ Selection::none()
+ );
+
+ $output = $this->emitted();
+
+ $warningAt = strpos($output, 'Warning:');
+ $processingAt = strpos($output, 'Processing:');
+
+ $this->assertNotFalse($warningAt);
+ $this->assertNotFalse($processingAt);
+ $this->assertLessThan($processingAt, $warningAt);
+ }
+
public function testAnUnwritableAssetFailsLoudly(): void
{
$assets = $this->outputDir . '/assets';
@@ -223,19 +251,22 @@ private function clean(): void
$this->remove($this->outputDir);
}
- private function command(?Formatter $formatter = null): GenerateCommand
+ private function command(?Formatter $formatter = null, string $format = 'markdown'): GenerateCommand
{
$stdout = fopen('php://memory', 'rb+');
$this->assertIsResource($stdout);
+ $this->stdout = $stdout;
+
return new GenerateCommand(
new ReaderFactory(),
$formatter ?? new MarkdownFormatter(),
+ $format,
$stdout
);
}
- private function config(string $assetsDir = ''): Config
+ private function config(string $assetsDir = '', string $templatesDir = ''): Config
{
return new Config(
'zephir',
@@ -246,10 +277,24 @@ private function config(string $assetsDir = ''): Config
'phalcon',
'zep',
'Phalcon',
- $assetsDir
+ $assetsDir,
+ $templatesDir
);
}
+ /**
+ * Everything the last command() built wrote to its stdout.
+ */
+ private function emitted(): string
+ {
+ $stdout = $this->stdout;
+ $this->assertIsResource($stdout);
+
+ rewind($stdout);
+
+ return (string) stream_get_contents($stdout);
+ }
+
/**
* Depth first, because a run can write into nested directories now that the
* assets destination need not be the one the documents go in.
diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php
index 085269d..6719776 100644
--- a/tests/Unit/ConfigTest.php
+++ b/tests/Unit/ConfigTest.php
@@ -50,6 +50,13 @@ public function testAConfiguredAssetsDirectoryIsResolvedAgainstTheRoot(): void
$this->assertSame('/project/docs/assets/css', $config->assetsDir());
}
+ public function testAnAbsoluteTemplatesPathWins(): void
+ {
+ $config = Config::fromArray($this->values(['templates' => '/elsewhere/tpl']), '/project');
+
+ $this->assertSame('/elsewhere/tpl', $config->templatesDir());
+ }
+
/**
* The namespace is a name, not a path, so the separators a project might
* write around it are stripped rather than carried into every page key and
@@ -76,6 +83,22 @@ public function testAnEmptyOrAbsentAssetsKeyFallsBackToTheOutputDirectory(): voi
$this->assertSame($empty->outputDir(), $empty->assetsDir());
}
+ public function testARedirectedRunKeepsTemplatesAndDropsAssets(): void
+ {
+ $config = Config::fromArray(
+ $this->values(['assets' => '/assets', 'templates' => '/tpl']),
+ '/project'
+ );
+
+ $copy = $config->withOutputDir('/elsewhere');
+
+ // Templates are input: a redirected run must read the same files or it
+ // is not the same run. Assets are output, and a redirected run wants
+ // everything it produces in one place.
+ $this->assertSame('/tpl', $copy->templatesDir());
+ $this->assertSame('/elsewhere', $copy->assetsDir());
+ }
+
/**
* A root that already ends in a separator must not produce a doubled one.
*/
@@ -170,6 +193,20 @@ public function testSourceUrlNormalizesBackslashes(): void
);
}
+ public function testTemplatesDefaultsToEmptyWhenAbsent(): void
+ {
+ $config = Config::fromArray($this->values(), '/project');
+
+ $this->assertSame('', $config->templatesDir());
+ }
+
+ public function testTemplatesIsResolvedRelativeToTheConfigurationFile(): void
+ {
+ $config = Config::fromArray($this->values(['templates' => 'docs/tpl']), '/project');
+
+ $this->assertSame('/project/docs/tpl', $config->templatesDir());
+ }
+
public function testTrailingSeparatorsAreNormalized(): void
{
$config = new Config(
diff --git a/tests/Unit/ExceptionsTest.php b/tests/Unit/ExceptionsTest.php
index 550bbc4..179417a 100644
--- a/tests/Unit/ExceptionsTest.php
+++ b/tests/Unit/ExceptionsTest.php
@@ -22,8 +22,11 @@
use Phalcon\Quill\Exceptions\MissingConfigurationKey;
use Phalcon\Quill\Exceptions\MissingDependency;
use Phalcon\Quill\Exceptions\MissingDocument;
+use Phalcon\Quill\Exceptions\MissingTemplate;
+use Phalcon\Quill\Exceptions\MissingTemplateDirectory;
use Phalcon\Quill\Exceptions\UnknownFormat;
use Phalcon\Quill\Exceptions\UnknownLanguage;
+use Phalcon\Quill\Exceptions\UnknownPlaceholder;
use Phalcon\Quill\Exceptions\WriteFailed;
use PHPUnit\Framework\TestCase;
use RuntimeException;
@@ -112,6 +115,41 @@ public function testAMissingDocumentNamesTheFile(): void
);
}
+ /**
+ * Silently ignoring the key would produce a successful run that applied no
+ * override, so the message has to offer both ways out rather than only
+ * report the fault.
+ */
+ public function testAMissingTemplateDirectoryOffersBothWaysOut(): void
+ {
+ $exception = new MissingTemplateDirectory('/project/docs/tpl');
+
+ $this->assertSame(
+ "The configured templates directory '/project/docs/tpl' is not a"
+ . ' directory. Remove the `templates` key to use the shipped'
+ . ' templates, or correct the path - a run that silently ignored'
+ . ' it would apply no override and still report success.',
+ $exception->getMessage()
+ );
+ }
+
+ public function testAMissingTemplateListsEveryPathItTried(): void
+ {
+ $exception = new MissingTemplate(
+ 'class',
+ ['/project/tpl/markdown/class.tpl', '/vendor/quill/resources/templates/markdown/class.tpl']
+ );
+
+ $this->assertSame(
+ "The template 'class' was not found. Looked in:"
+ . ' /project/tpl/markdown/class.tpl,'
+ . ' /vendor/quill/resources/templates/markdown/class.tpl.'
+ . ' The shipped set ships with quill, so a copy that cannot find'
+ . ' it is incomplete rather than misconfigured.',
+ $exception->getMessage()
+ );
+ }
+
public function testAnIncompatibleDocumentReportsBothVersions(): void
{
$exception = new IncompatibleDocument('/project/model.json', 6, 7);
@@ -156,6 +194,22 @@ public function testAnUnknownLanguageListsTheOnesThatWork(): void
);
}
+ /**
+ * Every unsupplied token at once: fixing them one run at a time is the
+ * difference between one edit and five.
+ */
+ public function testAnUnknownPlaceholderNamesEveryTokenNothingSupplies(): void
+ {
+ $exception = new UnknownPlaceholder('markdown/class', ['badge', 'title']);
+
+ $this->assertSame(
+ "The template 'markdown/class' uses {{badge}}, {{title}}, which"
+ . ' nothing supplies. Placeholders a template does not use are'
+ . ' ignored; ones it invents are not.',
+ $exception->getMessage()
+ );
+ }
+
public function testAWriteFailureBlamesThePermissionsRatherThanTheUser(): void
{
$exception = new WriteFailed('/root/out.md');
@@ -182,8 +236,11 @@ public function testEveryExceptionIsCatchableAsOneAndIsARuntimeFailure(): void
new MissingConfigurationKey('branch'),
new MissingDependency('a/b', 'Something'),
new MissingDocument('/a'),
+ new MissingTemplate('class', ['/a']),
+ new MissingTemplateDirectory('/a'),
new UnknownFormat('pdf', ['json']),
new UnknownLanguage('cobol', ['php']),
+ new UnknownPlaceholder('markdown/class', ['title']),
new WriteFailed('/a'),
];
diff --git a/tests/Unit/Formatter/GoldenPageTest.php b/tests/Unit/Formatter/GoldenPageTest.php
new file mode 100644
index 0000000..43e58a2
--- /dev/null
+++ b/tests/Unit/Formatter/GoldenPageTest.php
@@ -0,0 +1,238 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Quill\Tests\Unit\Formatter;
+
+use Phalcon\Quill\Config;
+use Phalcon\Quill\Formatter\MarkdownFormatter;
+use Phalcon\Quill\Model\ClassDefinition;
+use Phalcon\Quill\Model\ClassDefinitionCollection;
+use Phalcon\Quill\Model\ConstantDefinition;
+use Phalcon\Quill\Model\ConstantDefinitionCollection;
+use Phalcon\Quill\Model\Imports;
+use Phalcon\Quill\Model\Location;
+use Phalcon\Quill\Model\Members;
+use Phalcon\Quill\Model\MethodDefinition;
+use Phalcon\Quill\Model\MethodDefinitionCollection;
+use Phalcon\Quill\Model\ParameterDefinition;
+use Phalcon\Quill\Model\ParameterDefinitionCollection;
+use Phalcon\Quill\Model\PropertyDefinition;
+use Phalcon\Quill\Model\PropertyDefinitionCollection;
+use Phalcon\Quill\Model\Registry;
+use Phalcon\Quill\Model\Relations;
+use Phalcon\Quill\Model\Structure;
+use Phalcon\Quill\Selection;
+use PHPUnit\Framework\TestCase;
+
+use function dirname;
+use function file_get_contents;
+use function file_put_contents;
+use function getenv;
+use function is_file;
+
+/**
+ * Byte equality against a committed page.
+ *
+ * The expectation was generated from the formatter as it stood before the
+ * markup moved into templates, which is what makes it evidence rather than a
+ * restatement. It then pins the whitespace convention: a template that gains
+ * or loses a newline changes what mkdocs renders, and nothing else in the
+ * suite would notice.
+ *
+ * Regenerate deliberately, never to make a red test green:
+ *
+ * docker exec -e UPDATE_GOLDEN=1 quill-8.1 \
+ * vendor/bin/phpunit -c resources/phpunit.xml.dist --filter GoldenPageTest
+ *
+ * Then read the diff. An intentional markup change shows up there; an
+ * accidental one shows up there too, which is the point.
+ */
+final class GoldenPageTest extends TestCase
+{
+ public function testThePageMatchesTheCommittedExpectation(): void
+ {
+ $documents = (new MarkdownFormatter())->format(
+ $this->registry(),
+ $this->config(),
+ new Selection('', '')
+ );
+
+ $page = $documents['phalcon_sample'] ?? self::fail('phalcon_sample page missing');
+ $path = dirname(__DIR__, 2) . '/Fixtures/golden/phalcon_sample.md';
+
+ if (getenv('UPDATE_GOLDEN') !== false) {
+ file_put_contents($path, $page);
+ }
+
+ if (!is_file($path)) {
+ self::fail('Golden file missing. Generate it with UPDATE_GOLDEN=1.');
+ }
+
+ $this->assertSame((string) file_get_contents($path), $page);
+ }
+
+ private function config(): Config
+ {
+ return new Config(
+ 'zephir',
+ '/sources',
+ '/unused',
+ 'phalcon/cphalcon',
+ '5.0.x',
+ 'phalcon',
+ 'zep',
+ 'Phalcon'
+ );
+ }
+
+ /**
+ * Five declarations chosen to reach every branch of the page layout: an
+ * abstract class carrying the members, a final class that extends it, the
+ * interface it implements, the trait it uses, and a plain class for the
+ * default badge. Between them they exercise all five badges, the
+ * inheritance tree with an ancestor and with a child, the implements
+ * annotation, both inline lists, every member section, and each of the
+ * conditional slots - a class with no description, a constant with none, a
+ * property with no default, a method with no return type and a method with
+ * no description.
+ */
+ private function registry(): Registry
+ {
+ $base = new ClassDefinition(
+ new Location('Phalcon\\Sample\\Base', 'Phalcon\\Sample', 'Sample/Base.zep'),
+ Structure::classType(true, false),
+ 'The abstract root.',
+ new Imports([], []),
+ new Relations([], [], []),
+ new Members(
+ new ConstantDefinitionCollection([
+ new ConstantDefinition('LIMIT', '10', 'int', 'How many.'),
+ new ConstantDefinition('MARKER', null, 'string', ''),
+ ]),
+ new PropertyDefinitionCollection([
+ new PropertyDefinition('label', 'protected', false, 'null', 'string|null', 'The label.', []),
+ new PropertyDefinition('secret', 'private', false, null, 'mixed', 'Hidden.', []),
+ new PropertyDefinition('store', 'public', false, null, 'array', '', []),
+ ]),
+ new MethodDefinitionCollection([
+ new MethodDefinition(
+ 'describe',
+ ['public'],
+ 'public',
+ new ParameterDefinitionCollection([
+ new ParameterDefinition('text', 'string', "'none'"),
+ new ParameterDefinition('depth', 'int', '0'),
+ ]),
+ 'string',
+ 'Describes the subject.'
+ ),
+ new MethodDefinition(
+ 'conceal',
+ ['protected'],
+ 'protected',
+ new ParameterDefinitionCollection([
+ new ParameterDefinition('flag', 'bool', null),
+ ]),
+ null,
+ ''
+ ),
+ new MethodDefinition(
+ 'internal',
+ ['private'],
+ 'private',
+ new ParameterDefinitionCollection([]),
+ 'void',
+ 'Never rendered.'
+ ),
+ ])
+ )
+ );
+
+ $child = new ClassDefinition(
+ new Location('Phalcon\\Sample\\Child', 'Phalcon\\Sample', 'Sample/Child.zep'),
+ Structure::classType(false, true),
+ 'The concrete leaf.',
+ new Imports(
+ ['Phalcon\\Sample\\Contract', 'Phalcon\\Sample\\Base'],
+ ['Base' => 'Phalcon\\Sample\\Base', 'Contract' => 'Phalcon\\Sample\\Contract']
+ ),
+ new Relations(['Base'], ['Contract'], ['Helper']),
+ new Members(
+ new ConstantDefinitionCollection(),
+ new PropertyDefinitionCollection(),
+ new MethodDefinitionCollection()
+ )
+ );
+
+ $contract = new ClassDefinition(
+ new Location('Phalcon\\Sample\\Contract', 'Phalcon\\Sample', 'Sample/Contract.zep'),
+ Structure::interface(),
+ 'What the leaf promises.',
+ new Imports([], []),
+ new Relations([], [], []),
+ new Members(
+ new ConstantDefinitionCollection(),
+ new PropertyDefinitionCollection(),
+ new MethodDefinitionCollection([
+ new MethodDefinition(
+ 'describe',
+ ['public'],
+ 'public',
+ new ParameterDefinitionCollection([
+ new ParameterDefinition('text', 'string', null),
+ ]),
+ 'string',
+ 'Describes the subject.'
+ ),
+ ])
+ )
+ );
+
+ $helper = new ClassDefinition(
+ new Location('Phalcon\\Sample\\Helper', 'Phalcon\\Sample', 'Sample/Helper.zep'),
+ Structure::trait(),
+ 'Shared behavior.',
+ new Imports([], []),
+ new Relations([], [], []),
+ new Members(
+ new ConstantDefinitionCollection(),
+ new PropertyDefinitionCollection(),
+ new MethodDefinitionCollection()
+ )
+ );
+
+ $plain = new ClassDefinition(
+ new Location('Phalcon\\Sample\\Plain', 'Phalcon\\Sample', 'Sample/Plain.zep'),
+ Structure::classType(false, false),
+ '',
+ new Imports([], []),
+ new Relations([], [], []),
+ new Members(
+ new ConstantDefinitionCollection(),
+ new PropertyDefinitionCollection(),
+ new MethodDefinitionCollection()
+ )
+ );
+
+ return new Registry(
+ ClassDefinitionCollection::fromDefinitions([
+ $base,
+ $child,
+ $contract,
+ $helper,
+ $plain,
+ ]),
+ 'Phalcon'
+ );
+ }
+}
diff --git a/tests/Unit/Formatter/Markdown/MarkupContractTest.php b/tests/Unit/Formatter/Markdown/MarkupContractTest.php
new file mode 100644
index 0000000..844b522
--- /dev/null
+++ b/tests/Unit/Formatter/Markdown/MarkupContractTest.php
@@ -0,0 +1,151 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Quill\Tests\Unit\Formatter\Markdown;
+
+use Phalcon\Quill\Formatter\Markdown\Classes;
+use PHPUnit\Framework\TestCase;
+use ReflectionClass;
+
+use function dirname;
+use function file_get_contents;
+use function glob;
+use function implode;
+use function preg_match_all;
+use function preg_replace;
+use function preg_split;
+use function str_starts_with;
+use function trim;
+
+/**
+ * The rendering contract, which after templating lives in three places: the
+ * templates that emit the class names, api.css that styles them, and the site
+ * theme supplying the --api-* palette. Distance between the first two is nil,
+ * so a drift is trivial to fix - the failure is that nothing tells you it
+ * drifted. badge--trait and .api-used-by have both already cost follow-up work
+ * that way.
+ *
+ * The reverse direction is deliberately not asserted. Selectors api.css
+ * carries that no template emits would flag dead CSS, but the file also holds
+ * --api-* custom properties and structural selectors that never appear as
+ * template tokens, so the check would be more noise than signal.
+ */
+final class MarkupContractTest extends TestCase
+{
+ /**
+ * The two declared names that are prefixes rather than complete classes,
+ * and everything the formatter appends to them. Without these, the first
+ * assertion would wave a new `badge--enum` through on the prefix alone and
+ * nothing would ask api.css for a rule.
+ *
+ * @var array>
+ */
+ private const PREFIX_VARIANTS = [
+ 'badge--' => ['abstract', 'class', 'final', 'interface', 'trait'],
+ 'vis-' => ['protected', 'public'],
+ ];
+
+ public function testEveryClassNameInATemplateIsDeclared(): void
+ {
+ $declared = $this->declared();
+
+ foreach ($this->tokens() as $token => $file) {
+ $known = false;
+ foreach ($declared as $name) {
+ if ($token === $name || str_starts_with($token, $name)) {
+ $known = true;
+
+ break;
+ }
+ }
+
+ $this->assertTrue(
+ $known,
+ "'" . $token . "' in " . $file . ' is not declared in Classes.'
+ . ' Add the constant and a rule in api.css, or correct the template.'
+ );
+ }
+ }
+
+ public function testEveryDeclaredNameHasASelector(): void
+ {
+ $css = (string) file_get_contents(dirname(__DIR__, 4) . '/resources/api.css');
+
+ foreach ($this->declared() as $name) {
+ $variants = self::PREFIX_VARIANTS[$name] ?? [''];
+
+ foreach ($variants as $variant) {
+ $this->assertStringContainsString(
+ '.' . $name . $variant,
+ $css,
+ "Classes declares '" . $name . $variant . "' and api.css does not style it."
+ );
+ }
+ }
+ }
+
+ /**
+ * @return list
+ */
+ private function declared(): array
+ {
+ /** @var list $values */
+ $values = (new ReflectionClass(Classes::class))->getConstants();
+
+ return $values;
+ }
+
+ /**
+ * Every class name the shipped templates emit, mapped to the file it came
+ * from so a failure names it.
+ *
+ * Placeholders are stripped before the attributes are read, which turns
+ * `badge--{{structure}}` into `badge--` and `vis-{{visibility}}` into
+ * `vis-` - exactly the prefixes Classes declares.
+ *
+ * @return array
+ */
+ private function tokens(): array
+ {
+ $directory = dirname(__DIR__, 4) . '/resources/templates/markdown';
+ $tokens = [];
+
+ foreach (glob($directory . '/*.tpl') ?: [] as $path) {
+ $body = (string) preg_replace(
+ '/\{\{[a-zA-Z][a-zA-Z0-9]*\}\}/',
+ '',
+ (string) file_get_contents($path)
+ );
+
+ preg_match_all('/class="([^"]*)"/', $body, $attributes);
+ foreach ($attributes[1] as $attribute) {
+ foreach (preg_split('/\s+/', trim($attribute)) ?: [] as $token) {
+ if ($token !== '') {
+ $tokens[$token] = $path;
+ }
+ }
+ }
+
+ // The `{ .name }` attribute lists mkdocs reads, which is how the
+ // source button and both inline lists carry their class.
+ preg_match_all('/\{\s*\.([A-Za-z0-9_-]+)\s*\}/', $body, $lists);
+ foreach ($lists[1] as $token) {
+ $tokens[$token] = $path;
+ }
+ }
+
+ $this->assertNotSame([], $tokens, 'No templates were read: ' . implode(', ', [$directory]));
+
+ return $tokens;
+ }
+}
diff --git a/tests/Unit/Reader/ZephirReaderTest.php b/tests/Unit/Reader/ZephirReaderTest.php
index aa6049c..4a635ce 100644
--- a/tests/Unit/Reader/ZephirReaderTest.php
+++ b/tests/Unit/Reader/ZephirReaderTest.php
@@ -90,6 +90,26 @@ public function testANullDefaultMakesTheParameterNullable(): void
$this->assertSame('int', $plain['count']);
}
+ /**
+ * Zephir writes no union, so a declared type with a null default is the
+ * only one it can express - and it is the same thing the PHP twin spells
+ * `?string`. Read as the bare declaration, the two implementations would
+ * report a parity difference that is not one.
+ */
+ public function testATypedPropertyWithANullDefaultIsNullable(): void
+ {
+ $properties = [];
+ foreach ($this->shapes()->members->properties->all() as $property) {
+ $properties[$property->name] = $property;
+ }
+
+ $this->assertSame('string|null', $properties['title']->varType);
+
+ // The same declaration without the null default stays bare, so it is
+ // the default doing the widening rather than the type being optional.
+ $this->assertSame('array', $properties['registry']->varType);
+ }
+
public function testCapturesPrivateMembers(): void
{
$class = $this->sample();
diff --git a/tests/Unit/Template/TemplatesTest.php b/tests/Unit/Template/TemplatesTest.php
new file mode 100644
index 0000000..202eb2f
--- /dev/null
+++ b/tests/Unit/Template/TemplatesTest.php
@@ -0,0 +1,209 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Quill\Tests\Unit\Template;
+
+use Phalcon\Quill\Exceptions\MissingTemplate;
+use Phalcon\Quill\Exceptions\MissingTemplateDirectory;
+use Phalcon\Quill\Exceptions\UnknownPlaceholder;
+use Phalcon\Quill\Template\Templates;
+use PHPUnit\Framework\TestCase;
+
+use function dirname;
+use function implode;
+
+/**
+ * Resolution order, substitution and the three ways a template set can be
+ * wrong.
+ *
+ * The fixtures under `tests/Fixtures/templates` stand in for a consumer's own
+ * directory; the shipped set under `resources/templates` is the fallback.
+ */
+final class TemplatesTest extends TestCase
+{
+ public function testACustomFileWinsForThatNameOnly(): void
+ {
+ $custom = Templates::for('markdown', $this->fixtures());
+ $shipped = Templates::for('markdown', '');
+
+ // The same name, two bodies: the custom directory is consulted first
+ // rather than merged into the shipped one.
+ $this->assertStringContainsString(
+ 'CUSTOM',
+ $custom->render('class', $this->classValues())
+ );
+ $this->assertStringNotContainsString(
+ 'CUSTOM',
+ $shipped->render('class', $this->classValues())
+ );
+
+ // A name the custom directory does not carry still resolves, so
+ // overriding one template is not vendoring the rest.
+ $this->assertStringContainsString(
+ 'All classes are prefixed with',
+ $custom->render('page', ['classes' => '', 'namespace' => 'Phalcon'])
+ );
+ }
+
+ public function testAFileOutsideTheFormatDirectoryIsReported(): void
+ {
+ $warnings = Templates::unrecognized('markdown', $this->fixtures());
+
+ $joined = implode("\n", $warnings);
+
+ $this->assertStringContainsString("sits outside 'markdown/'", $joined);
+ $this->assertStringContainsString('misplaced.tpl', $joined);
+ }
+
+ /**
+ * The known-name set is the shipped directory's own listing, so a format
+ * that ships none has no set to be outside of and the call is inert.
+ */
+ public function testAFormatWithNoShippedTemplatesIsInert(): void
+ {
+ $this->assertSame([], Templates::unrecognized('json', $this->fixtures()));
+ }
+
+ public function testAnUnmatchedTemplateNameIsReported(): void
+ {
+ $warnings = Templates::unrecognized('markdown', $this->fixtures());
+
+ $this->assertStringContainsString('constants-row.tpl', $warnings[0]);
+ $this->assertStringContainsString("Did you mean 'constant-row.tpl'?", $warnings[0]);
+ }
+
+ public function testAnUnsuppliedPlaceholderThrowsNamingEveryToken(): void
+ {
+ $templates = Templates::for('markdown', $this->fixtures());
+
+ $this->expectException(UnknownPlaceholder::class);
+ $this->expectExceptionMessage('{{alpha}}, {{beta}}');
+
+ $templates->render('pair', []);
+ }
+
+ public function testARepeatedPlaceholderIsSubstitutedEverywhere(): void
+ {
+ $templates = Templates::for('markdown', $this->fixtures());
+
+ $this->assertSame(
+ 'twice and twice again',
+ $templates->render('repeat', ['word' => 'twice'])
+ );
+ }
+
+ public function testAShippedTemplateIsRendered(): void
+ {
+ // No custom directory, so the only place this can come from is the set
+ // that ships with quill.
+ $templates = Templates::for('markdown', '');
+
+ $this->assertStringContainsString(
+ 'Sample\\Base',
+ $templates->render('class', $this->classValues())
+ );
+ }
+
+ public function testASuppliedValueNoPlaceholderUsesIsIgnored(): void
+ {
+ $templates = Templates::for('markdown', $this->fixtures());
+
+ // One-way strictness: a consumer's template may use fewer slots than
+ // it is handed without that being an error.
+ $this->assertSame(
+ 'twice and twice again',
+ $templates->render('repeat', ['word' => 'twice', 'unused' => 'ignored'])
+ );
+ }
+
+ public function testATemplatesPathThatIsNotADirectoryThrows(): void
+ {
+ $this->expectException(MissingTemplateDirectory::class);
+ $this->expectExceptionMessage('is not a directory');
+
+ Templates::for('markdown', $this->fixtures() . '/markdown/class.tpl');
+ }
+
+ public function testATemplateWithNoPlaceholdersRendersVerbatim(): void
+ {
+ $templates = Templates::for('markdown', $this->fixtures());
+
+ $this->assertSame(
+ 'nothing to substitute',
+ $templates->render('verbatim', [])
+ );
+ }
+
+ public function testAValueContainingAPlaceholderIsNotRescanned(): void
+ {
+ $templates = Templates::for('markdown', $this->fixtures());
+
+ // strtr makes a single pass, so a description that happens to contain
+ // {{title}} is text, not an instruction.
+ $this->assertSame(
+ 'literal {{title}} here',
+ $templates->render('echo', ['value' => '{{title}}'])
+ );
+ }
+
+ public function testExactlyOneTrailingNewlineIsStripped(): void
+ {
+ // The file ends "...
\n\n" - a blank line, which is how a template
+ // whose output must end in a newline is written. One newline goes, one
+ // survives.
+ $templates = Templates::for('markdown', $this->fixtures());
+
+ $this->assertSame("x
\n", $templates->render('trailing', []));
+ }
+
+ public function testMissingInBothThrowsNamingEveryPathItTried(): void
+ {
+ $templates = Templates::for('markdown', $this->fixtures());
+
+ $this->expectException(MissingTemplate::class);
+ $this->expectExceptionMessage('tests/Fixtures/templates/markdown/nowhere.tpl');
+
+ $templates->render('nowhere', []);
+ }
+
+ /**
+ * Every slot the shipped class template will ever carry. Supplied whole so
+ * the two tests that render it keep passing as the real markup replaces the
+ * stub - a value no template uses is ignored, which is what makes a
+ * superset safe.
+ *
+ * @return array
+ */
+ private function classValues(): array
+ {
+ return [
+ 'badge' => 'Class',
+ 'constants' => '',
+ 'description' => '',
+ 'methods' => '',
+ 'properties' => '',
+ 'sourceUrl' => 'https://example.com/Base.zep',
+ 'structure' => 'class',
+ 'summary' => '',
+ 'title' => 'Sample\\Base',
+ 'tree' => '',
+ 'usedBy' => '',
+ 'uses' => '',
+ ];
+ }
+
+ private function fixtures(): string
+ {
+ return dirname(__DIR__, 2) . '/Fixtures/templates';
+ }
+}