diff --git a/packages/typo3-docs-theme/resources/config/typo3-docs-theme.php b/packages/typo3-docs-theme/resources/config/typo3-docs-theme.php index 74781ac5f..ab66db985 100644 --- a/packages/typo3-docs-theme/resources/config/typo3-docs-theme.php +++ b/packages/typo3-docs-theme/resources/config/typo3-docs-theme.php @@ -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; @@ -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; @@ -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; @@ -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() @@ -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]); }; diff --git a/packages/typo3-docs-theme/src/Compiler/NodeTransformers/AbstractHeadingLintTransformer.php b/packages/typo3-docs-theme/src/Compiler/NodeTransformers/AbstractHeadingLintTransformer.php new file mode 100644 index 000000000..d1ef16ce6 --- /dev/null +++ b/packages/typo3-docs-theme/src/Compiler/NodeTransformers/AbstractHeadingLintTransformer.php @@ -0,0 +1,75 @@ + + */ +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'); + } +} diff --git a/packages/typo3-docs-theme/src/Compiler/NodeTransformers/LintDiscouragedPhrasesTransformer.php b/packages/typo3-docs-theme/src/Compiler/NodeTransformers/LintDiscouragedPhrasesTransformer.php new file mode 100644 index 000000000..0d4de43ae --- /dev/null +++ b/packages/typo3-docs-theme/src/Compiler/NodeTransformers/LintDiscouragedPhrasesTransformer.php @@ -0,0 +1,70 @@ + */ + 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 */ + 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)); + } +} diff --git a/packages/typo3-docs-theme/src/Compiler/NodeTransformers/MissingAnchorHeadingLintTransformer.php b/packages/typo3-docs-theme/src/Compiler/NodeTransformers/MissingAnchorHeadingLintTransformer.php new file mode 100644 index 000000000..9be4c36e2 --- /dev/null +++ b/packages/typo3-docs-theme/src/Compiler/NodeTransformers/MissingAnchorHeadingLintTransformer.php @@ -0,0 +1,55 @@ +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(), + ); + } +} diff --git a/packages/typo3-docs-theme/src/Compiler/NodeTransformers/SentenceCaseHeadingLintTransformer.php b/packages/typo3-docs-theme/src/Compiler/NodeTransformers/SentenceCaseHeadingLintTransformer.php new file mode 100644 index 000000000..873dde0c5 --- /dev/null +++ b/packages/typo3-docs-theme/src/Compiler/NodeTransformers/SentenceCaseHeadingLintTransformer.php @@ -0,0 +1,93 @@ + */ + 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 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)); + } +} diff --git a/packages/typo3-docs-theme/src/DependencyInjection/Typo3DocsThemeExtension.php b/packages/typo3-docs-theme/src/DependencyInjection/Typo3DocsThemeExtension.php index 99010a7b6..82c8b677a 100644 --- a/packages/typo3-docs-theme/src/DependencyInjection/Typo3DocsThemeExtension.php +++ b/packages/typo3-docs-theme/src/DependencyInjection/Typo3DocsThemeExtension.php @@ -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', ''), ], ], ); diff --git a/packages/typo3-docs-theme/src/EventListeners/SourceLintListener.php b/packages/typo3-docs-theme/src/EventListeners/SourceLintListener.php new file mode 100644 index 000000000..e2cfc7e97 --- /dev/null +++ b/packages/typo3-docs-theme/src/EventListeners/SourceLintListener.php @@ -0,0 +1,53 @@ + $rules */ + public function __construct( + private readonly Typo3DocsThemeSettings $themeSettings, + private readonly LoggerInterface $logger, + private readonly iterable $rules, + ) {} + + public function __invoke(PreParseDocument $event): void + { + if (!$this->themeSettings->isEnabled('lint')) { + return; + } + + $context = ['rst-file' => $event->getFileName()]; + $contents = $event->getContents(); + + foreach ($this->rules as $rule) { + foreach ($rule->lint($contents) as $warning) { + $this->logger->warning($warning, $context); + } + } + } +} diff --git a/packages/typo3-docs-theme/src/Lint/SkippedHeadingLevelSourceRule.php b/packages/typo3-docs-theme/src/Lint/SkippedHeadingLevelSourceRule.php new file mode 100644 index 000000000..3fbacfe55 --- /dev/null +++ b/packages/typo3-docs-theme/src/Lint/SkippedHeadingLevelSourceRule.php @@ -0,0 +1,153 @@ +'; + + public function lint(string $contents): array + { + $lines = explode("\n", $contents); + $lineCount = count($lines); + + /** @var list $styleOrder distinct adornment styles in first-encounter order */ + $styleOrder = []; + $previousLevel = 0; + $warnings = []; + + for ($i = 0; $i < $lineCount; $i++) { + [$title, $style, $consumed] = $this->matchHeading($lines, $i); + if ($title === null) { + continue; + } + + $existing = array_search($style, $styleOrder, true); + if ($existing === false) { + $styleOrder[] = $style; + $level = count($styleOrder); + } else { + $level = $existing + 1; + } + + if ($level > $previousLevel + 1) { + $warnings[] = sprintf('Heading "%s" (line %d) skips a heading level.', trim($title), $i + 1); + } + + $previousLevel = $level; + $i += $consumed - 1; + } + + return $warnings; + } + + /** + * Try to match a heading starting at $lines[$index], supporting both the + * underline-only and overline+underline forms. + * + * @param list $lines + * @return array{0: string|null, 1: string, 2: int} [title, style, lines consumed] + */ + private function matchHeading(array $lines, int $index): array + { + $line = $lines[$index]; + + // Overline + title + underline: adornment, title, matching adornment. + $overChar = $this->adornmentChar($line); + if ($overChar !== null && isset($lines[$index + 2]) && $this->isTitleLine($lines[$index + 1])) { + $underChar = $this->adornmentChar($lines[$index + 2]); + $title = $lines[$index + 1]; + if ($underChar === $overChar && $this->coversTitle($line, $title) && $this->coversTitle($lines[$index + 2], $title)) { + return [$title, 'over:' . $overChar, 3]; + } + } + + // Title + underline. + if ($this->isTitleLine($line) && isset($lines[$index + 1])) { + $underChar = $this->adornmentChar($lines[$index + 1]); + if ($underChar !== null && $this->coversTitle($lines[$index + 1], $line)) { + return [$line, 'under:' . $underChar, 2]; + } + } + + return [null, '', 1]; + } + + /** + * Return the single adornment character a line is built from, or null if the + * line is not an adornment line. + */ + private function adornmentChar(string $line): string|null + { + if ($this->isIndented($line)) { + return null; + } + $trimmed = trim($line); + if ($trimmed === '' || strlen($trimmed) < 2) { + return null; + } + $char = substr($trimmed, 0, 1); + if (!str_contains(self::ADORNMENT_CHARACTERS, $char)) { + return null; + } + + return $trimmed === str_repeat($char, strlen($trimmed)) ? $char : null; + } + + private function isTitleLine(string $line): bool + { + return !$this->isIndented($line) && trim($line) !== '' && $this->adornmentChar($line) === null; + } + + private function isIndented(string $line): bool + { + return $line !== '' && ($line[0] === ' ' || $line[0] === "\t"); + } + + private function coversTitle(string $adornment, string $title): bool + { + return mb_strlen(trim($adornment)) >= mb_strlen(trim($title)); + } +} diff --git a/packages/typo3-docs-theme/src/Lint/SourceLintRule.php b/packages/typo3-docs-theme/src/Lint/SourceLintRule.php new file mode 100644 index 000000000..e6c597424 --- /dev/null +++ b/packages/typo3-docs-theme/src/Lint/SourceLintRule.php @@ -0,0 +1,28 @@ + human-readable warning messages (one per finding) + */ + public function lint(string $contents): array; +} diff --git a/packages/typo3-docs-theme/src/Settings/Typo3DocsThemeSettings.php b/packages/typo3-docs-theme/src/Settings/Typo3DocsThemeSettings.php index 33f178bb1..4f1062f34 100644 --- a/packages/typo3-docs-theme/src/Settings/Typo3DocsThemeSettings.php +++ b/packages/typo3-docs-theme/src/Settings/Typo3DocsThemeSettings.php @@ -2,6 +2,9 @@ namespace T3Docs\Typo3DocsTheme\Settings; +use function in_array; +use function strtolower; + final class Typo3DocsThemeSettings { /** @@ -31,4 +34,14 @@ public function getAllSettings(): array { return $this->settings; } + + /** + * Interpret a string setting as a boolean flag. Accepts the common truthy + * tokens and treats everything else (including an unset key) as false, so + * an unrecognised value fails safe to "off". + */ + public function isEnabled(string $key): bool + { + return in_array(strtolower($this->getSettings($key, 'false')), ['1', 'true', 'yes', 'on'], true); + } } diff --git a/packages/typo3-docs-theme/tests/unit/Compiler/NodeTransformers/HeadingLintTransformersTest.php b/packages/typo3-docs-theme/tests/unit/Compiler/NodeTransformers/HeadingLintTransformersTest.php new file mode 100644 index 000000000..fb4c09c04 --- /dev/null +++ b/packages/typo3-docs-theme/tests/unit/Compiler/NodeTransformers/HeadingLintTransformersTest.php @@ -0,0 +1,101 @@ + $settings + */ + #[Test] + #[DataProvider('sentenceCaseProvider')] + public function sentenceCaseRuleFlagsTitleCaseHeadings(array $settings, string $heading, bool $expectWarning): void + { + $logger = self::spyLogger(); + $transformer = new SentenceCaseHeadingLintTransformer(new Typo3DocsThemeSettings($settings), $logger); + $transformer->enterNode(self::section($heading), self::createMock(CompilerContextInterface::class)); + + self::assertCount($expectWarning ? 1 : 0, $logger->warnings); + } + + /** + * @return iterable, string, bool}> + */ + public static function sentenceCaseProvider(): iterable + { + yield 'sentence case is fine' => [['lint' => 'true'], 'Installing the extension manager', false]; + yield 'title case is flagged' => [['lint' => 'true'], 'Installing the Extension Manager', true]; + yield 'single capitalized word is allowed' => [['lint' => 'true'], 'Working with Fluid', false]; + yield 'acronyms are not flagged' => [['lint' => 'true'], 'Configuring TYPO3 and API access', false]; + yield 'camel case identifiers are not flagged' => [['lint' => 'true'], 'Using the ViewHelper base class', false]; + yield 'default proper nouns are allowed' => [['lint' => 'true'], 'Using Composer and Docker together', false]; + yield 'custom allow list is honoured' => [['lint' => 'true', 'lint_heading_allowed_words' => 'Foo, Bar'], 'The Foo and Bar widgets', false]; + yield 'single word heading' => [['lint' => 'true'], 'Installation', false]; + yield 'disabled by default' => [[], 'Installing the Extension Manager', false]; + } + + /** + * @param array $settings + */ + #[Test] + #[DataProvider('missingAnchorProvider')] + public function missingAnchorRuleFlagsUnanchoredSubHeadings(array $settings, int $level, bool $withAnchor, bool $expectWarning): void + { + $logger = self::spyLogger(); + $transformer = new MissingAnchorHeadingLintTransformer(new Typo3DocsThemeSettings($settings), $logger); + + $section = self::section('Some Heading', $level); + if ($withAnchor) { + $section->addChildNode(new AnchorNode('some-label')); + } + $transformer->enterNode($section, self::createMock(CompilerContextInterface::class)); + + self::assertCount($expectWarning ? 1 : 0, $logger->warnings); + } + + /** + * @return iterable, int, bool, bool}> + */ + public static function missingAnchorProvider(): iterable + { + yield 'sub-heading without anchor is flagged' => [['lint' => 'true'], 2, false, true]; + yield 'sub-heading with anchor is fine' => [['lint' => 'true'], 2, true, false]; + yield 'document title is exempt' => [['lint' => 'true'], 1, false, false]; + yield 'disabled by default' => [[], 2, false, false]; + } + + private static function section(string $heading, int $level = 2): SectionNode + { + return new SectionNode(new TitleNode(new InlineCompoundNode([new PlainTextInlineNode($heading)]), $level, 'heading-id')); + } + + private static function spyLogger(): AbstractLogger + { + return new class () extends AbstractLogger { + /** @var list */ + public array $warnings = []; + + /** @param mixed[] $context */ + public function log($level, string|Stringable $message, array $context = []): void + { + if ($level === 'warning') { + $this->warnings[] = (string) $message; + } + } + }; + } +} diff --git a/packages/typo3-docs-theme/tests/unit/Compiler/NodeTransformers/LintDiscouragedPhrasesTransformerTest.php b/packages/typo3-docs-theme/tests/unit/Compiler/NodeTransformers/LintDiscouragedPhrasesTransformerTest.php new file mode 100644 index 000000000..47e44757f --- /dev/null +++ b/packages/typo3-docs-theme/tests/unit/Compiler/NodeTransformers/LintDiscouragedPhrasesTransformerTest.php @@ -0,0 +1,86 @@ + $settings + * @param list $expectedPhrases the discouraged phrases expected to be reported for $heading + */ + #[Test] + #[DataProvider('lintProvider')] + public function reportsDiscouragedPhrasesInHeadings(array $settings, string $heading, array $expectedPhrases): void + { + $logger = new class () extends AbstractLogger { + /** @var list */ + public array $warnings = []; + + /** @param mixed[] $context */ + public function log($level, string|Stringable $message, array $context = []): void + { + if ($level === 'warning') { + $this->warnings[] = (string) $message; + } + } + }; + + $transformer = new LintDiscouragedPhrasesTransformer(new Typo3DocsThemeSettings($settings), $logger); + + $section = new SectionNode(new TitleNode(new InlineCompoundNode([new PlainTextInlineNode($heading)]), 1, 'heading-id')); + $transformer->enterNode($section, self::createMock(CompilerContextInterface::class)); + + self::assertCount(count($expectedPhrases), $logger->warnings); + foreach ($expectedPhrases as $phrase) { + self::assertNotEmpty( + array_filter($logger->warnings, static fn(string $w): bool => str_contains($w, '"' . $phrase . '"')), + sprintf('Expected a warning for phrase "%s", got: %s', $phrase, implode(' | ', $logger->warnings)), + ); + } + } + + /** + * @return iterable, string, list}> + */ + public static function lintProvider(): iterable + { + // Opt-in: nothing happens unless `lint` is truthy. + yield 'disabled by default' => [[], 'Installing in Non-Composer mode', []]; + yield 'disabled explicitly' => [['lint' => 'false'], 'Installing in Non-Composer mode', []]; + yield 'disabled on garbage token' => [['lint' => 'enabled'], 'Installing in Non-Composer mode', []]; + yield 'enabled via true' => [['lint' => 'true'], 'Installing in Non-Composer mode', ['Non-Composer mode']]; + yield 'enabled via 1' => [['lint' => '1'], 'Installing in Non-Composer mode', ['Non-Composer mode']]; + yield 'enabled via yes' => [['lint' => 'yes'], 'Installing in Non-Composer mode', ['Non-Composer mode']]; + yield 'enabled case-insensitive token' => [['lint' => 'TRUE'], 'Installing in Non-Composer mode', ['Non-Composer mode']]; + + // Default phrase only triggers when present. + yield 'no discouraged phrase' => [['lint' => 'true'], 'Installing with Composer', []]; + + // Case-insensitive phrase matching. + yield 'case-insensitive heading' => [['lint' => 'true'], 'The NON-COMPOSER MODE chapter', ['Non-Composer mode']]; + + // Word-boundary matching: no false positive inside a larger word. + yield 'word boundary no false positive' => [['lint' => 'true', 'lint_discouraged_phrases' => 'id'], 'The Identifier field', []]; + yield 'word boundary real match' => [['lint' => 'true', 'lint_discouraged_phrases' => 'id'], 'The id field', ['id']]; + + // Custom phrase list replaces the default. + yield 'custom list replaces default' => [['lint' => 'true', 'lint_discouraged_phrases' => 'foo, bar'], 'Non-Composer mode is fine here', []]; + yield 'custom list matches' => [['lint' => 'true', 'lint_discouraged_phrases' => 'foo, bar'], 'foo and bar', ['foo', 'bar']]; + + // Whitespace and empty fragments are ignored; duplicates are de-duplicated (one warning, not two). + yield 'whitespace and empty fragments' => [['lint' => 'true', 'lint_discouraged_phrases' => ' foo , , bar '], 'foo bar', ['foo', 'bar']]; + yield 'duplicate phrase warns once' => [['lint' => 'true', 'lint_discouraged_phrases' => 'foo, foo'], 'foo here', ['foo']]; + } +} diff --git a/packages/typo3-docs-theme/tests/unit/EventListeners/SourceLintListenerTest.php b/packages/typo3-docs-theme/tests/unit/EventListeners/SourceLintListenerTest.php new file mode 100644 index 000000000..c10e333c5 --- /dev/null +++ b/packages/typo3-docs-theme/tests/unit/EventListeners/SourceLintListenerTest.php @@ -0,0 +1,92 @@ +warnings); + } + + #[Test] + public function runsEveryRuleAndLogsWithFileContextWhenEnabled(): void + { + $logger = self::spyLogger(); + $listener = new \T3Docs\Typo3DocsTheme\EventListeners\SourceLintListener( + new Typo3DocsThemeSettings(['lint' => 'true']), + $logger, + [self::rule(['first finding']), self::rule(['second finding', 'third finding'])], + ); + + $listener(self::event('content', 'Foo/Bar.rst')); + + self::assertSame(['first finding', 'second finding', 'third finding'], $logger->warnings); + // No "[filename]" prefix in the message; the file lives in the log context instead. + self::assertSame([['rst-file' => 'Foo/Bar.rst'], ['rst-file' => 'Foo/Bar.rst'], ['rst-file' => 'Foo/Bar.rst']], $logger->contexts); + } + + /** @param list $warnings */ + private static function rule(array $warnings): SourceLintRule + { + return new class ($warnings) implements SourceLintRule { + /** @param list $warnings */ + public function __construct(private readonly array $warnings) {} + + public function lint(string $contents): array + { + return $this->warnings; + } + }; + } + + private static function event(string $contents, string $fileName = 'index.rst'): PreParseDocument + { + // PreParseDocument's Parser dependency is final (unstubbable) and the listener + // never uses it, so build the event without the constructor and set only the + // two fields the listener reads. + $reflection = new ReflectionClass(PreParseDocument::class); + $event = $reflection->newInstanceWithoutConstructor(); + $reflection->getProperty('fileName')->setValue($event, $fileName); + $reflection->getProperty('contents')->setValue($event, $contents); + + return $event; + } + + private static function spyLogger(): AbstractLogger + { + return new class () extends AbstractLogger { + /** @var list */ + public array $warnings = []; + /** @var list */ + public array $contexts = []; + + /** @param mixed[] $context */ + public function log($level, string|\Stringable $message, array $context = []): void + { + if ($level === 'warning') { + $this->warnings[] = (string) $message; + $this->contexts[] = $context; + } + } + }; + } +} diff --git a/packages/typo3-docs-theme/tests/unit/Lint/SkippedHeadingLevelSourceRuleTest.php b/packages/typo3-docs-theme/tests/unit/Lint/SkippedHeadingLevelSourceRuleTest.php new file mode 100644 index 000000000..9d836011c --- /dev/null +++ b/packages/typo3-docs-theme/tests/unit/Lint/SkippedHeadingLevelSourceRuleTest.php @@ -0,0 +1,51 @@ + $expectedHeadings headings (by text) expected to be flagged as skipping a level + */ + #[Test] + #[DataProvider('provider')] + public function detectsSkippedHeadingLevels(string $contents, array $expectedHeadings): void + { + $warnings = (new SkippedHeadingLevelSourceRule())->lint($contents); + + self::assertCount(count($expectedHeadings), $warnings, implode(' | ', $warnings)); + foreach ($expectedHeadings as $heading) { + self::assertNotEmpty( + array_filter($warnings, static fn(string $w): bool => str_contains($w, '"' . $heading . '"')), + $heading, + ); + } + } + + /** + * @return iterable}> + */ + public static function provider(): iterable + { + yield 'no headings' => ["just a paragraph\nand another line\n", []]; + yield 'consistent nesting' => ["Aaaa\n====\n\nBbbb\n----\n", []]; + yield 'overline form' => ["====\nAaaa\n====\n\nBbbb\n----\n", []]; + + // = (l1), - (l2), back to = (l1), then a brand-new ~ style => level 3 after level 1 = skip. + yield 'skip via new deep style' => ["Aaaa\n====\n\nBbbb\n----\n\nCccc\n====\n\nDddd\n~~~~\n", ['Dddd']]; + + // Adornment-like lines inside an indented literal/code block must be ignored. + yield 'code block adornments ignored' => ["Title\n=====\n\nSub\n~~~~\n\n::\n\n xx\n ==\n yy\n ^^\n", []]; + + // A genuine skip is still detected even when a code block sits in between. + yield 'skip still detected past a code block' => ["Aaaa\n====\n\nBbbb\n----\n\nCccc\n====\n\n::\n\n code\n ~~~~\n\nDddd\n~~~~\n", ['Dddd']]; + + // Underline shorter than the title is not a valid heading adornment. + yield 'underline too short' => ["LongTitle\n==\n\nmore\n", []]; + } +} diff --git a/tests/Integration/tests/lint/lint-discouraged-phrases-disabled/expected/index.html b/tests/Integration/tests/lint/lint-discouraged-phrases-disabled/expected/index.html new file mode 100644 index 000000000..2532d9a94 --- /dev/null +++ b/tests/Integration/tests/lint/lint-discouraged-phrases-disabled/expected/index.html @@ -0,0 +1,8 @@ + +
+

