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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 66 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ return [
'prefix' => 'phalcon',
'extension' => 'zep',
'namespace' => 'Phalcon',
'templates' => 'output/docs/templates',
];
```

Expand All @@ -87,11 +88,47 @@ return [
| `repository`, `branch`, `prefix` | build the "Source on GitHub" link: `https://github.com/<repository>/blob/<branch>/<prefix>/<path>` |
| `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/* <site>/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 `<namespace>_<segment>.<ext>`
Expand Down Expand Up @@ -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).
Expand Down
3 changes: 2 additions & 1 deletion bin/quill
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down
3 changes: 3 additions & 0 deletions resources/templates/markdown/class-description.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@

{{description}}

7 changes: 7 additions & 0 deletions resources/templates/markdown/class.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@


## {{title}}

<span class="badge badge--{{structure}}">{{badge}}</span>
[:material-github: Source on GitHub]({{sourceUrl}}){ .src-btn }
{{description}}{{tree}}{{uses}}{{usedBy}}{{summary}}{{constants}}{{properties}}{{methods}}
5 changes: 5 additions & 0 deletions resources/templates/markdown/constant-row.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div class="api-item">
<code class="ret">{{type}}</code>
<code class="sig"><span class="sc">{{name}}</span>{{default}}</code>
{{description}}</div>

6 changes: 6 additions & 0 deletions resources/templates/markdown/constants.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

### Constants

<div class="api-list">
{{rows}}</div>

2 changes: 2 additions & 0 deletions resources/templates/markdown/index-line.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- [{{namespace}} {{label}}]({{page}}.md)

9 changes: 9 additions & 0 deletions resources/templates/markdown/index.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
hide:
- toc
---

# API Index
- - -

{{lines}}
3 changes: 3 additions & 0 deletions resources/templates/markdown/method-description.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@

{{description}}

3 changes: 3 additions & 0 deletions resources/templates/markdown/method-group.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@

<div class="api-group">{{label}} · {{count}}</div>
{{methods}}
7 changes: 7 additions & 0 deletions resources/templates/markdown/method.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@

#### `{{name}}()` { #{{anchor}} }

```php
{{signature}}
```
{{description}}
3 changes: 3 additions & 0 deletions resources/templates/markdown/methods.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@

### Methods
{{groups}}
9 changes: 9 additions & 0 deletions resources/templates/markdown/page.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
hide:
- navigation
---

!!! info "NOTE"

All classes are prefixed with `{{namespace}}`
{{classes}}
6 changes: 6 additions & 0 deletions resources/templates/markdown/properties.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

### Properties

<div class="api-list">
{{rows}}</div>

6 changes: 6 additions & 0 deletions resources/templates/markdown/property-row.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div class="api-item">
<code class="vis vis-{{visibility}}">{{visibility}}</code>
<code class="ret">{{type}}</code>
<code class="sig"><span class="sv">${{name}}</span>{{default}}</code>
{{description}}</div>

2 changes: 2 additions & 0 deletions resources/templates/markdown/row-description.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<span class="desc">{{description}}</span>

2 changes: 2 additions & 0 deletions resources/templates/markdown/summary-return-type.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<code class="ret">{{type}}</code>

5 changes: 5 additions & 0 deletions resources/templates/markdown/summary-row.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<a class="api-item" href="#{{anchor}}">
<code class="vis vis-{{visibility}}">{{visibility}}</code>
{{returnType}}<code class="sig">{{signature}}</code>
{{description}}</a>

6 changes: 6 additions & 0 deletions resources/templates/markdown/summary.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

### Method Summary

<div class="api-list">
{{rows}}</div>

7 changes: 7 additions & 0 deletions resources/templates/markdown/tree.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@

<div class="api-tree" markdown>

{{lines}}

</div>

4 changes: 4 additions & 0 deletions resources/templates/markdown/used-by.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

__Used by__ {{entries}}
{ .api-used-by }

4 changes: 4 additions & 0 deletions resources/templates/markdown/uses.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

__Uses__ {{entries}}
{ .api-uses }

11 changes: 11 additions & 0 deletions src/Cli/GenerateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
) {
}
Expand All @@ -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);

Expand Down
33 changes: 29 additions & 4 deletions src/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, '/');
}

/**
Expand All @@ -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),
Expand All @@ -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) : '',
);
}

Expand Down Expand Up @@ -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,
Expand All @@ -214,6 +237,8 @@ public function withOutputDir(string $outputDir): self
$this->sourcePrefix,
$this->extension,
$this->rootNamespace,
'',
$this->templatesDir,
);
}
}
32 changes: 32 additions & 0 deletions src/Exceptions/MissingTemplate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

/**
* This file is part of the Phalcon Quill.
*
* (c) Phalcon Team <team@phalcon.io>
*
* 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<string> $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.'
);
}
}
Loading