diff --git a/.github/workflows/docs_tests.yml b/.github/workflows/docs_tests.yml index d2c1b1a68e7..2bb05eac2a3 100644 --- a/.github/workflows/docs_tests.yml +++ b/.github/workflows/docs_tests.yml @@ -6,12 +6,16 @@ on: paths: - 'docs/**' - 'packages/reflex-components-core/src/reflex_components_core/core/upload.py' + - 'packages/reflex-site-shared/**' + - 'packages/integrations-docs/**' - '.github/workflows/docs_tests.yml' push: branches: ["main"] paths: - 'docs/**' - 'packages/reflex-components-core/src/reflex_components_core/core/upload.py' + - 'packages/reflex-site-shared/**' + - 'packages/integrations-docs/**' - '.github/workflows/docs_tests.yml' permissions: diff --git a/docs/app/reflex_docs/docgen_pipeline.py b/docs/app/reflex_docs/docgen_pipeline.py index c7709c86574..8cdb4558d8d 100644 --- a/docs/app/reflex_docs/docgen_pipeline.py +++ b/docs/app/reflex_docs/docgen_pipeline.py @@ -53,6 +53,7 @@ text_comp, ) from reflex_site_shared.constants import REFLEX_ASSETS_CDN +from reflex_site_shared.integrations import rewrite_integration_doc_images_in_source # --------------------------------------------------------------------------- # Exec environment — mirrors reflex_docgen's module-based exec mechanism @@ -847,6 +848,7 @@ def render_docgen_document( ``None`` if no FAQ block is found. """ source = Path(actual_filepath).read_text(encoding="utf-8") + source = rewrite_integration_doc_images_in_source(source) source, faq_script = _extract_faqs_jsonld(source) transformer = ReflexDocTransformer( virtual_filepath=str(virtual_filepath), filename=str(actual_filepath) diff --git a/docs/app/tests/test_integrations.py b/docs/app/tests/test_integrations.py new file mode 100644 index 00000000000..f2ad32a89df --- /dev/null +++ b/docs/app/tests/test_integrations.py @@ -0,0 +1,71 @@ +"""Tests for reflex_site_shared.integrations URL helpers.""" + +import re +from pathlib import Path + +import integrations_docs +import pytest +from reflex_site_shared.integrations import ( + RAW_DOC_IMAGES_PREFIX, + _integrations_doc_images_url, + rewrite_integration_doc_images_in_source, +) + +DOC_IMAGES_DIR = Path(integrations_docs.__file__).parent / "images" / "docs" + + +@pytest.fixture(autouse=True) +def _backend_only(monkeypatch: pytest.MonkeyPatch) -> None: + """Skip the symlink step so the helpers run without filesystem side effects.""" + monkeypatch.setenv("REFLEX_BACKEND_ONLY", "true") + + +def test_rewrite_source_replaces_all_doc_image_urls() -> None: + """Every raw GitHub doc-image URL in the source is rewritten to its local asset URL.""" + source = ( + f"![one]({RAW_DOC_IMAGES_PREFIX}okta_auth_1.png)\n" + f"text\n" + f"![two]({RAW_DOC_IMAGES_PREFIX}descope.webp)\n" + ) + rewritten = rewrite_integration_doc_images_in_source(source) + local_prefix = _integrations_doc_images_url() + assert RAW_DOC_IMAGES_PREFIX not in rewritten + assert f"({local_prefix}okta_auth_1.png)" in rewritten + assert f"({local_prefix}descope.webp)" in rewritten + + +def test_rewrite_source_without_doc_images_unchanged() -> None: + """Source without any raw GitHub doc-image URL is returned unchanged.""" + source = ( + "# Title\n\n" + "![logo](https://example.com/logo.svg)\n" + "![aws](https://raw.githubusercontent.com/reflex-dev/integrations-docs/refs/heads/main/images/logos/light/aws.svg)\n" + ) + assert rewrite_integration_doc_images_in_source(source) == source + + +def test_doc_image_references_exist_locally() -> None: + """Every screenshot URL in the docs must resolve to a local image, since the rewrite serves it locally.""" + missing = [ + f"{md.name}: {image_name}" + for md in sorted(integrations_docs.DOCS_DIR.glob("*.md")) + for image_name in re.findall( + re.escape(RAW_DOC_IMAGES_PREFIX) + r"([^)\s]+)", md.read_text() + ) + if not (DOC_IMAGES_DIR / image_name).is_file() + ] + assert not missing, f"docs reference images missing from images/docs/: {missing}" + + +def test_doc_image_references_use_image_syntax() -> None: + """Screenshot URLs must use ![alt](url) image syntax, not a [!alt](url) link typo.""" + malformed = [ + md.name + for md in sorted(integrations_docs.DOCS_DIR.glob("*.md")) + if re.search( + r"(? str: - """Symlink the integrations_docs logos into assets/external and return the public URL. + +def _integrations_images_url(subdir: str) -> str: + """Symlink integrations_docs/images/ into assets/external and return its public URL. + + Args: + subdir: The image subdirectory to expose (e.g. ``"logos"`` or ``"docs"``). Returns: - The public frontend URL prefix for the integrations_docs logos directory. + The public frontend URL prefix for the integrations_docs images subdirectory. Raises: - RuntimeError: If the integrations_docs logos directory cannot be found. + RuntimeError: If the integrations_docs images directory cannot be found. """ - src = Path(integrations_docs.__file__).parent / "images" / "logos" + src = Path(integrations_docs.__file__).parent / "images" / subdir if not src.is_dir(): - msg = f"integrations_docs logos directory not found at {src}" + msg = f"integrations_docs images directory not found at {src}" raise RuntimeError(msg) - relative_path = f"/{constants.Dirs.EXTERNAL_APP_ASSETS}/integrations_docs/logos/" + relative_path = f"/{constants.Dirs.EXTERNAL_APP_ASSETS}/integrations_docs/{subdir}/" if not EnvironmentVariables.REFLEX_BACKEND_ONLY.get(): dst = ( Path.cwd() / constants.Dirs.APP_ASSETS / constants.Dirs.EXTERNAL_APP_ASSETS / "integrations_docs" - / "logos" + / subdir ) dst.parent.mkdir(parents=True, exist_ok=True) if not dst.is_symlink() or dst.resolve() != src.resolve(): @@ -41,6 +48,26 @@ def _integrations_logos_url() -> str: return get_config().prepend_frontend_path(relative_path) +@once +def _integrations_logos_url() -> str: + """Return the public URL prefix for the integrations_docs logos directory. + + Returns: + The public frontend URL prefix for the integrations_docs logos directory. + """ + return _integrations_images_url("logos") + + +@once +def _integrations_doc_images_url() -> str: + """Return the public URL prefix for the integrations_docs screenshots directory. + + Returns: + The public frontend URL prefix for the integrations_docs docs images directory. + """ + return _integrations_images_url("docs") + + def format_integration_name(integration_name: str) -> str: """Normalize an integration name into the slug used by its logo filename. @@ -66,3 +93,19 @@ def get_integration_logo_url( The public URL for the SVG logo. """ return f"{_integrations_logos_url()}{theme}/{format_integration_name(integration_name)}.svg" + + +def rewrite_integration_doc_images_in_source(source: str) -> str: + """Rewrite raw GitHub integrations-docs screenshot URLs in a markdown source to local URLs. + + Operates on the raw markdown text before parsing. + + Args: + source: The markdown document source. + + Returns: + The source with every raw GitHub doc screenshot URL replaced by its local asset URL. + """ + if RAW_DOC_IMAGES_PREFIX not in source: + return source + return source.replace(RAW_DOC_IMAGES_PREFIX, _integrations_doc_images_url())