Skip to content
Draft
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
24 changes: 23 additions & 1 deletion packages/typo3-docs-theme/resources/config/typo3-docs-theme.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use phpDocumentor\Guides\Event\PostParseDocument;
use phpDocumentor\Guides\Event\PostProjectNodeCreated;
use phpDocumentor\Guides\Event\PostRenderProcess;
use phpDocumentor\Guides\Event\PreParseDocument;
use phpDocumentor\Guides\Event\PreParseProcess;
use phpDocumentor\Guides\Graphs\Renderer\PlantumlServerRenderer;
use phpDocumentor\Guides\ReferenceResolvers\DelegatingReferenceResolver;
Expand All @@ -22,7 +23,10 @@
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\CollectFileObjectsTransformer;
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\CollectPrefixLinkTargetsTransformer;
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\ConfvalMenuNodeTransformer;
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\LintDiscouragedPhrasesTransformer;
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\MissingAnchorHeadingLintTransformer;
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\RedirectsNodeTransformer;
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\SentenceCaseHeadingLintTransformer;
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\RemoveInterlinkSelfReferencesFromCrossReferenceNodeTransformer;
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\ReplacePermalinksNodeTransformer;
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\SortMenuEntriesByToctreeTransformer;
Expand All @@ -46,7 +50,9 @@
use T3Docs\Typo3DocsTheme\EventListeners\CopyResources;
use T3Docs\Typo3DocsTheme\EventListeners\IgnoreLocalizationsFolders;
use T3Docs\Typo3DocsTheme\EventListeners\OriginalFileNameSetter;
use T3Docs\Typo3DocsTheme\EventListeners\SourceLintListener;
use T3Docs\Typo3DocsTheme\EventListeners\TestingModeActivator;
use T3Docs\Typo3DocsTheme\Lint\SkippedHeadingLevelSourceRule;
use T3Docs\Typo3DocsTheme\Inventory\DefaultInterlinkParser;
use T3Docs\Typo3DocsTheme\Inventory\DefaultInventoryUrlBuilder;
use T3Docs\Typo3DocsTheme\Inventory\InterlinkParserInterface;
Expand Down Expand Up @@ -119,6 +125,12 @@
->tag('phpdoc.guides.compiler.nodeTransformers')
->set(Typo3TalkNodeTransformer::class)
->tag('phpdoc.guides.compiler.nodeTransformers')
->set(LintDiscouragedPhrasesTransformer::class)
->tag('phpdoc.guides.compiler.nodeTransformers')
->set(SentenceCaseHeadingLintTransformer::class)
->tag('phpdoc.guides.compiler.nodeTransformers')
->set(MissingAnchorHeadingLintTransformer::class)
->tag('phpdoc.guides.compiler.nodeTransformers')
->set(TwigExtension::class)
->tag('twig.extension')
->autowire()
Expand Down Expand Up @@ -255,5 +267,15 @@
->tag('event_listener', ['event' => PreParseProcess::class])

->set(OriginalFileNameSetter::class)
->tag('event_listener', ['event' => PostParseDocument::class]);
->tag('event_listener', ['event' => PostParseDocument::class])

