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
177 changes: 177 additions & 0 deletions .github/scripts/check-links.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<?php

declare(strict_types=1);

/*
* Confere todo link relativo e toda âncora dos markdowns do repositório.
*
* A documentação são 17 arquivos desde que o README foi quebrado em docs/, e o
* link entre eles é relativo. Link relativo quebra em silêncio: markdown não
* reclama, o PHPStan não olha e o GitHub só mostra o 404 para quem clicar.
* Este script é o que reclama.
*
* Para cada link que não é http(s), mailto ou tel:
* - o arquivo (ou a pasta) apontado precisa existir;
* - se o link tem #âncora, algum título daquele arquivo precisa gerar essa
* âncora pela regra do GitHub — minúsculas, pontuação fora, espaço vira
* hífen, acento fica.
*
* Título dentro de bloco de código NÃO conta como título. Foi assim que a
* cerca não fechada no exemplo da Cielo passou meses escondida: o `### Rede`
* virou código, e dez links do sumário ficaram apontando para âncoras que não
* existiam mais. Um verificador que ignorasse as cercas diria que estava tudo
* bem.
*
* Uso: php .github/scripts/check-links.php (ou composer test:links)
*/

$raiz = dirname(__DIR__, 2);
$pular = ['vendor', 'node_modules', '.git', '.claude'];

/**
* âncora que o GitHub gera para um título.
*
* @param string $titulo
* @return string
*/
function ancoraDe(string $titulo): string
{
$texto = trim($titulo);
$texto = function_exists('mb_strtolower') ? mb_strtolower($texto, 'UTF-8') : strtolower($texto);
$texto = (string) preg_replace('/[`*_]/u', '', $texto);
$texto = (string) preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $texto);

return str_replace(' ', '-', trim($texto));
}

/**
* linhas do arquivo que estão fora de bloco de código.
*
* @param string $arquivo
* @return array<int, string> número da linha => conteúdo
*/
function linhasForaDeBloco(string $arquivo): array
{
$fora = [];
$cerca = false;

foreach (explode("\n", (string) file_get_contents($arquivo)) as $i => $linha) {
if (str_starts_with(ltrim($linha), '```')) {
$cerca = !$cerca;

continue;
}

if (!$cerca) {
$fora[$i + 1] = $linha;
}
}

return $fora;
}

/**
* âncoras que os títulos daquele arquivo geram.
*
* @param string $arquivo
* @return array<int, string>
*/
function ancorasDe(string $arquivo): array
{
$ancoras = [];

foreach (linhasForaDeBloco($arquivo) as $linha) {
if (preg_match('/^#{1,6} (.+)$/u', $linha, $m) === 1) {
$ancoras[] = ancoraDe($m[1]);
}
}

return $ancoras;
}

/* todo markdown do repositório, menos o que não é nosso */
$arquivos = [];
$arvore = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($raiz, FilesystemIterator::SKIP_DOTS),
static fn (SplFileInfo $item): bool => !in_array($item->getFilename(), $pular, true)
)
);

foreach ($arvore as $item) {
if ($item instanceof SplFileInfo && $item->getExtension() === 'md') {
$arquivos[] = $item->getPathname();
}
}

sort($arquivos);

$cache = [];
$conferidos = 0;
$quebrados = [];

foreach ($arquivos as $arquivo) {
foreach (linhasForaDeBloco($arquivo) as $numero => $linha) {
if (preg_match_all('/\[[^\]]*\]\(([^)\s]+)\)/u', $linha, $achados) === 0) {
continue;
}

foreach ($achados[1] as $destino) {
if (preg_match('#^(https?|mailto|tel):#i', $destino) === 1) {
continue;
}

$conferidos++;
[$caminho, $ancora] = array_pad(explode('#', $destino, 2), 2, '');
$relativo = str_replace($raiz . '/', '', $arquivo);

if ($caminho === '') {
$alvo = $arquivo;
} else {
$alvo = realpath(dirname($arquivo) . '/' . $caminho);

if ($alvo === false) {
$quebrados[] = sprintf('%s:%d %s — não existe', $relativo, $numero, $destino);

continue;
}

if (!str_starts_with($alvo, $raiz)) {
$quebrados[] = sprintf('%s:%d %s — sai do repositório', $relativo, $numero, $destino);

continue;
}
}

if ($ancora === '' || !str_ends_with($alvo, '.md')) {
continue;
}

$cache[$alvo] ??= ancorasDe($alvo);

if (!in_array($ancora, $cache[$alvo], true)) {
$quebrados[] = sprintf(
'%s:%d %s — nenhum título gera essa âncora em %s',
$relativo,
$numero,
$destino,
str_replace($raiz . '/', '', $alvo)
);
}
}
}
}

