<!-- Thank you for your contribution! -->
<!-- Unless your change is trivial, please create an issue to discuss the change before creating a PR -->

## Change Summary

I noticed similar or identical logic in several places and extracted a couple of methods to reduce duplication.

The two commits are fairly independent and it may be easier to view their diffs separately to understand the changes. The first is probably a bit more compelling.

Note that the original code replaced by `clean_schema` wasn't always exactly the same, but the differences didn't really strike me as intentional, and either way it seemed good to draw attention to those differences and consider discussing them or adding comments to clarify.

## Related issue number

There's no issue because this isn't fixing/adding anything notable, it's just refactoring. I thought it made more sense to discuss the changes here rather than in a separate issue.

## Checklist

* [x] The pull request title is a good summary of the changes - it will be used in the changelog
* [x] Unit tests for the changes exist
* [x] Tests pass on CI
* [x] Documentation reflects the changes where applicable
* [x] My PR is ready to review, **please add a comment including the phrase "please review" to assign reviewers**


Selected Reviewer: @adriangb
Refactoring schema-related code. <!-- Thank you for your contribution! -->
<!-- Unless your change is trivial, please create an issue to discuss the change before creating a PR -->

## Change Summary

I noticed similar or identical logic in several places and extracted a couple of methods to reduce duplication.

The two commits are fairly independent and it may be easier to view their diffs separately to understand the changes. The first is probably a bit more compelling.

Note that the original code replaced by `clean_schema` wasn't always exactly the same, but the differences didn't really strike me as intentional, and either way it seemed good to draw attention to those differences and consider discussing them or adding comments to clarify.

## Related issue number

There's no issue because this isn't fixing/adding anything notable, it's just refactoring. I thought it made more sense to discuss the changes here rather than in a separate issue.

## Checklist

* [x] The pull request title is a good summary of the changes - it will be used in the changelog
* [x] Unit tests for the changes exist
* [x] Tests pass on CI
* [x] Documentation reflects the changes where applicable
* [x] My PR is ready to review, **please add a comment including the phrase "please review" to assign reviewers**


Selected Reviewer: @adriangb
import ast
import re
from functools import partial
from pathlib import Path
from typing import Tuple

from griffe.dataclasses import Object as GriffeObject
from griffe.extensions import VisitorExtension
from pymdownx.slugs import slugify

DOCS_PATH = Path(__file__).parent.parent
slugifier = slugify(case='lower')


def find_heading(content: str, slug: str, file_path: Path) -> Tuple[str, int]:
    for m in re.finditer('^#+ (.+)', content, flags=re.M):
        heading = m.group(1)
        h_slug = slugifier(heading, '-')
        if h_slug == slug:
            return heading, m.end()
    raise ValueError(f'heading with slug {slug!r} not found in {file_path}')


def insert_at_top(path: str, api_link: str) -> str:
    rel_file = path.rstrip('/') + '.md'
    file_path = DOCS_PATH / rel_file
    content = file_path.read_text()
    second_heading = re.search('^#+ ', content, flags=re.M)
    assert second_heading, 'unable to find second heading in file'
    first_section = content[: second_heading.start()]

    if f'[{api_link}]' not in first_section:
        print(f'inserting API link "{api_link}" at the top of {file_path.relative_to(DOCS_PATH)}')
        file_path.write_text('??? api "API Documentation"\n' f'    [`{api_link}`][{api_link}]<br>\n\n' f'{content}')

    heading = file_path.stem.replace('_', ' ').title()
    return f'!!! abstract "Usage Documentation"\n    [{heading}](../{rel_file})\n'


def replace_links(m: re.Match, *, api_link: str) -> str:
    path_group = m.group(1)
    if '#' not in path_group:
        # no heading id, put the content at the top of the page
        return insert_at_top(path_group, api_link)

    usage_path, slug = path_group.split('#', 1)
    rel_file = usage_path.rstrip('/') + '.md'
    file_path = DOCS_PATH / rel_file
    content = file_path.read_text()
    heading, heading_end = find_heading(content, slug, file_path)

    next_heading = re.search('^#+ ', content[heading_end:], flags=re.M)
    if next_heading:
        next_section = content[heading_end : heading_end + next_heading.start()]
    else:
        next_section = content[heading_end:]

    if f'[{api_link}]' not in next_section:
        print(f'inserting API link "{api_link}" into {file_path.relative_to(DOCS_PATH)}')
        file_path.write_text(
            f'{content[:heading_end]}\n\n'
            '??? api "API Documentation"\n'
            f'    [`{api_link}`][{api_link}]<br>'
            f'{content[heading_end:]}'
        )

    return f'!!! abstract "Usage Documentation"\n    [{heading}](../{rel_file}#{slug})\n'


