Skip to content
Closed
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
22 changes: 22 additions & 0 deletions bin/validate-md-links
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ declare(strict_types=1);
* Checks that internal links (relative paths and anchors) in Markdown files
* point to existing files and valid anchors. External URLs, mailto: and
* images are skipped. Links inside fenced code blocks are ignored.
* Valid anchor targets are ATX headings (# ...) and explicit HTML anchors
* (<a id="..."> / <a name="...">), both GitHub-compatible.
*
* Usage:
* php bin/validate-md-links.php [options]
Expand Down Expand Up @@ -265,6 +267,21 @@ function generateSlug(string $heading): string
return $slug ?? '';
}

/**
* Extract explicit HTML anchor ids from a line: <a id="..."> or <a name="...">.
* GitHub honours both forms as link targets, so the validator must too.
*
* @return list<string>
*/
function extractHtmlAnchors(string $line): array
{
if (!preg_match_all('/<a\s[^>]*?\b(?:id|name)\s*=\s*(["\'])([^"\'>]+)\1/i', $line, $m)) {
return [];
}

return array_values(array_filter($m[2]));
}

/**
* Build an anchor index from file content.
* Returns map: [slug => true] (all anchors including deduplicated ones).
Expand All @@ -281,6 +298,11 @@ function buildAnchorIndex(string $content): array
$slugCounts = [];

foreach (explode("\n", $content) as $line) {
// Explicit HTML anchors: <a id="..."> / <a name="...">, used as-is
foreach (extractHtmlAnchors($line) as $id) {
$anchors[$id] = true;
}

// Match ATX headings: # ... ######
if (!preg_match('/^#{1,6}\s+(.+)$/', $line, $m)) {
continue;
Expand Down
1 change: 1 addition & 0 deletions docs/conventions/ops/validate-md-links.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ description: Настройка и использование валидатор
- Внешние URL (`https://`, `mailto:`), изображения и ссылки внутри `fenced code blocks` **игнорируются**.
- Валидатор поддерживает `inline`-ссылки, `reference-style`-ссылки и якоря (`anchors`).
- Генерация `slug` якоря совместима с GitHub: нижний регистр (`lower-case`), русские символы, дубликаты с суффиксами `-1`, `-2`.
- Целями якорей считаются заголовки ATX (`#`…`######`) и явные HTML-якоря `<a id="...">` / `<a name="...">` — обе формы работают на GitHub и принимаются как есть, без преобразования `slug`.

## Что проверяется

Expand Down
16 changes: 15 additions & 1 deletion tests/MdLinksValidator/MdLinksValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,23 @@ public function testValidReferenceLinks(): void
$this->assertStringNotContainsString('second-id', self::$fixtureOutput);
}

public function testRecognizesExplicitHtmlAnchors(): void
{
// Explicit <a id="..."> / <a name="..."> anchors are GitHub-valid targets
$this->assertStringNotContainsString('#explicit-term', self::$fixtureOutput);
$this->assertStringNotContainsString('#shared-term', self::$fixtureOutput);
$this->assertStringNotContainsString('#legacy-anchor', self::$fixtureOutput);
}

public function testDetectsBrokenExplicitAnchor(): void
{
$this->assertStringContainsString('root.md', self::$fixtureOutput);
$this->assertStringContainsString('anchor not found: #no-such-explicit', self::$fixtureOutput);
}

public function testReportsCorrectErrorCount(): void
{
$this->assertStringContainsString('Found 5 broken link(s)', self::$fixtureOutput);
$this->assertStringContainsString('Found 6 broken link(s)', self::$fixtureOutput);
}

public function testNoFailOptionExitsZero(): void
Expand Down
14 changes: 14 additions & 0 deletions tests/fixtures/md-links/root.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,17 @@ More content.
A link to [duplicate heading](#duplicate-heading-1).

A link to [russian anchor](subdir/target.md#русский-заголовок).

## Explicit HTML anchors

<a id="explicit-term"></a>
**Explicit term** — definition with an explicit anchor.

A [local explicit anchor](#explicit-term) in this file.

A [cross-file explicit anchor](subdir/target.md#shared-term).

<a name="legacy-anchor"></a>
A [legacy name anchor](#legacy-anchor).

A [broken explicit anchor](#no-such-explicit).
3 changes: 3 additions & 0 deletions tests/fixtures/md-links/subdir/target.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Target File

<a id="shared-term"></a>
**Shared term** — target for a cross-file explicit anchor.

## Section One

Target content.
Expand Down
92 changes: 92 additions & 0 deletions todo/done/TASK-fix-md-links-html-anchors.todo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
type: fix
created: 2026-09-21 23:25:00 (1790007900)
due:
started: 2026-09-21 23:25:30 (1790007930)
completed: 2026-09-21 16:28:27 (1790008107)
cancelled:
value: V2
complexity: C1
priority: P2
cost_plan:
cost_fact:
depends_on:
epic:
author: Бэкендер Тони (pi)
assignee: Бэкендер Тони (pi)
branch: task/md-links-html-anchors
pr: https://github.com/prikotov/coding-standard/pull/126
status: done
---

# TASK-fix-md-links-html-anchors: validate-md-links — распознавать явные HTML-якоря

## 0. Простое описание (Human Brief)

### Проблема простыми словами (Problem)
- `validate-md-links` строит индекс якорей только из заголовков ATX (`#`…`######`).
- Пакет `prikotov/git-workflow` использует в глоссарии явные HTML-якоря `<a id="...">` (валидные на GitHub): валидатор ложно помечает ссылки `#deployment`, `glossary.md#release-publishing` как битые.

### Варианты или путь решения (Solution Sketch)
- Дополнить `buildAnchorIndex()` сбором явных HTML-якорей `<a id="...">` / `<a name="...">` (принимаются как есть, без slug-преобразования — так же, как их разрешает GitHub).
- Покрыть фикстурами и тестами, обновить документацию инструмента.

### Ожидаемый результат (Expected Result)
- Ссылки на явные HTML-якоря валидируются корректно; документация `prikotov/git-workflow` проходит проверку без ложных срабатываний.

## 1. Концепция и Цель (Concept and Goal)

### История (User Story)
> **User Story:** Как потребитель пакета, я хочу, чтобы `validate-md-links` признавал явные HTML-якоря `<a id>`/`<a name>` целями ссылок — как это делает GitHub, — чтобы документация с глоссариями не падала в проверках из-за ложных `broken-anchor`.

### Цель по SMART (Goal)
- В `bin/validate-md-links` индекс якорей собирает и заголовки, и явные HTML-якоря; добавлены фикстуры/тесты (валидные и битые явные якоря); обновлена дока `docs/conventions/ops/validate-md-links.md`; `composer check` зелёный.

## 2. Контекст и Границы (Context and Scope)
* **Где делаем:** `bin/validate-md-links`, `tests/fixtures/md-links/`, `tests/MdLinksValidator/MdLinksValidatorTest.php`, `docs/conventions/ops/validate-md-links.md`.
* **Границы (Out of Scope):** не менять slug-генерацию заголовков; не поддерживать прочие HTML-конструкции (кроме `<a id>`/`<a name>`); не трогать остальные инструменты пакета.

## 3. Требования, MoSCoW (Requirements)
### 🔴 Обязательно (Must Have)
- [x] `buildAnchorIndex()` собирает явные HTML-якоря `<a id="...">` / `<a name="...">` (регистронезависимо, кавычки обоих видов, якорь принимается как есть).
- [x] Фикстуры: валидные локальный/межфайловый/legacy `name`-якорь и битый явный якорь.
- [x] Тесты: распознавание валидных, детект битого, счётчик ошибок обновлён.
- [x] Документация инструмента описывает поддержку HTML-якорей.
### ⚫ Won't Have (Не будем делать)
- Поддержку многострочных HTML-якорей и якорей внутри `inline code` (те же ограничения, что и у заголовков).

## 4. План реализации (Implementation Plan)
1. [x] Добавить `extractHtmlAnchors()` и включить его в `buildAnchorIndex()`.
2. [x] Расширить фикстуры и тесты.
3. [x] Обновить документацию.
4. [x] Прогнать `composer check` и PHPUnit; сверить на документации `prikotov/git-workflow`.

## 5. Критерии приёмки (Definition of Done)
- [x] `vendor/bin/phpunit tests/MdLinksValidator/` — зелёный (21 тест).
- [x] `composer check` — зелёный (289 тестов, PHPStan, phpcs, validate-docs, language).
- [x] `php bin/validate-md-links /path/to/git-workflow/docs/git-workflow/` — все ссылки валидны (исходный кейс закрыт).

## 6. Самопроверка (Verification)
```bash
vendor/bin/phpunit
composer check
php bin/validate-md-links <git-workflow>/docs/git-workflow/
```

## 7. Риски и зависимости (Risks и Dependencies)
- Поведение совместимо: индекс только расширяется (новые цели), ложных «битых» меньше, новые ошибки возможны только там, где ссылки вели на несуществующие явные якоря — это корректное срабатывание.

## 8. Источники (Sources)
- `bin/validate-md-links` — `buildAnchorIndex()`.
- Документация `prikotov/git-workflow` (`docs/git-workflow/glossary.md`) — исходный кейс с `<a id>`.

## 9. Комментарии (Comments)
- Обнаружено при обновлении `prikotov/git-workflow` до v0.4.0 в проекте `prikotov/task-orchestrator` (PR #404).

## История изменений (Change History)
| Дата | Автор (роль) | Изменение |
| :--- | :--- | :--- |
| 2026-09-21 23:25:00 (1790007900) | Бэкендер Тони (pi) | Создание задачи |
| 2026-09-21 23:26:00 (1790007960) | Бэкендер Тони (pi) | Старт задачи, заполнение постановки |
| 2026-09-21 23:30:00 (1790008200) | Бэкендер Тони (pi) | Реализация, фикстуры, тесты, документация; проверки зелёные |
| 2026-09-21 23:40:00 (1790008800) | Бэкендер Тони (pi) | PR #126 создан, CI зелёный, задача закрыта |
Loading