From f7168121b891dcb666ff2d9609e8af6082ca9bec Mon Sep 17 00:00:00 2001 From: Ace Data Cloud Dev Date: Sun, 4 Oct 2026 20:25:39 +0800 Subject: [PATCH 1/4] fix: reconcile complete versioned documentation across locales --- .github/workflows/sync.yml | 109 ++++--- README.md | 127 ++++---- ar/mcp/overview.mdx | 3 - de/mcp/overview.mdx | 3 - el/mcp/overview.mdx | 3 - en/mcp/overview.mdx | 3 - es/mcp/overview.mdx | 3 - fi/mcp/overview.mdx | 3 - fr/mcp/overview.mdx | 3 - it/mcp/overview.mdx | 3 - ja/mcp/overview.mdx | 3 - ko/mcp/overview.mdx | 3 - pl/mcp/overview.mdx | 3 - pt/mcp/overview.mdx | 3 - ru/mcp/overview.mdx | 3 - scripts/sync_from_platformbackend.py | 396 ++++++++++++++---------- sr/mcp/overview.mdx | 3 - sv/mcp/overview.mdx | 3 - tests/test_publication.py | 191 ++++++++++++ tests/test_sync_from_platformbackend.py | 184 +---------- uk/mcp/overview.mdx | 3 - zh-Hant/mcp/overview.mdx | 3 - 22 files changed, 566 insertions(+), 492 deletions(-) create mode 100644 tests/test_publication.py diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 9c96aa8d8..9decdc58f 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -1,11 +1,20 @@ name: Sync Docs from PlatformBackend on: + push: + branches: [main] + paths: + - 'scripts/**' + - '.github/workflows/sync.yml' repository_dispatch: - types: [sync-docs, platform-contracts-updated] + types: [platform-contracts-updated] workflow_dispatch: schedule: - - cron: "0 6 * * *" + - cron: "37 * * * *" + +concurrency: + group: docs-publication-main + cancel-in-progress: false permissions: contents: write @@ -26,62 +35,79 @@ jobs: uses: actions/checkout@v4 with: path: Docs + ref: main token: ${{ secrets.BOT_GITHUB_TOKEN }} - - name: Resolve PlatformBackend ref + - name: Checkout current PlatformBackend source + uses: actions/checkout@v4 + with: + repository: AceDataCloud/PlatformBackend + ref: main + fetch-depth: 0 + path: PlatformBackend + token: ${{ secrets.BOT_GITHUB_TOKEN }} + + - name: Verify source event and current contract id: backend env: EVENT_NAME: ${{ github.event_name }} - EVENT_TYPE: ${{ github.event.action }} SOURCE_SHA: ${{ github.event.client_payload.source_sha }} run: | - if [ "$EVENT_NAME" = "repository_dispatch" ] && [ "$EVENT_TYPE" = "platform-contracts-updated" ]; then + actual=$(git -C PlatformBackend rev-parse HEAD) + if [ "$EVENT_NAME" = "repository_dispatch" ]; then if ! printf '%s' "$SOURCE_SHA" | grep -Eq '^[0-9a-f]{40}$'; then - echo "::error::Direct contract events require a full PlatformBackend source_sha." + echo "::error::Contract events require a full source_sha." exit 1 fi - echo "ref=$SOURCE_SHA" >> "$GITHUB_OUTPUT" - echo "direct=true" >> "$GITHUB_OUTPUT" - else - echo "ref=main" >> "$GITHUB_OUTPUT" - echo "direct=false" >> "$GITHUB_OUTPUT" + # An old queued event is a reconciliation signal, never permission + # to replay an older snapshot over newer published content. + git -C PlatformBackend merge-base --is-ancestor "$SOURCE_SHA" "$actual" fi - - - name: Checkout PlatformBackend - uses: actions/checkout@v4 - with: - repository: AceDataCloud/PlatformBackend - ref: ${{ steps.backend.outputs.ref }} - path: PlatformBackend - token: ${{ secrets.BOT_GITHUB_TOKEN }} - - - name: Verify PlatformBackend source - if: steps.backend.outputs.direct == 'true' - run: | - actual=$(git -C PlatformBackend rev-parse HEAD) - test "$actual" = "${{ steps.backend.outputs.ref }}" + echo "sha=$actual" >> "$GITHUB_OUTPUT" bundle=$(mktemp -d) python PlatformBackend/scripts/ecosystem_contracts.py \ - --backend-dir PlatformBackend compile \ - --output-dir "$bundle" \ - --source-sha "$actual" + --backend-dir PlatformBackend compile --output-dir "$bundle" --source-sha "$actual" python PlatformBackend/scripts/ecosystem_contracts.py verify \ - --bundle-dir "$bundle" \ - --source-sha "$actual" + --bundle-dir "$bundle" --source-sha "$actual" - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Run sync script + - name: Reconcile public documents + id: publication working-directory: Docs env: PUBLIC_EXAMPLE_DENYLIST: ${{ secrets.PUBLIC_EXAMPLE_DENYLIST }} run: | + set +e python -u scripts/sync_from_platformbackend.py \ --backend-dir ../PlatformBackend \ - --output-dir . + --output-dir . \ + --report "$RUNNER_TEMP/docs-sync-report.json" + result=$? + set -e + if [ "$result" -ne 0 ] && [ "$result" -ne 2 ]; then + exit "$result" + fi + echo "incomplete=$([ "$result" -eq 2 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + python - "$RUNNER_TEMP/docs-sync-report.json" >> "$GITHUB_STEP_SUMMARY" <<'PY' + import json, sys + report = json.load(open(sys.argv[1])) + print(f"Current document versions: {report['ready']}/{report['total']}") + for item in report['pending'][:100]: + print(f"- {item['language']}/{item['source_key']}: {item['status']}") + PY + + - name: Preserve publication completeness report + if: always() + uses: actions/upload-artifact@v4 + with: + name: docs-sync-report + path: ${{ runner.temp }}/docs-sync-report.json + if-no-files-found: warn + retention-days: 14 - name: Check for changes id: changes @@ -95,9 +121,8 @@ jobs: fi echo "changed=true" >> "$GITHUB_OUTPUT" - changed_files=$(git diff --cached --name-only | head -300) - echo "Files changed:" - echo "$changed_files" + changed_files=$(git diff --cached --name-only) + git diff --cached --stat changed_services=$(echo "$changed_files" \ | grep -oP '^(?:openapi/|[^/]+/guides/|[^/]+/mcp/)\K[^/.]+' \ @@ -113,6 +138,18 @@ jobs: run: | git config user.name "Ace Data Cloud Dev" git config user.email "dev@acedata.cloud" - git commit -m "docs: sync from PlatformBackend [automated]" + latest=$(git -C ../PlatformBackend ls-remote origin refs/heads/main | cut -f1) + test "$latest" = "${{ steps.backend.outputs.sha }}" || { + echo "::error::PlatformBackend advanced during publication; retry from current main." + exit 1 + } + git commit -m "docs: sync from PlatformBackend [automated]" \ + -m "Source: ${{ steps.backend.outputs.sha }}" git push echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Require complete publication + if: steps.publication.outputs.incomplete == 'true' + run: | + echo "::error::Publication is incomplete. Current pages were refreshed; retained or missing translations are listed in docs-sync-report." + exit 1 diff --git a/README.md b/README.md index fce11c506..09ba291a0 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,77 @@ -# Mintlify Starter Kit +# AceDataCloud Docs + +Mintlify documentation for AceDataCloud. Preview with `mint dev`; check public +links with `mint broken-links`. Navigation and site settings live in `docs.json`. + +## Sources and ownership + +- PlatformBackend owns Markdown source files and OpenAPI definitions. Edit the + source, not generated Docs pages. +- The read-only `/api/v1/documents/publication/?lang=en` backend endpoint exports + public documents by stable source key, including standalone guides and MCP. + It includes exact-locale content, source SHA-256, and translation readiness. + Display-oriented API/sibling relations are not a publication contract. +- Every configured locale's `guides/` and `mcp/` directories are generated. + `mcp/overview.mdx` is explicitly hand-authored and preserved. Public source + records without an old service route use `guides/platform/.mdx`. + Existing routes and the exact Coding route map are preserved. +- `openapi/` contains specs for APIs with public platform document records; + private markers and temporary publication holds are still enforced. +- Generated navigation is reconciled atomically with pages: Coding translations, + additional public guides, MCP pages, and OpenAPI groups become discoverable; + retired generated links are removed. Existing editorial groups are preserved. +- Quickstarts, concepts, pricing, FAQ, and other editorial pages are maintained + here. They are not automatically rewritten from API changes. + +## Synchronization contract + +`Sync Ecosystem Contracts` is the only source-change trigger. The Docs workflow +serializes all writers, checks out current `main` after acquiring the queue, and +validates that an event SHA belongs to the current backend history. Old events +cannot republish old source snapshots. A final source check rejects a backend +revision that advanced during generation; normal Git push rejects concurrent +editorial changes without overwriting them. + +Hourly reconciliation picks up asynchronous source deployment and translation +completion even when no new Git event occurs. Before switching this consumer on, +deploy the backend publication endpoint; unavailable or malformed feeds fail +closed and do not fall back to the old display API. + +For every generated page, the database source hash must match the checked-out +Markdown and the target translation must be current. Catalog identity must also +remain consistent across locale reads. There is no automatic language fallback. +When a translation is pending, an existing page is retained for availability but +is explicitly reported as incomplete; a missing page is not fabricated. Ready +pages and public withdrawals can still publish. Exit status **2** means partial +publication and fails the workflow's final completeness gate. Other nonzero +statuses abort publication; **0** means every expected page is current. + +The workflow retains `docs-sync-report` for 14 days and puts pending page keys, +locales, and reasons in the job summary. Generated output is staged and validated +before an atomic publish; interruption recovery includes navigation and all MCP +locales. Customer example sanitization remains mandatory. + +## Verification -Use the starter kit to get your docs deployed and ready to customize. - -Click the green **Use this template** button at the top of this repo to copy the Mintlify starter kit. The starter kit contains examples with - -- Guide pages -- Navigation -- Customizations -- API reference pages -- Use of popular components - -**[Follow the full quickstart guide](https://starter.mintlify.com/quickstart)** - -## AI-assisted writing - -Set up your AI coding tool to work with Mintlify: - -```bash -npx skills add https://mintlify.com/docs -``` - -This command installs Mintlify's documentation skill for your configured AI tools like Claude Code, Cursor, Windsurf, and others. The skill includes component reference, writing standards, and workflow guidance. - -See the [AI tools guides](/ai-tools) for tool-specific setup. - -## Development - -Install the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview your documentation changes locally. To install, use the following command: - -``` -npm i -g mint -``` - -Run the following command at the root of your documentation, where your `docs.json` is located: - -``` -mint dev +```sh +python -m compileall -q scripts tests +python -m unittest +mint broken-links ``` -View your local preview at `http://localhost:3000`. - -## Publishing changes - -Install our GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app) to propagate changes from your repo to your deployment. Changes are deployed to production automatically after pushing to the default branch. - -## Need help? - -### Troubleshooting - -- If your dev environment isn't running: Run `mint update` to ensure you have the most recent version of the CLI. -- If a page loads as a 404: Make sure you are running in a folder with a valid `docs.json`. - -### Resources -- [Mintlify documentation](https://mintlify.com/docs) - -## Automated tests +For read-only reconciliation, run: ```sh -python3 -m unittest +python scripts/sync_from_platformbackend.py --backend-dir ../PlatformBackend \ + --output-dir . --dry-run --report /tmp/docs-sync-report.json ``` -CI discovers tests by filename instead of maintaining a per-file list. Add Python -tests as `test_*.py` in `tests/`; no workflow change is needed. +`--preview-dir /tmp/docs-preview` preserves dry-run output in a new directory for +review and link checking. Set `PUBLIC_EXAMPLE_DENYLIST` and `--require-denylist` to +include private-value checks in a dry run; without it the report explicitly marks +that check unverified. Actual publication always requires the configured denylist. + +`--catalog-dir /path/to/snapshots` loads files named `zh-cn.json`, `en.json`, etc. +from the same publication exporter for reproducible, offline verification. Reports +belong outside the generated output tree. No sync command writes the backend DB +or generates translations. diff --git a/ar/mcp/overview.mdx b/ar/mcp/overview.mdx index 802df6481..8e26e7eaf 100644 --- a/ar/mcp/overview.mdx +++ b/ar/mcp/overview.mdx @@ -30,9 +30,6 @@ description: "خادم بروتوكول سياق النموذج لتكامل أ خادم Shorturl MCP - - خادم Sora MCP - خادم Suno MCP diff --git a/de/mcp/overview.mdx b/de/mcp/overview.mdx index bbb01a6e5..76a885e9f 100644 --- a/de/mcp/overview.mdx +++ b/de/mcp/overview.mdx @@ -30,9 +30,6 @@ Ace Data Cloud bietet MCP (Model Context Protocol) Server an, die es KI-Assisten Shorturl MCP-Server - - Sora MCP-Server - Suno MCP-Server diff --git a/el/mcp/overview.mdx b/el/mcp/overview.mdx index 4eda5665e..85a8bf66a 100644 --- a/el/mcp/overview.mdx +++ b/el/mcp/overview.mdx @@ -30,9 +30,6 @@ description: "Διακομιστής Model Context Protocol για ενσωμά Διακομιστής MCP Shorturl - - Διακομιστής MCP Sora - Διακομιστής MCP Suno diff --git a/en/mcp/overview.mdx b/en/mcp/overview.mdx index 13fbf263f..2f71f70b6 100644 --- a/en/mcp/overview.mdx +++ b/en/mcp/overview.mdx @@ -31,9 +31,6 @@ We ship official **MCP (Model Context Protocol) servers** for every core service ByteDance image - - OpenAI video - Google video diff --git a/es/mcp/overview.mdx b/es/mcp/overview.mdx index a3d089892..9f24647c2 100644 --- a/es/mcp/overview.mdx +++ b/es/mcp/overview.mdx @@ -30,9 +30,6 @@ Ace Data Cloud proporciona servidores MCP (Model Context Protocol) que permiten Servidor MCP Shorturl - - Servidor MCP Sora - Servidor MCP Suno diff --git a/fi/mcp/overview.mdx b/fi/mcp/overview.mdx index 1eb5ebe50..fb38cb58a 100644 --- a/fi/mcp/overview.mdx +++ b/fi/mcp/overview.mdx @@ -30,9 +30,6 @@ Ace Data Cloud tarjoaa MCP (Model Context Protocol) -palvelimen, jonka avulla Cl Shorturl MCP -palvelin - - Sora MCP -palvelin - Suno MCP -palvelin diff --git a/fr/mcp/overview.mdx b/fr/mcp/overview.mdx index d1afcb0dd..81e935567 100644 --- a/fr/mcp/overview.mdx +++ b/fr/mcp/overview.mdx @@ -30,9 +30,6 @@ Ace Data Cloud propose des serveurs MCP (Model Context Protocol) permettant aux Serveur MCP Shorturl - - Serveur MCP Sora - Serveur MCP Suno diff --git a/it/mcp/overview.mdx b/it/mcp/overview.mdx index aa1faa8ff..a660e68a7 100644 --- a/it/mcp/overview.mdx +++ b/it/mcp/overview.mdx @@ -30,9 +30,6 @@ Ace Data Cloud offre server MCP (Model Context Protocol) che permettono agli ass Server MCP Shorturl - - Server MCP Sora - Server MCP Suno diff --git a/ja/mcp/overview.mdx b/ja/mcp/overview.mdx index b82c6cfaf..c4e27a0c9 100644 --- a/ja/mcp/overview.mdx +++ b/ja/mcp/overview.mdx @@ -30,9 +30,6 @@ Ace Data Cloud は MCP(Model Context Protocol)サーバーを提供してお Shorturl MCP サーバー - - Sora MCP サーバー - Suno MCP サーバー diff --git a/ko/mcp/overview.mdx b/ko/mcp/overview.mdx index 04ba0fd0e..91eeba46f 100644 --- a/ko/mcp/overview.mdx +++ b/ko/mcp/overview.mdx @@ -30,9 +30,6 @@ Ace Data Cloud는 Claude, Cursor, Windsurf 등 AI 어시스턴트가 직접 당 Shorturl MCP 서버 - - Sora MCP 서버 - Suno MCP 서버 diff --git a/pl/mcp/overview.mdx b/pl/mcp/overview.mdx index 74b3b5dbc..5715687f8 100644 --- a/pl/mcp/overview.mdx +++ b/pl/mcp/overview.mdx @@ -30,9 +30,6 @@ Ace Data Cloud oferuje serwery MCP (Model Context Protocol), które umożliwiaj Serwer MCP Shorturl - - Serwer MCP Sora - Serwer MCP Suno diff --git a/pt/mcp/overview.mdx b/pt/mcp/overview.mdx index e3024a606..388c797d3 100644 --- a/pt/mcp/overview.mdx +++ b/pt/mcp/overview.mdx @@ -30,9 +30,6 @@ Ace Data Cloud oferece servidores MCP (Model Context Protocol) que permitem que Servidor MCP Shorturl - - Servidor MCP Sora - Servidor MCP Suno diff --git a/ru/mcp/overview.mdx b/ru/mcp/overview.mdx index eb9cf5f5a..268c43194 100644 --- a/ru/mcp/overview.mdx +++ b/ru/mcp/overview.mdx @@ -30,9 +30,6 @@ Ace Data Cloud предоставляет сервер MCP (Model Context Protoc Сервер Shorturl MCP - - Сервер Sora MCP - Сервер Suno MCP diff --git a/scripts/sync_from_platformbackend.py b/scripts/sync_from_platformbackend.py index 7794a255f..68ec194bd 100644 --- a/scripts/sync_from_platformbackend.py +++ b/scripts/sync_from_platformbackend.py @@ -11,6 +11,7 @@ import argparse import copy import html +import hashlib import json import os import re @@ -27,7 +28,7 @@ START = time.time() BASE_URL = "https://api.acedata.cloud" -DOCUMENTS_URL = "https://platform.acedata.cloud/api/v1/documents/?limit=1000" +PUBLICATION_URL = "https://platform.acedata.cloud/api/v1/documents/publication/" SHARED_GUIDE_ALIASES = {"kling_assets": ("kling_elements", "kling_voices")} TRANSACTION_FILE = ".docs-sync-transaction.json" BACKUP_DIR = ".docs-sync-backup" @@ -398,7 +399,7 @@ def validate_manifest(manifest: Any) -> None: raise RuntimeError("Invalid Docs sync transaction item") relative = Path(item["relative"]) parts = relative.parts - allowed = relative == Path("openapi") or (len(parts) == 2 and parts[1] == "guides") or relative == Path("zh-Hans/mcp") + allowed = relative in {Path("openapi"), Path("docs.json")} or (len(parts) == 2 and parts[0] in LANGUAGE_SOURCE_DIRS and parts[1] in {"guides", "mcp"}) if relative.is_absolute() or ".." in parts or not allowed or item["relative"] in seen: raise RuntimeError("Unsafe Docs sync transaction path") seen.add(item["relative"]) @@ -788,8 +789,7 @@ def merge_openapi_specs(backend_dir: Path, service: dict[str, Any]) -> dict[str, continue spec = load_openapi_spec(backend_dir, api["id"]) if not spec: - log(f" WARNING: missing OpenAPI spec for {api.get('id')} ({service.get('alias')})") - continue + raise RuntimeError(f"Missing public OpenAPI spec for {api.get('id')} ({service.get('alias')})") if spec.get("x-private"): continue merged["paths"].update({path: operation for path, operation in spec.get("paths", {}).items() @@ -922,66 +922,64 @@ def build_doc_service_map( result[doc_key] = matched_alias continue - log(f" WARNING: unmapped development doc {doc_key}, skipping") result[doc_key] = None return result -def index_localized_guides(payload: Any, language: str) -> dict[str, dict[str, str]]: - items = payload.get("items") if isinstance(payload, dict) else payload - if not isinstance(items, list): - raise RuntimeError(f"Invalid document feed for {language}") - - guides: dict[str, dict[str, str]] = {} - for item in items: - if not isinstance(item, dict): - continue - api = item.get("api") - sibling = item.get("sibling") - if not isinstance(api, dict) or not isinstance(sibling, dict) or not sibling.get("content"): - continue - - candidates = [item.get("alias", ""), sibling.get("alias", "").removesuffix("-integration")] - api_path = (api.get("path") or "").strip("/").replace("/", "_") - definition = api.get("definition") or {} - operation = definition.get("paths", {}).get(api.get("path"), {}).get((api.get("method") or "").lower(), {}) - operation_id = operation.get("operationId") - namespace = (api.get("path") or "").strip("/").split("/")[0] - if isinstance(operation_id, str) and operation_id.startswith(namespace + "_"): - candidates.append(operation_id) - - guide = { - "content": sibling["content"], - "title": sibling.get("title") or sibling.get("name") or "", - } - for candidate in candidates: - if candidate: - key = normalize(candidate) - existing = guides.get(key) - if existing and existing != guide: - raise RuntimeError(f"Conflicting localized guide key {candidate!r} for {language}") - guides[key] = guide - if api_path: - guides.setdefault(normalize(api_path), guide) - - if not guides: - raise RuntimeError(f"No localized guides found for {language}") - return guides - - -def load_localized_guides(language: str) -> dict[str, dict[str, str]]: - request = Request(DOCUMENTS_URL, headers={"Accept-Language": language}) - for attempt in range(3): - try: - with urlopen(request, timeout=60) as response: - payload = json.load(response) - return index_localized_guides(payload, language) - except (HTTPError, URLError, RemoteDisconnected, OSError, RuntimeError, json.JSONDecodeError): - if attempt == 2: - raise - time.sleep(2**attempt) - raise AssertionError("unreachable") +def load_publication_catalog(language: str, catalog_dir: Path | None = None) -> dict[str, Any]: + language = language.lower() + if catalog_dir: + payload = load_json(catalog_dir / f"{language}.json") + else: + request = Request(f"{PUBLICATION_URL}?lang={language}") + for attempt in range(3): + try: + with urlopen(request, timeout=60) as response: + payload = json.load(response) + break + except (HTTPError, URLError, RemoteDisconnected, OSError, json.JSONDecodeError): + if attempt == 2: + raise + time.sleep(2**attempt) + if (not isinstance(payload, dict) or payload.get("schema_version") != 1 + or payload.get("language") != language or payload.get("source_language") != "zh-cn" + or not isinstance(payload.get("items"), list) or not payload["items"] + or not isinstance(payload.get("public_api_ids"), list) + or not re.fullmatch(r"[0-9a-f]{64}", payload.get("catalog_hash", ""))): + raise RuntimeError(f"Invalid publication catalog for {language}") + records = {} + for item in payload["items"]: + key = item.get("source_key", "") + if (not re.fullmatch(r"(?:development_|mcp_)[a-zA-Z0-9_-]+|x402_integration_guide", key) + or key in records or item.get("status") not in {"ready", "missing", "stale", "missing_source"}): + raise RuntimeError(f"Invalid or duplicate publication identity for {language}") + if item.get("source_hash") is not None and not re.fullmatch(r"[0-9a-f]{64}", item["source_hash"]): + raise RuntimeError(f"Invalid source hash for {language}/{key}") + if item["status"] == "ready": + body = item.get("content") + if not isinstance(body, str) or not body or digest(body) != item.get("content_hash"): + raise RuntimeError(f"Invalid publication content for {language}/{key}") + records[key] = item + return {**payload, "records": records} + + +def digest(content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def load_publication_catalogs(languages: list[str], catalog_dir: Path | None = None) -> dict[str, Any]: + catalogs = {} + identity = None + for language in languages: + catalog = load_publication_catalog(LANGUAGE_SOURCE_DIRS[language], catalog_dir) + current = (catalog["catalog_hash"], sorted(catalog["public_api_ids"]), + sorted((key, item["source_hash"]) for key, item in catalog["records"].items())) + if identity is not None and current != identity: + raise RuntimeError("Public source catalog changed between locale reads; retry with a consistent snapshot") + identity = current + catalogs[language] = catalog + return catalogs def guide_description(output_language: str, service_name: str) -> str: @@ -1033,106 +1031,143 @@ def sync_openapi(backend_dir: Path, output_dir: Path, services: list[dict[str, A log(f" wrote {generated} OpenAPI specs") -def sync_guides( - backend_dir: Path, - output_dir: Path, - services_by_alias: dict[str, dict[str, Any]], - doc_service_map: dict[str, str | None], - languages: list[str], - fallback_root: Path | None = None, - exact_records: dict[str, dict[str, str]] | None = None, -) -> None: - log("Syncing localized guides") - total = 0 - source_dir = backend_dir / "docs" - markdown_files = sorted(source_dir.glob("development_*.md")) - if not markdown_files: - raise RuntimeError(f"No development docs found in {source_dir}") +# overview is hand-authored; every other MCP page is generated in every locale. +MANUAL_MCP_PAGES = {"overview.mdx"} +MORE_GUIDES = { + "zh-Hans": "更多指南", "zh-Hant": "更多指南", "en": "More guides", "ja": "その他のガイド", + "ko": "추가 가이드", "es": "Más guías", "fr": "Autres guides", "de": "Weitere Anleitungen", + "pt": "Mais guias", "ru": "Другие руководства", "ar": "المزيد من الأدلة", "it": "Altre guide", + "sv": "Fler guider", "uk": "Інші посібники", "pl": "Więcej przewodników", +} - for output_language in languages: - source_language = LANGUAGE_SOURCE_DIRS.get(output_language, output_language) - localized_guides = None if output_language == "zh-Hans" else load_localized_guides(source_language) - language_total = 0 - missing_guides: list[str] = [] - for markdown_file in markdown_files: - doc_key = markdown_file.stem.removeprefix("development_") - if doc_key.endswith("_title") or doc_key in SKIP_DOC_KEYS: - continue - service_alias = doc_service_map.get(doc_key) - if not service_alias: - continue - service = services_by_alias.get(service_alias, {}) - service_name = service.get("display_name") or service_alias.replace("-", " ").title() - exact = (exact_records or {}).get(doc_key) - relative_output = Path(exact["output_path"]) if exact else Path("guides") / service_alias / f"{doc_key}.mdx" - localized_guide = localized_guides.get(normalize(doc_key)) if localized_guides else None - if localized_guides and not localized_guide: - for alias in SHARED_GUIDE_ALIASES.get(doc_key, ()): - localized_guide = localized_guides.get(normalize(alias)) - if localized_guide: - break - if localized_guides and not localized_guide: - missing_guides.append(doc_key) - fallback = fallback_root / output_language / relative_output if fallback_root else None - if fallback and fallback.exists(): - target = output_dir / output_language / relative_output - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(fallback, target) - total += 1 - language_total += 1 - continue - content = localized_guide["content"] if localized_guide else markdown_file.read_text(encoding="utf-8") - fallback_title = localized_guide["title"] if localized_guide else doc_key.replace("_", " ").title() - mdx = convert_markdown_to_mdx( - content, - fallback_title, - guide_description(output_language, service_name), - ) - write_text(output_dir / output_language / relative_output, mdx) - total += 1 - language_total += 1 - - if language_total == 0: - raise RuntimeError(f"No guide pages generated for {output_language}") - if missing_guides: - log(f" WARNING: {output_language} missing {len(missing_guides)} localized guides: {', '.join(missing_guides)}") - log(f" {output_language}: wrote {language_total} guide pages") - - x402_path = source_dir / "x402_integration_guide.md" if output_language == "zh-Hans" else None - if x402_path and x402_path.exists(): - mdx = convert_markdown_to_mdx(x402_path.read_text(encoding="utf-8"), "X402 Integration Guide") - write_text(output_dir / output_language / "guides" / "x402.mdx", mdx) - total += 1 - - # OAuth "Sign in with Ace Data Cloud" is a platform-level guide (no service alias), - # so the loop above skips it — emit it explicitly like x402. - oauth_path = source_dir / "development_oauth_apps.md" if output_language == "zh-Hans" else None - if oauth_path and oauth_path.exists(): - mdx = convert_markdown_to_mdx(oauth_path.read_text(encoding="utf-8"), "OAuth Integration Guide") - write_text(output_dir / output_language / "guides" / "oauth.mdx", mdx) - total += 1 - - log(f" wrote {total} guide pages") - - -def sync_mcp_docs(backend_dir: Path, output_dir: Path, languages: list[str]) -> None: - log("Syncing localized MCP docs") - total = 0 - for output_language in languages: - if output_language != "zh-Hans": +def publication_routes(backend_dir: Path, catalog: dict[str, Any], doc_service_map: dict[str, str | None], + exact_records: dict[str, Any]) -> tuple[dict[str, Path], dict[str, str]]: + routes = {} + excluded = {} + for key in catalog["records"]: + source = backend_dir / "docs" / f"{key}.md" + doc_key = key.removeprefix("development_") + if not source.is_file(): + excluded[key] = "no_repository_source" + continue + if doc_key in SKIP_DOC_KEYS or any(key.startswith(f"development_{alias}_") for alias in EXCLUDED_SERVICES): + excluded[key] = "publication_hold" + continue + if key.startswith("mcp_"): + relative = Path("mcp") / f"{key.removeprefix('mcp_')}.mdx" + elif key == "x402_integration_guide": + relative = Path("guides/x402.mdx") + elif key == "development_oauth_apps": + relative = Path("guides/oauth.mdx") + elif doc_key in exact_records: + relative = Path(exact_records[doc_key]["output_path"]) + else: + # Preserve established URLs; public standalone guides have a stable + # platform path rather than silently disappearing from the export. + alias = doc_service_map.get(doc_key) or "platform" + relative = Path("guides") / alias / f"{doc_key}.mdx" + if relative in routes.values(): + raise RuntimeError(f"Conflicting publication output: {relative}") + routes[key] = relative + if not routes: + raise RuntimeError("No public source documents to publish") + return routes, excluded + + +def sync_document_pages(backend_dir: Path, output_dir: Path, previous: Path, + catalogs: dict[str, Any], routes: dict[str, Path], + services_by_alias: dict[str, Any] | None = None) -> list[dict[str, Any]]: + results = [] + for language, catalog in catalogs.items(): + for name in MANUAL_MCP_PAGES: + source = previous / language / "mcp" / name + if source.is_file(): + target = output_dir / language / "mcp" / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + for key, relative in routes.items(): + record = catalog["records"][key] + source = (backend_dir / "docs" / f"{key}.md").read_text(encoding="utf-8") + status = record["status"] + if record["source_hash"] != digest(source): + status = "source_not_deployed" + target = output_dir / language / relative + old = previous / language / relative + retained = False + if status == "ready": + description = None + if relative.parts[0] == "mcp": + description = f"{relative.stem} MCP server integration" + elif len(relative.parts) == 3: + alias = relative.parts[1] + service = (services_by_alias or {}).get(alias, {}) + name = service.get("display_name") or alias.replace("-", " ").title() + description = guide_description(language, name) + write_text(target, convert_markdown_to_mdx(record["content"], key.replace("_", " ").title(), description)) + elif old.is_file(): + # Availability and freshness are separate: retain the published + # page, but report incomplete and make the workflow fail its gate. + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(old, target) + retained = True + results.append({"language": language, "source_key": key, "path": str(Path(language) / relative), + "source_hash": digest(source), "status": status, "retained_previous": retained}) + return results + + +def refresh_generated_navigation(root: Path, languages: list[str], exact_records: dict[str, Any]) -> None: + config = load_json(root / "docs.json") + for language in config["navigation"]["languages"]: + locale = language["language"] + if locale not in languages: continue - source_dir = backend_dir / "docs" - for markdown_file in sorted(source_dir.glob("mcp_*.md")): - mcp_name = markdown_file.stem.removeprefix("mcp_") - mdx = convert_markdown_to_mdx( - markdown_file.read_text(encoding="utf-8"), - f"{mcp_name.replace('-', ' ').title()} MCP Server", - f"{mcp_name} MCP server integration", - ) - write_text(output_dir / output_language / "mcp" / f"{mcp_name}.mdx", mdx) - total += 1 - log(f" wrote {total} MCP pages") + tabs = language.get("tabs", []) + if not tabs: + raise RuntimeError(f"Missing guide navigation for {locale}") + api_tab = next((tab for tab in tabs if any("openapi" in group for group in tab.get("groups", []))), None) + if api_tab: + api_tab["groups"] = [group for group in api_tab["groups"] + if "openapi" not in group or (root / group["openapi"]["source"].lstrip("/")).is_file()] + known_specs = {group["openapi"]["source"] for group in api_tab["groups"] if "openapi" in group} + for spec in sorted((root / "openapi").glob("*.json")): + source = f"/openapi/{spec.name}" + if source not in known_specs: + directory = f"api-reference/{spec.stem}" if locale == "zh-Hans" else f"{locale}/api-reference/{spec.stem}" + api_tab["groups"].append({"group": load_json(spec)["info"]["title"], + "openapi": {"source": source, "directory": directory}}) + prefixes = (f"{locale}/guides/", f"{locale}/mcp/") + + def prune(value: Any) -> None: + if isinstance(value, dict): + if isinstance(value.get("pages"), list): + value["pages"] = [p for p in value["pages"] if not isinstance(p, str) + or not p.startswith(prefixes) or (root / f"{p}.mdx").is_file()] + for child in value.values(): + prune(child) + elif isinstance(value, list): + for child in value: + prune(child) + + prune(tabs) + groups = tabs[0].setdefault("groups", []) + groups[:] = [g for g in groups if g.get("group") != MORE_GUIDES[locale]] + for group in groups: + if group.get("group") == "Coding": + group["pages"] = [f"{locale}/{r['output_path'].removesuffix('.mdx')}" for r in exact_records.values() + if (root / locale / r["output_path"]).is_file()] + known: list[str] = [] + collect_page_paths(tabs, known) + additional = sorted(str(p.relative_to(root).with_suffix("")) for p in (root / locale / "guides").rglob("*.mdx") + if str(p.relative_to(root).with_suffix("")) not in known) + if additional: + groups.append({"group": MORE_GUIDES[locale], "icon": "book-open", "pages": additional}) + mcp_tab = next((t for t in tabs if "MCP" in t.get("tab", "")), None) + if mcp_tab: + group = mcp_tab["groups"][0] + group["pages"] = sorted({p for p in group.get("pages", []) if isinstance(p, str)} + | {str(p.relative_to(root).with_suffix("")) for p in (root / locale / "mcp").glob("*.mdx")}) + write_json(root / "docs.json", config) def get_docs_languages(output_dir: Path) -> list[str]: @@ -1142,8 +1177,8 @@ def get_docs_languages(output_dir: Path) -> list[str]: def managed_paths(languages: list[str]) -> list[Path]: - paths = [Path("openapi"), Path("zh-Hans") / "mcp"] - paths.extend(Path(language) / "guides" for language in languages) + paths = [Path("openapi"), Path("docs.json")] + paths.extend(Path(language) / folder for language in languages for folder in ("guides", "mcp")) return paths @@ -1160,11 +1195,19 @@ def main() -> int: parser.add_argument("--output-dir", required=True, type=Path) parser.add_argument("--validate-only", action="store_true") parser.add_argument("--require-denylist", action="store_true") + parser.add_argument("--catalog-dir", type=Path, help="Read exported publication snapshots for offline verification") + parser.add_argument("--report", type=Path, help="Write publication completeness report outside the output tree") + parser.add_argument("--dry-run", action="store_true", help="Validate and report without publishing generated files") + parser.add_argument("--preview-dir", type=Path, help="Save dry-run output to a new directory for review") args = parser.parse_args() output_dir = args.output_dir.resolve() + if args.preview_dir and (not args.dry_run or args.preview_dir.resolve().is_relative_to(output_dir)): + raise SystemExit("--preview-dir requires --dry-run and a directory outside the output tree") restore_transaction(output_dir) - denylist = load_denylist(required=args.require_denylist or not args.validate_only) + denylist = load_denylist(required=args.require_denylist or not (args.validate_only or args.dry_run)) + if args.dry_run and not all(denylist): + log("Dry run: private-value denylist is unavailable; publication still requires it") if args.validate_only: validate_generated_tree(output_dir, denylist) log("Generated tree validation passed") @@ -1179,25 +1222,46 @@ def main() -> int: documented_aliases = get_documented_service_aliases(output_dir) services = load_services(backend_dir, documented_aliases) - services_by_alias = {service["alias"]: service for service in services} exact_records = load_exact_doc_records(backend_dir, services) doc_service_map = build_doc_service_map(services, backend_dir, exact_records) languages = get_docs_languages(output_dir) + if not languages: + raise RuntimeError("No supported publication languages configured") log(f"Languages: {', '.join(languages)}") + catalogs = load_publication_catalogs(languages, args.catalog_dir) + source_catalog = catalogs[languages[0]] + routes, excluded = publication_routes(backend_dir, source_catalog, doc_service_map, exact_records) + public_api_ids = set(source_catalog["public_api_ids"]) + public_services = [{**service, "apis": [api for api in service.get("apis", []) if api["id"] in public_api_ids]} + for service in services] managed = managed_paths(languages) with tempfile.TemporaryDirectory(prefix="docs-sync-", dir=output_dir.parent) as temporary_directory: staging_dir = Path(temporary_directory) / "output" shutil.copytree(output_dir, staging_dir, ignore=shutil.ignore_patterns(".git")) clear_managed_paths(staging_dir, managed) - sync_openapi(backend_dir, staging_dir, services) - sync_guides(backend_dir, staging_dir, services_by_alias, doc_service_map, languages, output_dir, exact_records) - sync_mcp_docs(backend_dir, staging_dir, languages) + # docs.json is committed atomically with generated pages, not cleared. + shutil.copy2(output_dir / "docs.json", staging_dir / "docs.json") + sync_openapi(backend_dir, staging_dir, public_services) + results = sync_document_pages(backend_dir, staging_dir, output_dir, catalogs, routes, + {service["alias"]: service for service in services}) + refresh_generated_navigation(staging_dir, languages, exact_records) neutralize_generated_tree(staging_dir, denylist) validate_generated_tree(staging_dir, denylist) - publish_generated_tree(staging_dir, output_dir, managed) - log("Done") - return 0 + pending = [item for item in results if item["status"] != "ready"] + report = {"schema_version": 1, "complete": not pending, "catalog_hash": source_catalog["catalog_hash"], + "private_value_validation": all(denylist), + "total": len(results), "ready": len(results) - len(pending), "pending": pending, "excluded": excluded} + if args.report: + write_json(args.report, report) + if args.preview_dir: + shutil.copytree(staging_dir, args.preview_dir) + if not args.dry_run: + publish_generated_tree(staging_dir, output_dir, managed) + log(f"Publication: {report['ready']}/{report['total']} current; {len(pending)} pending") + for item in pending[:20]: + log(f" PENDING {item['language']}/{item['source_key']}: {item['status']}") + return 2 if pending else 0 if __name__ == "__main__": diff --git a/sr/mcp/overview.mdx b/sr/mcp/overview.mdx index 5f2c2838c..ce6bf8c06 100644 --- a/sr/mcp/overview.mdx +++ b/sr/mcp/overview.mdx @@ -30,9 +30,6 @@ Ace Data Cloud пружа MCP (Model Context Protocol) сервере који Shorturl MCP сервер - - Sora MCP сервер - Suno MCP сервер diff --git a/sv/mcp/overview.mdx b/sv/mcp/overview.mdx index 47273bf85..fc19e9f14 100644 --- a/sv/mcp/overview.mdx +++ b/sv/mcp/overview.mdx @@ -30,9 +30,6 @@ Ace Data Cloud erbjuder MCP (Model Context Protocol)-servrar som gör det möjli Shorturl MCP-server - - Sora MCP-server - Suno MCP-server diff --git a/tests/test_publication.py b/tests/test_publication.py new file mode 100644 index 000000000..cf0946231 --- /dev/null +++ b/tests/test_publication.py @@ -0,0 +1,191 @@ +import copy +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from tests.test_sync_from_platformbackend import sync + + +def record(key, source="# Source", content="# Translated", status="ready"): + return {"source_key": key, "source_hash": sync.digest(source), "status": status, + "content": content if status == "ready" else None, + "content_hash": sync.digest(content) if status == "ready" else None} + + +def catalog(language="en", records=None): + records = records or [record("development_codex")] + return {"schema_version": 1, "source_language": "zh-cn", "language": language, + "catalog_hash": "a" * 64, "public_api_ids": ["public-api"], "items": records} + + +class PublicationTests(unittest.TestCase): + def test_partial_reconciliation_publishes_ready_pages_but_returns_nonzero(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + backend, output, snapshots = root / "backend", root / "output", root / "catalogs" + (backend / "docs").mkdir(parents=True) + (backend / "cost").mkdir() + (backend / "cost/service_api_mapping.json").write_text(json.dumps([{"alias": "codex", "apis": []}])) + (backend / "docs/development_codex.md").write_text("# Source") + snapshots.mkdir() + output.mkdir() + config = {"navigation": {"languages": [{"language": lang, "tabs": [ + {"tab": "Guides", "groups": [{"group": "Coding", "pages": []}]}, + {"tab": "MCP", "groups": [{"group": "MCP", "pages": []}]}]} for lang in ["zh-Hans", "en"]]}} + (output / "docs.json").write_text(json.dumps(config)) + old = output / "en/guides/codex/codex.mdx" + old.parent.mkdir(parents=True) + old.write_text("# Previous") + (snapshots / "zh-cn.json").write_text(json.dumps(catalog("zh-cn", [record("development_codex", content="# Source")]))) + (snapshots / "en.json").write_text(json.dumps(catalog("en", [record("development_codex", status="stale")]))) + argv = ["sync", "--backend-dir", str(backend), "--output-dir", str(output), + "--catalog-dir", str(snapshots), "--report", str(root / "report.json")] + with patch("sys.argv", argv), patch.object(sync, "load_exact_doc_records", return_value={}), patch.dict( + sync.os.environ, {"PUBLIC_EXAMPLE_DENYLIST": "identifier:test-id\nhost:test.invalid\nterm:test-private-term"} + ): + self.assertEqual(sync.main(), 2) + self.assertEqual(old.read_text(), "# Previous") + self.assertIn("# Source".removeprefix("# "), (output / "zh-Hans/guides/codex/codex.mdx").read_text()) + report = json.loads((root / "report.json").read_text()) + self.assertFalse(report["complete"]) + self.assertEqual(report["ready"], 1) + (snapshots / "en.json").write_text(json.dumps(catalog())) + self.assertEqual(sync.main(), 0) + self.assertIn("Translated", old.read_text()) + first = {str(p.relative_to(output)): p.read_bytes() for p in output.rglob("*") if p.is_file()} + self.assertEqual(sync.main(), 0) + self.assertEqual(first, {str(p.relative_to(output)): p.read_bytes() for p in output.rglob("*") if p.is_file()}) + + def test_catalog_accepts_text_and_mcp_without_api_siblings(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + payload = catalog(records=[record("development_codex"), record("mcp_seedance")]) + (root / "en.json").write_text(json.dumps(payload)) + actual = sync.load_publication_catalog("en", root) + self.assertEqual(set(actual["records"]), {"development_codex", "mcp_seedance"}) + + def test_catalog_rejects_wrong_locale_duplicate_identity_and_corrupt_content(self): + payloads = [catalog("zh-cn"), catalog(records=[record("development_codex")] * 2), catalog()] + payloads[-1]["items"][0]["content"] = "Tampered" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for payload in payloads: + (root / "en.json").write_text(json.dumps(payload)) + with self.assertRaises(RuntimeError): + sync.load_publication_catalog("en", root) + + def test_catalog_change_during_locale_reads_aborts(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + en, zh = catalog(), catalog("zh-cn") + zh["items"][0]["source_hash"] = "b" * 64 + for language, payload in [("en", en), ("zh-cn", zh)]: + (root / f"{language}.json").write_text(json.dumps(payload)) + with self.assertRaisesRegex(RuntimeError, "changed between"): + sync.load_publication_catalogs(["en", "zh-Hans"], root) + + def test_routes_cover_standalone_mcp_exact_maps_and_publication_holds(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "docs").mkdir() + keys = ["development_codex", "development_sdk", "mcp_seedance", "development_kling_assets", + "development_oauth_apps", "x402_integration_guide", "development_flux_generate_video"] + for key in keys: + (root / "docs" / f"{key}.md").write_text("# Source") + records = {key: record(key) for key in keys} + routes, excluded = sync.publication_routes(root, {"records": records}, + {"flux_generate_video": "flux"}, {"codex": {"output_path": "guides/codex/exact.mdx"}}) + self.assertEqual(routes["development_codex"], Path("guides/codex/exact.mdx")) + self.assertEqual(routes["development_sdk"], Path("guides/platform/sdk.mdx")) + self.assertEqual(routes["mcp_seedance"], Path("mcp/seedance.mdx")) + self.assertEqual(routes["development_flux_generate_video"], Path("guides/flux/flux_generate_video.mdx")) + self.assertEqual(routes["development_oauth_apps"], Path("guides/oauth.mdx")) + self.assertEqual(routes["x402_integration_guide"], Path("guides/x402.mdx")) + self.assertEqual(excluded["development_kling_assets"], "publication_hold") + + def test_all_locales_receive_mcp_and_pending_pages_are_not_counted_as_current(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + backend, old, output = root / "backend", root / "old", root / "output" + (backend / "docs").mkdir(parents=True) + keys = ["mcp_seedance", "development_codex", "development_sdk", "development_new"] + for key in keys: + (backend / "docs" / f"{key}.md").write_text("# Source") + routes = {"mcp_seedance": Path("mcp/seedance.mdx"), "development_codex": Path("guides/codex/codex.mdx"), + "development_sdk": Path("guides/platform/sdk.mdx"), "development_new": Path("guides/platform/new.mdx")} + records = {key: record(key) for key in keys} + records["development_codex"]["status"] = "stale" + records["development_sdk"]["source_hash"] = sync.digest("# Previous source") + records["development_new"]["status"] = "missing" + for relative in ["en/guides/codex/codex.mdx", "en/mcp/overview.mdx", "en/mcp/seedance.mdx"]: + path = old / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("previous") + catalogs = {locale: {"records": copy.deepcopy(records)} for locale in sync.LANGUAGE_SOURCE_DIRS} + results = sync.sync_document_pages(backend, output, old, catalogs, routes) + for locale in sync.LANGUAGE_SOURCE_DIRS: + self.assertIn("Translated", (output / locale / "mcp/seedance.mdx").read_text()) + self.assertEqual((output / "en/mcp/overview.mdx").read_text(), "previous") + self.assertEqual((output / "en/guides/codex/codex.mdx").read_text(), "previous") + en = {row["source_key"]: row for row in results if row["language"] == "en"} + self.assertEqual(en["development_codex"]["status"], "stale") + self.assertTrue(en["development_codex"]["retained_previous"]) + self.assertEqual(en["development_sdk"]["status"], "source_not_deployed") + self.assertEqual(en["development_new"]["status"], "missing") + self.assertFalse((output / "en/guides/platform/new.mdx").exists()) + + def test_navigation_exposes_new_translations_and_removes_retired_pages(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config = {"navigation": {"languages": [{"language": "en", "tabs": [ + {"tab": "Guides", "groups": [{"group": "Coding", "pages": []}, + {"group": "Getting Started", "pages": ["en/quickstart", "en/guides/retired/old"]}]}, + {"tab": "MCP Servers", "groups": [{"group": "MCP", "pages": ["en/mcp/sora"]}]}, + {"tab": "API Reference", "groups": [{"group": "Retired", "openapi": {"source": "/openapi/retired.json"}}]}]}]}} + (root / "docs.json").write_text(json.dumps(config)) + (root / "openapi").mkdir() + (root / "openapi/live.json").write_text(json.dumps({"info": {"title": "Live"}})) + for path in ["en/guides/codex/desktop.mdx", "en/guides/platform/sdk.mdx", "en/mcp/seedance.mdx", "en/mcp/overview.mdx"]: + file = root / path + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text("body") + exact = {"codex_desktop": {"output_path": "guides/codex/desktop.mdx"}} + sync.refresh_generated_navigation(root, ["en"], exact) + once = (root / "docs.json").read_text() + sync.refresh_generated_navigation(root, ["en"], exact) + self.assertEqual(once, (root / "docs.json").read_text()) + self.assertIn("en/quickstart", once) + self.assertIn("en/guides/codex/desktop", once) + self.assertIn("en/guides/platform/sdk", once) + self.assertIn("en/mcp/overview", once) + self.assertNotIn("en/mcp/sora", once) + self.assertNotIn("en/guides/retired/old", once) + self.assertNotIn("/openapi/retired.json", once) + self.assertIn("/openapi/live.json", once) + + def test_transaction_can_restore_navigation_and_every_mcp_locale(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + old, stage = root / "old", root / "stage" + for base, content in [(old, "before"), (stage, "after")]: + (base / "en/mcp").mkdir(parents=True) + (base / "en/mcp/seedance.mdx").write_text(content) + (base / "docs.json").write_text(content) + real_replace = sync.os.replace + + def interrupt(source, target): + if Path(source) == stage / "en/mcp": + raise KeyboardInterrupt + return real_replace(source, target) + + with patch.object(sync.os, "replace", side_effect=interrupt), self.assertRaises(KeyboardInterrupt): + sync.publish_generated_tree(stage, old, [Path("docs.json"), Path("en/mcp")]) + self.assertEqual((old / "docs.json").read_text(), "before") + self.assertEqual((old / "en/mcp/seedance.mdx").read_text(), "before") + + def test_missing_public_openapi_is_an_error(self): + with tempfile.TemporaryDirectory() as directory: + with self.assertRaisesRegex(RuntimeError, "Missing public OpenAPI"): + sync.merge_openapi_specs(Path(directory), {"alias": "test", "apis": [{"id": "missing"}]}) diff --git a/tests/test_sync_from_platformbackend.py b/tests/test_sync_from_platformbackend.py index 72de919b6..41093140d 100644 --- a/tests/test_sync_from_platformbackend.py +++ b/tests/test_sync_from_platformbackend.py @@ -37,25 +37,6 @@ def test_private_api_marker_is_respected_without_a_path_hold(self) -> None: }), encoding="utf-8") self.assertIsNone(sync.merge_openapi_specs(backend, {"alias": "test", "apis": [{"id": "private"}]})) - def test_flux_video_guide_is_restored_with_image_and_task_guides(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - backend = root / "backend" - (backend / "docs").mkdir(parents=True) - keys = ["flux_generate_video", "flux_generate_image", "flux_tasks"] - localized = {} - for key in keys: - (backend / "docs" / f"development_{key}.md").write_text(f"# {key}", encoding="utf-8") - localized[sync.normalize(key)] = {"content": f"# {key}", "title": key} - old = root / "old/en/guides/flux" / f"{key}.mdx" - old.parent.mkdir(parents=True, exist_ok=True) - old.write_text(f"# old {key}", encoding="utf-8") - with patch.object(sync, "load_localized_guides", return_value=localized): - sync.sync_guides(backend, root / "out", {"flux": {}}, dict.fromkeys(keys, "flux"), - ["en", "zh-Hans"], fallback_root=root / "old") - for language in ["en", "zh-Hans"]: - for key in keys: - self.assertTrue((root / "out" / language / f"guides/flux/{key}.mdx").exists()) def test_private_kling_operations_are_excluded_from_stale_mapping(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: @@ -71,50 +52,7 @@ def test_private_kling_operations_are_excluded_from_stale_mapping(self) -> None: merged = sync.merge_openapi_specs(backend, service) self.assertEqual(set(merged["paths"]), paths - sync.PRIVATE_API_PATHS) - def test_shared_kling_assets_guide_uses_localized_element_alias(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - backend = root / "backend" - (backend / "docs").mkdir(parents=True) - (backend / "docs/development_kling_assets.md").write_text("# 中文资产指南", encoding="utf-8") - localized = {sync.normalize("kling_elements"): { - "content": "# Kling Assets\n\nVoice creation costs 0.07 Credits.", "title": "Kling Elements"}} - # Exercise alias resolution independently of the temporary publication hold. - with patch.object(sync, "load_localized_guides", return_value=localized), patch.object( - sync, "SKIP_DOC_KEYS", sync.SKIP_DOC_KEYS - {"kling_assets"} - ): - sync.sync_guides(backend, root / "out", {"kling": {"display_name": "Kling"}}, - {"kling_assets": "kling"}, ["en"]) - content = (root / "out/en/guides/kling/kling_assets.mdx").read_text(encoding="utf-8") - self.assertIn("Voice creation costs 0.07 Credits.", content) - self.assertNotIn("中文", content) - def test_unverified_kling_guides_stay_hidden_with_localized_and_fallback_content(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - backend = root / "backend" - (backend / "docs").mkdir(parents=True) - hidden = {"kling_apparel", "kling_virtual_try_on", "kling_assets"} - keys = hidden | {"kling_videos"} - localized = {} - for key in keys: - (backend / "docs" / f"development_{key}.md").write_text(f"# {key}", encoding="utf-8") - localized[sync.normalize(key)] = {"content": f"# {key}", "title": key} - fallback = root / "old" / "en/guides/kling" / f"{key}.mdx" - fallback.parent.mkdir(parents=True, exist_ok=True) - fallback.write_text("Previously published content", encoding="utf-8") - services = [{"alias": "kling", "apis": [{"path": "/kling/videos"}]}] - mapping = sync.build_doc_service_map(services, backend) - for key in hidden: - self.assertIsNone(mapping[key]) - # Even a stale caller-provided map cannot republish the held guides. - with patch.object(sync, "load_localized_guides", return_value=localized): - sync.sync_guides(backend, root / "out", {"kling": {}}, - dict.fromkeys(keys, "kling"), ["en", "zh-Hans"], fallback_root=root / "old") - for language in ["en", "zh-Hans"]: - for key in hidden: - self.assertFalse((root / "out" / language / "guides/kling" / f"{key}.mdx").exists()) - self.assertTrue((root / "out" / language / "guides/kling/kling_videos.mdx").exists()) def test_build_doc_service_map_reads_flattened_docs_root(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: @@ -158,34 +96,6 @@ def test_exact_records_override_heuristic_service_and_output(self) -> None: self.assertEqual(mapping["tool_route"], "coding") self.assertEqual(exact["tool_route"]["output_path"], "guides/coding/custom_name.mdx") - def test_sync_guides_writes_exact_output_path(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - backend = root / "backend" - output = root / "output" - docs = backend / "docs" - docs.mkdir(parents=True) - (docs / "development_tool_route.md").write_text("# Exact tool\n\nBody", encoding="utf-8") - exact = { - "tool_route": { - "source_doc_key": "development_tool_route", - "service_alias": "coding", - "output_path": "guides/coding/exact_name.mdx", - "canonical_alias": "tool-route", - } - } - - sync.sync_guides( - backend, - output, - {"coding": {"alias": "coding", "display_name": "Coding"}}, - {"tool_route": "coding"}, - ["zh-Hans"], - exact_records=exact, - ) - - self.assertTrue((output / "zh-Hans/guides/coding/exact_name.mdx").is_file()) - self.assertFalse((output / "zh-Hans/guides/coding/tool_route.mdx").exists()) def test_exact_records_fail_closed_on_invalid_authority(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: @@ -243,101 +153,11 @@ def test_coding_nav_manifest_covers_all_locales_and_preserves_urls(self) -> None 25, ) - def test_index_localized_guides_maps_alias_and_api_path(self) -> None: - payload = { - "items": [ - { - "alias": "gemini-videos", - "api": {"path": "/gemini/videos"}, - "sibling": { - "alias": "gemini-videos-integration", - "title": "Gemini Video API Integration Guide", - "content": "# Gemini Video\n\nLocalized body", - }, - } - ] - } - - guides = sync.index_localized_guides(payload, "en") - - self.assertEqual(guides["geminivideos"]["title"], "Gemini Video API Integration Guide") - self.assertIn("Localized body", guides["geminivideos"]["content"]) - - def test_index_localized_guides_ignores_reciprocal_text_sibling(self) -> None: - payload = { - "items": [ - { - "alias": "suno-voices", - "api": {"path": "/suno/voices"}, - "sibling": { - "alias": "suno-voices-integration", - "title": "Suno Voice Clone API Integration Instructions", - "content": "Localized integration guide", - }, - }, - { - "alias": "suno-voices-integration", - "api": None, - "sibling": { - "alias": "suno-voices", - "title": "Suno Voices API", - "content": "API document body", - }, - }, - ] - } - guides = sync.index_localized_guides(payload, "en") - self.assertEqual(guides["sunovoices"]["title"], "Suno Voice Clone API Integration Instructions") - self.assertEqual(guides["sunovoices"]["content"], "Localized integration guide") - def test_index_localized_guides_resolves_namespaced_operation_id(self) -> None: - payload = {"items": [{ - "alias": "flux-videos", - "api": {"path": "/flux/videos", "method": "POST", "definition": { - "paths": {"/flux/videos": {"post": {"operationId": "flux_generate_video"}}} - }}, - "sibling": {"alias": "flux-videos-integration", "content": "# FLUX Video"}, - }]} - guides = sync.index_localized_guides(payload, "en") - self.assertEqual(guides["fluxgeneratevideo"]["content"], "# FLUX Video") - - def test_index_localized_guides_rejects_empty_feed(self) -> None: - with self.assertRaisesRegex(RuntimeError, "No localized guides"): - sync.index_localized_guides({"items": []}, "en") - - def test_index_localized_guides_rejects_collisions(self) -> None: - payload = { - "items": [ - {"alias": "same-key", "api": {"path": "/one"}, "sibling": {"title": "One", "content": "One"}}, - {"alias": "same_key", "api": {"path": "/two"}, "sibling": {"title": "Two", "content": "Two"}}, - ] - } - - with self.assertRaisesRegex(RuntimeError, "Conflicting localized guide key"): - sync.index_localized_guides(payload, "en") - - def test_index_localized_guides_does_not_overwrite_alias_with_shared_api_path(self) -> None: - payload = { - "items": [ - { - "alias": "fish-model", - "api": {"path": "/fish/model"}, - "sibling": {"title": "Fish Model", "content": "Model"}, - }, - { - "alias": "fish-model-query", - "api": {"path": "/fish/model"}, - "sibling": {"title": "Fish Model Query", "content": "Query"}, - }, - ] - } - guides = sync.index_localized_guides(payload, "en") - self.assertEqual(guides["fishmodel"]["content"], "Model") - self.assertEqual(guides["fishmodelquery"]["content"], "Query") def test_guide_description_uses_output_language(self) -> None: self.assertEqual(sync.guide_description("zh-Hans", "Gemini"), "Gemini 集成指南 - Ace Data Cloud") @@ -469,10 +289,10 @@ def test_openapi_artifact_validation_rejects_result_cdn_only_in_responses(self) request_only["paths"]["/items"]["post"]["responses"]["200"]["content"]["application/json"]["example"]["video_url"] = "https://media.invalid/video.mp4" self.assertTrue(sync.invalid_openapi_response_artifact_urls(request_only)) - def test_managed_paths_only_owns_generated_mcp_locale(self) -> None: + def test_managed_paths_owns_all_generated_locales(self) -> None: paths = sync.managed_paths(["zh-Hans", "en"]) self.assertIn(Path("zh-Hans/mcp"), paths) - self.assertNotIn(Path("en/mcp"), paths) + self.assertIn(Path("en/mcp"), paths) def test_clear_managed_paths_removes_stale_files(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: diff --git a/uk/mcp/overview.mdx b/uk/mcp/overview.mdx index 7ae0419ed..a7b1629b0 100644 --- a/uk/mcp/overview.mdx +++ b/uk/mcp/overview.mdx @@ -30,9 +30,6 @@ Ace Data Cloud надає сервер MCP (Model Context Protocol), який д Сервер Shorturl MCP - - Сервер Sora MCP - Сервер Suno MCP diff --git a/zh-Hant/mcp/overview.mdx b/zh-Hant/mcp/overview.mdx index aa81221c1..467c1600c 100644 --- a/zh-Hant/mcp/overview.mdx +++ b/zh-Hant/mcp/overview.mdx @@ -30,9 +30,6 @@ Ace Data Cloud 提供 MCP(Model Context Protocol)伺服器,讓 Claude、Cu Shorturl MCP 伺服器 - - Sora MCP 伺服器 - Suno MCP 伺服器 From dc895175a47f021d55084d352a4ddc5b4823cf68 Mon Sep 17 00:00:00 2001 From: Ace Data Cloud Dev Date: Sun, 4 Oct 2026 20:43:54 +0800 Subject: [PATCH 2/4] fix: validate generated MDX and reconcile legacy locale pages --- .github/workflows/sync.yml | 12 + .github/workflows/validate-sync.yml | 6 + README.md | 9 +- package-lock.json | 2200 ++++++++++++++++++++++++++ package.json | 7 +- scripts/sync_from_platformbackend.py | 25 +- scripts/validate_mdx.mjs | 52 + tests/mdx-validation.test.mjs | 20 + tests/test_publication.py | 7 + 9 files changed, 2333 insertions(+), 5 deletions(-) create mode 100644 package-lock.json create mode 100644 scripts/validate_mdx.mjs create mode 100644 tests/mdx-validation.test.mjs diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 9decdc58f..344ba7700 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -109,6 +109,18 @@ jobs: if-no-files-found: warn retention-days: 14 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: Docs/package-lock.json + + - name: Validate generated MDX before pushing + working-directory: Docs + run: | + npm ci --ignore-scripts + node scripts/validate_mdx.mjs . + - name: Check for changes id: changes working-directory: Docs diff --git a/.github/workflows/validate-sync.yml b/.github/workflows/validate-sync.yml index b5da6b948..63d59124e 100644 --- a/.github/workflows/validate-sync.yml +++ b/.github/workflows/validate-sync.yml @@ -21,3 +21,9 @@ jobs: run: | python -m compileall -q scripts tests python -m unittest + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + - run: npm ci --ignore-scripts + - run: node --test tests/mdx-validation.test.mjs diff --git a/README.md b/README.md index 09ba291a0..d9ab746bc 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ links with `mint broken-links`. Navigation and site settings live in `docs.json` It includes exact-locale content, source SHA-256, and translation readiness. Display-oriented API/sibling relations are not a publication contract. - Every configured locale's `guides/` and `mcp/` directories are generated. + Existing Greek, Finnish and Serbian directories are also reconciled because + their unlisted URLs remain accessible, without adding them to the language menu. `mcp/overview.mdx` is explicitly hand-authored and preserved. Public source records without an old service route use `guides/platform/.mdx`. Existing routes and the exact Coding route map are preserved. @@ -49,13 +51,18 @@ statuses abort publication; **0** means every expected page is current. The workflow retains `docs-sync-report` for 14 days and puts pending page keys, locales, and reasons in the job summary. Generated output is staged and validated before an atomic publish; interruption recovery includes navigation and all MCP -locales. Customer example sanitization remains mandatory. +locales. A separate MDX compilation gate parses all generated pages before Git +push. Plain Markdown braces are escaped outside code rather than interpreted as +JavaScript expressions. Customer example sanitization remains mandatory. ## Verification ```sh python -m compileall -q scripts tests python -m unittest +npm ci --ignore-scripts +node --test tests/mdx-validation.test.mjs +node scripts/validate_mdx.mjs /path/to/generated-preview mint broken-links ``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..091ace1ef --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2200 @@ +{ + "name": "docs", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "docs", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@playwright/test": "^1.58.2" + }, + "devDependencies": { + "@mdx-js/mdx": "3.1.1", + "remark-gfm": "4.0.1" + } + }, + "node_modules/@mdx-js/mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", + "integrity": "sha1-xf/ZkadTaxSeFxde7lehoqURxtE=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-scope": "^1.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "recma-build-jsx": "^1.0.0", + "recma-jsx": "^1.0.0", + "recma-stringify": "^1.0.0", + "rehype-recma": "^1.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha1-gIAgxG6La5H51ijNDry7Mr39i2w=", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha1-ItHMnVQtNZPK6nZPl0MGqzYobuc=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha1-zz8Oh2177hWpOrkluCv1cKOQSiQ=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha1-hYqI6iDzT+ZREfAFpon6Hr9w3Bg=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha1-SAIN5MDmNJL0yp20IGjBCPaLf48=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha1-fM9y7dLxqn3TQ34YDGQ3NYWATdY=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdx": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz", + "integrity": "sha1-weVBEyZbFSAhqwr+BDTjyt2Qv+M=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha1-BSqmekjszEMJ1/AZG35BQ0uQu3g=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha1-rKqw+RnOaczmKcLU7S60rcG2wgw=", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha1-Xi4TdMCjC1pC6LCDUjoiXGlF+Io=", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha1-T68BstbTJr/u2XrqH1IiC19MGUA=", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha1-ftW7VZCLOy8bxVxq8WU7rafweTc=", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha1-zHPmBip+sD59GcItiws0Uf2b/u8=", + "dev": true, + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha1-0m9c2P5db4MqMVF7n3w1YEC6bV0=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha1-F6O/gjAuCHDW2kOgExGovAKj7PU=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha1-LQnC5yzZUjB2zLIRV9/2atQ/zCI=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha1-HxrblAyXGksiujndymthjcblays=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha1-dryDqQc4kB17wiOp6TdZ/dVgEls=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha1-hcZrBB5DtHIQ+vQBJ4q/gIrEXLk=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha1-ZAJXF0+fQsdAtA87Ve51KST+78o=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha1-TonJRYrLYbyP7xn0UplzsjkoOe4=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha1-xq5DLZvZZiWC/OCHCbA4xY6ePWo=", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha1-PkBgN2CHTC5YZ2kbWZ1zp9oltT8=", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha1-JkQhTxmX057Q7g7OcjNUkKesZ74=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha1-TbfCyk3G4Og0wwvnDJS7yXbccBg=", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/esast-util-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", + "integrity": "sha1-jRz7Ua1TTS8VncJQ5gTzR4p58a0=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esast-util-from-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", + "integrity": "sha1-UUe+w0zJ2kSsz1L4fyOaQKw+giU=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "acorn": "^8.0.0", + "esast-util-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha1-RoMSa1ALYXYvLb66zhgG6L4xscg=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", + "integrity": "sha1-NEveamTIox0VIx5e6eKXVmppHC0=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", + "integrity": "sha1-ttC87R3MTwbyXPDO2istyvmBaPE=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha1-C170xP8TUIs03NAez6lF9h/OXb0=", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-scope": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.1.tgz", + "integrity": "sha1-KOHhJLXXkvRj9D7z0WkCNkNLBeo=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", + "integrity": "sha1-EKb7kkgU5qu2K+zw0rxN6lHQTxc=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha1-E6mp9A/1DtDAIvgx3fS1jQVEb+s=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha1-Z8PlSexAKkh7T8GT0ZU6UkdSNA0=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha1-+LETa0Bx+9jrFAr/hYsQGewpFfo=", + "dev": true, + "license": "MIT" + }, + "node_modules/hast-util-to-estree": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", + "integrity": "sha1-5lTByTdGRRNWlcwKufcLj8r3M9c=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha1-/zGJeq5Z9iIy4hWU6sfva2MzPpg=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha1-d3jtnTyS3Z6MXI9kiknCH8UctiE=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha1-sfxov8AxO4aFdF5EZON/k3a5yQk=", + "dev": true, + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha1-AQcgU+p8EDbfPH0Zptqux/GeeJs=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha1-fAP76W4+kxET5X+WSwo2jMLf2HU=", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha1-lGnS3BkNAhT9h9eLeMrswMwU7vc=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha1-hrW/Zo/KMHSY0xnfwDKJ14GpACc=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha1-1lAl7ew2V84DL9fbY8l4g+rtcfA=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha1-YvpnzZWHQqFXSvnzmGY2QQLZDNQ=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", + "integrity": "sha1-NL68g+mTjK4W4OAX5KmBSoMw08Q=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha1-/kTW1BD/nW8uoXl6P2CqTStjHCo=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha1-cKMXTIlOFN9yKr9DvCUMuuRLEd8=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha1-yVgiuRqrdfGKTL6LL1G4c+0s8Mc=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha1-LN9juSwqMxQGsPsNtMB3wbAzF1E=", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha1-q9VXYwM3vTCm1aS9glLhwtwIddU=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha1-d3jp2co99yOMwr0/orG/amWxlAM=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha1-1E756O0oOsjBFlqw0N/QWMJ2TBY=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha1-ekNftiI6crCGKzOvvXErba6HjTg=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha1-5oCV0vikMD7yQJSrZC4QR7mRqTY=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha1-eS+c8DYbRr7h/fHvNr6sQkoJnEE=", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha1-Q/CrrJrcdW4ghvY4IqOMjTw6UJY=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha1-/QTGeip0me+5BailxXjd3J/a2g0=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha1-AZz751etYt1VfbNaaV5zFLzJ+pc=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha1-fMCo3sMOrwS3salmGpKtszgqpuM=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha1-1/+EykmaV+LAYK5nVIrZUOaJoFM=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.3.tgz", + "integrity": "sha512-wgyJtgUkUcdU7zci7uuwc/tzoAhm0TswWhaXuvnWolqug+8jY7bgNkBBL0dYBnHgc/tHL7lCzRdPsdUxUKKBqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha1-elEhR1VWoE5+3etnsmSq550xKBQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha1-PhM3arld16XP0OKVYN/pmWV7PFs=", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha1-Yoau6WhsRGLB41UqnVBf7dzuuTU=", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha1-TatW1OOYuYU/b+TvrE/JNh8+B1A=", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha1-hhBt+LOmkrX2qSKA04eb5r5G2SM=", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha1-8m2KeAe1mF+6E89hRltYyl/33Fc=", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha1-vMNNgFY5gpmQ7BdcPuoSu1t4Hyw=", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", + "integrity": "sha1-Q9BY2ZlTL7MEEZWjw8BcRvqEVDs=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha1-/8mL22SXmJAvqfxWifZ/nByQIEQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha1-HSUogeo110aYQjq0SRfh9bGXuS0=", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha1-taLg7USSiPP29sVENYFZVXVJ3hg=", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha1-3iGysEX9IFm9ANNnRggd44OQ1Uo=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha1-j++OD3CB8EdPvdkt61DJkKAmRjk=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha1-UmfvqX8eUlTvx/ILRZo4yyEFi6E=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha1-uwmYhhBYnAfRweRCUoWJUEGz36k=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha1-I35KpdWKlYY/AQMtnumwkPHebpQ=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha1-BrJrKYPE0nv8xlezPiUTTUhosLE=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha1-L5h4MaQNTFEKwmHomFLE6XA8zaY=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha1-R/vNk0caP8yrhs/wOEf8NVLbEFE=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha1-05n6+cRcoUyLS+mLHqSBvO2Htik=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha1-Kg9JCrCL/1zC/V7sbdDKBPibMKk=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha1-/PFbZgl5OI5vEYzba/fXnXPSb+U=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha1-bLmVguXScehO/KjmGoB5lNcWHrI=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha1-DVHRwJVVHPqsNoMmljz1XxX1QLg=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha1-56imtVpH5aBscg1aHEq66MN8mPM=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha1-5AQDCWSBmGtBwQZif5j3LU0QuCU=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha1-ww13sugyrPZSb4vxqke8nJQ4wW0=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha1-4aLWLN0jcjCirhGDkCexk4HjHos=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha1-q4l4m4GKWHUrc9a1UjhiG3+qj9c=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha1-2K3lug8xl6HPaimZ+7/mNXoaGe4=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha1-5dpJTo6ysHGg0I+zT2zv7GwKGbg=", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha1-YdRvXtKOTuYundxD1rAQGIRD8Vk=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha1-Ea9XsSfjJId3SEH3pOVOqxZtA8Q=", + "dev": true, + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha1-mbVvn2mxtwxE8Av4Sy/lI0iuJRE=", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha1-5XZlvDKEbCE6w5oeTVvGIo52s3Y=", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha1-CAmzQmTplcC/zTInAooeNSEK+Ao=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/recma-build-jsx": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", + "integrity": "sha1-wC8p4EfhA9L6sgVJVOF2G46iU8Q=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-jsx": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", + "integrity": "sha1-WOcY9F4hAu0L8vqZTwW3DXaAGho=", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn-jsx": "^5.0.0", + "estree-util-to-js": "^2.0.0", + "recma-parse": "^1.0.0", + "recma-stringify": "^1.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/recma-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", + "integrity": "sha1-w1HhYbsKtH2GuSqYqdiR+baBS1I=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "esast-util-from-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", + "integrity": "sha1-VGMgMGMeDHVGE2/574/ejntE8TA=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-to-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-recma": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", + "integrity": "sha1-1o72NE0FkWvZbiVADGJhd1QRqnY=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "hast-util-to-estree": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha1-MyJ7KnQ5dnDTV78FwJjq+FE/DWs=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha1-BH+XA4vH7Dh667SwpP4jd5mZ2EU=", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha1-qmB0P8s36/awaSBOtNowTkDbRaE=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha1-Kt2q3agMqb2aoNp2PnTRYydoOzc=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha1-TFsB3XEcJp3xqq4RdD634udjb9M=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha1-o2WKuH5bZCnIofO6AIPUxhyj7wI=", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha1-Hs2dI1CjhEVyw/SjErzrAYNIhZ8=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha1-s7ee9fJ3zErHPK6wI2xbqTmzpPM=", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha1-KQiUEYf4V+eeKOnNeACLmgs+Do0=", + "dev": true, + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha1-HSLw5yZruMbYyuXK9OxPAF4I9hE=", + "dev": true, + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha1-2ALjMqB9+GHEiALAQyEBexvYczg=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha1-lKYL1r03XBUsHfkRpLEdWwJW9Q8=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha1-9mZ3YQpcCp7pDKsrjU1mA3Am2eE=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha1-0KP4by3Q23rNfYwkeAgLXGf5xqk=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha1-Z48gq1yhIHqX1+qKOINzyc+Ja+Q=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha1-2U2k31llKdH6o95QYgLwyaI/IgA=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha1-RJxuIaiA4IVb9aq63rOnQDFKusI=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha1-mioosKp2oV4NpwoIpYY6LwYOJGg=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha1-d333+5hlLOFrS3zZmdChpA76OgI=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha1-NlKrHEllMYUr9VprrFevmB68OKs=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha1-h7RN3de3DwZBwuPtCGS6c+LqjfQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha1-yCfUsKy3b8PmhaTG7CkC1RBw6dc=", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json index 42cb483a2..bc87fae5c 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,17 @@ "description": "Use the starter kit to get your docs deployed and ready to customize.", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "python3 -m unittest && node --test tests/mdx-validation.test.mjs", + "validate:mdx": "node scripts/validate_mdx.mjs" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@playwright/test": "^1.58.2" + }, + "devDependencies": { + "@mdx-js/mdx": "3.1.1", + "remark-gfm": "4.0.1" } } diff --git a/scripts/sync_from_platformbackend.py b/scripts/sync_from_platformbackend.py index 68ec194bd..8a3e53354 100644 --- a/scripts/sync_from_platformbackend.py +++ b/scripts/sync_from_platformbackend.py @@ -55,6 +55,9 @@ "sv": "sv", "uk": "uk", "pl": "pl", + "el": "el", + "fi": "fi", + "sr": "sr", } EXCLUDED_SERVICES = { @@ -988,15 +991,26 @@ def guide_description(output_language: str, service_name: str) -> str: def sanitize_html_for_mdx(content: str) -> str: - parts = re.split(r"(^```.*?^```)", content, flags=re.MULTILINE | re.DOTALL) + parts = re.split(r"(^```.*?^```|^~~~.*?^~~~)", content, flags=re.MULTILINE | re.DOTALL) for index, part in enumerate(parts): - if part.startswith("```"): + if part.startswith(("```", "~~~")): continue part = re.sub(r"(<[a-zA-Z][^>]*)\bclass=", r"\1className=", part) for tag in ("img", "br", "hr", "input", "source", "meta", "link"): part = re.sub(rf"(<{tag}\b[^>]*?)(?", r"\1 />", part) part = re.sub(r"<(https?://[^>]+)>", r"[\1](\1)", part) part = re.sub(r"<(?![a-zA-Z/!])", r"<", part) + # These sources are Markdown, not executable JSX. Literal braces in + # prose (including malformed translated code fences) must stay text. + # Keep inline code unchanged, just as fenced examples are unchanged. + escaped = [] + cursor = 0 + for match in re.finditer(r"(?P`+).*?(?P=ticks)", part, flags=re.DOTALL): + escaped.append(part[cursor:match.start()].replace("{", "{").replace("}", "}")) + escaped.append(match[0]) + cursor = match.end() + escaped.append(part[cursor:].replace("{", "{").replace("}", "}")) + part = "".join(escaped) parts[index] = part return "".join(parts) @@ -1173,7 +1187,12 @@ def prune(value: Any) -> None: def get_docs_languages(output_dir: Path) -> list[str]: docs_json = load_json(output_dir / "docs.json") languages = [entry["language"] for entry in docs_json.get("navigation", {}).get("languages", [])] - return [language for language in languages if language in LANGUAGE_SOURCE_DIRS] + result = [language for language in languages if language in LANGUAGE_SOURCE_DIRS] + # Unlisted legacy locale URLs are still served by Mintlify. Existing + # generated directories must not remain permanently stale or unparseable. + result.extend(language for language in LANGUAGE_SOURCE_DIRS if language not in result + and any((output_dir / language / folder).is_dir() for folder in ("guides", "mcp"))) + return result def managed_paths(languages: list[str]) -> list[Path]: diff --git a/scripts/validate_mdx.mjs b/scripts/validate_mdx.mjs new file mode 100644 index 000000000..9756df180 --- /dev/null +++ b/scripts/validate_mdx.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +// Parse generated pages without evaluating any MDX/JSX or importing page code. +import { compile } from '@mdx-js/mdx'; +import remarkGfm from 'remark-gfm'; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export async function validateMdx(content) { + const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, ''); + await compile(body, { remarkPlugins: [remarkGfm] }); +} + +async function walk(directory) { + let entries; + try { entries = await readdir(directory, { withFileTypes: true }); } + catch (error) { if (error.code === 'ENOENT') return []; throw error; } + const files = []; + for (const entry of entries) { + const filename = path.join(directory, entry.name); + if (entry.isDirectory()) files.push(...await walk(filename)); + else if (entry.name.endsWith('.mdx')) files.push(filename); + } + return files.sort(); +} + +async function main() { + const root = path.resolve(process.argv[2] || '.'); + const config = JSON.parse(await readFile(path.join(root, 'docs.json'), 'utf8')); + const failures = []; + let count = 0; + const languages = new Set(config.navigation.languages.map(({ language }) => language)); + for (const entry of await readdir(root, { withFileTypes: true })) { + if (entry.isDirectory() && !entry.name.startsWith('.')) languages.add(entry.name); + } + for (const language of languages) { + for (const folder of ['guides', 'mcp']) { + for (const filename of await walk(path.join(root, language, folder))) { + count++; + try { await validateMdx(await readFile(filename, 'utf8')); } + catch (error) { failures.push(`${path.relative(root, filename)}: ${error.reason || error.message}`); } + } + } + } + for (const failure of failures) console.error(failure); + console.log(`MDX syntax: ${count - failures.length}/${count} pages valid`); + if (failures.length) process.exitCode = 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + await main(); +} diff --git a/tests/mdx-validation.test.mjs b/tests/mdx-validation.test.mjs new file mode 100644 index 000000000..104be4e2c --- /dev/null +++ b/tests/mdx-validation.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import test from 'node:test'; +import { validateMdx } from '../scripts/validate_mdx.mjs'; + +test('generated Markdown preserves literal JSON and placeholders outside code', async () => { + const markdown = '# Example\n\nLiteral {token}. Inline `{token}`.\n\n```json\n{"pitch": 60}\n```\n{"pitch": 61}\n\n~~~json\n{"pitch": 62}\n~~~\n'; + const generated = execFileSync('python3', ['-c', + 'import sys;from scripts.sync_from_platformbackend import convert_markdown_to_mdx;print(convert_markdown_to_mdx(sys.stdin.read(),"Example"))' + ], { input: markdown, encoding: 'utf8' }); + assert.ok(generated.includes('Literal {token}')); + assert.ok(generated.includes('`{token}`')); + assert.ok(generated.includes('```json\n{"pitch": 60}\n```')); + await validateMdx(generated); +}); + +test('syntax gate rejects malformed MDX without executing it', async () => { + await assert.rejects(validateMdx('# Broken\n\n{"pitch": 60}')); + await assert.rejects(validateMdx('unclosed')); +}); diff --git a/tests/test_publication.py b/tests/test_publication.py index cf0946231..22b3ac716 100644 --- a/tests/test_publication.py +++ b/tests/test_publication.py @@ -21,6 +21,13 @@ def catalog(language="en", records=None): class PublicationTests(unittest.TestCase): + def test_existing_legacy_locale_pages_remain_in_the_reconciliation_scope(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "docs.json").write_text(json.dumps({"navigation": {"languages": [{"language": "en"}]}})) + (root / "el/guides").mkdir(parents=True) + self.assertEqual(sync.get_docs_languages(root), ["en", "el"]) + def test_partial_reconciliation_publishes_ready_pages_but_returns_nonzero(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) From b5212996c2378506fef9c77e243b19b5c56f05ac Mon Sep 17 00:00:00 2001 From: Ace Data Cloud Dev Date: Sun, 4 Oct 2026 21:04:12 +0800 Subject: [PATCH 3/4] fix: resolve publication links and validate every site page --- .github/workflows/sync.yml | 2 +- README.md | 7 +- ar/introduction.mdx | 16 ++-- ar/mcp/overview.mdx | 21 +++--- ar/quickstart.mdx | 4 +- de/introduction.mdx | 16 ++-- de/mcp/overview.mdx | 21 +++--- de/quickstart.mdx | 4 +- el/introduction.mdx | 16 ++-- el/mcp/overview.mdx | 21 +++--- el/quickstart.mdx | 4 +- en/faq/billing.mdx | 2 +- en/introduction.mdx | 16 ++-- en/mcp/overview.mdx | 28 ++----- en/pricing/overview.mdx | 2 +- es/introduction.mdx | 16 ++-- es/mcp/overview.mdx | 21 +++--- es/quickstart.mdx | 4 +- fi/introduction.mdx | 16 ++-- fi/mcp/overview.mdx | 21 +++--- fi/quickstart.mdx | 4 +- fr/introduction.mdx | 16 ++-- fr/mcp/overview.mdx | 21 +++--- fr/quickstart.mdx | 4 +- it/introduction.mdx | 16 ++-- it/mcp/overview.mdx | 21 +++--- it/quickstart.mdx | 4 +- ja/introduction.mdx | 16 ++-- ja/mcp/overview.mdx | 21 +++--- ja/quickstart.mdx | 4 +- ko/introduction.mdx | 16 ++-- ko/mcp/overview.mdx | 21 +++--- ko/quickstart.mdx | 4 +- package-lock.json | 109 ++++++++++++++++++++++++++- package.json | 3 +- pl/introduction.mdx | 16 ++-- pl/mcp/overview.mdx | 21 +++--- pl/quickstart.mdx | 4 +- pt/introduction.mdx | 16 ++-- pt/mcp/overview.mdx | 21 +++--- pt/quickstart.mdx | 4 +- ru/introduction.mdx | 16 ++-- ru/mcp/overview.mdx | 21 +++--- ru/quickstart.mdx | 4 +- scripts/sync_from_platformbackend.py | 65 ++++++++++++++-- scripts/validate_mdx.mjs | 61 +++++++++++---- sr/introduction.mdx | 16 ++-- sr/mcp/overview.mdx | 21 +++--- sr/quickstart.mdx | 4 +- sv/introduction.mdx | 16 ++-- sv/mcp/overview.mdx | 21 +++--- sv/quickstart.mdx | 4 +- tests/mdx-validation.test.mjs | 23 +++++- tests/test_publication.py | 19 +++++ uk/introduction.mdx | 16 ++-- uk/mcp/overview.mdx | 21 +++--- uk/quickstart.mdx | 4 +- zh-Hans/authentication.mdx | 8 +- zh-Hans/concepts/async-tasks.mdx | 8 +- zh-Hans/concepts/responses.mdx | 2 +- zh-Hans/faq/billing.mdx | 6 +- zh-Hans/introduction.mdx | 16 ++-- zh-Hans/mcp/overview.mdx | 36 +++++++++ zh-Hans/pricing/overview.mdx | 8 +- zh-Hans/quickstart.mdx | 12 +-- zh-Hant/introduction.mdx | 16 ++-- zh-Hant/mcp/overview.mdx | 21 +++--- zh-Hant/quickstart.mdx | 4 +- 68 files changed, 648 insertions(+), 441 deletions(-) create mode 100644 zh-Hans/mcp/overview.mdx diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 344ba7700..6cd67c9c6 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -115,7 +115,7 @@ jobs: cache: npm cache-dependency-path: Docs/package-lock.json - - name: Validate generated MDX before pushing + - name: Validate MDX and local links before pushing working-directory: Docs run: | npm ci --ignore-scripts diff --git a/README.md b/README.md index d9ab746bc..e7f5fe3ba 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ links with `mint broken-links`. Navigation and site settings live in `docs.json` `mcp/overview.mdx` is explicitly hand-authored and preserved. Public source records without an old service route use `guides/platform/.mdx`. Existing routes and the exact Coding route map are preserved. +- Per-locale API reference indexes are generated from the published specs. + Source-file links are resolved to the correct locale route; platform-relative + document links are made absolute instead of being interpreted as Docs routes. - `openapi/` contains specs for APIs with public platform document records; private markers and temporary publication holds are still enforced. - Generated navigation is reconciled atomically with pages: Coding translations, @@ -51,8 +54,8 @@ statuses abort publication; **0** means every expected page is current. The workflow retains `docs-sync-report` for 14 days and puts pending page keys, locales, and reasons in the job summary. Generated output is staged and validated before an atomic publish; interruption recovery includes navigation and all MCP -locales. A separate MDX compilation gate parses all generated pages before Git -push. Plain Markdown braces are escaped outside code rather than interpreted as +locales. A separate MDX and local-link gate checks every site page before Git +push, including editorial entry points. Plain Markdown braces are escaped outside code rather than interpreted as JavaScript expressions. Customer example sanitization remains mandatory. ## Verification diff --git a/ar/introduction.mdx b/ar/introduction.mdx index 05ad41943..2c82254b8 100644 --- a/ar/introduction.mdx +++ b/ar/introduction.mdx @@ -8,16 +8,16 @@ description: "يوفر Ace Data Cloud منصة API موحدة للذكاء ال Ace Data Cloud هو منصة API موحدة للذكاء الاصطناعي، تتيح الوصول إلى خدمات الذكاء الاصطناعي الرائدة عالميًا من خلال مفتاح API واحد وواجهة موحدة. - + الوصول إلى نماذج مثل Claude و GPT و Gemini و DeepSeek و Grok و Kimi وغيرها عبر واجهة متوافقة مع OpenAI. - - توليد الصور باستخدام Midjourney و Flux و DALL·E و Seedream وغيرها. + + توليد الصور باستخدام و Flux و DALL·E و Seedream وغيرها. - - توليد الفيديوهات باستخدام Sora و Veo و Luma و Kling و Hailuo و Seedance وغيرها. + + توليد الفيديوهات باستخدام و Veo و Luma و Kling و Hailuo و Seedance وغيرها. - + توليد الموسيقى والصوتيات باستخدام Suno و Fish Audio و Producer وغيرها. @@ -36,10 +36,10 @@ Ace Data Cloud هو منصة API موحدة للذكاء الاصطناعي، ت التسجيل والحصول على رمز API - + وثائق API تفاعلية - + ربط مساعد الذكاء الاصطناعي بواجهاتنا البرمجية \ No newline at end of file diff --git a/ar/mcp/overview.mdx b/ar/mcp/overview.mdx index 8e26e7eaf..0fdeafb75 100644 --- a/ar/mcp/overview.mdx +++ b/ar/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "خادم بروتوكول سياق النموذج لتكامل أ توفر Ace Data Cloud خادم MCP (بروتوكول سياق النموذج) لتمكين مساعدي الذكاء الاصطناعي مثل Claude وCursor وWindsurf من استدعاء واجهات برمجة التطبيقات الخاصة بنا مباشرة. - + خادم Flux MCP - + خادم Luma MCP - - خادم Midjourney MCP - - + خادم Nanobanana MCP - + خادم Seedance MCP - + خادم Seedream MCP - + خادم Serp MCP - + خادم Shorturl MCP - + خادم Suno MCP - + خادم Veo MCP \ No newline at end of file diff --git a/ar/quickstart.mdx b/ar/quickstart.mdx index 198b28027..602a647b5 100644 --- a/ar/quickstart.mdx +++ b/ar/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. استكشاف المزيد من API - + تصفح واختبر جميع نقاط نهاية API بشكل تفاعلي - + دروس تفصيلية لكل خدمة \ No newline at end of file diff --git a/de/introduction.mdx b/de/introduction.mdx index 68c4dcb8a..e83891698 100644 --- a/de/introduction.mdx +++ b/de/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud bietet eine einheitliche AI API-Plattform, mit eine Ace Data Cloud ist eine einheitliche AI API-Plattform, die über einen API-Schlüssel und eine konsistente Schnittstelle Zugang zu weltweit führenden AI-Diensten bietet. - + Zugriff auf Modelle wie Claude, GPT, Gemini, DeepSeek, Grok, Kimi usw. über eine OpenAI-kompatible Schnittstelle. - - Bilderzeugung mit Midjourney, Flux, DALL·E, Seedream und weiteren. + + Bilderzeugung mit Flux, DALL·E, Seedream und weiteren. - - Videoproduktion mit Sora, Veo, Luma, Kling, Hailuo, Seedance und anderen. + + Videoproduktion mit Veo, Luma, Kling, Hailuo, Seedance und anderen. - + Musik- und Audioerzeugung mit Suno, Fish Audio, Producer und weiteren. @@ -36,10 +36,10 @@ Ace Data Cloud ist eine einheitliche AI API-Plattform, die über einen API-Schl Registrieren und API-Token erhalten - + Interaktive API-Dokumentation - + Verbinden Sie AI-Assistenten mit unserer API \ No newline at end of file diff --git a/de/mcp/overview.mdx b/de/mcp/overview.mdx index 76a885e9f..14e984e35 100644 --- a/de/mcp/overview.mdx +++ b/de/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "Model Context Protocol Server für die Integration von KI-Tools" Ace Data Cloud bietet MCP (Model Context Protocol) Server an, die es KI-Assistenten wie Claude, Cursor, Windsurf ermöglichen, direkt auf unsere API zuzugreifen. - + Flux MCP-Server - + Luma MCP-Server - - Midjourney MCP-Server - - + Nanobanana MCP-Server - + Seedance MCP-Server - + Seedream MCP-Server - + Serp MCP-Server - + Shorturl MCP-Server - + Suno MCP-Server - + Veo MCP-Server \ No newline at end of file diff --git a/de/quickstart.mdx b/de/quickstart.mdx index 11e9b6de0..ee4669f96 100644 --- a/de/quickstart.mdx +++ b/de/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. Weitere APIs entdecken - + Durchsuchen und interaktiv alle API-Endpunkte testen - + Detaillierte Integrationsanleitungen für jeden Dienst \ No newline at end of file diff --git a/el/introduction.mdx b/el/introduction.mdx index c32cd4bd6..77b4f34dc 100644 --- a/el/introduction.mdx +++ b/el/introduction.mdx @@ -8,16 +8,16 @@ description: "Το Ace Data Cloud προσφέρει μια ενοποιημέν Το Ace Data Cloud είναι μια ενοποιημένη πλατφόρμα AI API που επιτρέπει την πρόσβαση σε κορυφαίες παγκόσμιες υπηρεσίες AI μέσω ενός API Key και ενός συνεπούς interface. - + Πρόσβαση σε μοντέλα όπως Claude, GPT, Gemini, DeepSeek, Grok, Kimi μέσω συμβατού interface OpenAI. - - Δημιουργία εικόνων με Midjourney, Flux, DALL·E, Seedream κ.ά. + + Δημιουργία εικόνων με Flux, DALL·E, Seedream κ.ά. - - Δημιουργία βίντεο με Sora, Veo, Luma, Kling, Hailuo, Seedance κ.ά. + + Δημιουργία βίντεο με Veo, Luma, Kling, Hailuo, Seedance κ.ά. - + Δημιουργία μουσικής και ήχου με Suno, Fish Audio, Producer κ.ά. @@ -36,10 +36,10 @@ description: "Το Ace Data Cloud προσφέρει μια ενοποιημέν Εγγραφείτε και αποκτήστε API Token - + Διαδραστική τεκμηρίωση API - + Συνδέστε τον AI βοηθό σας με το API μας \ No newline at end of file diff --git a/el/mcp/overview.mdx b/el/mcp/overview.mdx index 85a8bf66a..079cc45ba 100644 --- a/el/mcp/overview.mdx +++ b/el/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "Διακομιστής Model Context Protocol για ενσωμά Το Ace Data Cloud παρέχει διακομιστές MCP (Model Context Protocol), επιτρέποντας σε βοηθούς AI όπως οι Claude, Cursor, Windsurf να καλούν απευθείας το API μας. - + Διακομιστής MCP Flux - + Διακομιστής MCP Luma - - Διακομιστής MCP Midjourney - - + Διακομιστής MCP Nanobanana - + Διακομιστής MCP Seedance - + Διακομιστής MCP Seedream - + Διακομιστής MCP Serp - + Διακομιστής MCP Shorturl - + Διακομιστής MCP Suno - + Διακομιστής MCP Veo \ No newline at end of file diff --git a/el/quickstart.mdx b/el/quickstart.mdx index e8172cf03..c53a8b87d 100644 --- a/el/quickstart.mdx +++ b/el/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. Εξερευνήστε περισσότερα API - + Περιηγηθείτε και δοκιμάστε διαδραστικά όλα τα σημεία τερματισμού API - + Αναλυτικά μαθήματα ενσωμάτωσης για κάθε υπηρεσία \ No newline at end of file diff --git a/en/faq/billing.mdx b/en/faq/billing.mdx index 0adb84d5e..365ad26b6 100644 --- a/en/faq/billing.mdx +++ b/en/faq/billing.mdx @@ -15,7 +15,7 @@ $$ \text{USD} = \text{Credits} \times \frac{\text{package.price}}{\text{package.amount}} $$ -Mainstream packages currently convert at roughly **$0.095215 / Credit**. +The conversion rate depends on the current package price and Credit amount. Check the live package details in the console before purchasing. ## When is billing applied? diff --git a/en/introduction.mdx b/en/introduction.mdx index f392fd1cf..7049eceb9 100644 --- a/en/introduction.mdx +++ b/en/introduction.mdx @@ -9,16 +9,16 @@ sidebarTitle: "Introduction" Ace Data Cloud is a unified AI API platform that provides access to leading AI services worldwide through a single API Key and consistent interface. - + Access models such as Claude, GPT, Gemini, DeepSeek, Grok, Kimi, and more via an OpenAI-compatible interface. - - Generate images using Midjourney, Flux, DALL·E, Seedream, and others. + + Generate images using Flux, DALL·E, Seedream, and others. - - Generate videos using Sora, Veo, Luma, Kling, Hailuo, Seedance, and more. + + Generate videos using Veo, Luma, Kling, Hailuo, Seedance, and more. - + Generate music and audio with Suno, Fish Audio, Producer, and others. @@ -37,10 +37,10 @@ Ace Data Cloud is a unified AI API platform that provides access to leading AI s Register and obtain your API Token - + Interactive API documentation - + Connect AI assistants to our API \ No newline at end of file diff --git a/en/mcp/overview.mdx b/en/mcp/overview.mdx index 2f71f70b6..c0cef28ef 100644 --- a/en/mcp/overview.mdx +++ b/en/mcp/overview.mdx @@ -25,9 +25,6 @@ We ship official **MCP (Model Context Protocol) servers** for every core service ## All MCP Servers - - AI image generation - ByteDance image @@ -64,23 +61,20 @@ Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (Mac) or ```json { "mcpServers": { - "midjourney": { - "command": "uvx", - "args": ["mcp-midjourney"], - "env": { - "ACEDATACLOUD_API_TOKEN": "YOUR_API_TOKEN" - } - }, "suno": { "command": "uvx", - "args": ["mcp-suno"], + "args": [ + "mcp-suno" + ], "env": { "ACEDATACLOUD_API_TOKEN": "YOUR_API_TOKEN" } }, "serp": { "command": "uvx", - "args": ["mcp-serp"], + "args": [ + "mcp-serp" + ], "env": { "ACEDATACLOUD_API_TOKEN": "YOUR_API_TOKEN" } @@ -97,15 +91,7 @@ In Cursor: `Settings` → `Tools & Integrations` → `Add new MCP server`, or ed ```json { - "mcpServers": { - "midjourney": { - "command": "uvx", - "args": ["mcp-midjourney"], - "env": { - "ACEDATACLOUD_API_TOKEN": "YOUR_API_TOKEN" - } - } - } + "mcpServers": {} } ``` diff --git a/en/pricing/overview.mdx b/en/pricing/overview.mdx index d2e63c23b..091c86d74 100644 --- a/en/pricing/overview.mdx +++ b/en/pricing/overview.mdx @@ -17,7 +17,7 @@ $$ \text{USD} = \text{Credits} \times \frac{\text{package.price}}{\text{package.amount}} $$ -Common packages currently convert at roughly **$0.095215 / Credit**. +The conversion rate depends on the current package price and Credit amount. Check the live package details in the console before purchasing. ## When is billing applied? diff --git a/es/introduction.mdx b/es/introduction.mdx index 6f9258f4d..fbebebcd5 100644 --- a/es/introduction.mdx +++ b/es/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud ofrece una plataforma unificada de API de IA, con u Ace Data Cloud es una plataforma unificada de API de IA que permite acceder a los principales servicios de IA a nivel mundial mediante una sola clave API y una interfaz consistente. - + Accede a modelos como Claude, GPT, Gemini, DeepSeek, Grok, Kimi, entre otros, a través de una interfaz compatible con OpenAI. - - Genera imágenes usando Midjourney, Flux, DALL·E, Seedream, entre otros. + + Genera imágenes usando Flux, DALL·E, Seedream, entre otros. - - Genera videos con Sora, Veo, Luma, Kling, Hailuo, Seedance, entre otros. + + Genera videos con Veo, Luma, Kling, Hailuo, Seedance, entre otros. - + Genera música y audio con Suno, Fish Audio, Producer, entre otros. @@ -36,10 +36,10 @@ Ace Data Cloud es una plataforma unificada de API de IA que permite acceder a lo Regístrate y obtén tu token API - + Documentación interactiva de la API - + Conecta asistentes de IA a nuestra API \ No newline at end of file diff --git a/es/mcp/overview.mdx b/es/mcp/overview.mdx index 9f24647c2..4ff6e32f0 100644 --- a/es/mcp/overview.mdx +++ b/es/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "Servidor Model Context Protocol para integración de herramientas Ace Data Cloud proporciona servidores MCP (Model Context Protocol) que permiten a asistentes de IA como Claude, Cursor, Windsurf, entre otros, llamar directamente a nuestra API. - + Servidor MCP Flux - + Servidor MCP Luma - - Servidor MCP Midjourney - - + Servidor MCP Nanobanana - + Servidor MCP Seedance - + Servidor MCP Seedream - + Servidor MCP Serp - + Servidor MCP Shorturl - + Servidor MCP Suno - + Servidor MCP Veo \ No newline at end of file diff --git a/es/quickstart.mdx b/es/quickstart.mdx index 35e9929b6..95ef92d90 100644 --- a/es/quickstart.mdx +++ b/es/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. Explorar más API - + Explore y pruebe interactivamente todos los endpoints de la API - + Tutoriales detallados de integración para cada servicio \ No newline at end of file diff --git a/fi/introduction.mdx b/fi/introduction.mdx index 4ea3fa592..1025470c3 100644 --- a/fi/introduction.mdx +++ b/fi/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud tarjoaa yhtenäisen AI API -alustan, jolla yhdellä Ace Data Cloud on yhtenäinen AI API -alusta, jonka avulla pääset käsiksi maailman johtaviin AI-palveluihin yhdellä API-avaimella ja yhtenäisellä rajapinnalla. - + Pääsy Claude-, GPT-, Gemini-, DeepSeek-, Grok-, Kimi-malleihin OpenAI-yhteensopivan rajapinnan kautta. - - Kuvien luonti Midjourney-, Flux-, DALL·E-, Seedream-malleilla. + + Kuvien luonti -, Flux-, DALL·E-, Seedream-malleilla. - - Videoiden luonti Sora-, Veo-, Luma-, Kling-, Hailuo-, Seedance-malleilla. + + Videoiden luonti -, Veo-, Luma-, Kling-, Hailuo-, Seedance-malleilla. - + Musiikin ja äänen luonti Suno-, Fish Audio-, Producer-malleilla. @@ -36,10 +36,10 @@ Ace Data Cloud on yhtenäinen AI API -alusta, jonka avulla pääset käsiksi maa Rekisteröidy ja hanki API-token - + Interaktiivinen API-dokumentaatio - + Yhdistä AI-avustaja APIimme \ No newline at end of file diff --git a/fi/mcp/overview.mdx b/fi/mcp/overview.mdx index fb38cb58a..883e6b115 100644 --- a/fi/mcp/overview.mdx +++ b/fi/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "Model Context Protocol -palvelin AI-työkalujen integrointiin" Ace Data Cloud tarjoaa MCP (Model Context Protocol) -palvelimen, jonka avulla Claude, Cursor, Windsurf ja muut AI-avustajat voivat kutsua suoraan APIamme. - + Flux MCP -palvelin - + Luma MCP -palvelin - - Midjourney MCP -palvelin - - + Nanobanana MCP -palvelin - + Seedance MCP -palvelin - + Seedream MCP -palvelin - + Serp MCP -palvelin - + Shorturl MCP -palvelin - + Suno MCP -palvelin - + Veo MCP -palvelin \ No newline at end of file diff --git a/fi/quickstart.mdx b/fi/quickstart.mdx index 029266cf9..0ea47e275 100644 --- a/fi/quickstart.mdx +++ b/fi/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. Tutustu lisää API:hin - + Selaa ja testaa kaikkia API-päätepisteitä interaktiivisesti - + Yksityiskohtaiset integraatio-oppaat jokaiselle palvelulle \ No newline at end of file diff --git a/fr/introduction.mdx b/fr/introduction.mdx index e04ddc5bb..c45267c13 100644 --- a/fr/introduction.mdx +++ b/fr/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud offre une plateforme API AI unifiée, un seul API K Ace Data Cloud est une plateforme API AI unifiée, permettant d'accéder aux services AI leaders mondiaux via une seule clé API et une interface cohérente. - + Accédez aux modèles Claude, GPT, Gemini, DeepSeek, Grok, Kimi, etc. via une interface compatible OpenAI. - - Générez des images avec Midjourney, Flux, DALL·E, Seedream, etc. + + Générez des images avec Flux, DALL·E, Seedream, etc. - - Générez des vidéos avec Sora, Veo, Luma, Kling, Hailuo, Seedance, etc. + + Générez des vidéos avec Veo, Luma, Kling, Hailuo, Seedance, etc. - + Générez de la musique et des audios avec Suno, Fish Audio, Producer, etc. @@ -36,10 +36,10 @@ Ace Data Cloud est une plateforme API AI unifiée, permettant d'accéder aux ser Inscrivez-vous et obtenez un jeton API - + Documentation API interactive - + Connectez votre assistant AI à notre API \ No newline at end of file diff --git a/fr/mcp/overview.mdx b/fr/mcp/overview.mdx index 81e935567..b38112278 100644 --- a/fr/mcp/overview.mdx +++ b/fr/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "Serveurs Model Context Protocol pour l'intégration d'outils IA" Ace Data Cloud propose des serveurs MCP (Model Context Protocol) permettant aux assistants IA tels que Claude, Cursor, Windsurf, etc., d'appeler directement notre API. - + Serveur MCP Flux - + Serveur MCP Luma - - Serveur MCP Midjourney - - + Serveur MCP Nanobanana - + Serveur MCP Seedance - + Serveur MCP Seedream - + Serveur MCP Serp - + Serveur MCP Shorturl - + Serveur MCP Suno - + Serveur MCP Veo \ No newline at end of file diff --git a/fr/quickstart.mdx b/fr/quickstart.mdx index 316664c4e..8d49f4cf9 100644 --- a/fr/quickstart.mdx +++ b/fr/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. Explorer davantage d'API - + Parcourez et testez de manière interactive tous les points d'accès API - + Tutoriels détaillés pour l'intégration de chaque service \ No newline at end of file diff --git a/it/introduction.mdx b/it/introduction.mdx index d879757c4..a12dbc7c8 100644 --- a/it/introduction.mdx +++ b/it/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud offre una piattaforma API AI unificata, con una sin Ace Data Cloud è una piattaforma API AI unificata che consente di accedere ai principali servizi AI globali tramite una singola API Key e un'interfaccia coerente. - + Accedi a modelli come Claude, GPT, Gemini, DeepSeek, Grok, Kimi tramite un'interfaccia compatibile con OpenAI. - - Genera immagini usando Midjourney, Flux, DALL·E, Seedream e altri. + + Genera immagini usando Flux, DALL·E, Seedream e altri. - - Crea video con Sora, Veo, Luma, Kling, Hailuo, Seedance e altri. + + Crea video con Veo, Luma, Kling, Hailuo, Seedance e altri. - + Produci musica e audio con Suno, Fish Audio, Producer e altri. @@ -36,10 +36,10 @@ Ace Data Cloud è una piattaforma API AI unificata che consente di accedere ai p Registrati e ottieni il token API - + Documentazione API interattiva - + Collega l'assistente AI alla nostra API \ No newline at end of file diff --git a/it/mcp/overview.mdx b/it/mcp/overview.mdx index a660e68a7..9fb43803d 100644 --- a/it/mcp/overview.mdx +++ b/it/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "Server Model Context Protocol per l'integrazione di strumenti AI" Ace Data Cloud offre server MCP (Model Context Protocol) che permettono agli assistenti AI come Claude, Cursor, Windsurf di chiamare direttamente la nostra API. - + Server MCP Flux - + Server MCP Luma - - Server MCP Midjourney - - + Server MCP Nanobanana - + Server MCP Seedance - + Server MCP Seedream - + Server MCP Serp - + Server MCP Shorturl - + Server MCP Suno - + Server MCP Veo \ No newline at end of file diff --git a/it/quickstart.mdx b/it/quickstart.mdx index 64db8abbd..ed0162a47 100644 --- a/it/quickstart.mdx +++ b/it/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. Esplora altre API - + Sfoglia e testa interattivamente tutti gli endpoint API - + Tutorial dettagliati per l'integrazione di ogni servizio \ No newline at end of file diff --git a/ja/introduction.mdx b/ja/introduction.mdx index 7bed3807b..0325afcfe 100644 --- a/ja/introduction.mdx +++ b/ja/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud は統一された AI API プラットフォーム Ace Data Cloud は統一された AI API プラットフォームで、1つの API Key と一貫したインターフェースを通じて、世界をリードする AI サービスにアクセスできます。 - + OpenAI 互換インターフェースを通じて Claude、GPT、Gemini、DeepSeek、Grok、Kimi などのモデルにアクセス。 - - Midjourney、Flux、DALL·E、Seedream などを使って画像を生成。 + + Flux、DALL·E、Seedream などを使って画像を生成。 - - Sora、Veo、Luma、Kling、Hailuo、Seedance などを使って動画を生成。 + + Veo、Luma、Kling、Hailuo、Seedance などを使って動画を生成。 - + Suno、Fish Audio、Producer などを使って音楽や音声を生成。 @@ -36,10 +36,10 @@ Ace Data Cloud は統一された AI API プラットフォームで、1つの A 登録して API トークンを取得 - + インタラクティブな API ドキュメント - + AI アシスタントを当社の API に接続 \ No newline at end of file diff --git a/ja/mcp/overview.mdx b/ja/mcp/overview.mdx index c4e27a0c9..5e8dec3ce 100644 --- a/ja/mcp/overview.mdx +++ b/ja/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "AI ツール統合のための Model Context Protocol サーバー Ace Data Cloud は MCP(Model Context Protocol)サーバーを提供しており、Claude、Cursor、Windsurf などの AI アシスタントが直接当社の API を呼び出せるようにしています。 - + Flux MCP サーバー - + Luma MCP サーバー - - Midjourney MCP サーバー - - + Nanobanana MCP サーバー - + Seedance MCP サーバー - + Seedream MCP サーバー - + Serp MCP サーバー - + Shorturl MCP サーバー - + Suno MCP サーバー - + Veo MCP サーバー \ No newline at end of file diff --git a/ja/quickstart.mdx b/ja/quickstart.mdx index 124fa8ab0..5200888f4 100644 --- a/ja/quickstart.mdx +++ b/ja/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. さらにAPIを探索する - + すべてのAPIエンドポイントを閲覧し、インタラクティブにテストできます - + 各サービスの詳細な統合チュートリアル \ No newline at end of file diff --git a/ko/introduction.mdx b/ko/introduction.mdx index 31a2cee52..cca6514fd 100644 --- a/ko/introduction.mdx +++ b/ko/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud는 통합 AI API 플랫폼으로, 하나의 API Key Ace Data Cloud는 하나의 API Key와 일관된 인터페이스를 통해 전 세계 선도 AI 서비스에 접근할 수 있는 통합 AI API 플랫폼입니다. - + OpenAI 호환 인터페이스를 통해 Claude, GPT, Gemini, DeepSeek, Grok, Kimi 등의 모델에 접근합니다. - - Midjourney, Flux, DALL·E, Seedream 등을 사용하여 이미지를 생성합니다. + + Flux, DALL·E, Seedream 등을 사용하여 이미지를 생성합니다. - - Sora, Veo, Luma, Kling, Hailuo, Seedance 등을 사용하여 비디오를 생성합니다. + + Veo, Luma, Kling, Hailuo, Seedance 등을 사용하여 비디오를 생성합니다. - + Suno, Fish Audio, Producer 등을 사용하여 음악과 오디오를 생성합니다. @@ -36,10 +36,10 @@ Ace Data Cloud는 하나의 API Key와 일관된 인터페이스를 통해 전 회원가입 후 API 토큰 받기 - + 인터랙티브 API 문서 - + AI 어시스턴트를 API에 연결하기 \ No newline at end of file diff --git a/ko/mcp/overview.mdx b/ko/mcp/overview.mdx index 91eeba46f..5104a5b17 100644 --- a/ko/mcp/overview.mdx +++ b/ko/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "AI 도구 통합을 위한 Model Context Protocol 서버" Ace Data Cloud는 Claude, Cursor, Windsurf 등 AI 어시스턴트가 직접 당사의 API를 호출할 수 있도록 MCP(Model Context Protocol) 서버를 제공합니다. - + Flux MCP 서버 - + Luma MCP 서버 - - Midjourney MCP 서버 - - + Nanobanana MCP 서버 - + Seedance MCP 서버 - + Seedream MCP 서버 - + Serp MCP 서버 - + Shorturl MCP 서버 - + Suno MCP 서버 - + Veo MCP 서버 \ No newline at end of file diff --git a/ko/quickstart.mdx b/ko/quickstart.mdx index 3e1669812..fa778721c 100644 --- a/ko/quickstart.mdx +++ b/ko/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. 더 많은 API 탐색하기 - + 모든 API 엔드포인트를 탐색하고 인터랙티브 테스트하기 - + 각 서비스별 상세 통합 튜토리얼 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 091ace1ef..da4165314 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ }, "devDependencies": { "@mdx-js/mdx": "3.1.1", - "remark-gfm": "4.0.1" + "remark-gfm": "4.0.1", + "remark-math": "6.0.0" } }, "node_modules/@mdx-js/mdx": { @@ -106,6 +107,13 @@ "@types/unist": "*" } }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha1-gL8+CBTQmoRkEqCw8UCUa3nDbD4=", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -265,6 +273,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha1-SDfqGy2me5xhamevuw+v7lZ7ymY=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -612,6 +630,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha1-ChOkLC3rT3TmHxYtRAuRZaVIAw8=", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -796,6 +831,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha1-jXndO6+KuKx4H2K4hTdoGQuaALA=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", @@ -1153,6 +1208,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha1-xC7jsd1amgNYToPdjwjj3lECEsE=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-extension-mdx-expression": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", @@ -1904,6 +1979,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha1-Cs33RnXxwZX+pu//p4WC9+1/wNc=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", @@ -2110,6 +2202,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha1-/qaKJWWECclGBAi8a0mRuWW1IWM=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", diff --git a/package.json b/package.json index bc87fae5c..087c63179 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@mdx-js/mdx": "3.1.1", - "remark-gfm": "4.0.1" + "remark-gfm": "4.0.1", + "remark-math": "6.0.0" } } diff --git a/pl/introduction.mdx b/pl/introduction.mdx index d1a7fbc1b..2e455e10e 100644 --- a/pl/introduction.mdx +++ b/pl/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud oferuje zunifikowaną platformę AI API, umożliwia Ace Data Cloud to zunifikowana platforma AI API, która umożliwia dostęp do wiodących na świecie usług AI za pomocą jednego klucza API i spójnego interfejsu. - + Dostęp do modeli takich jak Claude, GPT, Gemini, DeepSeek, Grok, Kimi i innych przez interfejs kompatybilny z OpenAI. - - Generowanie obrazów za pomocą Midjourney, Flux, DALL·E, Seedream i innych. + + Generowanie obrazów za pomocą Flux, DALL·E, Seedream i innych. - - Generowanie wideo za pomocą Sora, Veo, Luma, Kling, Hailuo, Seedance i innych. + + Generowanie wideo za pomocą Veo, Luma, Kling, Hailuo, Seedance i innych. - + Generowanie muzyki i dźwięku za pomocą Suno, Fish Audio, Producer i innych. @@ -36,10 +36,10 @@ Ace Data Cloud to zunifikowana platforma AI API, która umożliwia dostęp do wi Zarejestruj się i zdobądź token API - + Interaktywna dokumentacja API - + Połącz asystenta AI z naszym API \ No newline at end of file diff --git a/pl/mcp/overview.mdx b/pl/mcp/overview.mdx index 5715687f8..9dacb6790 100644 --- a/pl/mcp/overview.mdx +++ b/pl/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "Serwery Model Context Protocol do integracji narzędzi AI" Ace Data Cloud oferuje serwery MCP (Model Context Protocol), które umożliwiają asystentom AI takim jak Claude, Cursor, Windsurf bezpośrednie wywoływanie naszych API. - + Serwer MCP Flux - + Serwer MCP Luma - - Serwer MCP Midjourney - - + Serwer MCP Nanobanana - + Serwer MCP Seedance - + Serwer MCP Seedream - + Serwer MCP Serp - + Serwer MCP Shorturl - + Serwer MCP Suno - + Serwer MCP Veo \ No newline at end of file diff --git a/pl/quickstart.mdx b/pl/quickstart.mdx index dfb1ebd00..37c609494 100644 --- a/pl/quickstart.mdx +++ b/pl/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. Odkryj więcej API - + Przeglądaj i testuj interaktywnie wszystkie punkty końcowe API - + Szczegółowe instrukcje integracji dla każdej usługi \ No newline at end of file diff --git a/pt/introduction.mdx b/pt/introduction.mdx index 980611f63..dfece7c6b 100644 --- a/pt/introduction.mdx +++ b/pt/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud oferece uma plataforma unificada de API de IA, com Ace Data Cloud é uma plataforma unificada de API de IA que permite acessar serviços líderes globais de IA através de uma única chave API e uma interface consistente. - + Acesse modelos como Claude, GPT, Gemini, DeepSeek, Grok, Kimi, entre outros, via interface compatível com OpenAI. - - Gere imagens usando Midjourney, Flux, DALL·E, Seedream, entre outros. + + Gere imagens usando Flux, DALL·E, Seedream, entre outros. - - Gere vídeos usando Sora, Veo, Luma, Kling, Hailuo, Seedance, entre outros. + + Gere vídeos usando Veo, Luma, Kling, Hailuo, Seedance, entre outros. - + Gere música e áudio usando Suno, Fish Audio, Producer, entre outros. @@ -36,10 +36,10 @@ Ace Data Cloud é uma plataforma unificada de API de IA que permite acessar serv Registre-se e obtenha seu token API - + Documentação interativa da API - + Conecte assistentes de IA à nossa API \ No newline at end of file diff --git a/pt/mcp/overview.mdx b/pt/mcp/overview.mdx index 388c797d3..0769d378f 100644 --- a/pt/mcp/overview.mdx +++ b/pt/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "Servidor Model Context Protocol para integração de ferramentas d Ace Data Cloud oferece servidores MCP (Model Context Protocol) que permitem que assistentes de IA como Claude, Cursor, Windsurf e outros chamem diretamente nossa API. - + Servidor MCP Flux - + Servidor MCP Luma - - Servidor MCP Midjourney - - + Servidor MCP Nanobanana - + Servidor MCP Seedance - + Servidor MCP Seedream - + Servidor MCP Serp - + Servidor MCP Shorturl - + Servidor MCP Suno - + Servidor MCP Veo \ No newline at end of file diff --git a/pt/quickstart.mdx b/pt/quickstart.mdx index 95f9cc452..1ec967b06 100644 --- a/pt/quickstart.mdx +++ b/pt/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. Explore Mais APIs - + Navegue e teste interativamente todos os endpoints da API - + Tutoriais detalhados de integração para cada serviço \ No newline at end of file diff --git a/ru/introduction.mdx b/ru/introduction.mdx index 13a408398..a4031b92c 100644 --- a/ru/introduction.mdx +++ b/ru/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud предоставляет единый AI API-пл Ace Data Cloud — это единая AI API-платформа, которая через один API Key и единый интерфейс предоставляет доступ к ведущим мировым AI-сервисам. - + Доступ к моделям Claude, GPT, Gemini, DeepSeek, Grok, Kimi и другим через совместимый с OpenAI интерфейс. - - Генерация изображений с помощью Midjourney, Flux, DALL·E, Seedream и других. + + Генерация изображений с помощью Flux, DALL·E, Seedream и других. - - Генерация видео с использованием Sora, Veo, Luma, Kling, Hailuo, Seedance и других. + + Генерация видео с использованием Veo, Luma, Kling, Hailuo, Seedance и других. - + Создание музыки и аудио с помощью Suno, Fish Audio, Producer и других. @@ -36,10 +36,10 @@ Ace Data Cloud — это единая AI API-платформа, которая Зарегистрируйтесь и получите API Token - + Интерактивная документация API - + Подключите AI-ассистентов к нашему API \ No newline at end of file diff --git a/ru/mcp/overview.mdx b/ru/mcp/overview.mdx index 268c43194..4560f0f06 100644 --- a/ru/mcp/overview.mdx +++ b/ru/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "Сервер Model Context Protocol для интеграции AI Ace Data Cloud предоставляет сервер MCP (Model Context Protocol), который позволяет помощникам AI, таким как Claude, Cursor, Windsurf, напрямую вызывать наш API. - + Сервер Flux MCP - + Сервер Luma MCP - - Сервер Midjourney MCP - - + Сервер Nanobanana MCP - + Сервер Seedance MCP - + Сервер Seedream MCP - + Сервер Serp MCP - + Сервер Shorturl MCP - + Сервер Suno MCP - + Сервер Veo MCP \ No newline at end of file diff --git a/ru/quickstart.mdx b/ru/quickstart.mdx index 5d5f5c58f..f3627371f 100644 --- a/ru/quickstart.mdx +++ b/ru/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. Изучение дополнительных API - + Просматривайте и тестируйте все API-эндпоинты интерактивно - + Подробные инструкции по интеграции для каждого сервиса \ No newline at end of file diff --git a/scripts/sync_from_platformbackend.py b/scripts/sync_from_platformbackend.py index 8a3e53354..034581645 100644 --- a/scripts/sync_from_platformbackend.py +++ b/scripts/sync_from_platformbackend.py @@ -402,7 +402,7 @@ def validate_manifest(manifest: Any) -> None: raise RuntimeError("Invalid Docs sync transaction item") relative = Path(item["relative"]) parts = relative.parts - allowed = relative in {Path("openapi"), Path("docs.json")} or (len(parts) == 2 and parts[0] in LANGUAGE_SOURCE_DIRS and parts[1] in {"guides", "mcp"}) + allowed = relative in {Path("openapi"), Path("docs.json")} or (len(parts) == 2 and parts[0] in LANGUAGE_SOURCE_DIRS and parts[1] in {"guides", "mcp", "api-reference.mdx"}) if relative.is_absolute() or ".." in parts or not allowed or item["relative"] in seen: raise RuntimeError("Unsafe Docs sync transaction path") seen.add(item["relative"]) @@ -991,9 +991,9 @@ def guide_description(output_language: str, service_name: str) -> str: def sanitize_html_for_mdx(content: str) -> str: - parts = re.split(r"(^```.*?^```|^~~~.*?^~~~)", content, flags=re.MULTILINE | re.DOTALL) + parts = re.split(r"(^```.*?^```|^~~~.*?^~~~|^\$\$.*?^\$\$)", content, flags=re.MULTILINE | re.DOTALL) for index, part in enumerate(parts): - if part.startswith(("```", "~~~")): + if part.startswith(("```", "~~~", "$$")): continue part = re.sub(r"(<[a-zA-Z][^>]*)\bclass=", r"\1className=", part) for tag in ("img", "br", "hr", "input", "source", "meta", "link"): @@ -1005,7 +1005,7 @@ def sanitize_html_for_mdx(content: str) -> str: # Keep inline code unchanged, just as fenced examples are unchanged. escaped = [] cursor = 0 - for match in re.finditer(r"(?P`+).*?(?P=ticks)", part, flags=re.DOTALL): + for match in re.finditer(r"(?P`+).*?(?P=ticks)|\$[^$\n]+\$", part, flags=re.DOTALL): escaped.append(part[cursor:match.start()].replace("{", "{").replace("}", "}")) escaped.append(match[0]) cursor = match.end() @@ -1150,6 +1150,13 @@ def refresh_generated_navigation(root: Path, languages: list[str], exact_records directory = f"api-reference/{spec.stem}" if locale == "zh-Hans" else f"{locale}/api-reference/{spec.stem}" api_tab["groups"].append({"group": load_json(spec)["info"]["title"], "openapi": {"source": source, "directory": directory}}) + index_path = f"{locale}/api-reference" + cards = [f'OpenAPI JSON' + for spec in sorted((root / "openapi").glob("*.json"))] + write_text(root / f"{index_path}.mdx", "---\ntitle: " + yaml_quote(api_tab["tab"]) + + '\n---\n\n\n' + "\n".join(cards) + "\n\n") + if not any(index_path in group.get("pages", []) for group in api_tab["groups"]): + api_tab["groups"].insert(0, {"group": api_tab["tab"], "pages": [index_path]}) prefixes = (f"{locale}/guides/", f"{locale}/mcp/") def prune(value: Any) -> None: @@ -1184,6 +1191,50 @@ def prune(value: Any) -> None: write_json(root / "docs.json", config) +def rewrite_generated_links(root: Path, catalogs: dict[str, Any], routes: dict[str, Path]) -> None: + """Resolve source-file and platform-relative links in the output namespace.""" + for language, catalog in catalogs.items(): + def destination(target: str) -> str: + parsed = urlsplit(target) + if parsed.scheme or parsed.netloc or not parsed.path: + return target + if parsed.path.startswith("/documents/"): + return "https://platform.acedata.cloud" + target + key = Path(parsed.path).name.removesuffix(".md").removesuffix(".mdx") + if key in routes: + relative = Path(language) / routes[key] + if (root / relative).is_file(): + path = "/" + str(relative.with_suffix("")) + else: + aliases = catalog["records"][key].get("aliases") or [] + if not aliases: + return target + path = "https://platform.acedata.cloud/documents/" + aliases[0] + return path + ("?" + parsed.query if parsed.query else "") + ("#" + parsed.fragment if parsed.fragment else "") + if parsed.path.startswith("/") and (root / language / (parsed.path.lstrip("/") + ".mdx")).is_file(): + return f"/{language}" + target + return target + + for relative in routes.values(): + filename = root / language / relative + if not filename.is_file(): + continue + content = filename.read_text(encoding="utf-8") + parts = re.split(r"(^```.*?^```|^~~~.*?^~~~)", content, flags=re.MULTILINE | re.DOTALL) + tokens = re.compile(r'''(?P\[[^\]\n]*\]\()(?P[^\s)]+)(?P[^)]*\))|(?P`+[^`\n]*`+)|(?Phref=["'])(?P[^"']+)(?P["'])''') + def rewrite_token(match: re.Match) -> str: + if match["link"]: + return match["link"] + destination(match["target"]) + match["end"] + if match["code"]: + return match[0] + return match["href"] + destination(match["value"]) + match["quote"] + for index, part in enumerate(parts): + if part.startswith(("```", "~~~")): + continue + parts[index] = tokens.sub(rewrite_token, part) + write_text(filename, "".join(parts)) + + def get_docs_languages(output_dir: Path) -> list[str]: docs_json = load_json(output_dir / "docs.json") languages = [entry["language"] for entry in docs_json.get("navigation", {}).get("languages", [])] @@ -1198,6 +1249,7 @@ def get_docs_languages(output_dir: Path) -> list[str]: def managed_paths(languages: list[str]) -> list[Path]: paths = [Path("openapi"), Path("docs.json")] paths.extend(Path(language) / folder for language in languages for folder in ("guides", "mcp")) + paths.extend(Path(language) / "api-reference.mdx" for language in languages) return paths @@ -1257,7 +1309,9 @@ def main() -> int: managed = managed_paths(languages) with tempfile.TemporaryDirectory(prefix="docs-sync-", dir=output_dir.parent) as temporary_directory: staging_dir = Path(temporary_directory) / "output" - shutil.copytree(output_dir, staging_dir, ignore=shutil.ignore_patterns(".git")) + shutil.copytree(output_dir, staging_dir, ignore=shutil.ignore_patterns( + ".git", "node_modules", ".cache", "__pycache__", "test-results", "playwright-report" + )) clear_managed_paths(staging_dir, managed) # docs.json is committed atomically with generated pages, not cleared. shutil.copy2(output_dir / "docs.json", staging_dir / "docs.json") @@ -1265,6 +1319,7 @@ def main() -> int: results = sync_document_pages(backend_dir, staging_dir, output_dir, catalogs, routes, {service["alias"]: service for service in services}) refresh_generated_navigation(staging_dir, languages, exact_records) + rewrite_generated_links(staging_dir, catalogs, routes) neutralize_generated_tree(staging_dir, denylist) validate_generated_tree(staging_dir, denylist) pending = [item for item in results if item["status"] != "ready"] diff --git a/scripts/validate_mdx.mjs b/scripts/validate_mdx.mjs index 9756df180..bc5a9b8b6 100644 --- a/scripts/validate_mdx.mjs +++ b/scripts/validate_mdx.mjs @@ -2,13 +2,47 @@ // Parse generated pages without evaluating any MDX/JSX or importing page code. import { compile } from '@mdx-js/mdx'; import remarkGfm from 'remark-gfm'; -import { readFile, readdir } from 'node:fs/promises'; +import remarkMath from 'remark-math'; +import { readFile, readdir, stat } from 'node:fs/promises'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; export async function validateMdx(content) { const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, ''); - await compile(body, { remarkPlugins: [remarkGfm] }); + const links = []; + function collectLinks() { + return (tree) => { + function visit(node) { + if (['link', 'image'].includes(node.type)) links.push(node.url); + for (const attribute of node.attributes || []) { + if (['href', 'src'].includes(attribute.name) && typeof attribute.value === 'string') links.push(attribute.value); + } + for (const child of node.children || []) visit(child); + } + visit(tree); + }; + } + await compile(body, { remarkPlugins: [remarkGfm, remarkMath, collectLinks] }); + return links; +} + +export async function missingLocalLinks(root, filename, links) { + const missing = []; + for (const link of new Set(links)) { + if (!link || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(link)) continue; + const pathname = decodeURIComponent(link.split(/[?#]/, 1)[0]); + if (!pathname) continue; + const target = pathname.startsWith('/') ? path.join(root, pathname) : path.resolve(path.dirname(filename), pathname); + const candidates = [target, `${target}.mdx`, `${target}.md`, path.join(target, 'index.mdx')]; + let found = false; + for (const candidate of candidates) { + if (path.relative(root, candidate).startsWith('..')) continue; + try { if ((await stat(candidate)).isFile()) { found = true; break; } } + catch (error) { if (!['ENOENT', 'ENOTDIR'].includes(error.code)) throw error; } + } + if (!found) missing.push(link); + } + return missing; } async function walk(directory) { @@ -18,7 +52,7 @@ async function walk(directory) { const files = []; for (const entry of entries) { const filename = path.join(directory, entry.name); - if (entry.isDirectory()) files.push(...await walk(filename)); + if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') files.push(...await walk(filename)); else if (entry.name.endsWith('.mdx')) files.push(filename); } return files.sort(); @@ -26,24 +60,19 @@ async function walk(directory) { async function main() { const root = path.resolve(process.argv[2] || '.'); - const config = JSON.parse(await readFile(path.join(root, 'docs.json'), 'utf8')); const failures = []; let count = 0; - const languages = new Set(config.navigation.languages.map(({ language }) => language)); - for (const entry of await readdir(root, { withFileTypes: true })) { - if (entry.isDirectory() && !entry.name.startsWith('.')) languages.add(entry.name); - } - for (const language of languages) { - for (const folder of ['guides', 'mcp']) { - for (const filename of await walk(path.join(root, language, folder))) { - count++; - try { await validateMdx(await readFile(filename, 'utf8')); } - catch (error) { failures.push(`${path.relative(root, filename)}: ${error.reason || error.message}`); } - } + for (const filename of await walk(root)) { + count++; + try { + const links = await validateMdx(await readFile(filename, 'utf8')); + const missing = await missingLocalLinks(root, filename, links); + if (missing.length) failures.push(`${path.relative(root, filename)}: missing links ${missing.join(', ')}`); } + catch (error) { failures.push(`${path.relative(root, filename)}: ${error.reason || error.message}`); } } for (const failure of failures) console.error(failure); - console.log(`MDX syntax: ${count - failures.length}/${count} pages valid`); + console.log(`MDX syntax and local links: ${count - failures.length}/${count} pages valid`); if (failures.length) process.exitCode = 1; } diff --git a/sr/introduction.mdx b/sr/introduction.mdx index aa26e6212..c906e40ca 100644 --- a/sr/introduction.mdx +++ b/sr/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud pruža jedinstvenu AI API platformu, jedan API klju Ace Data Cloud je jedinstvena AI API platforma koja omogućava pristup vodećim svetskim AI uslugama putem jednog API ključa i konzistentnog interfejsa. - + Pristup modelima kao što su Claude, GPT, Gemini, DeepSeek, Grok, Kimi i drugi putem OpenAI kompatibilnog interfejsa. - - Generisanje slika korišćenjem Midjourney, Flux, DALL·E, Seedream i drugih. + + Generisanje slika korišćenjem Flux, DALL·E, Seedream i drugih. - - Generisanje video zapisa pomoću Sora, Veo, Luma, Kling, Hailuo, Seedance i drugih. + + Generisanje video zapisa pomoću Veo, Luma, Kling, Hailuo, Seedance i drugih. - + Generisanje muzike i audio zapisa korišćenjem Suno, Fish Audio, Producer i drugih. @@ -36,10 +36,10 @@ Ace Data Cloud je jedinstvena AI API platforma koja omogućava pristup vodećim Registrujte se i preuzmite API token - + Interaktivna API dokumentacija - + Povežite AI asistenta sa našim API-jem \ No newline at end of file diff --git a/sr/mcp/overview.mdx b/sr/mcp/overview.mdx index ce6bf8c06..77e2a3357 100644 --- a/sr/mcp/overview.mdx +++ b/sr/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "Model Context Protocol сервери за интеграцију Ace Data Cloud пружа MCP (Model Context Protocol) сервере који омогућавају AI асистентима као што су Claude, Cursor, Windsurf директан приступ нашим API-јима. - + Flux MCP сервер - + Luma MCP сервер - - Midjourney MCP сервер - - + Nanobanana MCP сервер - + Seedance MCP сервер - + Seedream MCP сервер - + Serp MCP сервер - + Shorturl MCP сервер - + Suno MCP сервер - + Veo MCP сервер \ No newline at end of file diff --git a/sr/quickstart.mdx b/sr/quickstart.mdx index 9f2a8cc82..9e8128af4 100644 --- a/sr/quickstart.mdx +++ b/sr/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. Istražite više API-ja - + Pregledajte i interaktivno testirajte sve API krajnje tačke - + Detaljni vodiči za integraciju za svaku uslugu \ No newline at end of file diff --git a/sv/introduction.mdx b/sv/introduction.mdx index 67900e9a1..d6d9ea90e 100644 --- a/sv/introduction.mdx +++ b/sv/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud erbjuder en enhetlig AI API-plattform där en API-n Ace Data Cloud är en enhetlig AI API-plattform som via en API-nyckel och ett enhetligt gränssnitt ger tillgång till världsledande AI-tjänster. - + Åtkomst till modeller som Claude, GPT, Gemini, DeepSeek, Grok, Kimi med OpenAI-kompatibelt gränssnitt. - - Generera bilder med Midjourney, Flux, DALL·E, Seedream med flera. + + Generera bilder med Flux, DALL·E, Seedream med flera. - - Skapa videor med Sora, Veo, Luma, Kling, Hailuo, Seedance med flera. + + Skapa videor med Veo, Luma, Kling, Hailuo, Seedance med flera. - + Skapa musik och ljud med Suno, Fish Audio, Producer med flera. @@ -36,10 +36,10 @@ Ace Data Cloud är en enhetlig AI API-plattform som via en API-nyckel och ett en Registrera dig och få en API-token - + Interaktiv API-dokumentation - + Anslut AI-assistenter till vårt API \ No newline at end of file diff --git a/sv/mcp/overview.mdx b/sv/mcp/overview.mdx index fc19e9f14..8f2846188 100644 --- a/sv/mcp/overview.mdx +++ b/sv/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "Model Context Protocol-servrar för AI-verktygsintegration" Ace Data Cloud erbjuder MCP (Model Context Protocol)-servrar som gör det möjligt för AI-assistenter som Claude, Cursor och Windsurf att direkt anropa vårt API. - + Flux MCP-server - + Luma MCP-server - - Midjourney MCP-server - - + Nanobanana MCP-server - + Seedance MCP-server - + Seedream MCP-server - + Serp MCP-server - + Shorturl MCP-server - + Suno MCP-server - + Veo MCP-server \ No newline at end of file diff --git a/sv/quickstart.mdx b/sv/quickstart.mdx index 3629e5268..8f5f99a7e 100644 --- a/sv/quickstart.mdx +++ b/sv/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. Utforska fler API:er - + Bläddra och testa alla API-endpoints interaktivt - + Detaljerade integrationsguider för varje tjänst \ No newline at end of file diff --git a/tests/mdx-validation.test.mjs b/tests/mdx-validation.test.mjs index 104be4e2c..d9a5fbcc6 100644 --- a/tests/mdx-validation.test.mjs +++ b/tests/mdx-validation.test.mjs @@ -1,14 +1,19 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import test from 'node:test'; -import { validateMdx } from '../scripts/validate_mdx.mjs'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { validateMdx, missingLocalLinks } from '../scripts/validate_mdx.mjs'; test('generated Markdown preserves literal JSON and placeholders outside code', async () => { const markdown = '# Example\n\nLiteral {token}. Inline `{token}`.\n\n```json\n{"pitch": 60}\n```\n{"pitch": 61}\n\n~~~json\n{"pitch": 62}\n~~~\n'; + const withMath = markdown + '\n$$\n\\frac{a}{b}\n$$\n'; const generated = execFileSync('python3', ['-c', 'import sys;from scripts.sync_from_platformbackend import convert_markdown_to_mdx;print(convert_markdown_to_mdx(sys.stdin.read(),"Example"))' - ], { input: markdown, encoding: 'utf8' }); + ], { input: withMath, encoding: 'utf8' }); assert.ok(generated.includes('Literal {token}')); + assert.ok(generated.includes('\\frac{a}{b}')); assert.ok(generated.includes('`{token}`')); assert.ok(generated.includes('```json\n{"pitch": 60}\n```')); await validateMdx(generated); @@ -18,3 +23,17 @@ test('syntax gate rejects malformed MDX without executing it', async () => { await assert.rejects(validateMdx('# Broken\n\n{"pitch": 60}')); await assert.rejects(validateMdx('unclosed')); }); + +test('Mintlify math remains valid alongside MDX expressions', async () => { + await validateMdx('$$\n\\text{USD} = \\frac{price}{amount}\n$$'); +}); + +test('link gate checks Markdown and card targets while preserving external URLs', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'docs-links-')); + try { + await mkdir(path.join(root, 'en')); + await writeFile(path.join(root, 'en/target.mdx'), '# Target'); + const links = await validateMdx('[Relative](target)\n\nTarget\n\n[External](https://example.org) [Missing](/en/absent)'); + assert.deepEqual(await missingLocalLinks(root, path.join(root, 'en/index.mdx'), links), ['/en/absent']); + } finally { await rm(root, { recursive: true }); } +}); diff --git a/tests/test_publication.py b/tests/test_publication.py index 22b3ac716..6c2e6b684 100644 --- a/tests/test_publication.py +++ b/tests/test_publication.py @@ -21,6 +21,25 @@ def catalog(language="en", records=None): class PublicationTests(unittest.TestCase): + def test_source_and_platform_links_resolve_without_rewriting_examples(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + routes = {"development_maestro_videos": Path("guides/maestro/videos.mdx"), + "development_maestro_tasks": Path("guides/maestro/tasks.mdx"), + "development_waiting": Path("guides/platform/waiting.mdx")} + for key in ["development_maestro_videos", "development_maestro_tasks"]: + file = root / "en" / routes[key] + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text("[`Tasks`](development_maestro_tasks.md#result) [Platform](/documents/example) " + "[Pending](development_waiting.md)\n\n`[Example](development_maestro_tasks.md)`") + catalogs = {"en": {"records": {key: {"aliases": ["waiting-guide"]} for key in routes}}} + sync.rewrite_generated_links(root, catalogs, routes) + result = (root / "en" / routes["development_maestro_videos"]).read_text() + self.assertIn("[`Tasks`](/en/guides/maestro/tasks#result)", result) + self.assertIn("[Platform](https://platform.acedata.cloud/documents/example)", result) + self.assertIn("[Pending](https://platform.acedata.cloud/documents/waiting-guide)", result) + self.assertIn("`[Example](development_maestro_tasks.md)`", result) + def test_existing_legacy_locale_pages_remain_in_the_reconciliation_scope(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/uk/introduction.mdx b/uk/introduction.mdx index bbc3e2aef..3ce79e11e 100644 --- a/uk/introduction.mdx +++ b/uk/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud пропонує єдину платформу AI A Ace Data Cloud — це єдина платформа AI API, яка через один API Key і уніфікований інтерфейс надає доступ до провідних світових AI сервісів. - + Доступ до моделей Claude, GPT, Gemini, DeepSeek, Grok, Kimi та інших через сумісний з OpenAI інтерфейс. - - Генерація зображень за допомогою Midjourney, Flux, DALL·E, Seedream та інших. + + Генерація зображень за допомогою Flux, DALL·E, Seedream та інших. - - Генерація відео за допомогою Sora, Veo, Luma, Kling, Hailuo, Seedance та інших. + + Генерація відео за допомогою Veo, Luma, Kling, Hailuo, Seedance та інших. - + Генерація музики та аудіо за допомогою Suno, Fish Audio, Producer та інших. @@ -36,10 +36,10 @@ Ace Data Cloud — це єдина платформа AI API, яка через Реєстрація та отримання API Token - + Інтерактивна документація API - + Підключення AI помічників до нашого API \ No newline at end of file diff --git a/uk/mcp/overview.mdx b/uk/mcp/overview.mdx index a7b1629b0..fe8b754e4 100644 --- a/uk/mcp/overview.mdx +++ b/uk/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "Сервер Model Context Protocol для інтеграції AI Ace Data Cloud надає сервер MCP (Model Context Protocol), який дозволяє AI-помічникам, таким як Claude, Cursor, Windsurf, безпосередньо викликати наш API. - + Сервер Flux MCP - + Сервер Luma MCP - - Сервер Midjourney MCP - - + Сервер Nanobanana MCP - + Сервер Seedance MCP - + Сервер Seedream MCP - + Сервер Serp MCP - + Сервер Shorturl MCP - + Сервер Suno MCP - + Сервер Veo MCP \ No newline at end of file diff --git a/uk/quickstart.mdx b/uk/quickstart.mdx index 5b946769f..87ab41763 100644 --- a/uk/quickstart.mdx +++ b/uk/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. Вивчення додаткових API - + Переглядайте та тестуйте всі API кінцеві точки інтерактивно - + Детальні інструкції з інтеграції для кожного сервісу \ No newline at end of file diff --git a/zh-Hans/authentication.mdx b/zh-Hans/authentication.mdx index 0305ac277..dffee1d79 100644 --- a/zh-Hans/authentication.mdx +++ b/zh-Hans/authentication.mdx @@ -137,16 +137,16 @@ Content-Type: application/json ## 下一步 - + 跑通你的第一个请求 - + 成功与失败的响应字段 - + 轮询与 Webhook 回调 - + 交互式 API 文档 diff --git a/zh-Hans/concepts/async-tasks.mdx b/zh-Hans/concepts/async-tasks.mdx index 13d8f42e5..1620995b5 100644 --- a/zh-Hans/concepts/async-tasks.mdx +++ b/zh-Hans/concepts/async-tasks.mdx @@ -34,7 +34,7 @@ POST 提交后**同步返回** `200`,主体里含 `task_id`: - **Midjourney / Suno** 等支持「等到出结果再返回」的接口:直接给完整结果(图片 URL 等) - **Veo / Sora 等**重型视频接口:只回一个 `task_id`,结果稍后通过轮询或 Webhook 拿 -具体看每个 API 的 [API 参考](/api-reference)。 +具体看每个 API 的 [API 参考](/zh-Hans/api-reference)。 ## 客户端轮询 @@ -161,15 +161,15 @@ def callback(): ## 失败处理 -不论用哪种方式,都可能拿到失败结果。失败响应的 `error.code` 与同步错误码一致——见 [响应格式](/concepts/responses)。**任务失败一般不扣费**;具体策略以服务详情页为准。 +不论用哪种方式,都可能拿到失败结果。失败响应的 `error.code` 与同步错误码一致——见 [响应格式](/zh-Hans/concepts/responses)。**任务失败一般不扣费**;具体策略以服务详情页为准。 ## 下一步 - + 所有错误码与状态码 - + Token、域名、安全 diff --git a/zh-Hans/concepts/responses.mdx b/zh-Hans/concepts/responses.mdx index 4d59d0df2..9c895434a 100644 --- a/zh-Hans/concepts/responses.mdx +++ b/zh-Hans/concepts/responses.mdx @@ -12,7 +12,7 @@ Ace Data Cloud 的响应结构因**调用类型**而异,主要分两类: ## 同步响应:成功 -成功时返回上游业务字段,**通常**包含一个 `success: true` 标记。具体字段以各 API 的 [API 参考](/api-reference) 为准。 +成功时返回上游业务字段,**通常**包含一个 `success: true` 标记。具体字段以各 API 的 [API 参考](/zh-Hans/api-reference) 为准。 例(Midjourney `/midjourney/imagine`): diff --git a/zh-Hans/faq/billing.mdx b/zh-Hans/faq/billing.mdx index 0e1278c36..3f91e52c9 100644 --- a/zh-Hans/faq/billing.mdx +++ b/zh-Hans/faq/billing.mdx @@ -15,7 +15,7 @@ $$ \text{USD} = \text{Credits} \times \frac{\text{package.price}}{\text{package.amount}} $$ -当前主流套餐的换算比例约为 **$0.095215 / Credit**。 +每个套餐的换算比例可能不同,请使用当前套餐的价格与 Credits 数量计算,并在购买前查看控制台中的实时套餐。 ## 什么时候扣费? @@ -84,10 +84,10 @@ HTTP 状态码是 **403**(不是 402)。请在控制台续费后再调用。 ## 相关 - + 按量计费规则 - + 所有错误码与状态码 diff --git a/zh-Hans/introduction.mdx b/zh-Hans/introduction.mdx index 943acefc7..55de1017c 100644 --- a/zh-Hans/introduction.mdx +++ b/zh-Hans/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud 提供统一 AI API 平台,一个 API Key 即可 Ace Data Cloud 是一个统一 AI API 平台,通过一个 API Key 和一致的接口,访问全球领先的 AI 服务。 - + 通过 OpenAI 兼容接口访问 Claude、GPT、Gemini、DeepSeek、Grok、Kimi 等模型。 - - 使用 Midjourney、Flux、DALL·E、Seedream 等生成图像。 + + 使用 Flux、DALL·E、Seedream 等生成图像。 - - 使用 Sora、Veo、Luma、Kling、Hailuo、Seedance 等生成视频。 + + 使用 Veo、Luma、Kling、Hailuo、Seedance 等生成视频。 - + 使用 Suno、Fish Audio、Producer 等生成音乐和音频。 @@ -36,10 +36,10 @@ Ace Data Cloud 是一个统一 AI API 平台,通过一个 API Key 和一致的 注册并获取 API Token - + 交互式 API 文档 - + 将 AI 助手连接到我们的 API diff --git a/zh-Hans/mcp/overview.mdx b/zh-Hans/mcp/overview.mdx new file mode 100644 index 000000000..0903b9406 --- /dev/null +++ b/zh-Hans/mcp/overview.mdx @@ -0,0 +1,36 @@ +--- +title: "MCP 服务器" +description: "用于 AI 工具集成的 Model Context Protocol 服务器" +--- + +Ace Data Cloud 提供 MCP(Model Context Protocol)服务器,让 Claude、Cursor、Windsurf 等 AI 助手直接调用我们的 API。 + + + + Flux MCP 服务器 + + + Luma MCP 服务器 + + + Nanobanana MCP 服务器 + + + Seedance MCP 服务器 + + + Seedream MCP 服务器 + + + Serp MCP 服务器 + + + Shorturl MCP 服务器 + + + Suno MCP 服务器 + + + Veo MCP 服务器 + + \ No newline at end of file diff --git a/zh-Hans/pricing/overview.mdx b/zh-Hans/pricing/overview.mdx index f21bf231c..70fa0fad2 100644 --- a/zh-Hans/pricing/overview.mdx +++ b/zh-Hans/pricing/overview.mdx @@ -17,7 +17,7 @@ $$ \text{USD} = \text{Credits} \times \frac{\text{package.price}}{\text{package.amount}} $$ -当前主流套餐的换算比例约为 **$0.095215 / Credit**。 +每个套餐的换算比例可能不同,请使用当前套餐的价格与 Credits 数量计算,并在购买前查看控制台中的实时套餐。 ## 计费时机 @@ -53,7 +53,7 @@ $$ - **实例余额**(Instance / Dedicated):在某个服务下创建的应用所分配的额度,只能用于该服务 - **通用余额**(General Balance):所有服务通用 - 默认从实例余额扣减;若实例应用开启了「允许使用通用余额」,实例余额不足时会自动落到通用余额 -- 余额不足时 API 返回 `403 used_up`(不是 402)——见 [响应格式](/concepts/responses) +- 余额不足时 API 返回 `403 used_up`(不是 402)——见 [响应格式](/zh-Hans/concepts/responses) ## 折扣 @@ -62,10 +62,10 @@ $$ ## 相关文档 - + Credits / USD 换算与常见疑问 - + 余额耗尽、内容审核等错误码 diff --git a/zh-Hans/quickstart.mdx b/zh-Hans/quickstart.mdx index 98ba47951..1793c9598 100644 --- a/zh-Hans/quickstart.mdx +++ b/zh-Hans/quickstart.mdx @@ -100,25 +100,25 @@ console.log(await res.json()); } ``` -完整错误码表见 [响应格式](/concepts/responses)。 +完整错误码表见 [响应格式](/zh-Hans/concepts/responses)。 ## 4. 异步:长视频 / 长音乐 -视频与音乐生成耗时更长,通常**只先返回 `task_id`**,结果通过轮询或 Webhook 拿。详见 [异步任务模型](/concepts/async-tasks)。 +视频与音乐生成耗时更长,通常**只先返回 `task_id`**,结果通过轮询或 Webhook 拿。详见 [异步任务模型](/zh-Hans/concepts/async-tasks)。 ## 5. 后续 - + 所有接口的交互式文档 - + Token、域名、错误码 - + 各品类计费方式 - + 在 Claude / Cursor 里直接调用 diff --git a/zh-Hant/introduction.mdx b/zh-Hant/introduction.mdx index e37edc7d0..a7336ce7b 100644 --- a/zh-Hant/introduction.mdx +++ b/zh-Hant/introduction.mdx @@ -8,16 +8,16 @@ description: "Ace Data Cloud 提供統一 AI API 平台,一個 API Key 即可 Ace Data Cloud 是一個統一 AI API 平台,通過一個 API Key 和一致的介面,訪問全球領先的 AI 服務。 - + 通過 OpenAI 相容介面訪問 Claude、GPT、Gemini、DeepSeek、Grok、Kimi 等模型。 - - 使用 Midjourney、Flux、DALL·E、Seedream 等生成圖像。 + + 使用 Flux、DALL·E、Seedream 等生成圖像。 - - 使用 Sora、Veo、Luma、Kling、Hailuo、Seedance 等生成視頻。 + + 使用 Veo、Luma、Kling、Hailuo、Seedance 等生成視頻。 - + 使用 Suno、Fish Audio、Producer 等生成音樂和音頻。 @@ -36,10 +36,10 @@ Ace Data Cloud 是一個統一 AI API 平台,通過一個 API Key 和一致的 註冊並取得 API Token - + 互動式 API 文件 - + 將 AI 助手連接到我們的 API \ No newline at end of file diff --git a/zh-Hant/mcp/overview.mdx b/zh-Hant/mcp/overview.mdx index 467c1600c..021038380 100644 --- a/zh-Hant/mcp/overview.mdx +++ b/zh-Hant/mcp/overview.mdx @@ -6,34 +6,31 @@ description: "用於 AI 工具集成的 Model Context Protocol 伺服器" Ace Data Cloud 提供 MCP(Model Context Protocol)伺服器,讓 Claude、Cursor、Windsurf 等 AI 助手直接調用我們的 API。 - + Flux MCP 伺服器 - + Luma MCP 伺服器 - - Midjourney MCP 伺服器 - - + Nanobanana MCP 伺服器 - + Seedance MCP 伺服器 - + Seedream MCP 伺服器 - + Serp MCP 伺服器 - + Shorturl MCP 伺服器 - + Suno MCP 伺服器 - + Veo MCP 伺服器 \ No newline at end of file diff --git a/zh-Hant/quickstart.mdx b/zh-Hant/quickstart.mdx index a8c2e3ea0..7f9a8db29 100644 --- a/zh-Hant/quickstart.mdx +++ b/zh-Hant/quickstart.mdx @@ -70,10 +70,10 @@ console.log(data); ## 3. 探索更多 API - + 瀏覽並互動式測試所有 API 端點 - + 每個服務的詳細整合教程 \ No newline at end of file From dd5c4a99a958dc1446f455c17234e6d33c570cab Mon Sep 17 00:00:00 2001 From: Ace Data Cloud Dev Date: Sun, 4 Oct 2026 21:47:09 +0800 Subject: [PATCH 4/4] refactor: reuse the document list for Docs synchronization --- README.md | 19 ++--- scripts/sync_from_platformbackend.py | 102 ++++++++++++++++++++------- tests/test_publication.py | 58 +++++++++++++-- 3 files changed, 141 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index e7f5fe3ba..52d99c10a 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,11 @@ links with `mint broken-links`. Navigation and site settings live in `docs.json` - PlatformBackend owns Markdown source files and OpenAPI definitions. Edit the source, not generated Docs pages. -- The read-only `/api/v1/documents/publication/?lang=en` backend endpoint exports - public documents by stable source key, including standalone guides and MCP. - It includes exact-locale content, source SHA-256, and translation readiness. - Display-oriented API/sibling relations are not a publication contract. +- Reuse the existing `/api/v1/documents/?lang=en` list, including normal + pagination. Its additive `content_source` metadata identifies the source file, + source SHA-256, exact-language readiness and content hash. Independent Text + guides and MCP pages are read directly rather than inferred from API siblings. + No new endpoint, response envelope or export mode is required. - Every configured locale's `guides/` and `mcp/` directories are generated. Existing Greek, Finnish and Serbian directories are also reconciled because their unlisted URLs remain accessible, without adding them to the language menu. @@ -39,12 +40,13 @@ editorial changes without overwriting them. Hourly reconciliation picks up asynchronous source deployment and translation completion even when no new Git event occurs. Before switching this consumer on, -deploy the backend publication endpoint; unavailable or malformed feeds fail -closed and do not fall back to the old display API. +deploy the additive metadata on the existing document list. Missing metadata or +malformed/incomplete pages fail instead of being treated as a successful sync. For every generated page, the database source hash must match the checked-out Markdown and the target translation must be current. Catalog identity must also -remain consistent across locale reads. There is no automatic language fallback. +remain consistent across locale reads. The normal list keeps its display fallback; the Docs consumer checks metadata +and never counts that fallback as a current translation. When a translation is pending, an existing page is retained for availability but is explicitly reported as incomplete; a missing page is not fabricated. Ready pages and public withdrawals can still publish. Exit status **2** means partial @@ -82,6 +84,7 @@ include private-value checks in a dry run; without it the report explicitly mark that check unverified. Actual publication always requires the configured denylist. `--catalog-dir /path/to/snapshots` loads files named `zh-cn.json`, `en.json`, etc. -from the same publication exporter for reproducible, offline verification. Reports +containing complete responses from the same document list for reproducible, +offline verification. Reports belong outside the generated output tree. No sync command writes the backend DB or generates translations. diff --git a/scripts/sync_from_platformbackend.py b/scripts/sync_from_platformbackend.py index 034581645..87ed84714 100644 --- a/scripts/sync_from_platformbackend.py +++ b/scripts/sync_from_platformbackend.py @@ -28,7 +28,7 @@ START = time.time() BASE_URL = "https://api.acedata.cloud" -PUBLICATION_URL = "https://platform.acedata.cloud/api/v1/documents/publication/" +DOCUMENTS_URL = "https://platform.acedata.cloud/api/v1/documents/" SHARED_GUIDE_ALIASES = {"kling_assets": ("kling_elements", "kling_voices")} TRANSACTION_FILE = ".docs-sync-transaction.json" BACKUP_DIR = ".docs-sync-backup" @@ -930,12 +930,13 @@ def build_doc_service_map( return result -def load_publication_catalog(language: str, catalog_dir: Path | None = None) -> dict[str, Any]: - language = language.lower() - if catalog_dir: - payload = load_json(catalog_dir / f"{language}.json") - else: - request = Request(f"{PUBLICATION_URL}?lang={language}") +def fetch_document_list(language: str) -> dict[str, Any]: + items = [] + expected = None + seen = set() + while expected is None or len(items) < expected: + request = Request(f"{DOCUMENTS_URL}?lang={language}&limit=1000&offset={len(items)}", + headers={"Origin": "https://platform.acedata.cloud", "Accept-Language": language}) for attempt in range(3): try: with urlopen(request, timeout=60) as response: @@ -945,26 +946,75 @@ def load_publication_catalog(language: str, catalog_dir: Path | None = None) -> if attempt == 2: raise time.sleep(2**attempt) - if (not isinstance(payload, dict) or payload.get("schema_version") != 1 - or payload.get("language") != language or payload.get("source_language") != "zh-cn" - or not isinstance(payload.get("items"), list) or not payload["items"] - or not isinstance(payload.get("public_api_ids"), list) - or not re.fullmatch(r"[0-9a-f]{64}", payload.get("catalog_hash", ""))): - raise RuntimeError(f"Invalid publication catalog for {language}") + total, page = payload.get("count"), payload.get("items") + if not isinstance(total, int) or total < 0 or not isinstance(page, list): + raise RuntimeError(f"Invalid document list for {language}") + if expected is not None and total != expected: + raise RuntimeError("Document list changed during pagination; retry") + expected = total + if not page and len(items) < expected: + raise RuntimeError("Document list ended before its reported count") + for item in page: + document_id = item.get("id") + if not document_id or document_id in seen: + raise RuntimeError("Missing or repeated document identity during pagination") + seen.add(document_id) + items.extend(page) + if len(items) > expected: + raise RuntimeError("Document list exceeds its reported count") + return {"count": expected, "items": items} + + +def load_publication_catalog(language: str, catalog_dir: Path | None = None) -> dict[str, Any]: + """Normalize the existing document list; no separate API or export mode.""" + language = language.lower() + payload = load_json(catalog_dir / f"{language}.json") if catalog_dir else fetch_document_list(language) + if (not isinstance(payload, dict) or not isinstance(payload.get("items"), list) + or not payload["items"] or payload.get("count") != len(payload["items"])): + raise RuntimeError(f"Incomplete document list for {language}") records = {} - for item in payload["items"]: - key = item.get("source_key", "") + api_ids = set() + for document in payload["items"]: + if document.get("private") is not False: + continue + if "content_source" not in document: + raise RuntimeError("Document list lacks content_source metadata; deploy the backend update first") + if document.get("api_id"): + api_ids.add(str(document["api_id"])) + metadata = document["content_source"] + if metadata is None: + continue + if not isinstance(metadata, dict): + raise RuntimeError(f"Invalid document source metadata for {language}") + key = metadata.get("source_key", "") if (not re.fullmatch(r"(?:development_|mcp_)[a-zA-Z0-9_-]+|x402_integration_guide", key) - or key in records or item.get("status") not in {"ready", "missing", "stale", "missing_source"}): - raise RuntimeError(f"Invalid or duplicate publication identity for {language}") - if item.get("source_hash") is not None and not re.fullmatch(r"[0-9a-f]{64}", item["source_hash"]): + or metadata.get("language") != language + or metadata.get("status") not in {"ready", "missing", "stale", "missing_source", "ambiguous"}): + raise RuntimeError(f"Invalid document source identity for {language}") + source_hash = metadata.get("source_hash") + if source_hash is not None and not re.fullmatch(r"[0-9a-f]{64}", source_hash): raise RuntimeError(f"Invalid source hash for {language}/{key}") - if item["status"] == "ready": - body = item.get("content") - if not isinstance(body, str) or not body or digest(body) != item.get("content_hash"): - raise RuntimeError(f"Invalid publication content for {language}/{key}") - records[key] = item - return {**payload, "records": records} + body = document.get("content") + if metadata["status"] == "ready": + if not source_hash or not isinstance(body, str) or not body or digest(body) != metadata.get("content_hash"): + raise RuntimeError(f"Document content changed during rendering for {language}/{key}") + item = {**metadata, "content": body if metadata["status"] == "ready" else None} + alias = document.get("alias") or str(document.get("id") or "") + if not alias: + raise RuntimeError(f"Missing document address for {language}/{key}") + previous = records.get(key) + if previous: + if {k: v for k, v in previous.items() if k != "aliases"} != item: + raise RuntimeError(f"Conflicting document source {key} for {language}") + previous["aliases"] = sorted(set(previous["aliases"]) | {alias}) + else: + records[key] = {**item, "aliases": [alias]} + if not records: + raise RuntimeError(f"No public source-backed documents for {language}") + identity = {"sources": [{k: item[k] for k in ("source_key", "aliases", "source_hash")} + for _, item in sorted(records.items())], "public_api_ids": sorted(api_ids)} + return {"records": records, "public_api_ids": sorted(api_ids), + "catalog_hash": digest(json.dumps(identity, sort_keys=True, ensure_ascii=False))} def digest(content: str) -> str: @@ -1104,7 +1154,7 @@ def sync_document_pages(backend_dir: Path, output_dir: Path, previous: Path, record = catalog["records"][key] source = (backend_dir / "docs" / f"{key}.md").read_text(encoding="utf-8") status = record["status"] - if record["source_hash"] != digest(source): + if record["source_hash"] is not None and record["source_hash"] != digest(source): status = "source_not_deployed" target = output_dir / language / relative old = previous / language / relative @@ -1266,7 +1316,7 @@ def main() -> int: parser.add_argument("--output-dir", required=True, type=Path) parser.add_argument("--validate-only", action="store_true") parser.add_argument("--require-denylist", action="store_true") - parser.add_argument("--catalog-dir", type=Path, help="Read exported publication snapshots for offline verification") + parser.add_argument("--catalog-dir", type=Path, help="Read complete document-list snapshots for offline verification") parser.add_argument("--report", type=Path, help="Write publication completeness report outside the output tree") parser.add_argument("--dry-run", action="store_true", help="Validate and report without publishing generated files") parser.add_argument("--preview-dir", type=Path, help="Save dry-run output to a new directory for review") diff --git a/tests/test_publication.py b/tests/test_publication.py index 6c2e6b684..d990e0085 100644 --- a/tests/test_publication.py +++ b/tests/test_publication.py @@ -1,5 +1,6 @@ import copy import json +import io import tempfile import unittest from pathlib import Path @@ -16,11 +17,60 @@ def record(key, source="# Source", content="# Translated", status="ready"): def catalog(language="en", records=None): records = records or [record("development_codex")] - return {"schema_version": 1, "source_language": "zh-cn", "language": language, - "catalog_hash": "a" * 64, "public_api_ids": ["public-api"], "items": records} + items = [{"id": f"doc-{i}", "alias": f"guide-{i}", "private": False, "api_id": None, + "content": item["content"] if item["content"] is not None else "# Display fallback", + "content_source": {**{k: v for k, v in item.items() if k != "content"}, "language": language}} + for i, item in enumerate(records)] + items.append({"id": "api-doc", "alias": "api", "private": False, "api_id": "public-api", "content_source": None}) + return {"count": len(items), "items": items} class PublicationTests(unittest.TestCase): + def test_existing_document_list_is_paginated_without_a_new_endpoint(self): + pages = [{"count": 3, "items": [{"id": "a"}, {"id": "b"}]}, + {"count": 3, "items": [{"id": "c"}]}] + urls = [] + + def open_page(request, timeout): + self.assertEqual(request.get_header("Origin"), "https://platform.acedata.cloud") + urls.append(request.full_url) + return io.BytesIO(json.dumps(pages.pop(0)).encode()) + + with patch.object(sync, "urlopen", side_effect=open_page): + result = sync.fetch_document_list("en") + self.assertEqual(len(result["items"]), 3) + self.assertEqual(urls, ["https://platform.acedata.cloud/api/v1/documents/?lang=en&limit=1000&offset=0", + "https://platform.acedata.cloud/api/v1/documents/?lang=en&limit=1000&offset=2"]) + + def test_pagination_rejects_a_changed_or_incomplete_document_list(self): + for last in [{"count": 3, "items": []}, {"count": 3, "items": [{"id": "a"}]}, + {"count": 4, "items": [{"id": "c"}]}]: + pages = [{"count": 3, "items": [{"id": "a"}, {"id": "b"}]}, last] + with patch.object(sync, "urlopen", side_effect=lambda *a, **kw: io.BytesIO(json.dumps(pages.pop(0)).encode())): + with self.assertRaises(RuntimeError): + sync.fetch_document_list("en") + + def test_metadata_is_required_and_private_documents_are_not_published(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + data = catalog() + data["items"].append({"id": "private", "private": True, "api_id": "private-api"}) + data["count"] += 1 + (root / "en.json").write_text(json.dumps(data)) + self.assertEqual(sync.load_publication_catalog("en", root)["public_api_ids"], ["public-api"]) + del data["items"][0]["content_source"] + (root / "en.json").write_text(json.dumps(data)) + with self.assertRaisesRegex(RuntimeError, "lacks content_source"): + sync.load_publication_catalog("en", root) + + def test_shared_source_aliases_are_deduplicated(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "en.json").write_text(json.dumps(catalog(records=[record("development_codex")] * 2))) + records = sync.load_publication_catalog("en", root)["records"] + self.assertEqual(list(records), ["development_codex"]) + self.assertEqual(records["development_codex"]["aliases"], ["guide-0", "guide-1"]) + def test_source_and_platform_links_resolve_without_rewriting_examples(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -93,7 +143,7 @@ def test_catalog_accepts_text_and_mcp_without_api_siblings(self): self.assertEqual(set(actual["records"]), {"development_codex", "mcp_seedance"}) def test_catalog_rejects_wrong_locale_duplicate_identity_and_corrupt_content(self): - payloads = [catalog("zh-cn"), catalog(records=[record("development_codex")] * 2), catalog()] + payloads = [catalog("zh-cn"), catalog(records=[record("development_codex"), record("development_codex", content="# Conflicting")]), catalog()] payloads[-1]["items"][0]["content"] = "Tampered" with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -106,7 +156,7 @@ def test_catalog_change_during_locale_reads_aborts(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) en, zh = catalog(), catalog("zh-cn") - zh["items"][0]["source_hash"] = "b" * 64 + zh["items"][0]["content_source"]["source_hash"] = "b" * 64 for language, payload in [("en", en), ("zh-cn", zh)]: (root / f"{language}.json").write_text(json.dumps(payload)) with self.assertRaisesRegex(RuntimeError, "changed between"):