// Source-level lint rules (#1157) and the listener that runs them.
// (Pure source-hygiene checks — tabs, trailing whitespace, line length —
// are intentionally NOT done here; they belong to .editorconfig /
// editorconfig-checker. Only RST-semantic source checks live here.)
->set(SkippedHeadingLevelSourceRule::class)
->tag('typo3docs.lint.source_rule')
->set(SourceLintListener::class)
->arg('$rules', tagged_iterator('typo3docs.lint.source_rule'))
->tag('event_listener', ['event' => PreParseDocument::class]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/

namespace T3Docs\Typo3DocsTheme\Compiler\NodeTransformers;

use phpDocumentor\Guides\Compiler\CompilerContextInterface;
use phpDocumentor\Guides\Compiler\NodeTransformer;
use phpDocumentor\Guides\Nodes\Node;
use phpDocumentor\Guides\Nodes\SectionNode;
use Psr\Log\LoggerInterface;
use T3Docs\Typo3DocsTheme\Settings\Typo3DocsThemeSettings;

/**
* Base class for opt-in, per-section heading lint rules (see #1157).
*
* The base owns the cross-cutting concerns shared by every heading rule:
* the opt-in gate (the `lint` theme setting, default off), the SectionNode
* filtering and the warning channel. Subclasses only implement checkSection().
*
* Linting is intentionally opt-in and warning-only: render-guides renders
* third-party extension documentation whose authors we cannot reliably reach,
* so a rule must never break or reject a render unless the caller explicitly
* enables linting and passes --fail-on-log.
*
* @implements NodeTransformer<SectionNode>
*/
abstract class AbstractHeadingLintTransformer implements NodeTransformer
{
public function __construct(
protected readonly Typo3DocsThemeSettings $themeSettings,
protected readonly LoggerInterface $logger,
) {}

final public function enterNode(Node $node, CompilerContextInterface $compilerContext): Node
{
if ($node instanceof SectionNode && $this->isLintEnabled()) {
$this->checkSection($node, $compilerContext);
}

return $node;
}

final public function leaveNode(Node $node, CompilerContextInterface $compilerContext): Node
{
return $node;
}

final public function supports(Node $node): bool
{
return $node instanceof SectionNode;
}

final public function getPriority(): int
{
// Read-only pass; ordering relative to other transformers is irrelevant.
return 1000;
}

abstract protected function checkSection(SectionNode $section, CompilerContextInterface $compilerContext): void;

protected function isLintEnabled(): bool
{
return $this->themeSettings->isEnabled('lint');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/

namespace T3Docs\Typo3DocsTheme\Compiler\NodeTransformers;

use phpDocumentor\Guides\Compiler\CompilerContextInterface;
use phpDocumentor\Guides\Nodes\SectionNode;

use function array_filter;
use function array_map;
use function array_unique;
use function array_values;
use function explode;
use function preg_match;
use function preg_quote;
use function sprintf;
use function trim;

/**
* Opt-in heading lint rule (#1157): warns when a section heading contains a
* discouraged phrase (e.g. "Non-Composer mode").
*
* The phrase list defaults to {@see self::DEFAULT_DISCOURAGED_PHRASES} and can
* be overridden via the comma-separated `lint_discouraged_phrases` setting.
* Matching is case-insensitive and bound to whole words, so a short phrase does
* not match inside a larger word (e.g. "id" does not flag "Identifier").
*/
final class LintDiscouragedPhrasesTransformer extends AbstractHeadingLintTransformer
{
/** @var list<string> */
private const DEFAULT_DISCOURAGED_PHRASES = ['Non-Composer mode'];

protected function checkSection(SectionNode $section, CompilerContextInterface $compilerContext): void
{
$heading = $section->getTitle()->toString();

foreach ($this->getDiscouragedPhrases() as $phrase) {
if (preg_match('/\b' . preg_quote($phrase, '/') . '\b/iu', $heading) !== 1) {
continue;
}
$this->logger->warning(
sprintf('Heading "%s" contains the discouraged phrase "%s".', $heading, $phrase),
$compilerContext->getLoggerInformation(),
);
}
}

/** @return list<string> */
private function getDiscouragedPhrases(): array
{
$configured = trim($this->themeSettings->getSettings('lint_discouraged_phrases', ''));
if ($configured === '') {
return self::DEFAULT_DISCOURAGED_PHRASES;
}

$phrases = array_filter(array_map(trim(...), explode(',', $configured)), static fn(string $phrase): bool => $phrase !== '');

return array_values(array_unique($phrases));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/

namespace T3Docs\Typo3DocsTheme\Compiler\NodeTransformers;

use phpDocumentor\Guides\Compiler\CompilerContextInterface;
use phpDocumentor\Guides\Nodes\AnchorNode;
use phpDocumentor\Guides\Nodes\SectionNode;

use function sprintf;

/**
* Opt-in heading lint rule (#1157): warns when a section heading has no explicit
* anchor (a `.. _label:` target), so it cannot be linked to with a stable
* permalink / `:ref:`.
*
* An explicit label is parsed into an {@see AnchorNode}. The upstream
* MoveAnchorTransformer (priority 30000) runs before this rule (priority 1000)
* and relocates each such AnchorNode into the section it precedes, so by the
* time this rule runs a labelled section has the AnchorNode as a direct child.
* A section is therefore considered anchored when any of its direct children is
* an AnchorNode. The top-level document title (heading level 1) is exempt: it is
* addressable by the document path itself.
*/
final class MissingAnchorHeadingLintTransformer extends AbstractHeadingLintTransformer
{
protected function checkSection(SectionNode $section, CompilerContextInterface $compilerContext): void
{
// The document title is referenceable by path; only sub-headings need an explicit anchor.
if ($section->getTitle()->getLevel() <= 1) {
return;
}

foreach ($section->getChildren() as $child) {
if ($child instanceof AnchorNode) {
return;
}
}

$this->logger->warning(
sprintf('Heading "%s" has no anchor; add a `.. _a-label:` before it so it can be referenced.', $section->getTitle()->toString()),
$compilerContext->getLoggerInformation(),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/

namespace T3Docs\Typo3DocsTheme\Compiler\NodeTransformers;

use phpDocumentor\Guides\Compiler\CompilerContextInterface;
use phpDocumentor\Guides\Nodes\SectionNode;

use function array_filter;
use function array_map;
use function array_merge;
use function array_slice;
use function count;
use function explode;
use function in_array;
use function preg_match;
use function preg_split;
use function sprintf;
use function strtolower;
use function trim;

/**
* Opt-in heading lint rule (#1157): warns when a heading looks like Title Case
* rather than sentence case (TYPO3 documentation uses sentence case).
*
* This is a deliberately conservative heuristic to avoid false positives on the
* many legitimately-capitalized terms in technical headings:
* - the first word is ignored (sentence case capitalizes it);
* - only simple Capitalized words (^[A-Z][a-z]+$) are counted, so ALL-CAPS
* acronyms (TYPO3, API, PHP) and CamelCase identifiers (ViewHelper) are
* never flagged;
* - known proper nouns are exempt via {@see self::DEFAULT_ALLOWED_WORDS} plus
* the comma-separated `lint_heading_allowed_words` setting;
* - a heading is only flagged when at least two such words occur, since a
* single capitalized word is most likely a proper noun.
*/
final class SentenceCaseHeadingLintTransformer extends AbstractHeadingLintTransformer
{
/** @var list<string> */
private const DEFAULT_ALLOWED_WORDS = [
'Composer', 'Fluid', 'Extbase', 'Camino', 'Bootstrap', 'Symfony', 'Twig',
'Docker', 'Packagist', 'Git', 'Vite', 'Node', 'Sass', 'Markdown', 'Linux',
'Windows', 'English', 'German',
];

private const TITLE_CASE_THRESHOLD = 2;

protected function checkSection(SectionNode $section, CompilerContextInterface $compilerContext): void
{
$heading = $section->getTitle()->toString();
$words = preg_split('/\s+/u', trim($heading), -1, PREG_SPLIT_NO_EMPTY);
if ($words === false || count($words) < 2) {
return;
}

$allowed = $this->getAllowedWords();
$titleCaseWords = 0;
// Skip the first word: sentence case capitalizes it legitimately.
foreach (array_slice($words, 1) as $word) {
if (preg_match('/^[A-Z][a-z]+$/', $word) === 1 && !in_array(strtolower($word), $allowed, true)) {
$titleCaseWords++;
}
}

if ($titleCaseWords >= self::TITLE_CASE_THRESHOLD) {
$this->logger->warning(
sprintf('Heading "%s" looks like Title Case; TYPO3 documentation uses sentence case.', $heading),
$compilerContext->getLoggerInformation(),
);
}
}

/** @return list<string> lower-cased allowed words */
private function getAllowedWords(): array
{
$configured = trim($this->themeSettings->getSettings('lint_heading_allowed_words', ''));
$extra = $configured === ''
? []
: array_filter(array_map(trim(...), explode(',', $configured)), static fn(string $w): bool => $w !== '');

return array_map(strtolower(...), array_merge(self::DEFAULT_ALLOWED_WORDS, $extra));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ public function load(array $configs, ContainerBuilder $container): void
'typo3_core_preferred' => $this->getConfigValue($configs, 'typo3_core_preferred', ''),
'confval_default' => $this->getConfigValue($configs, 'confval_default', 'Option'),
'disable_version_switch' => $this->getConfigValue($configs, 'disable_version_switch', ''),
'lint' => $this->getConfigValue($configs, 'lint', 'false'),
'lint_discouraged_phrases' => $this->getConfigValue($configs, 'lint_discouraged_phrases', ''),
'lint_heading_allowed_words' => $this->getConfigValue($configs, 'lint_heading_allowed_words', ''),
],
],
);
Expand Down
Loading