Installing in Non-Composer mode 

+ +

Body text.

+ +
+ diff --git a/tests/Integration/tests/lint/lint-discouraged-phrases-disabled/input/index.rst b/tests/Integration/tests/lint/lint-discouraged-phrases-disabled/input/index.rst new file mode 100644 index 000000000..74f4c2433 --- /dev/null +++ b/tests/Integration/tests/lint/lint-discouraged-phrases-disabled/input/index.rst @@ -0,0 +1,5 @@ +=============================== +Installing in Non-Composer mode +=============================== + +Body text. diff --git a/tests/Integration/tests/lint/lint-discouraged-phrases-enabled/expected/index.html b/tests/Integration/tests/lint/lint-discouraged-phrases-enabled/expected/index.html new file mode 100644 index 000000000..2532d9a94 --- /dev/null +++ b/tests/Integration/tests/lint/lint-discouraged-phrases-enabled/expected/index.html @@ -0,0 +1,8 @@ + +
+

Installing in Non-Composer mode 

+ +

Body text.

+ +
+ diff --git a/tests/Integration/tests/lint/lint-discouraged-phrases-enabled/expected/logs/warning.log b/tests/Integration/tests/lint/lint-discouraged-phrases-enabled/expected/logs/warning.log new file mode 100644 index 000000000..15a6ff573 --- /dev/null +++ b/tests/Integration/tests/lint/lint-discouraged-phrases-enabled/expected/logs/warning.log @@ -0,0 +1 @@ +app.WARNING: Heading "Installing in Non-Composer mode" contains the discouraged phrase "Non-Composer mode". {"rst-file":"index.rst"} [] diff --git a/tests/Integration/tests/lint/lint-discouraged-phrases-enabled/input/guides.xml b/tests/Integration/tests/lint/lint-discouraged-phrases-enabled/input/guides.xml new file mode 100644 index 000000000..2d68b1a90 --- /dev/null +++ b/tests/Integration/tests/lint/lint-discouraged-phrases-enabled/input/guides.xml @@ -0,0 +1,10 @@ + + + + diff --git a/tests/Integration/tests/lint/lint-discouraged-phrases-enabled/input/index.rst b/tests/Integration/tests/lint/lint-discouraged-phrases-enabled/input/index.rst new file mode 100644 index 000000000..74f4c2433 --- /dev/null +++ b/tests/Integration/tests/lint/lint-discouraged-phrases-enabled/input/index.rst @@ -0,0 +1,5 @@ +=============================== +Installing in Non-Composer mode +=============================== + +Body text. diff --git a/tests/Integration/tests/lint/lint-heading-levels/expected/index.html b/tests/Integration/tests/lint/lint-heading-levels/expected/index.html new file mode 100644 index 000000000..93a16d85b --- /dev/null +++ b/tests/Integration/tests/lint/lint-heading-levels/expected/index.html @@ -0,0 +1,30 @@ + +
+ +

