<!--
  canonical-url: https://www.avonture.be/fr/blog/docusaurus-tags
  published:     2026-02-02
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Gestion des tags dans Docusaurus

> Automatisez la gestion des tags Docusaurus avec ce script Python. Nettoyez les doublons, corrigez la casse et fusionnez les tags en toute sécurité sur des centaines de fichiers Markdown via Docker.

---
![Gestion des tags dans Docusaurus](/img/v2/tags_manager.webp)

**TL;DR**

Cet article présente un script Python maison conçu pour automatiser le nettoyage et la gestion des tags incohérents d'un blog Docusaurus. L'outil tourne dans un container Docker pour lister, renommer et supprimer des tags, tout en proposant des suggestions intelligentes pour fusionner les doublons ou corriger les variations de casse. Il aborde aussi les options de configuration natives de Docusaurus pour imposer des règles d'utilisation des tags plus strictes à l'avenir.

Plus de 200 articles plus tard, mon site Docusaurus a accumulé un joli chaos de tags. Trop de tags similaires, des majuscules incohérentes, du singulier contre du pluriel... Des tags avec un ou deux articles seulement. Et ainsi de suite.

Dans cet article, je partage un **script Python** que j'ai écrit pour automatiser la gestion des tags : en supprimer certains, en fusionner d'autres et nettoyer les incohérences.

C'est quasi impossible à faire à la main quand vous avez des centaines de fichiers Markdown. En plus, le faire manuellement risque de casser le formatage du frontmatter YAML.

## Voir les suggestions {#seeing-the-suggestions}

Une seule commande liste tous les tags et signale les doublons probables :

```bash
$ make tags-manager ARGS="list"
```

![Liste des tags](./images/list.webp)

## La solution : un gestionnaire de tags intelligent {#the-solution-a-smart-tag-manager}

Je voulais un outil capable de faire trois choses :

1. **Lister** tous les tags par fréquence.
2. **Suggérer** des optimisations (trouver les doublons ou les variations singulier/pluriel).
3. **Renommer** ou **supprimer** des tags sans rien casser.

### Suggestions intelligentes {#smart-suggestions}

La partie la plus sympa du script, ce sont les « suggestions d'optimisation ». Il compare chaque tag à tous les autres pour repérer les doublons potentiels.

Il cherche :

* **Les variations de casse** : `GitHub` contre `github`.
* **Les pluriels** : `snippet` contre `snippets`.
* **Les sous-chaînes** : `visual studio code` contre `vscode`.

Ça m'aide à repérer le bazar dont je ne soupçonnais même pas l'existence.

## Comment l'exécuter (Docker d'abord) {#how-to-run-it-docker-first}

Comme toujours, je ne veux pas polluer ma machine hôte avec des dépendances Python. Je lance ce script avec Docker — exactement comme le vérificateur d'images décrit dans [Running some checks on your Docusaurus images](/blog/docusaurus-check-images).

J'ai une cible `Makefile` prête, mais en gros, elle exécute une commande comme celle-ci :

```bash
$ docker run -it --rm -v .:/app -w /app --entrypoint /bin/sh python:3.14-slim -c "pip install --root-user-action=ignore oyaml python-frontmatter >/dev/null && python .scripts/tags-manager.py list"
```

Mais bon, cette commande est franchement difficile à retenir, donc j'utilise plutôt une cible [Makefile](/blog/makefile_tips) :

**makefile**

```
.PHONY: tags-manager
tags-manager: ## Tags manager
	@clear
ifeq ($(strip $(ARGS)),)
	@echo "--------------------------------------------------------"
	@echo "🚨 ERROR: Missing command arguments (ARGS)."
	@echo "--------------------------------------------------------"
	@echo "USAGE EXAMPLE:"
	@echo "  make tags-manager ARGS=\"delete tag\""
	@echo ""
	@echo "  make tags-manager ARGS=\"list\""
	@echo "  make tags-manager ARGS=\"list --sort count\""
	@echo "  make tags-manager ARGS=\"list --sort name\""
	@echo ""
	@echo "  make tags-manager ARGS=\"rename old,new\""
	@echo ""
	@echo "  make tags-manager ARGS=\"--help\""
	@echo ""
	@echo "For full help, run: make tags-manager ARGS=\"--help\""
	@echo "--------------------------------------------------------"
	@exit 1
else
	@echo "--- Running Tags Manager with arguments: $(ARGS) ---"
	docker run -it --rm -v ${PWD}:/app -w /app python sh -c "pip install oyaml python-frontmatter > /dev/null 2>&1 && python .scripts/tags-manager.py $(ARGS)"
endif
```