printf("%d arquivos, %d links relativos conferidos\n", count($arquivos), $conferidos);

if ($quebrados !== []) {
printf("\n%d quebrado(s):\n", count($quebrados));

foreach ($quebrados as $quebrado) {
printf(" %s\n", $quebrado);
}

exit(1);
}

echo "nenhum link quebrado\n";
6 changes: 6 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ jobs:
- name: Static analysis (PHPStan)
run: composer test:types

# a documentação são 17 arquivos com link relativo entre eles; link
# quebrado não aparece em teste nem em análise estática, só no 404 de
# quem clicar.
- name: Links da documentação
run: composer test:links

test:
name: PHP ${{ matrix.php }} - ${{ matrix.dependencies }}
runs-on: ubuntu-latest
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,32 @@ O de-para completo da v1 para a v2, quebra por quebra, está no
([#111], [#112]).
- Cobertura de testes medida no CI, num job só, com o número no log e no resumo
do job. Sem mínimo que reprove o build ([#111], [#112]).
- Verificador de links da documentação no gate, em `composer test:links`. Ele
confere que todo link relativo aponta para arquivo que existe e que toda
âncora corresponde a um título de verdade — título dentro de bloco de código
não conta, que foi como o erro abaixo passou tanto tempo escondido
([#124]).

### Alterado

- **A documentação saiu do README para `docs/`**, uma página por conceito e uma
por gateway, com índice. O README fica com o essencial: o que é, a matriz de
capacidades, requisitos, instalação e início rápido — de 46 KB para 11 KB.
**Link externo que apontava para âncora do README deixa de funcionar**; o
índice de [`docs/`](docs/) cobre todos os destinos ([#113], [#114]).
- A matriz de capacidades ganhou a coluna de **assinatura de webhook**, que
entrou na v2.3.0 e tinha ficado fora da tabela — quem lia era informado de
que a biblioteca fazia menos do que faz ([#113], [#114]).

### Corrigido

- **O README renderizava errado da Rede até a Efí.** O bloco de código do
exemplo de recorrência da Cielo nunca era fechado, e cercas alternam: o
título `### Rede` e a prosa embaixo dele saíam como código, os exemplos
seguintes saíam como prosa. Por causa disso, **dez links do sumário**
apontavam para âncoras que não existiam ([#113], [#114]).
- O link para o README no `.github/SUPPORT.md` apontava para `.github/README.md`
([#113], [#114]).

## [2.3.0] - 2026-10-22

Expand Down Expand Up @@ -438,3 +464,6 @@ Primeira versão publicada no Packagist, sob licença MIT.
[#102]: https://github.com/phpay-io/phpay/pull/102
[#111]: https://github.com/phpay-io/phpay/issues/111
[#112]: https://github.com/phpay-io/phpay/pull/112
[#113]: https://github.com/phpay-io/phpay/issues/113
[#114]: https://github.com/phpay-io/phpay/pull/114
[#124]: https://github.com/phpay-io/phpay/issues/124
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,11 @@ Rodar direto no host (requer PHP 8.2+ e Composer):

```bash
composer install
composer test # lint + unit + types — mesmo gate do CI
composer test # lint + unit + types + links — mesmo gate do CI
composer test:lint # pint --test (PSR-12 + regras do pint.json)
composer test:unit # pest
composer test:types # phpstan (nível e phpVersion vêm do phpstan.neon)
composer test:links # links relativos e âncoras dos markdowns (.github/scripts/check-links.php)
composer lint # pint -v (corrige)
```

Expand Down
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,12 @@
"test:lint": "pint --test -v",
"test:unit": "pest --colors=always",
"test:types": "phpstan analyse",
"test:links": "@php .github/scripts/check-links.php",
"test": [
"@test:lint",
"@test:unit",
"@test:types"
"@test:types",
"@test:links"
]
}
}
Loading