alpha 

+ +

text.

+ +
+ +

beta 

+ +

text.

+ +
+
+
+ +

gamma 

+ +

text.

+ +
+ +

deep jump 

+ +

text.

+ +
+
+ diff --git a/tests/Integration/tests/lint/lint-heading-levels/expected/logs/warning.log b/tests/Integration/tests/lint/lint-heading-levels/expected/logs/warning.log new file mode 100644 index 000000000..891698053 --- /dev/null +++ b/tests/Integration/tests/lint/lint-heading-levels/expected/logs/warning.log @@ -0,0 +1 @@ +app.WARNING: Heading "deep jump" (line 24) skips a heading level. {"rst-file":"index.rst"} [] diff --git a/tests/Integration/tests/lint/lint-heading-levels/input/guides.xml b/tests/Integration/tests/lint/lint-heading-levels/input/guides.xml new file mode 100644 index 000000000..a0823ec71 --- /dev/null +++ b/tests/Integration/tests/lint/lint-heading-levels/input/guides.xml @@ -0,0 +1,8 @@ + + + + diff --git a/tests/Integration/tests/lint/lint-heading-levels/input/index.rst b/tests/Integration/tests/lint/lint-heading-levels/input/index.rst new file mode 100644 index 000000000..5b76ea31f --- /dev/null +++ b/tests/Integration/tests/lint/lint-heading-levels/input/index.rst @@ -0,0 +1,27 @@ +.. _alpha: + +alpha +===== + +text. + +.. _beta: + +beta +---- + +text. + +.. _gamma: + +gamma +===== + +text. + +.. _deep: + +deep jump +~~~~~~~~~ + +text. diff --git a/tests/Integration/tests/lint/lint-rules-enabled/expected/index.html b/tests/Integration/tests/lint/lint-rules-enabled/expected/index.html new file mode 100644 index 000000000..dfed322ed --- /dev/null +++ b/tests/Integration/tests/lint/lint-rules-enabled/expected/index.html @@ -0,0 +1,21 @@ + +
+