## Le script {#the-script}

Voici le script Python complet. Il utilise `argparse` pour gérer les arguments en ligne de commande et `python-frontmatter` pour analyser les fichiers.

**.scripts/tags-manager.py**

```python
# tag_manager.py

import argparse
from typing import List, Dict, Any, Tuple, Set
import os
import glob
import subprocess
from collections import Counter

# YAML processing libraries
import oyaml as yaml
import frontmatter

# --- ANSI Color Codes ---
class Colors:
    """ANSI color codes for console output."""
    HEADER = '\033[95m'
    OKBLUE = '\033[94m'
    OKCYAN = '\033[96m'
    OKGREEN = '\033[92m'
    WARNING = '\033[93m'
    FAIL = '\033[91m'
    ENDC = '\033[0m'
    BOLD = '\033[1m'
    UNDERLINE = '\033[4m'

# --- Configuration ---
DOCS_DIR: str = 'blog'
FILE_PATTERN_BASE: str = os.path.join(DOCS_DIR, '**', '*.md*')
MAX_LENGTH_DIFFERENCE: int = 3 # Max difference in characters to suggest a merge based on substring

# Tags to exclude from merge suggestions (case-insensitive and order-independent)
# Example: ('php', 'phpcbf') will prevent 'php' vs 'phpcbf' from being suggested.
MERGE_EXCEPTIONS: Set[Tuple[str, str]] = {
    ('ftp', 'sftp'),
    ('git', 'github'),
    ('git', 'gitlab'),
    ('mysql', 'sql'),
    ('php', 'phpcbf'),
    ('php', 'phpcs'),
    ('php', 'phpdoc'),
    ('scp', 'winscp'),
    ('ssh', 'sshpass'),
    ('xml', 'xmlstarlet'),
}


# --- YAML Dumper Configuration ---

def custom_list_representer(dumper: yaml.SafeDumper, data: List[Any]) -> yaml.nodes.SequenceNode:
    """Forces Python lists to be represented in YAML flow style: [item1, item2]."""
    return dumper.represent_sequence('tag:yaml.org,2002:seq', data, flow_style=True)

def custom_string_representer(dumper: yaml.SafeDumper, data: str) -> yaml.nodes.ScalarNode:
    """Prevents PyYAML from folding long strings into multi-line blocks."""
    if '\n' in data:
        # Use block style if the string explicitly contains newlines
        return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
    # Use plain style for single-line strings, preventing folding
    return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='')


# Registering custom representers on the SafeDumper
yaml.add_representer(list, custom_list_representer, Dumper=yaml.SafeDumper)
yaml.add_representer(str, custom_string_representer, Dumper=yaml.SafeDumper)


def custom_dumper_factory(metadata: Dict[str, Any], content: str) -> str:
    """
    Generates the final file content string (front matter + markdown content)
    while preserving YAML formatting.
    """

    import io
    output = io.StringIO()

    yaml.dump(
        metadata,
        output,
        Dumper=yaml.SafeDumper,
        default_flow_style=False,
        allow_unicode=True,
        width=4096 # High width ensures long single-line strings are not folded
    )

    yaml_output: str = output.getvalue()

    return f"---\n{yaml_output}---\n{content}"


# ----------------------------------------------------------------------
# Core Tag Management Functions
# ----------------------------------------------------------------------

def list_tags(sort_by: str = 'count') -> None:
    """
    Collects and displays all tags, sorted either by count (desc) or name (asc).
    It also lists tags with case variations and suggests tags for potential merging.

    Args:
        sort_by: The primary sorting method ('count' or 'name').
    """

    tag_counter: Counter = Counter()

    print(f"{Colors.BOLD}Action:{Colors.ENDC} {Colors.OKBLUE}Listing all tags{Colors.ENDC} (Sort by: {Colors.OKCYAN}{sort_by.upper()}{Colors.ENDC})")
    print(f"Base Directory: {Colors.OKBLUE}{DOCS_DIR}/{Colors.ENDC}")

    if not os.path.exists(DOCS_DIR):
        print(f"{Colors.FAIL}{Colors.BOLD}ERROR: Directory '{DOCS_DIR}' not found.{Colors.ENDC}")
        return

    files: List[str] = glob.glob(FILE_PATTERN_BASE, recursive=True)

    if not files:
        print(f"{Colors.WARNING}No files found matching '{FILE_PATTERN_BASE}'.{Colors.ENDC}")
        return

    print(f"{Colors.OKBLUE}INFO: {len(files)} file(s) found. Processing tags...{Colors.ENDC}")

    case_variants: Dict[str, List[str]] = {}
    unique_tags: set[str] = set()

    for filepath in files:
        try:
            post = frontmatter.load(filepath)
            tags: List[str] = post.metadata.get('tags', [])

            if isinstance(tags, list):
                tag_counter.update(tags)

                for tag in tags:
                    tag_lower = tag.lower()

                    # Fill case variant dictionary
                    if tag_lower not in case_variants:
                        case_variants[tag_lower] = []
                    if tag not in case_variants[tag_lower]:
                        case_variants[tag_lower].append(tag)

                    # Fill unique tags set (case-sensitive)
                    unique_tags.add(tag)

        except Exception:
            pass

    print("\n" + "=" * 50)
    if not tag_counter:
        print(f"{Colors.WARNING}No tags found in the files.{Colors.ENDC}")
        return

    # --- PART 1: PRIMARY SORT AND LISTING ---

    tag_list: List[Tuple[str, int]] = list(tag_counter.items())

    if sort_by == 'count':
        # Default sort: Primary by Count (DESC), Secondary by Tag Name (ASC)
        tag_list.sort(key=lambda item: item[0]) # Secondary sort by name (ASC)
        tag_list.sort(key=lambda item: item[1], reverse=True) # Primary sort by count (DESC)

        sort_desc = "Frequency DESC, Name ASC"

    elif sort_by == 'name':
        # Name sort: Only by Tag Name (ASC)
        tag_list.sort(key=lambda item: item[0])

        sort_desc = "Name ASC"

    print(f"{Colors.HEADER}{Colors.BOLD}Top Tags ({sort_desc}):{Colors.ENDC}")
    for tag, count in tag_list:
        print(f"  {Colors.OKGREEN}{tag:<30}{Colors.ENDC}{Colors.BOLD}{Colors.OKBLUE}{count}{Colors.ENDC}")
    print("=" * 50)


    # --- PART 2 & 3: OPTIMIZATION SUGGESTIONS ---

    case_issues: List[List[str]] = [
        variants
        for variants in case_variants.values()
        if len(variants) > 1
    ]
    merge_suggestions: set[Tuple[str, str]] = set()
    sorted_unique_tags = sorted(list(unique_tags))


    # Logic to check if a pair is an exception (case-insensitive and order-independent)
    def is_exception(t1: str, t2: str) -> bool:
        t1_lower, t2_lower = t1.lower(), t2.lower()

        # Create the canonical lowercased and sorted tuple (e.g., ('php', 'phpcbf'))
        canonical_pair = tuple(sorted((t1_lower, t2_lower)))

        # Check against the MERGE_EXCEPTIONS set
        return canonical_pair in MERGE_EXCEPTIONS


    # Refined merge suggestion logic
    for i in range(len(sorted_unique_tags)):
        t1 = sorted_unique_tags[i]
        t1_lower = t1.lower()

        for j in range(i + 1, len(sorted_unique_tags)):
            t2 = sorted_unique_tags[j]
            t2_lower = t2.lower()

            # Skip tags that only differ by case
            if t1_lower == t2_lower:
                continue

            # Skip if the pair is explicitly listed as an exception
            if is_exception(t1, t2):
                continue

            pair = tuple(sorted((t1, t2)))

            # Heuristic 1: Singular/Plural Check (Highly reliable)
            # Example: 'snippet' vs 'snippets'
            if (t1_lower + 's' == t2_lower or t2_lower + 's' == t1_lower):
                merge_suggestions.add(pair)
                continue

            # Heuristic 2: Substring with Length Constraint

            len_diff = abs(len(t1_lower) - len(t2_lower))

            if len_diff > 0 and len_diff <= MAX_LENGTH_DIFFERENCE:

                # Check if the shorter tag is a substring of the longer tag
                if len(t1_lower) < len(t2_lower):
                    shorter_tag = t1_lower
                    longer_tag = t2_lower
                else:
                    shorter_tag = t2_lower
                    longer_tag = t1_lower

                if shorter_tag in longer_tag:
                    merge_suggestions.add(pair)


    print(f"\n{Colors.WARNING}{Colors.BOLD}--- TAG OPTIMIZATION SUGGESTIONS ---{Colors.ENDC}")

    # Display Case Issues (Part 2)
    if case_issues:
        print(f"{Colors.WARNING}1. Case Variations (Easy Duplicates):{Colors.ENDC}")

        for variants in sorted(case_issues, key=lambda x: x[0].lower()):
            counts_info = ", ".join([
                f"{v} ({tag_counter[v]})" for v in sorted(variants)
            ])
            print(f"  {Colors.FAIL}❌ Case Issue ({variants[0].lower()}): {Colors.ENDC}{counts_info}")
            print(f"    {Colors.OKCYAN}  Suggestion: Rename all to a canonical form (e.g., '{variants[0]}').{Colors.ENDC}")

    # Display Merge Suggestions (Part 3 - Refined)
    if merge_suggestions:
        print(f"\n{Colors.WARNING}2. Potential Merge Candidates (Singular/Plural or Small Prefix/Suffix):{Colors.ENDC}")

        for t1, t2 in sorted(list(merge_suggestions)):
            count1 = tag_counter[t1]
            count2 = tag_counter[t2]

            print(f"  {Colors.WARNING}⚠️ Found: {Colors.ENDC}{t1} ({count1}) vs {t2} ({count2})")
            print(f"    {Colors.OKCYAN}  Suggestion: Merge into a canonical form (e.g., '{t1}' or '{t2}').{Colors.ENDC}")

    if not case_issues and not merge_suggestions:
        print(f"  {Colors.OKGREEN}No obvious tag duplicates or merge candidates found.{Colors.ENDC}")

    print("-" * 50)


def run_node_script(script_name: str, extra_args: List[str]) -> int:
    """
    Proxies to a Node script under scripts/ — kept here so the single 'tags' cheatsheet entry
    stays the one wiring point, while the AI/corpus logic itself (Ollama, blog-corpus helpers)
    lives in JS and isn't duplicated in Python. Used by 'suggest' (scripts/suggest-tags.mjs)
    and 'review' (scripts/tags-review.mjs).
    """
    script_root = os.path.dirname(os.path.abspath(__file__))
    node_script = os.path.join(script_root, '..', 'scripts', script_name)

    result = subprocess.run(['node', node_script, *extra_args])
    return result.returncode


def set_tags_for_file(filepath: str, new_tags: List[str]) -> bool:
    """
    Overwrites one article's frontmatter 'tags' key with new_tags, via the same
    custom_dumper_factory used by delete/rename — forced flow style ([a, b, c]), including
    for a file whose tags were still block-style, which this normalizes on the way.
    """
    try:
        post = frontmatter.load(filepath)
    except Exception as e:
        print(f"{Colors.FAIL}  ❌ Could not read {filepath}: {e}{Colors.ENDC}")
        return False

    post.metadata['tags'] = new_tags
    full_content: str = custom_dumper_factory(post.metadata, post.content)

    with open(filepath, 'w', encoding='utf-8') as f:
        f.write(full_content)

    print(f"{Colors.OKGREEN}  ✓ Tags set to [{', '.join(new_tags)}] in: {filepath}{Colors.ENDC}")
    return True


# ----------------------------------------------------------------------
# Argparse Configuration (Remains unchanged)
# ----------------------------------------------------------------------

def _process_files(action: str, old_tag: str = None, new_tag: str = None) -> None:
    # ... (function body remains unchanged) ...

    if action == 'delete':
        operation_desc: str = f"{Colors.FAIL}Deleting tag '{old_tag}'{Colors.ENDC}"
    elif action == 'rename':
        operation_desc: str = f"{Colors.OKCYAN}Renaming tag '{old_tag}' to '{new_tag}'{Colors.ENDC}"
    else:
        return

    print(f"{Colors.BOLD}Operation:{Colors.ENDC} {operation_desc}")
    print(f"Base Directory: {Colors.OKBLUE}{DOCS_DIR}/{Colors.ENDC}")

    if not os.path.exists(DOCS_DIR):
        print(f"{Colors.FAIL}{Colors.BOLD}ERROR: Directory '{DOCS_DIR}' not found.{Colors.ENDC}")
        return

    files: List[str] = glob.glob(FILE_PATTERN_BASE, recursive=True)

    if not files:
        print(f"{Colors.WARNING}No files found matching '{FILE_PATTERN_BASE}'.{Colors.ENDC}")
        return

    print(f"{Colors.OKBLUE}INFO: {len(files)} file(s) found. Processing...{Colors.ENDC}")

    changes_made: int = 0
    old_tag_lower: str | None = old_tag.lower() if old_tag else None
    new_tag_lower: str | None = new_tag.lower() if new_tag else None

    for filepath in files:
        try:
            post = frontmatter.load(filepath)
            metadata: Dict[str, Any] = post.metadata
            file_changed: bool = False

            if 'tags' in metadata and isinstance(metadata['tags'], list):

                original_tags: List[str] = metadata['tags']
                new_tags: List[str] = []

                for tag in original_tags:
                    tag_lower: str = tag.lower()

                    if tag_lower == old_tag_lower:
                        if action == 'delete':
                            file_changed = True
                            continue

                        elif action == 'rename':
                            # Avoid adding the new tag if a case-insensitive match already exists
                            if new_tag_lower and new_tag_lower not in [t.lower() for t in new_tags]:
                                new_tags.append(new_tag)
                                file_changed = True

                    else:
                        # Ensure no duplicates in the new list (case-insensitive)
                        if tag_lower not in [t.lower() for t in new_tags]:
                            new_tags.append(tag)

                if file_changed:
                    post.metadata['tags'] = new_tags

                    # Generate content and write the file manually to preserve YAML formatting
                    full_content: str = custom_dumper_factory(post.metadata, post.content)

                    with open(filepath, 'w', encoding='utf-8') as f:
                        f.write(full_content)

                    if action == 'delete':
                        print(f"{Colors.OKGREEN}  ✓ Deleted '{old_tag}' from: {filepath}{Colors.ENDC}")
                    else:
                        print(f"{Colors.OKGREEN}  ✓ Renamed '{old_tag}' to '{new_tag}' in: {filepath}{Colors.ENDC}")

                    changes_made += 1

        except Exception as e:
            error_message: str = str(e)
            print(f"{Colors.FAIL}  ❌ FATAL ERROR PROCESSING {filepath}: {error_message}{Colors.ENDC}")

    print("-" * 50)
    print(f"{Colors.BOLD}Operation completed.{Colors.ENDC} {changes_made} file(s) modified.")

# ... (The rest of the argparse block remains the same) ...
if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description=f"{Colors.BOLD}{Colors.OKCYAN}Tag Manager Tool{Colors.ENDC} for Markdown files (path: {DOCS_DIR}).",
        epilog="Use 'python tag_manager.py <action> -h' for action-specific help.",
        formatter_class=argparse.RawTextHelpFormatter
    )

    # Define subparsers and actions
    subparsers = parser.add_subparsers(dest="action", help="Available actions.")

    # LIST action
    parser_list = subparsers.add_parser(
        'list',
        help=f"List all tags found in '{DOCS_DIR}', sorted by frequency and name."
    )
    parser_list.add_argument(
        '--sort',
        type=str,
        choices=['name', 'count'],
        default='count',
        help="Primary sorting method: 'count' (default, frequency descending) or 'name' (alphabetical ascending)."
    )

    # DELETE action
    parser_delete = subparsers.add_parser(
        'delete',
        help=f"Delete a specified tag from all files in '{DOCS_DIR}'. (Parameter: TAG)"
    )
    parser_delete.add_argument(
        "tag",
        type=str,
        metavar="<TAG_TO_DELETE>",
        help="The tag to DELETE (e.g., 'draft')."
    )

    # RENAME action
    parser_rename = subparsers.add_parser(
        'rename',
        help=f"Rename an old tag to a new one in all files in '{DOCS_DIR}'. (Parameters: OLD_TAG,NEW_TAG)"
    )
    parser_rename.add_argument(
        "tags",
        type=str,
        metavar="<OLD_TAG,NEW_TAG>",
        help="The tags to rename, in 'old_tag,new_tag' format (e.g., 'prog,programming')."
    )

    # SUGGEST action (proxies to scripts/suggest-tags.mjs — see run_node_script)
    parser_suggest = subparsers.add_parser(
        'suggest',
        help="Use AI (via scripts/suggest-tags.mjs) to suggest tags for a specific file."
    )
    parser_suggest.add_argument("file", type=str, help="Path to the .md file.")

    # REVIEW action — listed here for --help only; its own flags are handled before
    # parser.parse_args() runs (see the argv[1] == 'review' check below).
    subparsers.add_parser(
        'review',
        help="[--force] [--status] [<article>] — refresh .todos/0000-tags-review-journal.md via Ollama."
    )

    # SET-TAGS action
    parser_set_tags = subparsers.add_parser(
        'set-tags',
        help="Overwrite one article's 'tags' frontmatter key. (Parameters: FILE, TAG1,TAG2,...)"
    )
    parser_set_tags.add_argument("file", type=str, help="Path to the .md/.mdx file.")
    parser_set_tags.add_argument(
        "tags",
        type=str,
        metavar="<TAG1,TAG2,...>",
        help="Comma-separated final tag list, e.g. 'docusaurus,react,component'."
    )

    # Display full help page if no arguments are provided
    if len(os.sys.argv) == 1:
        parser.print_help()
        os.sys.exit(0)

    # 'review' takes its own free-form flags (--force, --status, an article path), which
    # argparse's REMAINDER handles unreliably once the first one starts with '-' (a known
    # argparse quirk). Bypassing argparse for this branch avoids it entirely; the subparser
    # above still exists so 'review' shows up in --help.
    if os.sys.argv[1] == 'review':
        os.sys.exit(run_node_script('tags-review.mjs', os.sys.argv[2:]))

    # Parse arguments
    args = parser.parse_args()

    # Execute actions
    if args.action == 'list':
        list_tags(args.sort)

    elif args.action == 'suggest':
        os.sys.exit(run_node_script('suggest-tags.mjs', [args.file]))

    elif args.action == 'set-tags':
        new_tags: List[str] = [t.strip() for t in args.tags.split(',') if t.strip()]

        if not new_tags:
            print(f"{Colors.FAIL}Error: at least one tag is required.{Colors.ENDC}")
            os.sys.exit(1)
        if not os.path.isfile(args.file):
            print(f"{Colors.FAIL}Error: file not found: {args.file}{Colors.ENDC}")
            os.sys.exit(1)

        os.sys.exit(0 if set_tags_for_file(args.file, new_tags) else 1)

    elif args.action == 'delete' or args.action == 'rename':

        old_tag: str | None = None
        new_tag: str | None = None

        if args.action == 'delete':
            old_tag = args.tag

        elif args.action == 'rename':
            if ',' not in args.tags:
                print(f"{Colors.FAIL}Error: Rename argument must be in 'OLD_TAG,NEW_TAG' format.{Colors.ENDC}")
                os.sys.exit(1)

            try:
                old_tag, new_tag = [t.strip() for t in args.tags.split(',', 1)]

                if not old_tag or not new_tag:
                     print(f"{Colors.FAIL}Error: Both tags (old and new) must be specified and non-empty.{Colors.ENDC}")
                     os.sys.exit(1)

            except ValueError:
                 print(f"{Colors.FAIL}Error: Problem splitting tags. Expected format: 'OLD_TAG,NEW_TAG'.{Colors.ENDC}")
                 os.sys.exit(1)

        _process_files(
            action=args.action,
            old_tag=old_tag,
            new_tag=new_tag
        )
```