def update_docstring(obj: GriffeObject) -> str:
    return re.sub(
        r'usage[\- ]docs: ?https://docs\.pydantic\.dev/.+?/(\S+)',
        partial(replace_links, api_link=obj.path),
        obj.docstring.value,
        flags=re.I,
    )


def update_docstrings_recursively(obj: GriffeObject) -> None:
    if obj.docstring:
        obj.docstring.value = update_docstring(obj)
    for member in obj.members.values():
        if not member.is_alias:
            update_docstrings_recursively(member)


class Extension(VisitorExtension):
    def visit_module(self, node: ast.AST) -> None:
        module = self.visitor.current.module
        update_docstrings_recursively(module)

import ast
import re
from functools import partial
from pathlib import Path
from typing import Tuple

from griffe.dataclasses import Object as GriffeObject
from griffe.extensions import VisitorExtension
from pymdownx.slugs import slugify

DOCS_PATH = Path(__file__).parent.parent
slugifier = slugify(case='lower')


def find_heading(content: str, slug: str, file_path: Path) -> Tuple[str, int]:
    for m in re.finditer('^#+ (.+)', content, flags=re.M):
        heading = m.group(1)
        h_slug = slugifier(heading, '-')
        if h_slug == slug:
            return heading, m.end()
    raise ValueError(f'heading with slug {slug!r} not found in {file_path}')


def insert_at_top(path: str, api_link: str) -> str:
    rel_file = path.rstrip('/') + '.md'
    file_path = DOCS_PATH / rel_file
    content = file_path.read_text()
    second_heading = re.search('^#+ ', content, flags=re.M)
    assert second_heading, 'unable to find second heading in file'
    first_section = content[: second_heading.start()]

    if f'[{api_link}]' not in first_section:
        print(f'inserting API link "{api_link}" at the top of {file_path.relative_to(DOCS_PATH)}')
        file_path.write_text('??? api "API Documentation"\n' f'    [`{api_link}`][{api_link}]<br>\n\n' f'{content}')

    heading = file_path.stem.replace('_', ' ').title()
    return f'!!! abstract "Usage Documentation"\n    [{heading}](../{rel_file})\n'


def replace_links(m: re.Match, *, api_link: str) -> str:
    path_group = m.group(1)
    if '#' not in path_group:
        # no heading id, put the content at the top of the page
        return insert_at_top(path_group, api_link)

    usage_path, slug = path_group.split('#', 1)
    rel_file = usage_path.rstrip('/') + '.md'
    file_path = DOCS_PATH / rel_file
    content = file_path.read_text()
    heading, heading_end = find_heading(content, slug, file_path)

    next_heading = re.search('^#+ ', content[heading_end:], flags=re.M)
    if next_heading:
        next_section = content[heading_end : heading_end + next_heading.start()]
    else:
        next_section = content[heading_end:]

    if f'[{api_link}]' not in next_section:
        print(f'inserting API link "{api_link}" into {file_path.relative_to(DOCS_PATH)}')
        file_path.write_text(
            f'{content[:heading_end]}\n\n'
            '??? api "API Documentation"\n'
            f'    [`{api_link}`][{api_link}]<br>'
            f'{content[heading_end:]}'
        )

    return f'!!! abstract "Usage Documentation"\n    [{heading}](../{rel_file}#{slug})\n'


def update_docstring(obj: GriffeObject) -> str:
    return re.sub(
        r'usage[\- ]docs: ?https://docs\.pydantic\.dev/.+?/(\S+)',
        partial(replace_links, api_link=obj.path),
        obj.docstring.value,
        flags=re.I,
    )


def update_docstrings_recursively(obj: GriffeObject) -> None:
    if obj.docstring:
        obj.docstring.value = update_docstring(obj)
    for member in obj.members.values():
        if not member.is_alias:
            update_docstrings_recursively(member)


class Extension(VisitorExtension):
    def visit_module(self, node: ast.AST) -> None:
        module = self.visitor.current.module
        update_docstrings_recursively(module)