Page title 

+ +

intro.

+ +
+ +

Anchored sub heading 

+ +

text.

+ +
+
+

Unanchored Sub Heading 

+ +

more text

+ +
+
+ diff --git a/tests/Integration/tests/lint/lint-rules-enabled/expected/logs/warning.log b/tests/Integration/tests/lint/lint-rules-enabled/expected/logs/warning.log new file mode 100644 index 000000000..1034eb19c --- /dev/null +++ b/tests/Integration/tests/lint/lint-rules-enabled/expected/logs/warning.log @@ -0,0 +1,2 @@ +app.WARNING: Heading "Unanchored Sub Heading" looks like Title Case; TYPO3 documentation uses sentence case. {"rst-file":"index.rst"} [] +app.WARNING: Heading "Unanchored Sub Heading" has no anchor; add a `.. _a-label:` before it so it can be referenced. {"rst-file":"index.rst"} [] diff --git a/tests/Integration/tests/lint/lint-rules-enabled/input/guides.xml b/tests/Integration/tests/lint/lint-rules-enabled/input/guides.xml new file mode 100644 index 000000000..a0823ec71 --- /dev/null +++ b/tests/Integration/tests/lint/lint-rules-enabled/input/guides.xml @@ -0,0 +1,8 @@ + + + + diff --git a/tests/Integration/tests/lint/lint-rules-enabled/input/index.rst b/tests/Integration/tests/lint/lint-rules-enabled/input/index.rst new file mode 100644 index 000000000..5d4751fcd --- /dev/null +++ b/tests/Integration/tests/lint/lint-rules-enabled/input/index.rst @@ -0,0 +1,17 @@ +========== +Page title +========== + +intro. + +.. _anchored-sub: + +Anchored sub heading +-------------------- + +text. + +Unanchored Sub Heading +---------------------- + +more text