## D'autres exemples {#more-examples}

**Renommer un tag :**

Si, en parcourant la liste des tags, je vois que j'ai un tag `snippets` et un tag `snippet`, je vais les fusionner comme ceci :

```bash
$ make tags-manager ARGS="rename snippets,snippet"
```

**Supprimer un tag :**

Je peux aussi constater qu'un tag précis n'apporte rien ; pour le supprimer, je lance quelque chose comme ça :

```bash
$ make tags-manager ARGS="delete draft"
```

**Bonus - Le fichier tags.yml**

Nativement, Docusaurus vous permet de créer un fichier spécial appelé `blog/tags.yml`.

Voici celui que j'utilise :

**blog/tags.yml**

```yaml
adminer:
  label: "Adminer"
  permalink: "/tags/adminer"
  description: "Lightweight web-based database management tool supporting MySQL, PostgreSQL, SQLite, and more."
ai:
  label: "Artificial Intelligence (AI)"
  permalink: "/tags/ai"
  description: "Tools, models, and concepts related to AI, including Ollama, Claude, and Gemini."
apache:
  label: "Apache HTTP Server"
  permalink: "/tags/apache"
  description: "Configuration and troubleshooting for the Apache web server."
api:
  label: "API"
  permalink: "/tags/api"
  description: "Development, consumption, and documentation of APIs (REST, OpenAPI, FastAPI, PostgREST)."
bash:
  label: "Bash"
  permalink: "/tags/bash"
  description: "Scripts, commands, and tips for the Bash shell."
bluesky:
  label: "Bluesky"
  permalink: "/tags/bluesky"
  description: "Bluesky social network — integration, sharing, and embedding in web projects."
code-quality:
  label: "Code Quality"
  permalink: "/tags/code-quality"
  description: "Tools and practices to improve code quality: static analysis, linters, formatters, and Git hooks (PHPStan, Ruff, pre-commit, etc.)."
component:
  label: "Component / UI"
  permalink: "/tags/component"
  description: "Creation and management of React or Docusaurus UI components."
customization:
  label: "Customization"
  permalink: "/tags/customization"
  description: "Personalizing shell environments, terminals, editors, or applications."
database:
  label: "Database"
  permalink: "/tags/database"
  description: "Concepts, administration, and queries for relational and NoSQL databases (MySQL, PostgreSQL, Oracle, SQL Server, Access)."
devcontainer:
  label: "DevContainer"
  permalink: "/tags/devcontainer"
  description: "Containerized development environments using VS Code Dev Containers and Docker."
doc-as-code:
  label: "Documentation as Code"
  permalink: "/tags/doc-as-code"
  description: "Managing documentation like software: diagrams (Mermaid, Markmap), Quarto slideshows, Pandoc, and visualization tools."
docker:
  label: "Docker"
  permalink: "/tags/docker"
  description: "Containerization, Docker Compose, and image management."
docusaurus:
  label: "Docusaurus"
  permalink: "/tags/docusaurus"
  description: "Building, customizing, and deploying Docusaurus static sites."
excel:
  label: "Microsoft Excel"
  permalink: "/tags/excel"
  description: "Formulas, VBA macros, and advanced usage of Microsoft Excel."
fzf:
  label: "fzf"
  permalink: "/tags/fzf"
  description: "Fuzzy finder for the command line — interactive search and selection."
git:
  label: "Git"
  permalink: "/tags/git"
  description: "Commands, workflow, and version control with Git."
github:
  label: "GitHub"
  permalink: "/tags/github"
  description: "GitHub platform, Actions, features, and integrations."
gitlab:
  label: "GitLab"
  permalink: "/tags/gitlab"
  description: "GitLab platform, CI/CD pipelines, and project management."
joomla:
  label: "Joomla"
  permalink: "/tags/joomla"
  description: "Joomla Content Management System installation and configuration."
laravel:
  label: "Laravel"
  permalink: "/tags/laravel"
  description: "Laravel PHP framework — events, Filament, Telescope, and testing."
linux:
  label: "Linux"
  permalink: "/tags/linux"
  description: "Administration, commands, and tips for Linux systems (Ubuntu, tools, shell utilities)."
makefile:
  label: "Makefile"
  permalink: "/tags/makefile"
  description: "Automating tasks and workflows with GNU Make."
markdown:
  label: "Markdown"
  permalink: "/tags/markdown"
  description: "Syntax, tools, and conversion of Markdown documents."
msaccess:
  label: "Microsoft Access"
  permalink: "/tags/msaccess"
  description: "Database development and VBA automation with Microsoft Access."
mysql:
  label: "MySQL"
  permalink: "/tags/mysql"
  description: "MySQL relational database — configuration, queries, and Docker-based setups."
nodejs:
  label: "Node.js"
  permalink: "/tags/nodejs"
  description: "Server-side JavaScript runtime environment and its ecosystem."
npm:
  label: "npm"
  permalink: "/tags/npm"
  description: "Node package manager — installing and managing JavaScript dependencies."
ollama:
  label: "Ollama"
  permalink: "/tags/ollama"
  description: "Local environment to run and manage large language models on your own machine."
oracle:
  label: "Oracle Database"
  permalink: "/tags/oracle"
  description: "Running and querying Oracle Database, including Docker-based setups."
php:
  label: "PHP"
  permalink: "/tags/php"
  description: "PHP development, environment setup, and tips."
phpmyadmin:
  label: "phpMyAdmin"
  permalink: "/tags/phpmyadmin"
  description: "Web-based MySQL/MariaDB administration interface."
postgresql:
  label: "PostgreSQL"
  permalink: "/tags/postgresql"
  description: "PostgreSQL relational database — configuration, queries, and Docker-based setups."
python:
  label: "Python"
  permalink: "/tags/python"
  description: "Python language development, data processing, and its ecosystem (FastAPI, Pandas)."
quarto:
  label: "Quarto"
  permalink: "/tags/quarto"
  description: "Scientific and technical publishing with Quarto — documents, slideshows, and reports."
react:
  label: "React"
  permalink: "/tags/react"
  description: "React and JavaScript — UI development, components, and tooling."
security:
  label: "Security"
  permalink: "/tags/security"
  description: "Web security, protection tools, and secure configuration."
self-hosted:
  label: "Self-Hosted"
  permalink: "/tags/self-hosted"
  description: "Software solutions hosted on your own server (Uptime Kuma, Roundcube, LimeSurvey, Matomo, etc.)."
ssh:
  label: "SSH (Secure Shell)"
  permalink: "/tags/ssh"
  description: "Secure remote connection, file transfer (SFTP, SCP), and SSH configuration."
ssl:
  label: "SSL / TLS"
  permalink: "/tags/ssl"
  description: "Security certificates, HTTPS configuration, and the SSL/TLS protocol."
tests:
  label: "Testing"
  permalink: "/tags/tests"
  description: "Strategies and tools for testing: unit (PHPUnit, Pest), functional (Cypress, Behat), and BDD."
vba:
  label: "VBA / VBScript"
  permalink: "/tags/vba"
  description: "Programming macros in Microsoft Office applications (Excel, Access) and Windows scripting with VBScript."
vscode:
  label: "VS Code"
  permalink: "/tags/vscode"
  description: "Configuration, extensions, tips, and productivity features for Visual Studio Code."
windows:
  label: "Microsoft Windows"
  permalink: "/tags/windows"
  description: "Windows administration, DOS/cmd, PowerShell, WinGet, and Windows-specific tooling."
windows-terminal:
  label: "Windows Terminal"
  permalink: "/tags/windows-terminal"
  description: "Using and configuring the Windows Terminal application."
winscp:
  label: "WinSCP"
  permalink: "/tags/winscp"
  description: "Graphical SFTP/FTP/SCP client for Windows, including PuTTY integration."
wsl:
  label: "WSL (Windows Subsystem for Linux)"
  permalink: "/tags/wsl"
  description: "Running a Linux environment on Windows using WSL2."
yarn:
  label: "Yarn"
  permalink: "/tags/yarn"
  description: "Fast, reliable, and secure dependency management for JavaScript."
zsh:
  label: "Zsh"
  permalink: "/tags/zsh"
  description: "The Z shell — configuration, plugins, functions, and productivity."
```

Avec un tel fichier, vous pouvez ensuite configurer votre fichier `docusaurus.config.js` et mettre l'attribut `onInlineTags` à `warn` ou `throw`.

Besoin de plus d'infos ? Lisez la documentation officielle [Tags File](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-docs#tags-file).

## Conclusion {#conclusion}

Garder un blog organisé est un travail permanent. Avec ce script, je peux auditer mes tags rapidement et m'assurer que `Docusaurus` ne devienne pas `docusaurus` ou `DocuSaurus` avec le temps.

N'hésitez pas à récupérer le script et à l'adapter à votre propre générateur de site statique !
