Repository: mozilla/sphinx-js Branch: master Commit: ec2c7bd0c99d Files: 159 Total size: 345.3 KB Directory structure: gitextract_jnq5w43z/ ├── .editorconfig ├── .github/ │ ├── codecov.yml │ ├── dependabot.yml │ └── workflows/ │ ├── check_ts.yml │ ├── ci.yml │ ├── release.yml │ └── test_report.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTORS ├── LICENSE ├── README.md ├── biome.json ├── noxfile.py ├── pyproject.toml ├── sphinx_js/ │ ├── __init__.py │ ├── analyzer_utils.py │ ├── directives.py │ ├── ir.py │ ├── js/ │ │ ├── cli.ts │ │ ├── convertTopLevel.ts │ │ ├── convertType.ts │ │ ├── importHooks.mjs │ │ ├── ir.ts │ │ ├── main.ts │ │ ├── package.json │ │ ├── redirectPrivateAliases.ts │ │ ├── registerImportHook.mjs │ │ ├── sphinxJsConfig.ts │ │ ├── tsconfig.json │ │ ├── typedocPatches.ts │ │ └── typedocPlugin.ts │ ├── jsdoc.py │ ├── parsers.py │ ├── py.typed │ ├── renderers.py │ ├── suffix_tree.py │ ├── templates/ │ │ ├── attribute.rst │ │ ├── class.rst │ │ ├── common.rst │ │ └── function.rst │ └── typedoc.py └── tests/ ├── __init__.py ├── conftest.py ├── roots/ │ ├── test-incremental_js/ │ │ ├── a.js │ │ ├── a.rst │ │ ├── a_b.rst │ │ ├── b.rst │ │ ├── conf.py │ │ ├── index.rst │ │ ├── inner/ │ │ │ └── b.js │ │ ├── jsdoc.json │ │ └── unrelated.rst │ └── test-incremental_ts/ │ ├── a.rst │ ├── a.ts │ ├── a_b.rst │ ├── b.rst │ ├── conf.py │ ├── index.rst │ ├── inner/ │ │ └── b.ts │ ├── tsconfig.json │ └── unrelated.rst ├── sphinxJsConfig.ts ├── test.ts ├── test_build_js/ │ ├── source/ │ │ ├── code.js │ │ ├── docs/ │ │ │ ├── autoattribute.rst │ │ │ ├── autoattribute_deprecated.rst │ │ │ ├── autoattribute_example.rst │ │ │ ├── autoattribute_see.rst │ │ │ ├── autoclass.rst │ │ │ ├── autoclass_alphabetical.rst │ │ │ ├── autoclass_deprecated.rst │ │ │ ├── autoclass_example.rst │ │ │ ├── autoclass_exclude_members.rst │ │ │ ├── autoclass_members.rst │ │ │ ├── autoclass_members_list.rst │ │ │ ├── autoclass_members_list_star.rst │ │ │ ├── autoclass_no_paramnames.rst │ │ │ ├── autoclass_private_members.rst │ │ │ ├── autoclass_see.rst │ │ │ ├── autofunction_callback.rst │ │ │ ├── autofunction_defaults_code.rst │ │ │ ├── autofunction_defaults_doclet.rst │ │ │ ├── autofunction_deprecated.rst │ │ │ ├── autofunction_destructured_params.rst │ │ │ ├── autofunction_example.rst │ │ │ ├── autofunction_explicit.rst │ │ │ ├── autofunction_long.rst │ │ │ ├── autofunction_minimal.rst │ │ │ ├── autofunction_see.rst │ │ │ ├── autofunction_short.rst │ │ │ ├── autofunction_static.rst │ │ │ ├── autofunction_typedef.rst │ │ │ ├── autofunction_variadic.rst │ │ │ ├── avoid_shadowing.rst │ │ │ ├── conf.py │ │ │ ├── getter_setter.rst │ │ │ ├── index.rst │ │ │ ├── injection.rst │ │ │ ├── union.rst │ │ │ └── unwrapped.rst │ │ └── more_code.js │ └── test_build_js.py ├── test_build_ts/ │ ├── source/ │ │ ├── class.ts │ │ ├── docs/ │ │ │ ├── async_function.rst │ │ │ ├── autoclass_class_with_interface_and_supers.rst │ │ │ ├── autoclass_constructorless.rst │ │ │ ├── autoclass_exported.rst │ │ │ ├── autoclass_interface_optionals.rst │ │ │ ├── autoclass_star.rst │ │ │ ├── automodule.rst │ │ │ ├── autosummary.rst │ │ │ ├── conf.py │ │ │ ├── deprecated.rst │ │ │ ├── example.rst │ │ │ ├── getset.rst │ │ │ ├── index.rst │ │ │ ├── inherited_docs.rst │ │ │ ├── predicate.rst │ │ │ ├── sphinx_link_in_description.rst │ │ │ ├── symbol.rst │ │ │ └── xrefs.rst │ │ ├── empty.ts │ │ ├── module.ts │ │ ├── tsconfig.json │ │ └── typedoc.json │ └── test_build_ts.py ├── test_build_xref_none/ │ ├── source/ │ │ ├── docs/ │ │ │ ├── conf.py │ │ │ └── index.rst │ │ └── main.ts │ └── test_build_xref_none.py ├── test_common_mark/ │ ├── source/ │ │ ├── code.js │ │ └── docs/ │ │ ├── conf.py │ │ └── index.md │ └── test_common_mark.py ├── test_dot_dot_paths/ │ ├── source/ │ │ ├── code.js │ │ └── docs/ │ │ ├── conf.py │ │ └── index.rst │ └── test_dot_dot_paths.py ├── test_incremental.py ├── test_init.py ├── test_ir.py ├── test_jsdoc_analysis/ │ ├── source/ │ │ ├── class.js │ │ └── function.js │ └── test_jsdoc.py ├── test_parsers.py ├── test_paths.py ├── test_renderers.py ├── test_suffix_tree.py ├── test_testing.py ├── test_typedoc_analysis/ │ ├── source/ │ │ ├── exports.ts │ │ ├── nodes.ts │ │ ├── subdir/ │ │ │ └── pathSegments.ts │ │ ├── tsconfig.json │ │ └── types.ts │ └── test_typedoc_analysis.py └── testing.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # http://editorconfig.org root = true [*] indent_style = space indent_size = 2 trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 end_of_line = lf [*.py] indent_size = 4 [*.rst] indent_size = 4 ================================================ FILE: .github/codecov.yml ================================================ comment: false codecov: branch: main require_ci_to_pass: false notify: wait_for_ci: false ================================================ FILE: .github/dependabot.yml ================================================ # Keep GitHub Actions up to date with GitHub's Dependabot... # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem version: 2 updates: - package-ecosystem: github-actions directory: / groups: github-actions: patterns: - "*" # Group all Actions updates into a single larger pull request schedule: interval: weekly ================================================ FILE: .github/workflows/check_ts.yml ================================================ name: check-ts on: pull_request: permissions: write-all jobs: ts: if: false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Get diff lines id: diff uses: Equip-Collaboration/diff-line-numbers@v1.1.0 with: include: '["\\.ts$"]' - name: Detecting files changed id: files uses: umani/changed-files@v4.2.0 with: repo-token: ${{ github.token }} pattern: '^.*\.ts$' - name: List files changed (you can remove this step, for monitoring only) run: | echo 'Files modified: ${{steps.files.outputs.files_updated}}' echo 'Files added: ${{steps.files.outputs.files_created}}' echo 'Files removed: ${{steps.files.outputs.files_deleted}}' - uses: Arhia/action-check-typescript@v1.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} use-check: true check-fail-mode: added files-changed: ${{steps.files.outputs.files_updated}} files-added: ${{steps.files.outputs.files_created}} files-deleted: ${{steps.files.outputs.files_deleted}} line-numbers: ${{steps.diff.outputs.lineNumbers}} output-behaviour: both comment-behaviour: new ts-config-path: "./sphinx_js/js/tsconfig.json" ================================================ FILE: .github/workflows/ci.yml ================================================ --- name: CI on: push: branches: [master] pull_request: branches: [master] jobs: test: runs-on: ubuntu-latest continue-on-error: true strategy: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] name: Python ${{ matrix.python-version}} steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6.0.0 with: python-version: ${{ matrix.python-version }} - name: Update pip and install dev requirements run: | python -m pip install --upgrade pip pip install nox - name: Test run: nox -s tests-${{ matrix.python-version }} - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - uses: actions/upload-artifact@v5 if: success() || failure() with: name: test-results-${{ matrix.python-version }} path: test-results.xml test-typedoc-versions: runs-on: ubuntu-latest continue-on-error: true strategy: fail-fast: false matrix: python-version: ["3.12"] typedoc-version: ["0.25", "0.26", "0.27", "0.28"] name: Python ${{ matrix.python-version}} + typedoc ${{ matrix.typedoc-version }} steps: - uses: actions/checkout@v6 - name: Set up Node uses: actions/setup-node@v6 with: node-version: 22 - name: Set up Python uses: actions/setup-python@v6.0.0 with: python-version: ${{ matrix.python-version }} - name: Update pip and install dev requirements run: | python -m pip install --upgrade pip pip install nox - name: Test run: nox -s "test_typedoc-${{ matrix.python-version }}(typedoc='${{ matrix.typedoc-version }}')" - uses: actions/upload-artifact@v5 if: success() || failure() with: name: test_typedoc-results-${{ matrix.python-version }}-${{ matrix.typedoc-version }} path: test-results.xml test-sphinx-versions: runs-on: ubuntu-latest continue-on-error: true strategy: fail-fast: false matrix: python-version: ["3.12"] sphinx-version: ["6"] name: Test sphinx 6 steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6.0.0 with: python-version: ${{ matrix.python-version }} - name: Update pip and install dev requirements run: | python -m pip install --upgrade pip pip install nox - name: Test run: nox -s "test_sphinx_6-${{ matrix.python-version }}" - uses: actions/upload-artifact@v5 if: success() || failure() with: name: test_sphinx_6-${{ matrix.python-version }} path: test-results.xml ================================================ FILE: .github/workflows/release.yml ================================================ name: CD on: release: types: [published] workflow_dispatch: schedule: - cron: "0 3 * * 1" env: FORCE_COLOR: 3 jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v4.2.2 with: # include tags so that hatch-vcs can infer the version fetch-depth: 0 # switch to fetch-tags: true when the following is fixed # see https://github.com/actions/checkout/issues/2041 # fetch-tags: true - name: Setup Python uses: actions/setup-python@bba65e51ff35d50c6dbaaacd8a4681db13aa7cb4 # v5.6.0 with: python-version: "3.12" - name: Build run: | python -m pip install build python -m build . - name: Store the distribution packages uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: python-package-distributions path: dist/ if-no-files-found: error publish: name: Publish to PyPI needs: [build] runs-on: ubuntu-latest if: github.event_name == 'release' && github.event.action == 'published' environment: name: pypi url: https://pypi.org/p/sphinx-js permissions: id-token: write # IMPORTANT: mandatory for trusted publishing attestations: write contents: read steps: - name: Download all the dists uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: path: dist/ merge-multiple: true - name: Generate artifact attestations uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 with: subject-path: "dist/*" - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 ================================================ FILE: .github/workflows/test_report.yml ================================================ name: "Test Report" on: workflow_run: workflows: ["CI"] types: - completed jobs: report: strategy: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12"] runs-on: ubuntu-latest steps: - uses: dorny/test-reporter@v2.2.0 with: artifact: test-results-${{ matrix.python-version }} name: Test report - ${{ matrix.python-version }} path: test-results.xml reporter: java-junit report-typedoc: strategy: fail-fast: false matrix: python-version: ["3.12"] typedoc-version: ["0.25"] runs-on: ubuntu-latest steps: - uses: dorny/test-reporter@v2.2.0 with: artifact: test_typedoc-results-${{ matrix.python-version }}-${{ matrix.typedoc-version }} name: Test report - Python ${{ matrix.python-version}} + typedoc ${{ matrix.typedoc-version }} path: test-results.xml reporter: java-junit report-sphinx: strategy: fail-fast: false matrix: python-version: ["3.12"] sphinx-version: ["6"] runs-on: ubuntu-latest steps: - uses: dorny/test-reporter@v2.2.0 with: artifact: test_sphinx_6-${{ matrix.python-version }} name: Test report - Sphinx 6 path: test-results.xml reporter: java-junit ================================================ FILE: .gitignore ================================================ /.eggs /.tox /.pytest_cache /build /dist /node_modules _build sphinx_js.egg-info/ # Python 3 */__pycache__/* # Python 2.7 *.pyc # Pycharm config .idea # VsCode config .vscode .DS_Store venv .venv coverage.xml test-results.xml node_modules tsconfig.tsbuildinfo ================================================ FILE: .pre-commit-config.yaml ================================================ default_language_version: python: "3.10" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: "v4.4.0" hooks: - id: check-added-large-files - id: check-case-conflict - id: check-merge-conflict exclude: README.MD - id: check-symlinks - id: check-yaml - id: debug-statements - id: end-of-file-fixer - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit rev: "v0.9.1" hooks: - id: ruff args: [--fix] - id: ruff-format - repo: https://github.com/biomejs/pre-commit rev: "v0.6.1" hooks: - id: biome-format additional_dependencies: ["@biomejs/biome@2.1.1"] types_or: [javascript, ts] - repo: https://github.com/rstcheck/rstcheck rev: "v6.2.5" hooks: - id: rstcheck exclude: (tests/|sphinx_js/templates) additional_dependencies: ["rstcheck[sphinx,toml]"] - repo: https://github.com/codespell-project/codespell rev: "v2.2.5" hooks: - id: codespell - repo: https://github.com/pre-commit/mirrors-mypy rev: "v1.5.1" hooks: - id: mypy exclude: (tests/) args: [] additional_dependencies: - attrs - cattrs - jinja2 - nox - pydantic - pytest - sphinx - types-docutils - types-parsimonious - types-setuptools ci: autoupdate_schedule: "quarterly" ================================================ FILE: CHANGELOG.md ================================================ ## Changelog ### 5.0.3: (March 30th, 2026) - Test Python 3.14 in CI (#302) - Fix compatibility with sphinx 9 (#301) - Fix: Errors generated from invalid xrefs should fail build (#300) ### 5.0.2: (October 17th, 2025) - Unpin markupsafe by @fmhoeger (#287) ### 5.0.1: (September 17th, 2025) - Fixed a bug that comment of the arrow function in the interface is not rendered correctly. (pyodide/sphinx-js#284) ### 5.0.0: (July 2nd, 2025) - Dropped support for Python 3.9 (pyodide/sphinx-js-fork#7) - Dropped support for typedoc 0.15, added support for typedoc 0.25--0.28 ( pyodide/sphinx-js-fork#11, pyodide/sphinx-js-fork#22, pyodide/sphinx-js-fork#31, pyodide/sphinx-js-fork#39, pyodide/sphinx-js-fork#41, pyodide/sphinx-js-fork#43 pyodide/sphinx-js-fork#52, pyodide/sphinx-js-fork#53, pyodide/sphinx-js-fork#54, pyodide/sphinx-js-fork#174, #266) - Added handling for TypeScript type parameters and type bounds. (pyodide/sphinx-js-fork#25) - Only monkeypatch Sphinx classes when sphinx_js extension is used (pyodide/sphinx-js-fork#27) - Allow using installation of `typedoc` or `jsdoc` from `node_modules` instead of requiring global install. (pyodide/sphinx-js-fork#33) - Handle markdown style codepens correctly in typedoc comments. (pyodide/sphinx-js-fork#47) - Added support for destructuring the documentation of keyword arguments in TypeScript using the `@destructure` tag or the `shouldDestructureArg` hook. ( pyodide/sphinx-js-fork#48, pyodide/sphinx-js-fork#74, pyodide/sphinx-js-fork#75, pyodide/sphinx-js-fork#101, pyodide/sphinx-js-fork#128) - Added rendering for cross references in TypeScript types. ( pyodide/sphinx-js-fork#51, pyodide/sphinx-js-fork#56, pyodide/sphinx-js-fork#67, pyodide/sphinx-js-fork#81, pyodide/sphinx-js-fork#82, pyodide/sphinx-js-fork#83, pyodide/sphinx-js-fork#153, pyodide/sphinx-js-fork#160) - Added rendering for function types in TypeScript documentation. ( pyodide/sphinx-js-fork#55, pyodide/sphinx-js-fork#58, pyodide/sphinx-js-fork#59) - Add async prefix to async functions (pyodide/sphinx-js-fork#65). - Added the `sphinx-js_type` css class around all types in documentation. This allows applying custom css just to types (pyodide/sphinx-js-fork#85) - Added `ts_type_bold` config option that applies css to `.sphinx-js_type` to render all types as bold. - Added `js:automodule` directive (pyodide/sphinx-js-fork#108) - Added `js:autosummary` directive (pyodide/sphinx-js-fork#109) - Added rendering for `queryType` (e.g., `let y: typeof x;`) (pyodide/sphinx-js-fork#124) - Added rendering for `typeOperator` (e.g., `let y: keyof x`) (pyodide/sphinx-js-fork#125) - Fixed crash when objects are reexported. (pyodide/sphinx-js-fork#126) - Added `jsdoc_tsconfig_path` which can specify the path to the `tsconfig.json` file that should be used. (pyodide/sphinx-js-fork#116) - Added a `js:interface` directive (pyodide/sphinx-js-fork#138). - Removed parentheses from xrefs to classes (pyodide/sphinx-js-fork#155). - Added a `:js:typealias:` directive (pyodide/sphinx-js-fork#156). - Added rendering for conditional, indexed access, inferred, mapped, optional, rest, and template litreal types (pyodide/sphinx-js-fork#157). - Added readonly prefix to readonly properties (pyodide/sphinx-js-fork#158). ### 4.0.0: (December 23rd, 2024) - Drop support for Python 3.8. - Add support for Python 3.12 and 3.13. - Add support for Sphinx 8.x.x. - Get CI working again. - Drop pin for MarkupSafe. (#244) - Add dependabot checking for GitHub actions. (Christian Clauss) - Fix wheel contents to not include tests. (#241) Thank you to Will Kahn-Greene and Christian Clauss! ### 3.2.2: (September 20th, 2023) - Remove Sphinx upper-bound requirement. (#227) - Drop support for Python 3.7. (#228) Thank you to Will Kahn-Greene! ### 3.2.1: (December 16th, 2022) - Fix xrefs to static functions. (#178) - Add support for jsdoc 4.0.0. (#215) Thank you to xsjad0 and Will Kahn-Greene! ### 3.2.0: (December 13th, 2022) - Add "static" in front of static methods. - Pin Jinja2 and markupsafe versions. (#190) - Track dependencies; do not read all documents. This improves speed of incremental updates. (#194) - Support Python 3.10 and 3.11. (#186) - Support Sphinx >= 4.1.0. (#209) - Fix types warning for `js_source_path` configuration item. (#182) Thank you Stefan 'hr' Berder, David Huggins-Daines, Nick Alexander, mariusschenzle, Erik Rose, lonnen, and Will Kahn-Greene! ### 3.1.2: (April 15th, 2021) - Remove our declared dependency on `docutils` to work around the way pip's greedy dependency resolver reacts to the latest version of Sphinx. pip fails when pip-installing sphinx-js because pip sees our "any version of docutils" declaration first (which resolves greedily to the latest version, 0.17) but later encounters Sphinx's apparently new `<0.17` constraint and gives up. We can revert this when pip's `--use-feature=2020-resolver` becomes the default. ### 3.1.1: (March 23rd, 2021) - Rewrite large parts of the suffix tree that powers path lookup. This fixes several crashes. ### 3.1: (September 10th, 2020) - Re-architect language analysis. There is now a well-documented intermediate representation between JSDoc- and TypeDoc-emitted JSON and the renderers. This should make it much faster to merge PRs. - Rewrite much of the TypeScript analysis engine so it feeds into the new IR. - TypeScript analysis used to crash if your codebase contained any overloaded functions. This no longer happens; we now arbitrarily use only the first function signature of each overloaded function. - Add support for static properties on TS classes. - Support variadic args in TS. - Support intersection types (`foo & bar`) in TS. - Remove the "exported from" module links from classes and interfaces. Functions never had them. Let's see if we miss them. - Pathnames for TypeScript objects no longer spuriously use `~` after the filename path segment; now they use `.` as in JS. - More generally, TS pathnames are now just like JS ones. There is no more `external:` prefix in front of filenames or `module:` in front of namespace names. - TS analyzer no longer cares with the current working directory is. - Tests now assert only what they care about rather than being brittle to the point of prohibiting any change. - No longer show args in the arg list that are utterly uninformative, lacking both description and type info. - Class attributes are now listed before methods unless manally ordered with `:members:`. ### 3.0.1: (August 10th, 2020) - Don't crash when encountering a `../` prefix on an object path. This can happen behind the scenes when `root_for_relative_js_paths` is set inward of the JS code. ### 3.0: (July 14th, 2020) - Make compatible with Sphinx 3, which requires Python 3. - Drop support for Python 2. - Make sphinx-js not care what the current working directory is, except for the TypeScript analyzer, which needs further work. - Properly RST-escape return types. ### 2.8: (September 16th, 2019) - Display generic TypeScript types properly. Make fields come before methods. (Paul Grau) - Combine constructor and class documentation at the top TypeScript classes. (Sebastian Weigand) - Switch to pytest as the testrunner. (Sebastian Weigand) - Add optional caching of JSDoc output, for large codebases. (Patrick Browne) - Fix the display of union types in TypeScript. (Sebastian Weigand) - Fix parsing breakage that began in typedoc 0.14.0. (Paul Grau) - Fix a data-intake crash with TypeScript. (Cristiano Santos) ### 2.7.1: (November 16th, 2018) - Fix a crash that would happen sometimes with UTF-8 on Windows. #67. - Always use conf.py's dir for JSDoc's working dir. #78. (Thomas Khyn) ### 2.7: (August 2nd, 2018)) - Add experimental TypeScript support. (Wim Yedema) ### 2.6: (July 26th, 2018) - Add support for `@deprecated` and `@see`. (David Li) - Notice and document JS variadic params nicely. (David Li) - Add linter to codebase. ### 2.5: (April 20th, 2018) - Use documented `@params` to help fill out the formal param list for a function. This keeps us from missing params that use destructuring. (flozz) - Improve error reporting when JSDoc is missing. - Add extracted default values to generated formal param lists. (flozz and erikrose) ### 2.4: (March 21, 2018) - Support the `@example` tag. (lidavidm) - Work under Windows. Before, we could hardly find any documentation. (flozz) - Properly unwrap multiple-line JSDoc tags, even if they have Windows line endings. (Wim Yedema) - Drop support for Python 3.3, since Sphinx has also done so. - Fix build-time crash when using recommonmark (for Markdown support) under Sphinx >=1.7.1. (jamrizzi) ### 2.3.1: (January 11th, 2018) - Find the `jsdoc` command on Windows, where it has a different name. Then patch up process communication so it doesn't hang. ### 2.3: (November 1st, 2017) - Add the ability to say "\*" within the `autoclass :members:` option, meaning "and all the members that I didn't explicitly list". ### 2.2: (October 10th, 2017) - Add `autofunction` support for `@callback` tags. (krassowski) - Add experimental `autofunction` support for `@typedef` tags. (krassowski) - Add a nice error message for when JSDoc can't find any JS files. - Pin six more tightly so `python_2_unicode_compatible` is sure to be around. ### 2.1: (August 30th, 2017) - Allow multiple folders in `js_source_path`. This is useful for gradually migrating large projects, one folder at a time, to JSDoc. Introduce `root_for_relative_js_paths` to keep relative paths unambiguous in the face of multiple source paths. - Aggregate PathTaken errors, and report them all at once. This means you don't have to run JSDoc repeatedly while cleaning up large projects. - Fix a bytes-vs-strings issue that crashed on versions of Python 3 before 3.6. (jhkennedy) - Tolerate JS files that have filename extensions other than ".js". Before, when combined with custom JSDoc configuration that ingested such files, incorrect object pathnames were generated, which led to spurious "No JSDoc documentation was found for object ..." errors. ### 2.0.1: (July 13th, 2017) - Fix spurious syntax errors while loading large JSDoc output by writing it to a temp file first. (jhkennedy) ### 2.0: (May 4th, 2017) - Deal with ambiguous object paths. Symbols with identical JSDoc longnames (such as two top-level things called "foo" in different files) will no longer have one shadow the other. Introduce an unambiguous path convention for referring to objects. Add a real parser to parse them rather than the dirty tricks we were using before. Backward compatibility breaks a little, because ambiguous references are now a fatal error, rather than quietly referring to the last definition JSDoc happened to encounter. - Index everything into a suffix tree so you can use any unique path suffix to refer to an object. - Other fallout of having a real parser: - Stop supporting "-" as a namepath separator. - No longer spuriously translate escaped separators in namepaths into dots. - Otherwise treat paths and escapes properly. For example, we can now handle symbols that contain "(". - Fix KeyError when trying to gather the constructor params of a plain old object labeled as a `@class`. ### 1.5.2: (March 22th, 2017) - Fix crash while warning that a specified longname isn't found. ### 1.5.1: (March 20th, 2017) - Sort `:members:` alphabetically when an order is not explicitly specified. ### 1.5: (March 17th, 2017) - Add `:members:` option to `autoclass`. - Add `:private-members:` and `:exclude-members:` options to go with it. - Significantly refactor to allow directive classes to talk to each other. ### 1.4: (March 10th, 2017) - Add `jsdoc_config_path` option. ### 1.3.1: (March 6th, 2017) - Tolerate @args and other info field lines that are wrapped in the source code. - Cite the file and line of the source comment in Sphinx-emitted warnings and errors. ### 1.3: (February 21st, 2017) - Add `autoattribute` directive. ### 1.2: (February 14th, 2017) - Always do full rebuilds; don't leave pages stale when JS code has changed but the RSTs have not. - Make Python-3-compatible. - Add basic `autoclass` directive. ### 1.1: (February 13th, 2017) - Add `:short-name:` option. ### 1.0: (February 7th, 2017) - Initial release, with just `js:autofunction` ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Code of Conduct ## Conduct We are committed to providing a friendly, safe and welcoming environment for all, regardless of level of experience, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other similar characteristic. - Please be kind and courteous. There’s no need to be mean or rude. - Please avoid using usernames that are overtly sexual or otherwise might detract from a friendly, safe, and welcoming environment for all. - Respect that people have differences of opinion and that every design or implementation choice carries trade-offs. There is seldom a single right answer. - We borrow the Recurse Center’s [“social rules”](https://www.recurse.com/manual#sub-sec-social-rules): no feigning surprise, no well-actually’s, no backseat driving, and no subtle -isms. - Please keep unstructured critique to a minimum. If you have solid ideas you want to experiment with, make a fork and see how it works. All feedback should be constructive in nature. If you need more detailed guidance around giving feedback, consult [Digital Ocean’s Code of Conduct](https://github.com/digitalocean/engineering-code-of-conduct#giving-and-receiving-feedback) - It is unacceptable to insult, demean, or harass anyone. We interpret the term “harassment” as defined in the [Citizen Code of Conduct](https://github.com/stumpsyn/policies/blob/master/citizen_code_of_conduct.md#4-unacceptable-behavior); if you are not sure about what harassment entails, please read their definition. In particular, we don’t tolerate behavior that excludes people in socially marginalized groups. - Private harassment is also unacceptable. No matter who you are, please contact any of the Pyodide core team members immediately if you are being harassed or made uncomfortable by a community member. Whether you are a regular contributor or a newcomer, we care about making this community a safe place for you and we’ve got your back. - Likewise spamming, trolling, flaming, baiting or other attention-stealing behavior is not welcome. ## Moderation These are the policies for upholding our community’s standards of conduct. If you feel that a thread needs moderation, please contact the Pyodide core team. 1. Remarks that violate the Pyodide standards of conduct are not allowed. This includes hateful, hurtful, oppressive, or exclusionary remarks. (Cursing is allowed, but never targeting another community member, and never in a hateful manner.) 2. Remarks that moderators find inappropriate are not allowed, even if they do not break a rule explicitly listed in the code of conduct. 3. Moderators will first respond to such remarks with a warning. 4. If the warning is unheeded, the offending community member will be temporarily banned. 5. If the community member comes back and continues to make trouble, they will be permanently banned. 6. Moderators may choose at their discretion to un-ban the community member if they offer the offended party a genuine apology. 7. If a moderator bans someone and you think it was unjustified, please take it up with that moderator, or with a different moderator, in private. Complaints about bans in-channel are not allowed. 8. Moderators are held to a higher standard than other community members. If a moderator creates an inappropriate situation, they should expect less leeway than others. 9. In the Pyodide community we strive to go the extra mile to look out for each other. Don’t just aim to be technically unimpeachable, try to be your best self. In particular, avoid flirting with offensive or sensitive issues, particularly if they’re off-topic; this all too often leads to unnecessary fights, hurt feelings, and damaged trust; worse, it can drive people away from the community entirely. 10. If someone takes issue with something you said or did, resist the urge to be defensive. Just stop doing what it was they complained about and apologize. Even if you feel you were misinterpreted or unfairly accused, chances are good there was something you could have communicated better — remember that it’s your responsibility to make your fellow Pyodide community members comfortable. Everyone wants to get along and we are all here first and foremost because we want to talk about science and cool technology. You will find that people will be eager to assume good intent and forgive as long as you earn their trust. 11. The enforcement policies listed above apply to all official Pyodide venues. If you wish to use this code of conduct for your own project, consider making a copy with your own moderation policy so as to avoid confusion. Adapted from the [Rust Code of Conduct](https://www.rust-lang.org/en-US/conduct.html), with further reference from [Digital Ocean Code of Conduct](https://github.com/digitalocean/engineering-code-of-conduct#giving-and-receiving-feedback), the [Recurse Center](https://www.recurse.com/code-of-conduct), the [Citizen Code of Conduct](http://citizencodeofconduct.org/), and the [Contributor Covenant](https://www.contributor-covenant.org/version/1/4/code-of-conduct.html). ================================================ FILE: CONTRIBUTORS ================================================ sphinx-js was originally written and maintained by Erik Rose and various contributors within and without the Mozilla Corporation and Foundation. It is now part of the Pyodide organization. It is currently maintained by Hood Chatham. Maintainer emeritus: * Will Kahn-Greene * Erik Rose * Lonnen Contributors: * Cristiano Santos * David Huggins-Daines * David Li * Fabien LOISON * Igor Loskutov * Jam Risser * Jared Dillard * Joseph H Kennedy * krassowski * mariusschenzle * Mike Cooper * Nicholas Bollweg * Nick Alexander * Patrick Browne * Paul Grau * Robert Helmer * Sebastian Weigand * Staś Małolepszy * Stefan 'hr' Berder * s-weigand * Tavian Barnes * Thomas Khyn * Wim Yedema ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2017 Mozilla Foundation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # sphinx-js ## Why When you write a JavaScript library, how do you explain it to people? If it's a small project in a domain your users are familiar with, JSDoc's alphabetical list of routines might suffice. But in a larger project, it is useful to intersperse prose with your API docs without having to copy and paste things. sphinx-js lets you use the industry-leading [Sphinx](https://sphinx-doc.org/) documentation tool with JS projects. It provides a handful of directives, patterned after the Python-centric [autodoc](https://www.sphinx-doc.org/en/latest/ext/autodoc.html) ones, for pulling JSDoc-formatted documentation into reStructuredText pages. And, because you can keep using JSDoc in your code, you remain compatible with the rest of your JS tooling, like Google's Closure Compiler. sphinx-js also works with TypeScript, using the TypeDoc tool in place of JSDoc and emitting all the type information you would expect. ## Setup 1. Install JSDoc (or TypeDoc if you're writing TypeScript): ```bash npm install jsdoc ``` or: ```bash npm install typedoc@0.28 ``` JSDoc 3.6.3 and 4.0.0 and TypeDoc 0.25--0.28 are known to work. 2. Install sphinx-js, which will pull in Sphinx itself as a dependency: ```bash pip install sphinx-js ``` 3. Make a documentation folder in your project by running `sphinx-quickstart` and answering its questions: ```bash cd my-project sphinx-quickstart ``` ``` Please enter values for the following settings (just press Enter to accept a default value, if one is given in brackets). Selected root path: . You have two options for placing the build directory for Sphinx output. Either, you use a directory "_build" within the root path, or you separate "source" and "build" directories within the root path. > Separate source and build directories (y/n) [n]: The project name will occur in several places in the built documentation. > Project name: My Project > Author name(s): Fred Fredson > Project release []: 1.0 If the documents are to be written in a language other than English, you can select a language here by its language code. Sphinx will then translate text that it generates into that language. For a list of supported codes, see https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-language. > Project language [en]: Selected root path: . You have two options for placing the build directory for Sphinx output. Either, you use a directory "_build" within the root path, or you separate "source" and "build" directories within the root path. > Separate source and build directories (y/n) [n]: The project name will occur in several places in the built documentation. > Project name: My Project > Author name(s): Fred Fredson > Project release []: 1.0 If the documents are to be written in a language other than English, you can select a language here by its language code. Sphinx will then translate text that it generates into that language. For a list of supported codes, see https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-language. > Project language [en]: ``` 4. In the generated Sphinx `conf.py` file, turn on `sphinx_js` by adding it to `extensions`: ```python extensions = ['sphinx_js'] ``` 5. If you want to document TypeScript, add: ```python js_language = 'typescript' ``` to `conf.py` as well. 6. If your JS source code is anywhere but at the root of your project, add: ```python js_source_path = '../somewhere/else' ``` on a line by itself in `conf.py`. The root of your JS source tree should be where that setting points, relative to the `conf.py` file. The default, `../`, works well when there is a `docs` folder at the root of your project and your source code lives directly inside the root. 7. If you have special JSDoc or TypeDoc configuration, add: ```python jsdoc_config_path = '../conf.json' ``` to `conf.py` as well. 8. If you're documenting only JS or TS and no other languages (like C), you can set your "primary domain" to JS in `conf.py`: ```python primary_domain = 'js' ``` The domain is `js` even if you're writing TypeScript. Then you can omit all the "js:" prefixes in the directives below. ## History sphinx-js was created in 2017 by Erik Rose at Mozilla. It was transferred from Mozilla to the Pyodide organization in 2025. ## Use In short, in a Sphinx project, use the following directives to pull in your JSDoc documentation, then tell Sphinx to render it all by running `make html` in your docs directory. If you have never used Sphinx or written reStructuredText before, here is [where we left off in its tutorial](https://www.sphinx-doc.org/en/stable/tutorial.html#defining-document-structure). For a quick start, just add things to index.rst until you prove things are working. ### autofunction First, document your JS code using standard JSDoc formatting: ```javascript /** * Return the ratio of the inline text length of the links in an element to * the inline text length of the entire element. * * @param {Node} node - Types or not: either works. * @throws {PartyError|Hearty} Multiple types work fine. * @returns {Number} Types and descriptions are both supported. */ function linkDensity(node) { const length = node.flavors.get("paragraphish").inlineLength; const lengthWithoutLinks = inlineTextLength( node.element, (element) => element.tagName !== "A", ); return (length - lengthWithoutLinks) / length; } ``` Then, reference your documentation using sphinx-js directives. Our directives work much like Sphinx's standard autodoc ones. You can specify just a function's name: ```rst .. js:autofunction:: someFunction ``` and a nicely formatted block of documentation will show up in your docs. You can also throw in your own explicit parameter list, if you want to note optional parameters: ```rst .. js:autofunction:: someFunction(foo, bar[, baz]) ``` Parameter properties and destructuring parameters also work fine, using [standard JSDoc syntax](https://jsdoc.app/tags-param.html#parameters-with-properties): ```javascript /** * Export an image from the given canvas and save it to the disk. * * @param {Object} options Output options * @param {string} options.format The output format (``jpeg``, ``png``, or * ``webp``) * @param {number} options.quality The output quality when format is * ``jpeg`` or ``webp`` (from ``0.00`` to ``1.00``) */ function saveCanvas({ format, quality }) { // ... } ``` Extraction of default parameter values works as well. These act as expected, with a few caveats: ```javascript /** * You must declare the params, even if you have nothing else to say, so * JSDoc will extract the default values. * * @param [num] * @param [str] * @param [bool] * @param [nil] */ function defaultsDocumentedInCode( num = 5, str = "true", bool = true, nil = null, ) {} /** * JSDoc guesses types for things like "42". If you have a string-typed * default value that looks like a number or boolean, you'll need to * specify its type explicitly. Conversely, if you have a more complex * value like an arrow function, specify a non-string type on it so it * isn't interpreted as a string. Finally, if you have a disjoint type like * {string|Array} specify string first if you want your default to be * interpreted as a string. * * @param {function} [func=() => 5] * @param [str=some string] * @param {string} [strNum=42] * @param {string|Array} [strBool=true] * @param [num=5] * @param [nil=null] */ function defaultsDocumentedInDoclet(func, strNum, strBool, num, nil) {} ``` You can even add additional content. If you do, it will appear just below any extracted documentation: ```rst .. js:autofunction:: someFunction Here are some things that will appear... * Below * The * Extracted * Docs Enjoy! ``` `js:autofunction` has one option, `:short-name:`, which comes in handy for chained APIs whose implementation details you want to keep out of sight. When you use it on a class method, the containing class won't be mentioned in the docs, the function will appear under its short name in indices, and cross references must use the short name as well (`:func:`someFunction``): ```rst .. js:autofunction:: someClass#someFunction :short-name: ``` `autofunction` can also be used on callbacks defined with the [@callback tag](https://jsdoc.app/tags-callback.html). There is experimental support for abusing `autofunction` to document [@typedef tags](https://jsdoc.app/tags-typedef.html) as well, though the result will be styled as a function, and `@property` tags will fall misleadingly under an "Arguments" heading. Still, it's better than nothing until we can do it properly. If you are using typedoc, it also is possible to destructure keyword arguments by using the `@destructure` tag: ```typescript /** * @param options * @destructure options */ function f({x , y } : { /** The x value */ x : number, /** The y value */ y : string }){ ... } ``` will be documented like: ``` options.x (number) The x value options.y (number) The y value ``` ### autoclass We provide a `js:autoclass` directive which documents a class with the concatenation of its class comment and its constructor comment. It shares all the features of `js:autofunction` and even takes the same `:short-name:` flag, which can come in handy for inner classes. The easiest way to use it is to invoke its `:members:` option, which automatically documents all your class's public methods and attributes: ```rst .. js:autoclass:: SomeEs6Class(constructor, args, if, you[, wish]) :members: ``` You can add private members by saying: ```rst .. js:autoclass:: SomeEs6Class :members: :private-members: ``` Privacy is determined by JSDoc `@private` tags or TypeScript's `private` keyword. Exclude certain members by name with `:exclude-members:`: ```rst .. js:autoclass:: SomeEs6Class :members: :exclude-members: Foo, bar, baz ``` Or explicitly list the members you want. We will respect your ordering. ```rst .. js:autoclass:: SomeEs6Class :members: Qux, qum ``` When explicitly listing members, you can include `*` to include all unmentioned members. This is useful to have control over ordering of some elements, without having to include an exhaustive list. ```rst .. js:autoclass:: SomeEs6Class :members: importMethod, *, uncommonlyUsedMethod ``` Finally, if you want full control, pull your class members in one at a time by embedding `js:autofunction` or `js:autoattribute`: ```rst .. js:autoclass:: SomeEs6Class .. js:autofunction:: SomeEs6Class#someMethod Additional content can go here and appears below the in-code comments, allowing you to intersperse long prose passages and examples that you don't want in your code. ``` ### autoattribute This is useful for documenting public properties: ```javascript class Fnode { constructor(element) { /** * The raw DOM element this wrapper describes */ this.element = element; } } ``` And then, in the docs: ```rst .. autoclass:: Fnode .. autoattribute:: Fnode#element ``` This is also the way to document ES6-style getters and setters, as it omits the trailing `()` of a function. The assumed practice is the usual JSDoc one: document only one of your getter/setter pair: ```javascript class Bing { /** The bong of the bing */ get bong() { return this._bong; } set bong(newBong) { this._bong = newBong * 2; } } ``` And then, in the docs: ```rst .. autoattribute:: Bing#bong ``` ### automodule This directive documents all exports on a module. For example: ```rst .. js:automodule:: package.submodule ``` ### autosummary This directive should be paired with an automodule directive (which may occur in a distinct rst file). It makes a summary table with links to the entries generated by the automodule directive. Usage: ```rst .. js:automodule:: package.submodule ``` ## Dodging Ambiguity With Pathnames If you have same-named objects in different files, use pathnames to disambiguate them. Here's a particularly long example: ```rst .. js:autofunction:: ./some/dir/some/file.SomeClass#someInstanceMethod.staticMethod~innerMember ``` You may recognize the separators `#.~` from [JSDoc namepaths](https://jsdoc.app/about-namepaths.html); they work the same here. For conciseness, you can use any unique suffix, as long as it consists of complete path segments. These would all be equivalent to the above, assuming they are unique within your source tree: ``` innerMember staticMethod~innerMember SomeClass#someInstanceMethod.staticMethod~innerMember some/file.SomeClass#someInstanceMethod.staticMethod~innerMember ``` Things to note: - We use simple file paths rather than JSDoc's `module:` prefix or TypeDoc's `external:` or `module:` ones. - We use simple backslash escaping exclusively rather than switching escaping schemes halfway through the path; JSDoc itself [is headed that way as well](https://github.com/jsdoc3/jsdoc/issues/876). The characters that need to be escaped are `#.~(/`, though you do not need to escape the dots in a leading `./` or `../`. A really horrible path might be: ``` some/path\ with\ spaces/file.topLevelObject#instanceMember.staticMember\(with\(parens ``` - Relative paths are relative to the `js_source_path` specified in the config. Absolute paths are not allowed. Behind the scenes, sphinx-js will change all separators to dots so that: - Sphinx's "shortening" syntax works: ":func:\`~InwardRhs.atMost\`" prints as merely`atMost()`. (For now, you should always use dots rather than other namepath separators: `#~`.) - Sphinx indexes more informatively, saying methods belong to their classes. ## Saving Keystrokes By Setting The Primary Domain To save some keystrokes, you can set: ```python primary_domain = 'js' ``` in `conf.py` and then use `autofunction` rather than `js:autofunction`. ## TypeScript: Getting Superclass and Interface Links To Work To have a class link to its superclasses and implemented interfaces, you'll need to document the superclass (or interface) somewhere using `js:autoclass` or `js:class` and use the class's full (but dotted) path when you do: ```rst .. js:autoclass:: someFile.SomeClass ``` Unfortunately, Sphinx's `~` syntax doesn't work in these spots, so users will see the full paths in the documentation. ## TypeScript: Cross references TypeScript types will be converted to cross references. To render cross references, you can define a hook in `conf.py` called `ts_type_xref_formatter`. It should take two arguments: the first argument is the sphinx confix, and the second is an `sphinx_js.ir.TypeXRef` object. This has a `name` field and two variants: - a `sphinx_js.ir.TypeXRefInternal` with fields `path` and `kind` - a `sphinx_js.ir.TypeXRefExternal` with fields `name`, `package`, `sourcefilename` and `qualifiedName` The return value should be restructured text that you wish to be inserted in place of the type. For example: ```python def ts_xref_formatter(config, xref): if isinstance(xref, TypeXRefInternal): name = rst.escape(xref.name) return f":js:{xref.kind}:`{name}`" else: # Otherwise, don't insert a xref return xref.name ``` ## Configuration Reference ### `js_language` Use 'javascript' or 'typescript' depending on the language you use. The default is 'javascript'. ### `js_source_path` A list of directories to scan (non-recursively) for JS or TS source files, relative to Sphinx's conf.py file. Can be a string instead if there is only one. If there is more than one, `root_for_relative_js_paths` must be specified as well. Defaults to `../`. ### `root_for_relative_js_paths` Relative JS entity paths are resolved relative to this path. Defaults to `js_source_path` if not present. ### `jsdoc_config_path` A conf.py-relative path to a JSDoc config file, which is useful if you want to specify your own JSDoc options, like recursion and custom filename matching. If using TypeDoc, you can also point to a `typedoc.json` file. ### `jsdoc_tsconfig_path` If using TypeDoc, specify the path of `tsconfig.json` file ### `ts_type_xref_formatter` A function for formatting TypeScript type cross references. See the "TypeScript: Cross references" section below. ### `ts_type_bold` Make all TypeScript types bold if `true`. ### `ts_sphinx_js_config` A link to a TypeScript config file. ## The `ts_sphinx_js_config` file This file should be a TypeScript module. It's executed in a context where it can import `typedoc` and `sphinx_js`. These functions take TypeDoc IR objects as arguments. Since the TypeDoc IR is unstable, this config may often break when switching TypeDoc versions. However, these hooks are very powerful so using them may be worthwhile anyways. This API is experimental and may change in the future. For an example, you can see Pyodide's config file [here](shouldDestructureArg). This file should export a config object with some of the three following functions: - `shouldDestructureArg: (param: ParameterReflection) => boolean` This function takes a `ParameterReflection` and decides if it should be destructured. If so, it's equivalent to putting a `@destructure` tag for the argument. For example: ```typescript function shouldDestructureArg(param: ParameterReflection) { return param.name === "options"; } ``` - `preConvert?: (app: Application) => Promise;` This hook is called with the TypeDoc application as argument before the TypeScript files are parsed. For example, it can be used to add extra TypeDoc plugins. - `postConvert: (app: Application, project: ProjectReflection, typeDocToIRMap: Map) => void` This hook is called after the sphinx_js IR is created. It can be used to modify the IR arbitrarily. It is very experimental and subject to breaking changes. For example, this `postConvert` hook removes the constructor from classes marked with `@hideconstructor`. ```typescript function postConvert(app, project, typeDocToIRMap) { for (const [key, value] of typeDocToIRMap.entries()) { if ( value.kind === "class" && value.modifier_tags.includes("@hideconstructor") ) { value.constructor_ = null; } } } ``` To use it, you'll also need to add a tag definition for `@hideconstructor` to your `tsdoc.json` file: ```json { "tagDefinitions": [ { "tagName": "@hideconstructor", "syntaxKind": "modifier" } ] } ``` This `postConvert` hook hides external attributes and functions from the documentation: ```typescript function postConvert(app, project, typeDocToIRMap) { for (const [key, value] of typeDocToIRMap.entries()) { if (value.kind === "attribute" || value.kind === "function") { value.is_private = key.flags.isExternal || key.flags.isPrivate; } } } ``` ## How sphinx-js finds typedoc / jsdoc 1. If the environment variable `SPHINX_JS_NODE_MODULES` is defined, it is expected to point to a `node_modules` folder in which typedoc / jsdoc is installed. 2. If `SPHINX_JS_NODE_MODULES` is not defined, we look in the directory of `conf.py` for a `node_modules` folder in which typedoc / jsdoc. If this is not found, we look for a `node_modules` folder in the parent directories until we make it to the root of the file system. 3. We check if `typedoc` / `jsdoc` are on the PATH, if so we use that. 4. If none of the previous approaches located `typedoc` / `jsdoc` we raise an error. ## Example A good example using most of sphinx-js's functionality is the Fathom documentation. A particularly juicy page is . Click the "View page source" link to see the raw directives. For a TypeScript example, see [the Pyodide api docs](https://pyodide.org/en/stable/usage/api/js-api.html). [ReadTheDocs](https://readthedocs.org/) is the canonical hosting platform for Sphinx docs and now supports sphinx-js. Put this in the `.readthedocs.yml` file at the root of your repo: ```yaml python: install: - requirements: docs/requirements.txt ``` Then put the version of sphinx-js you want in `docs/requirements.txt`. For example: ``` sphinx-js==3.1.2 ``` ## Caveats - We don't understand the inline JSDoc constructs like `{@link foo}`; you have to use Sphinx-style equivalents for now, like `:js:func:`foo`` (or simply `:func:`foo`` if you have set `primary_domain = 'js'` in conf.py. - So far, we understand and convert the JSDoc block tags `@param`, `@returns`, `@throws`, `@example` (without the optional ``), `@deprecated`, `@see`, and their synonyms. Other ones will go _poof_ into the ether. ## Tests Run the tests using nox, which will also install JSDoc and TypeDoc at pinned versions: ```bash pip install nox nox ``` ## Provenance sphinx-js was originally written and maintained by Erik Rose and various contributors within and without the Mozilla Corporation and Foundation. See `CONTRIBUTORS` for details. ================================================ FILE: biome.json ================================================ { "$schema": "https://biomejs.dev/schemas/2.1.1/schema.json", "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false }, "files": { "includes": [ "**/*", "!**/build/**", "!**/.mypy_cache/**", "!**/.vscode/**" ] }, "formatter": { "enabled": true, "formatWithErrors": false, "indentStyle": "space", "indentWidth": 2, "lineEnding": "lf", "lineWidth": 80, "attributePosition": "auto", "bracketSameLine": false, "bracketSpacing": true, "expand": "auto", "useEditorconfig": true }, "linter": { "enabled": false, "rules": { "recommended": true } }, "javascript": { "formatter": { "quoteProperties": "asNeeded", "trailingCommas": "all", "semicolons": "always", "arrowParentheses": "always", "bracketSameLine": false, "quoteStyle": "double", "attributePosition": "auto", "bracketSpacing": true } }, "html": { "formatter": { "selfCloseVoidElements": "always" } }, "assist": { "enabled": false, "actions": { "source": { "organizeImports": "on" } } } } ================================================ FILE: noxfile.py ================================================ from pathlib import Path from textwrap import dedent import nox from nox.sessions import Session PROJECT_ROOT = Path(__file__).parent @nox.session(python=["3.10", "3.11", "3.12", "3.13", "3.14"]) def tests(session: Session) -> None: session.install(".[test]") venvroot = Path(session.bin).parent (venvroot / "node_modules").mkdir() with session.chdir(venvroot): session.run( "npm", "i", "--no-save", "jsdoc@4.0.0", "typedoc@0.25", external=True ) session.run( "pytest", "--junitxml=test-results.xml", "--cov=sphinx_js", "--cov-report", "xml", ) def typecheck_ts(session: Session, typedoc: str) -> None: if typedoc == "0.26": # Upstream type errors here =( return # Typecheck with session.chdir("sphinx_js/js"): session.run("npm", "i", f"typedoc@{typedoc}", external=True) session.run("npm", "i", external=True) session.run("npx", "tsc", external=True) @nox.session(python=["3.12"]) @nox.parametrize("typedoc", ["0.25", "0.26", "0.27", "0.28"]) def test_typedoc(session: Session, typedoc: str) -> None: typecheck_ts(session, typedoc) # Install python dependencies session.install(".[test]") venvroot = Path(session.bin).parent node_modules = (venvroot / "node_modules").resolve() node_modules.mkdir() with session.chdir(venvroot): # Install node dependencies session.run( "npm", "i", "--no-save", "tsx", "jsdoc@4.0.0", f"typedoc@{typedoc}", external=True, ) # Run typescript tests test_file = (PROJECT_ROOT / "tests/test.ts").resolve() register_import_hook = PROJECT_ROOT / "sphinx_js/js/registerImportHook.mjs" ts_tests = Path(venvroot / "ts_tests") # Write script to a file so that it is easy to rerun without reinstalling dependencies. ts_tests.write_text( dedent( f"""\ #!/bin/sh npx typedoc --version TYPEDOC_NODE_MODULES={venvroot} node --import {register_import_hook} --import {node_modules / "tsx/dist/loader.mjs"} --test {test_file} """ ) ) ts_tests.chmod(0o777) session.run(ts_tests, external=True) # Run Python tests session.run("pytest", "--junitxml=test-results.xml", "-k", "not js") @nox.session(python=["3.12"]) def test_sphinx_6(session: Session) -> None: session.install("sphinx<7") session.install(".[test]") venvroot = Path(session.bin).parent (venvroot / "node_modules").mkdir() with session.chdir(venvroot): session.run( "npm", "i", "--no-save", "jsdoc@4.0.0", "typedoc@0.25", external=True ) session.run("pytest", "--junitxml=test-results.xml", "-k", "not js") ================================================ FILE: pyproject.toml ================================================ [build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [project] name = "sphinx-js" description = "Support for using Sphinx on JSDoc-documented JS code" readme = "README.md" license = {text = "MIT"} authors = [ {name = "Hood Chatham", email = "roberthoodchatham@gmail.com"}, ] requires-python = ">=3.10" dependencies = [ "attrs", "cattrs<25.1", "Jinja2>2.0", "markupsafe", "parsimonious>=0.10.0,<0.11.0", "Sphinx>=4.1.0", ] keywords = [ "docs", "documentation", "javascript", "js", "jsdoc", "restructured", "sphinx", "typedoc", "typescript", ] classifiers = [ "Framework :: Sphinx :: Domain", "Framework :: Sphinx :: Extension", "Intended Audience :: Developers", "Natural Language :: English", "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Topic :: Documentation :: Sphinx", "Topic :: Software Development :: Documentation", "Typing :: Typed", ] dynamic = ["version"] [project.urls] Homepage = "https://github.com/pyodide/sphinx-js" [project.optional-dependencies] test = [ "beautifulsoup4", "build", "defusedxml", "nox", "pytest-cov", "recommonmark", "twine", ] [tool.hatch.version] source = "vcs" [tool.hatch.build.targets.sdist] include = [ "/sphinx_js", "/tests", "/LICENSE", "/requirements_dev.txt", "/noxfile.py", "/README.md", ] [tool.hatch.build.targets.wheel] packages = ["sphinx_js"] [tool.mypy] python_version = "3.10" show_error_codes = true warn_unreachable = true enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] # Strict checks warn_unused_configs = true check_untyped_defs = true disallow_any_generics = true disallow_subclassing_any = true disallow_untyped_calls = true disallow_untyped_defs = true disallow_incomplete_defs = true disallow_untyped_decorators = true no_implicit_optional = true warn_redundant_casts = true warn_unused_ignores = true warn_return_any = true no_implicit_reexport = true strict_equality = true [[tool.mypy.overrides]] module = "sphinx_js.parsers" disallow_untyped_defs = false disallow_untyped_calls = false [tool.ruff] lint.select = [ "E", # pycodestyles "W", # pycodestyles "F", # pyflakes "B0", # bugbear (all B0* checks enabled by default) "B904", # bugbear (Within an except clause, raise exceptions with raise ... from err) "B905", # bugbear (zip() without an explicit strict= parameter set.) "UP", # pyupgrade "I", # isort "PGH", # pygrep-hooks ] lint.ignore = ["E402", "E501", "E731", "E741", "B904", "B020", "UP031"] target-version = "py310" ================================================ FILE: sphinx_js/__init__.py ================================================ from os.path import join, normpath from pathlib import Path from textwrap import dedent from typing import Any from sphinx.application import Sphinx from sphinx.errors import SphinxError from .directives import ( add_directives, ) from .jsdoc import Analyzer as JsAnalyzer from .typedoc import Analyzer as TsAnalyzer SPHINX_JS_CSS = "sphinx_js.css" def make_css_file(app: Sphinx) -> None: dst = Path(app.outdir) / "_static" / SPHINX_JS_CSS text = "" if app.config.ts_type_bold: text = dedent( """\ .sphinx_js-type { font-weight: bolder; } """ ) dst.write_text(text) def on_build_finished(app: Sphinx, exc: Exception | None) -> None: if exc or app.builder.format != "html": return make_css_file(app) def setup(app: Sphinx) -> None: app.setup_extension("sphinx.ext.autosummary") # I believe this is the best place to run jsdoc. I was tempted to use # app.add_source_parser(), but I think the kind of source it's referring to # is RSTs. app.connect("builder-inited", analyze) add_directives(app) # TODO: We could add a js:module with app.add_directive_to_domain(). app.add_config_value("js_language", default="javascript", rebuild="env") app.add_config_value( "js_source_path", default=["../"], rebuild="env", types=[str, list] ) # We could use a callable as the "default" param here, but then we would # have had to duplicate or build framework around the logic that promotes # js_source_path to a list and calls abspath() on it. It's simpler this way # until we need to access js_source_path from more than one place. app.add_config_value("root_for_relative_js_paths", None, "env") app.add_config_value("jsdoc_config_path", default=None, rebuild="env") app.add_config_value("jsdoc_tsconfig_path", default=None, rebuild="env") app.add_config_value("ts_type_xref_formatter", None, "env") app.add_config_value("ts_type_bold", False, "env") app.add_config_value("ts_sphinx_js_config", None, "env") app.add_css_file(SPHINX_JS_CSS) app.connect("build-finished", on_build_finished) def analyze(app: Sphinx) -> None: """Run JSDoc or another analysis tool across a whole codebase, and squirrel away its results in a language-specific Analyzer.""" # Normalize config values: source_paths = ( [app.config.js_source_path] if isinstance(app.config.js_source_path, str) else app.config.js_source_path ) abs_source_paths = [normpath(join(app.confdir, path)) for path in source_paths] root_for_relative_paths = root_or_fallback( normpath(join(app.confdir, app.config.root_for_relative_js_paths)) if app.config.root_for_relative_js_paths else None, abs_source_paths, ) # Pick analyzer: try: analyzer: Any = {"javascript": JsAnalyzer, "typescript": TsAnalyzer}[ app.config.js_language ] except KeyError: raise SphinxError( "Unsupported value of js_language in config: %s" % app.config.js_language ) # Analyze source code: app._sphinxjs_analyzer = analyzer.from_disk( # type:ignore[attr-defined] abs_source_paths, app, root_for_relative_paths ) def root_or_fallback( root_for_relative_paths: str | None, abs_source_paths: list[str] ) -> str: """Return the path that relative JS entity paths in the docs are relative to. Fall back to the sole JS source path if the setting is unspecified. :arg root_for_relative_paths: The absolute-ized root_for_relative_js_paths setting. None if the user hasn't specified it. :arg abs_source_paths: Absolute paths of dirs to scan for JS code """ if root_for_relative_paths: return root_for_relative_paths else: if len(abs_source_paths) > 1: raise SphinxError( "Since more than one js_source_path is specified in conf.py, root_for_relative_js_paths must also be specified. This allows paths beginning with ./ or ../ to be unambiguous." ) else: return abs_source_paths[0] ================================================ FILE: sphinx_js/analyzer_utils.py ================================================ """Conveniences shared among analyzers""" import os import shutil from collections.abc import Callable, Sequence from functools import cache, wraps from json import dump, load from pathlib import Path from typing import Any, ParamSpec, TypeVar from sphinx.errors import SphinxError def program_name_on_this_platform(program: str) -> str: """Return the name of the executable file on the current platform, given a command name with no extension.""" return program + ".cmd" if os.name == "nt" else program @cache def search_node_modules(cmdname: str, cmdpath: str, dir: str | Path) -> str: if "SPHINX_JS_NODE_MODULES" in os.environ: return str(Path(os.environ["SPHINX_JS_NODE_MODULES"]) / cmdpath) # We want to include "curdir" in parent_dirs, so add a garbage suffix parent_dirs = (Path(dir) / "garbage").parents # search for local install for base in parent_dirs: typedoc = base / "node_modules" / cmdpath if typedoc.is_file(): return str(typedoc.resolve()) # perhaps it's globally installed result = shutil.which(cmdname) if result: return str(Path(result).resolve()) raise SphinxError( f'{cmdname} was not found. Install it using "npm install {cmdname}".' ) class Command: def __init__(self, program: str): self.program = program_name_on_this_platform(program) self.args: list[str] = [] def add(self, *args: str) -> None: self.args.extend(args) def make(self) -> list[str]: return [self.program] + self.args T = TypeVar("T") P = ParamSpec("P") def cache_to_file( get_filename: Callable[..., str | None], ) -> Callable[[Callable[P, T]], Callable[P, T]]: """Return a decorator that will cache the result of ``get_filename()`` to a file :arg get_filename: A function which receives the original arguments of the decorated function """ def decorator(fn: Callable[P, T]) -> Callable[P, T]: @wraps(fn) def decorated(*args: P.args, **kwargs: P.kwargs) -> Any: filename = get_filename(*args, **kwargs) if filename and os.path.isfile(filename): with open(filename, encoding="utf-8") as f: return load(f) res = fn(*args, **kwargs) if filename: with open(filename, "w", encoding="utf-8") as f: dump(res, f, indent=2) return res return decorated return decorator def is_explicitly_rooted(path: str) -> bool: """Return whether a relative path is explicitly rooted relative to the cwd, rather than starting off immediately with a file or folder name. It's nice to have paths start with "./" (or "../", "../../", etc.) so, if a user is that explicit, we still find the path in the suffix tree. """ return path.startswith(("../", "./")) or path in ("..", ".") def dotted_path(segments: Sequence[str]) -> str: """Convert a JS object path (``['dir/', 'file/', 'class#', 'instanceMethod']``) to a dotted style that Sphinx will better index. Strip off any leading relative-dir segments (./ or ../) because they lead to invalid paths like ".....foo". Ultimately, we should thread ``base_dir`` into this and construct a full path based on that. """ if not segments: return "" segments_without_separators = [ s[:-1] for s in segments[:-1] if s not in ["./", "../"] ] segments_without_separators.append(segments[-1]) return ".".join(segments_without_separators) ================================================ FILE: sphinx_js/directives.py ================================================ """These are the actual Sphinx directives we provide, but they are skeletal. The real meat is in their parallel renderer classes, in renderers.py. The split is due to the unfortunate trick we need here of having functions return the directive classes after providing them the ``app`` symbol, where we store the JSDoc output, via closure. The renderer classes, able to be top-level classes, can access each other and collaborate. """ import re from collections.abc import Iterable from functools import cache from os.path import join, relpath from typing import Any, cast from docutils import nodes from docutils.nodes import Node from docutils.parsers.rst import Directive from docutils.parsers.rst import Parser as RstParser from docutils.parsers.rst.directives import flag from sphinx import addnodes from sphinx.addnodes import desc_signature from sphinx.application import Sphinx from sphinx.domains import ObjType, javascript from sphinx.domains.javascript import ( JavaScriptDomain, JSCallable, JSConstructor, JSObject, JSXRefRole, ) from sphinx.locale import _ from sphinx.util.docfields import GroupedField, TypedField from sphinx.writers.html5 import HTML5Translator from sphinx.writers.latex import LaTeXTranslator from sphinx.writers.text import TextTranslator from .renderers import ( AutoAttributeRenderer, AutoClassRenderer, AutoFunctionRenderer, AutoModuleRenderer, AutoSummaryRenderer, Renderer, new_document_from_parent, ) def unescape(escaped: str) -> str: # For some reason the string we get has a bunch of null bytes in it?? # Remove them... escaped = escaped.replace("\x00", "") # For some reason the extra slash before spaces gets lost between the .rst # source and when this directive is called. So don't replace "\" => # "" return re.sub(r"\\([^ ])", r"\1", escaped) def _members_to_exclude(arg: str | None) -> set[str]: """Return a set of members to exclude given a comma-delim list of them. Exclude none if none are passed. This differs from autodocs' behavior, which excludes all. That seemed useless to me. """ return set(a.strip() for a in (arg or "").split(",")) def sphinx_js_type_role( # type: ignore[no-untyped-def] role, rawtext, text, lineno, inliner, options=None, content=None, ): """ The body should be escaped rst. This renders its body as rst and wraps the result in """ unescaped = unescape(text) parent_doc = inliner.document source = parent_doc.get("source", "") # Get line number stored by new_document_from_parent in rst_nodes if we can # find it, otherwise use lineno of directive. line = getattr(parent_doc, "sphinx_js_source_line", None) or lineno doc = new_document_from_parent(source, parent_doc) # Prepend newlines so errors report correct line number padded = "\n" * (line - 1) + unescaped RstParser().parse(padded, doc) n = nodes.inline(text) n["classes"].append("sphinx_js-type") n += doc.children[0].children return [n], [] class JSXrefMixin: def make_xref( self, rolename: Any, domain: Any, target: Any, innernode: Any = nodes.emphasis, contnode: Any = None, env: Any = None, inliner: Any = None, location: Any = None, ) -> Any: # Set inliner to None just like the PythonXrefMixin does so the # xref doesn't get rendered as a function. return super().make_xref( # type:ignore[misc] rolename, domain, target, innernode, contnode, env, inliner=None, location=None, ) class JSTypedField(JSXrefMixin, TypedField): pass class JSGroupedField(JSXrefMixin, GroupedField): pass # Cache this to guarantee it only runs once. @cache def fix_js_make_xref() -> None: """Monkeypatch to fix sphinx.domains.javascript TypedField and GroupedField Fixes https://github.com/sphinx-doc/sphinx/issues/11021 """ # Replace javascript module javascript.TypedField = JSTypedField # type:ignore[attr-defined] javascript.GroupedField = JSGroupedField # type:ignore[attr-defined] # Fix the one place TypedField and GroupedField are used in the javascript # module javascript.JSCallable.doc_field_types = [ JSTypedField( "arguments", label=_("Arguments"), names=("argument", "arg", "parameter", "param"), typerolename="func", typenames=("paramtype", "type"), ), JSGroupedField( "errors", label=_("Throws"), rolename="func", names=("throws",), can_collapse=True, ), ] + javascript.JSCallable.doc_field_types[2:] # Cache this to guarantee it only runs once. @cache def fix_staticfunction_objtype() -> None: """Override js:function directive with one that understands static and async prefixes """ JavaScriptDomain.directives["function"] = JSFunction @cache def add_type_param_field_to_directives() -> None: typeparam_field = JSGroupedField( "typeparam", label="Type parameters", rolename="func", names=("typeparam",), can_collapse=True, ) JSCallable.doc_field_types.insert(0, typeparam_field) JSConstructor.doc_field_types.insert(0, typeparam_field) class JsDirective(Directive): """Abstract directive which knows how to pull things out of our IR""" has_content = True required_arguments = 1 optional_arguments = 0 final_argument_whitespace = True option_spec = {"short-name": flag} def _run(self, renderer_class: type[Renderer], app: Sphinx) -> list[Node]: renderer = renderer_class.from_directive(self, app) note_dependencies(app, renderer.dependencies()) return renderer.rst_nodes() class JsDirectiveWithChildren(JsDirective): option_spec = JsDirective.option_spec.copy() option_spec.update( { "members": lambda members: ( [m.strip() for m in members.split(",")] if members else [] ), "exclude-members": _members_to_exclude, "private-members": flag, } ) def note_dependencies(app: Sphinx, dependencies: Iterable[str]) -> None: """Note dependencies of current document. :arg app: Sphinx application object :arg dependencies: iterable of filename strings relative to root_for_relative_paths """ for fn in dependencies: # Dependencies in the IR are relative to `root_for_relative_paths`, itself # relative to the configuration directory. analyzer = app._sphinxjs_analyzer # type:ignore[attr-defined] abs = join(analyzer._base_dir, fn) # Sphinx dependencies are relative to the source directory. rel = relpath(abs, app.srcdir) app.env.note_dependency(rel) def auto_function_directive_bound_to_app(app: Sphinx) -> type[Directive]: class AutoFunctionDirective(JsDirective): """js:autofunction directive, which spits out a js:function directive Takes a single argument which is a JS function name combined with an optional formal parameter list, all mashed together in a single string. """ def run(self) -> list[Node]: return self._run(AutoFunctionRenderer, app) return AutoFunctionDirective def auto_class_directive_bound_to_app(app: Sphinx) -> type[Directive]: class AutoClassDirective(JsDirectiveWithChildren): """js:autoclass directive, which spits out a js:class directive Takes a single argument which is a JS class name combined with an optional formal parameter list for the constructor, all mashed together in a single string. """ def run(self) -> list[Node]: return self._run(AutoClassRenderer, app) return AutoClassDirective def auto_attribute_directive_bound_to_app(app: Sphinx) -> type[Directive]: class AutoAttributeDirective(JsDirective): """js:autoattribute directive, which spits out a js:attribute directive Takes a single argument which is a JS attribute name. """ def run(self) -> list[Node]: return self._run(AutoAttributeRenderer, app) return AutoAttributeDirective class desc_js_type_parameter_list(nodes.Part, nodes.Inline, nodes.FixedTextElement): """Node for a javascript type parameter list. Unlike normal parameter lists, we use angle braces <> as the braces. Based on sphinx.addnodes.desc_type_parameter_list """ child_text_separator = ", " def astext(self) -> str: return f"<{nodes.FixedTextElement.astext(self)}>" def html5_visit_desc_js_type_parameter_list( self: HTML5Translator, node: nodes.Element ) -> None: """Define the html/text rendering for desc_js_type_parameter_list. Based on sphinx.writers.html5.visit_desc_type_parameter_list """ if hasattr(self, "_visit_sig_parameter_list"): # Sphinx 7 return self._visit_sig_parameter_list(node, addnodes.desc_parameter, "<", ">") # Sphinx <7 self.body.append('<') self.first_param = 1 # type:ignore[attr-defined] self.optional_param_level = 0 # How many required parameters are left. self.required_params_left = sum( [isinstance(c, addnodes.desc_parameter) for c in node.children] ) self.param_separator = node.child_text_separator def html5_depart_desc_js_type_parameter_list( self: HTML5Translator, node: nodes.Element ) -> None: """Define the html/text rendering for desc_js_type_parameter_list. Based on sphinx.writers.html5.depart_desc_type_parameter_list """ if hasattr(self, "_depart_sig_parameter_list"): # Sphinx 7 return self._depart_sig_parameter_list(node) # Sphinx <7 self.body.append('>') def text_visit_desc_js_type_parameter_list( self: TextTranslator, node: nodes.Element ) -> None: if hasattr(self, "_visit_sig_parameter_list"): # Sphinx 7 return self._visit_sig_parameter_list(node, addnodes.desc_parameter, "<", ">") # Sphinx <7 self.add_text("<") self.first_param = 1 # type:ignore[attr-defined] def text_depart_desc_js_type_parameter_list( self: TextTranslator, node: nodes.Element ) -> None: if hasattr(self, "_depart_sig_parameter_list"): # Sphinx 7 return self._depart_sig_parameter_list(node) # Sphinx <7 self.add_text(">") def latex_visit_desc_type_parameter_list( self: LaTeXTranslator, node: nodes.Element ) -> None: pass def latex_depart_desc_type_parameter_list( self: LaTeXTranslator, node: nodes.Element ) -> None: pass def add_param_list_to_signode(signode: desc_signature, params: str) -> None: paramlist = desc_js_type_parameter_list() for arg in params.split(","): paramlist += addnodes.desc_parameter("", "", addnodes.desc_sig_name(arg, arg)) signode += paramlist def handle_typeparams_for_signature( self: JSObject, sig: str, signode: desc_signature, *, keep_callsig: bool ) -> tuple[str, str]: """Generic function to handle type params in the sig line for interfaces, classes, and functions. For interfaces and classes we don't prefer the look with parentheses so we also remove them (by setting keep_callsig to False). """ typeparams = None if "<" in sig and ">" in sig: base, _, rest = sig.partition("<") typeparams, _, params = rest.partition(">") sig = base + params res = JSCallable.handle_signature(cast(JSCallable, self), sig, signode) sig = sig.strip() lastchild = None # Check for call signature, if present take it off if signode.children[-1].astext().endswith(")"): lastchild = signode.children[-1] signode.remove(lastchild) if typeparams: add_param_list_to_signode(signode, typeparams) # if we took off a call signature and we want to keep it put it back. if keep_callsig and lastchild: signode += lastchild return res class JSFunction(JSCallable): """Variant of JSCallable that can take static/async prefixes""" option_spec = { **JSCallable.option_spec, "static": flag, "async": flag, } def get_display_prefix( self, ) -> list[Any]: result = [] for name in ["static", "async"]: if name in self.options: result.extend( [ addnodes.desc_sig_keyword(name, name), addnodes.desc_sig_space(), ] ) return result def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]: return handle_typeparams_for_signature(self, sig, signode, keep_callsig=True) class JSInterface(JSCallable): """An interface directive. Based on sphinx.domains.javascript.JSConstructor. """ allow_nesting = True def get_display_prefix(self) -> list[Node]: return [ addnodes.desc_sig_keyword("interface", "interface"), addnodes.desc_sig_space(), ] def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]: return handle_typeparams_for_signature(self, sig, signode, keep_callsig=False) class JSTypeAlias(JSObject): doc_field_types = [ JSGroupedField( "typeparam", label="Type parameters", names=("typeparam",), can_collapse=True, ) ] def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]: return handle_typeparams_for_signature(self, sig, signode, keep_callsig=False) class JSClass(JSConstructor): def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]: return handle_typeparams_for_signature(self, sig, signode, keep_callsig=True) @cache def patch_JsObject_get_index_text() -> None: """Add our additional object types to the index""" orig_get_index_text = JSObject.get_index_text def patched_get_index_text( self: JSObject, objectname: str, name_obj: tuple[str, str] ) -> str: name, obj = name_obj if self.objtype == "interface": return _("%s() (interface)") % name return orig_get_index_text(self, objectname, name_obj) JSObject.get_index_text = patched_get_index_text # type:ignore[method-assign] def auto_module_directive_bound_to_app(app: Sphinx) -> type[Directive]: class AutoModuleDirective(JsDirectiveWithChildren): required_arguments = 1 def run(self) -> list[Node]: return self._run(AutoModuleRenderer, app) return AutoModuleDirective def auto_summary_directive_bound_to_app(app: Sphinx) -> type[Directive]: class JsDocSummary(JsDirective): required_arguments = 1 def run(self) -> list[Node]: return self._run(AutoSummaryRenderer, app) return JsDocSummary def add_directives(app: Sphinx) -> None: fix_js_make_xref() fix_staticfunction_objtype() add_type_param_field_to_directives() patch_JsObject_get_index_text() app.add_role("sphinx_js_type", sphinx_js_type_role) app.add_directive_to_domain( "js", "autofunction", auto_function_directive_bound_to_app(app) ) app.add_directive_to_domain( "js", "autoclass", auto_class_directive_bound_to_app(app) ) app.add_directive_to_domain( "js", "autoattribute", auto_attribute_directive_bound_to_app(app) ) app.add_directive_to_domain( "js", "automodule", auto_module_directive_bound_to_app(app) ) app.add_directive_to_domain( "js", "autosummary", auto_summary_directive_bound_to_app(app) ) app.add_directive_to_domain("js", "class", JSClass) app.add_role_to_domain("js", "class", JSXRefRole()) JavaScriptDomain.object_types["interface"] = ObjType(_("interface"), "interface") app.add_directive_to_domain("js", "interface", JSInterface) app.add_role_to_domain("js", "interface", JSXRefRole()) JavaScriptDomain.object_types["typealias"] = ObjType(_("type alias"), "typealias") app.add_directive_to_domain("js", "typealias", JSTypeAlias) app.add_role_to_domain("js", "typealias", JSXRefRole()) app.add_node( desc_js_type_parameter_list, html=( html5_visit_desc_js_type_parameter_list, html5_depart_desc_js_type_parameter_list, ), text=( text_visit_desc_js_type_parameter_list, text_depart_desc_js_type_parameter_list, ), latex=( latex_visit_desc_type_parameter_list, latex_depart_desc_type_parameter_list, ), ) ================================================ FILE: sphinx_js/ir.py ================================================ """Intermediate representation that JS and TypeScript are transformed to for use by the rest of sphinx-js This results from my former inability to review any but the most trivial TypeScript PRs due to jsdoc's JSON output format being undocumented, often surprising, and occasionally changing. This IR is not intended to be a lossless representation of either jsdoc's or typedoc's output. Nor is it meant to generalize to other uses like static analysis. Ideally, it provides the minimum information necessary to render our Sphinx templates about JS and TS entities. Any expansion or generalization of the IR should be driven by needs of those templates and the (minimal) logic around them. The complexity of doing otherwise has no payback. I was conflicted about introducing an additional representation, since a multiplicity of representations incurs conversion complexity costs at a superlinear rate. However, I think it is essential complexity here. The potentially simpler approach would have been to let the RST template vars required by our handful of directives be the IR. However, we still would have wanted to factor out formatting like the joining of types with "|" and the unwrapping of comments, making another representation inevitable. Therefore, let's at least have a well-documented one and one slightly more likely to survive template changes. This has to match js/ir.ts """ from collections.abc import Callable, Sequence from typing import Any, Literal, ParamSpec, TypeVar import cattrs from attrs import Factory, define, field from .analyzer_utils import dotted_path @define class TypeXRefIntrinsic: name: str type: Literal["intrinsic"] = "intrinsic" @define class TypeXRefInternal: name: str path: list[str] type: Literal["internal"] = "internal" kind: str | None = None @define class TypeXRefExternal: name: str package: str # TODO: use snake case for these like for everything else sourcefilename: str | None qualifiedName: str | None type: Literal["external"] = "external" TypeXRef = TypeXRefExternal | TypeXRefInternal | TypeXRefIntrinsic @define class DescriptionName: text: str type: Literal["name"] = "name" @define class DescriptionText: text: str type: Literal["text"] = "text" @define class DescriptionCode: code: str type: Literal["code"] = "code" DescriptionItem = DescriptionName | DescriptionText | DescriptionCode Description = str | Sequence[DescriptionItem] #: Human-readable type of a value. None if we don't know the type. Type = str | list[str | TypeXRef] | None class Pathname: """A partial or full path to a language entity. Example: ``['./', 'dir/', 'dir/', 'file.', 'object.', 'object#', 'object']`` """ def __init__(self, segments: Sequence[str]): self.segments = segments def __str__(self) -> str: return "".join(self.segments) def __repr__(self) -> str: return "Pathname(%r)" % self.segments def __eq__(self, other: Any) -> bool: return isinstance(other, self.__class__) and self.segments == other.segments def dotted(self) -> str: return dotted_path(self.segments) @define class _NoDefault: """A conspicuous no-default value that will show up in templates to help troubleshoot code paths that grab ``Param.default`` without checking ``Param.has_default`` first.""" _no_default: bool = True def __repr__(self) -> str: return "NO_DEFAULT" NO_DEFAULT = _NoDefault() @define(slots=False) class _Member: """An IR object that is a member of another, as a method is a member of a class or interface""" #: Whether this member is required to be provided by a subclass of a class #: or implementor of an interface is_abstract: bool #: Whether this member is optional in the TypeScript sense of being allowed #: on but not required of an object to conform to a type is_optional: bool #: Whether this member can be accessed on the container itself rather than #: just on instances of it is_static: bool #: Is a private member of a class or, at least in JSDoc, a @namespace: is_private: bool @define class TypeParam: name: str extends: Type description: Description = "" @define class Param: """A parameter of either a function or (in the case of TS, which has classes parametrized by type) a class.""" name: str #: The description text (like all other description fields in the IR) #: retains any line breaks and subsequent indentation whitespace that were #: in the source code. description: Description = "" has_default: bool = False is_variadic: bool = False type: Type | None = None #: Return the default value of this parameter, string-formatted so it can #: be immediately suffixed to an equal sign in a formal param list. For #: example, the number 6 becomes the string "6" to create ``foo=6``. If # : has_default=True, this must be set. default: str | _NoDefault = NO_DEFAULT def __attrs_post_init__(self) -> None: if self.has_default and self.default is NO_DEFAULT: raise ValueError( "Tried to construct a Param with has_default=True but without `default` specified." ) @define class Exc: """One kind of exception that can be raised by a function""" #: The type of exception can have type: Type description: Description @define class Return: """One kind of thing a function can return""" #: The type this kind of return value can have type: Type description: Description @define class Module: filename: str deppath: str | None path: Pathname line: int attributes: list["TopLevel"] = Factory(list) functions: list["Function"] = Factory(list) classes: list["Class"] = Factory(list) interfaces: list["Interface"] = Factory(list) type_aliases: list["TypeAlias"] = Factory(list) @define(slots=False) class TopLevel: """A language object with an independent existence A TopLevel entity is a potentially strong entity in the database sense; one of these can exist on its own and not merely as a datum attached to another entity. For example, Returns do not qualify, since they cannot exist without a parent Function. And, though a given Attribute may be attached to a Class, Attributes can also exist top-level in a module. These are also complex entities: the sorts of thing with the potential to include the kinds of subentities referenced by the fields defined herein. """ #: The short name of the object, regardless of whether it's a class or #: function or typedef or param. #: #: This is usually the same as the last item of path.segments but not #: always. For example, in JSDoc Attributes defined with @property, name is #: defined but path is empty. This was a shortcut and could be corrected at #: some point. If it is, we can stop storing name as a separate field. Also #: TypeScript constructors are named "new WhateverClass". They should #: instead be called "constructor". name: str #: The namepath-like unambiguous identifier of the object, e.g. ``['./', #: 'dir/', 'dir/', 'file/', 'object.', 'object#', 'object']`` path: Pathname #: The basename of the file the object is from, e.g. "foo.js" filename: str #: The path to the dependency, i.e., the file the object is from. #: Either absolute or relative to the root_for_relative_js_paths. deppath: str | None #: The human-readable description of the entity or '' if absent description: Description modifier_tags: list[str] = field(kw_only=True, factory=list) block_tags: dict[str, Sequence[Description]] = field(kw_only=True, factory=dict) #: Line number where the object (excluding any prefixing comment) begins line: int | None #: Explanation of the deprecation (which implies True) or True or False deprecated: Description | bool #: List of preformatted textual examples examples: Sequence[Description] #: List of paths to also refer the reader to see_alsos: list[str] #: Explicitly documented sub-properties of the object, a la jsdoc's #: @properties properties: list["Attribute"] #: None if not exported for use by outside code. Otherwise, the Sphinx #: dotted path to the module it is exported from, e.g. 'foo.bar' exported_from: Pathname | None #: Descriminator kind: str = field(kw_only=True) #: Is it a root documentation item? Used by autosummary. documentation_root: bool = field(kw_only=True, default=False) @define(slots=False) class Attribute(TopLevel, _Member): """A property of an object These are called attributes to match up with Sphinx's autoattribute directive which is used to display them. """ #: The type this property's value can have type: Type readonly: bool = False kind: Literal["attribute"] = "attribute" @define class Function(TopLevel, _Member): """A function or a method of a class""" is_async: bool params: list[Param] exceptions: list[Exc] returns: list[Return] type_params: list[TypeParam] = Factory(list) kind: Literal["function"] = "function" @define class _MembersAndSupers: """An IR object that can contain members and extend other types""" #: Class members, concretized ahead of time for simplicity. (Otherwise, #: we'd have to pass the doclets_by_class map in and keep it around, along #: with a callable that would create the member IRs from it on demand.) #: Does not include the default constructor. members: list[Function | Attribute] #: Objects this one extends: for example, superclasses of a class or #: superinterfaces of an interface supers: list[Type] @define class Interface(TopLevel, _MembersAndSupers): """An interface, a la TypeScript""" type_params: list[TypeParam] = Factory(list) kind: Literal["interface"] = "interface" @define class Class(TopLevel, _MembersAndSupers): #: The default constructor for this class. Absent if the constructor is #: inherited. constructor_: Function | None #: Whether this is an abstract class is_abstract: bool #: Interfaces this class implements interfaces: list[Type] # There's room here for additional fields like @example on the class doclet # itself. These are supported and extracted by jsdoc, but they end up in an # `undocumented: True` doclet and so are presently filtered out. But we do # have the space to include them someday. type_params: list[TypeParam] = Factory(list) kind: Literal["class"] = "class" @define class TypeAlias(TopLevel): type: Type type_params: list[TypeParam] = Factory(list) kind: Literal["typeAlias"] = "typeAlias" TopLevelUnion = Class | Interface | Function | Attribute | TypeAlias # Now make a serializer/deserializer. # TODO: Add tests to make sure that serialization and deserialization are a # round trip. def json_to_ir(json: Any) -> list[TopLevelUnion]: """Structure raw json into a list of TopLevels""" return converter.structure(json, list[TopLevelUnion]) converter = cattrs.Converter() # We just serialize Pathname as a list converter.register_unstructure_hook(Pathname, lambda x: x.segments) converter.register_structure_hook(Pathname, lambda x, _: Pathname(x)) # Nothing else needs custom serialization. Add a decorator to register custom # deserializers for the various unions. P = ParamSpec("P") T = TypeVar("T") def _structure(*types: Any) -> Callable[[Callable[P, T]], Callable[P, T]]: def dec(func: Callable[P, T]) -> Callable[P, T]: for ty in types: converter.register_structure_hook(ty, func) return func return dec @_structure(Description, Description | bool) def structure_description(x: Any, _: Any) -> Description | bool: if isinstance(x, str): return x if isinstance(x, bool): return x return converter.structure(x, list[DescriptionItem]) def get_type_literal(t: type[DescriptionText]) -> str: """Take the "blah" from the type annotation in type: Literal["blah"] """ return t.__annotations__["type"].__args__[0] # type:ignore[no-any-return] description_type_map = { get_type_literal(t): t for t in [DescriptionName, DescriptionText, DescriptionCode] } @_structure(DescriptionItem) def structure_description_item(x: Any, _: Any) -> DescriptionItem: # Look up the expected type of x from the value of x["type"] return converter.structure(x, description_type_map[x["type"]]) @_structure(Type) def structure_type(x: Any, _: Any) -> Type: if isinstance(x, str) or x is None: return x return converter.structure(x, list[str | TypeXRef]) @_structure(str | TypeXRef) def structure_str_or_xref(x: Any, _: Any) -> Type: if isinstance(x, str): return x return converter.structure(x, TypeXRef) # type:ignore[arg-type] @_structure(str | _NoDefault) def structure_str_or_nodefault(x: Any, _: Any) -> str | _NoDefault: if isinstance(x, str): return x return NO_DEFAULT ================================================ FILE: sphinx_js/js/cli.ts ================================================ import { Application, ArgumentsReader, TypeDocReader, PackageJsonReader, TSConfigReader, ProjectReflection, } from "typedoc"; import { Converter } from "./convertTopLevel.ts"; import { SphinxJsConfig } from "./sphinxJsConfig.ts"; import { fileURLToPath } from "url"; import { redirectPrivateTypes } from "./redirectPrivateAliases.ts"; import { TopLevelIR } from "./ir.ts"; const ExitCodes = { Ok: 0, OptionError: 1, CompileError: 3, ValidationError: 4, OutputError: 5, ExceptionThrown: 6, Watching: 7, }; export class ExitError extends Error { code: number; constructor(code: number) { super(); this.code = code; } } async function bootstrapAppTypedoc0_25(args: string[]): Promise { return await Application.bootstrapWithPlugins( { plugin: [fileURLToPath(import.meta.resolve("./typedocPlugin.ts"))], }, [ new ArgumentsReader(0, args), new TypeDocReader(), new PackageJsonReader(), new TSConfigReader(), new ArgumentsReader(300, args), ], ); } async function makeApp(args: string[]): Promise { // Most of this stuff is copied from typedoc/src/lib/cli.ts let app = await bootstrapAppTypedoc0_25(args); if (app.options.getValue("version")) { console.log(app.toString()); throw new ExitError(ExitCodes.Ok); } app.extraData = {}; app.options.getValue("modifierTags").push("@hidetype", "@omitFromAutoModule"); app.options.getValue("blockTags").push("@destructure", "@summaryLink"); return app; } async function loadConfig( configPath: string | undefined, ): Promise { if (!configPath) { return {}; } const configModule = await import(configPath); return configModule.config; } async function typedocConvert(app: Application): Promise { // Most of this stuff is copied from typedoc/src/lib/cli.ts const project = await app.convert(); if (!project) { throw new ExitError(ExitCodes.CompileError); } const preValidationWarnCount = app.logger.warningCount; app.validate(project); const hadValidationWarnings = app.logger.warningCount !== preValidationWarnCount; if (app.logger.hasErrors()) { throw new ExitError(ExitCodes.ValidationError); } if ( hadValidationWarnings && (app.options.getValue("treatWarningsAsErrors") || app.options.getValue("treatValidationWarningsAsErrors")) ) { throw new ExitError(ExitCodes.ValidationError); } return project; } export async function run( args: string[], ): Promise<[Application, TopLevelIR[]]> { let app = await makeApp(args); const userConfigPath = app.options.getValue("sphinxJsConfig"); const config = await loadConfig(userConfigPath); app.logger.info(`Loaded user config from ${userConfigPath}`); const symbolToType = redirectPrivateTypes(app); await config.preConvert?.(app); const project = await typedocConvert(app); const basePath = app.options.getValue("basePath"); const converter = new Converter(project, basePath, config, symbolToType); converter.computePaths(); const result = converter.convertAll(); await config.postConvert?.(app, project, converter.typedocToIRMap); return [app, result]; } ================================================ FILE: sphinx_js/js/convertTopLevel.ts ================================================ import { Comment, CommentDisplayPart, DeclarationReflection, ParameterReflection, ProjectReflection, ReferenceType, ReflectionKind, ReflectionVisitor, SignatureReflection, SomeType, TypeContext, TypeParameterReflection, } from "typedoc"; import { referenceToXRef, convertType, convertTypeLiteral, } from "./convertType.ts"; import { NO_DEFAULT, Attribute, Class, Description, DescriptionItem, Interface, IRFunction, Member, Param, Pathname, Return, TopLevelIR, TopLevel, Type, TypeParam, } from "./ir.ts"; import { sep, relative } from "path"; import { SphinxJsConfig } from "./sphinxJsConfig.ts"; import { ReadonlySymbolToType } from "./redirectPrivateAliases.ts"; export function parseFilePath(path: string, base_dir: string): string[] { // First we want to know if path is under base_dir. // Get directions from base_dir to the path const rel = relative(base_dir, path); let pathSegments: string[]; if (!rel.startsWith("..")) { // We don't have to go up so path is under base_dir pathSegments = rel.split(sep); } else { // It's not under base_dir... maybe it's in a global node_modules or // something? This makes it look the same as if it were under a local // node_modules. pathSegments = path.split(sep); pathSegments.reverse(); const idx = pathSegments.indexOf("node_modules"); if (idx !== -1) { pathSegments = pathSegments.slice(0, idx + 1); } pathSegments.reverse(); } // Remove the file suffix from the last entry if it exists. If there is no ., // then this will leave it alone. let lastEntry = pathSegments.pop(); if (lastEntry !== undefined) { pathSegments.push(lastEntry.slice(0, lastEntry.lastIndexOf("."))); } // Add a . to the start and a / after every entry so that if we join the // entries it looks like the correct relative path. // Hopefully it was actually a relative path of some sort... pathSegments.unshift("."); for (let i = 0; i < pathSegments.length - 1; i++) { pathSegments[i] += "/"; } return pathSegments; } /** * We currently replace {a : () => void} with {a() => void}. "a" is a reflection * with a TypeLiteral type kind, and the type has name "__type". We don't want * this to appear in the docs so we have to check for it and remove it. */ function isAnonymousTypeLiteral( refl: DeclarationReflection | SignatureReflection, ): boolean { return refl.kindOf(ReflectionKind.TypeLiteral) && refl.name === "__type"; } /** * A ReflectionVisitor that computes the path for each reflection for us. * * We want to compute the paths for both DeclarationReflections and * SignatureReflections. */ class PathComputer implements ReflectionVisitor { readonly basePath: string; // The maps we're trying to fill in. readonly pathMap: Map; readonly filePathMap: Map< DeclarationReflection | SignatureReflection, Pathname >; // Record which reflections are documentation roots. Used in sphinx for // automodule and autosummary directives. readonly documentationRoots: Set; // State for the visitor parentKind: ReflectionKind | undefined; parentSegments: string[]; filePath: string[]; constructor( basePath: string, pathMap: Map, filePathMap: Map, documentationRoots: Set, ) { this.pathMap = pathMap; this.filePathMap = filePathMap; this.basePath = basePath; this.documentationRoots = documentationRoots; this.parentKind = undefined; this.parentSegments = []; this.filePath = []; } /** * If the name of the reflection is supposed to be a symbol, it should look * something like [Symbol.iterator] but typedoc just shows it as [iterator]. * Downstream lexers to color the docs split on dots, but we don't want that * because here the dot is part of the name. Instead, we add a dot lookalike. */ static fixSymbolName(refl: DeclarationReflection | SignatureReflection) { const SYMBOL_PREFIX = "[Symbol\u2024"; if (refl.name.startsWith("[") && !refl.name.startsWith(SYMBOL_PREFIX)) { // Probably a symbol (are there other reasons the name would start with "["?) // \u2024 looks like a period but is not a period. // This isn't ideal, but otherwise the coloring is weird. refl.name = SYMBOL_PREFIX + refl.name.slice(1); } } /** * The main logic for this visitor. static for easier readability. */ static computePath( refl: DeclarationReflection | SignatureReflection, parentKind: ReflectionKind, parentSegments: string[], filePath: string[], ): Pathname { // If no parentSegments, this is a "root", use the file path as the // parentSegments. // We have to copy the segments because we're going to mutate it. const segments = Array.from( parentSegments.length > 0 ? parentSegments : filePath, ); // Skip some redundant names const suppressReflName = refl.kindOf( // Module names are redundant with the file path ReflectionKind.Module | // Signature names are redundant with the callable. TODO: do we want to // handle callables with multiple signatures? ReflectionKind.ConstructorSignature | ReflectionKind.CallSignature, ) || isAnonymousTypeLiteral(refl); if (suppressReflName) { return segments; } if (segments.length > 0) { // Add delimiter. For most things use a . e.g., parent.name but for // nonstatic class members we write Class#member const delimiter = parentKind === ReflectionKind.Class && !refl.flags.isStatic ? "#" : "."; segments[segments.length - 1] += delimiter; } // Add the name of the current reflection to the list segments.push(refl.name); return segments; } setPath(refl: DeclarationReflection | SignatureReflection): Pathname { PathComputer.fixSymbolName(refl); const segments = PathComputer.computePath( refl, this.parentKind!, this.parentSegments, this.filePath, ); if (isAnonymousTypeLiteral(refl)) { // Rename the anonymous type literal to share its name with the attribute // it is the type of. refl.name = this.parentSegments.at(-1)!; } this.pathMap.set(refl, segments); this.filePathMap.set(refl, this.filePath); return segments; } // The visitor methods project(project: ProjectReflection) { // Compute the set of documentation roots. // This consists of all children of the Project and all children of Modules. for (const child of project.children || []) { this.documentationRoots.add(child); } for (const module of project.getChildrenByKind(ReflectionKind.Module)) { for (const child of module.children || []) { this.documentationRoots.add(child); } } // Visit children project.children?.forEach((x) => x.visit(this)); } declaration(refl: DeclarationReflection) { if (refl.sources) { this.filePath = parseFilePath(refl.sources![0].fileName, this.basePath); } const segments = this.setPath(refl); // Update state for children const origParentSegs = this.parentSegments; const origParentKind = this.parentKind; this.parentSegments = segments; this.parentKind = refl.kind; // Visit children refl.children?.forEach((child) => child.visit(this)); refl.signatures?.forEach((child) => child.visit(this)); if ( refl.kind === ReflectionKind.Property && refl.type?.type == "reflection" ) { // If the property has a function type, we replace it with a function // described by the declaration. Just in case that happens we compute the // path for the declaration here. refl.type.declaration.visit(this); } // Restore state this.parentSegments = origParentSegs; this.parentKind = origParentKind; } signature(refl: SignatureReflection) { this.setPath(refl); } } // Some utilities for manipulating comments /** * Convert CommentDisplayParts from typedoc IR to sphinx-js comment IR. * @param content List of CommentDisplayPart * @returns */ function renderCommentContent(content: CommentDisplayPart[]): Description { return content.map((x): DescriptionItem => { if (x.kind === "code") { return { type: "code", code: x.text }; } if (x.kind === "text") { return { type: "text", text: x.text }; } throw new Error("Not implemented"); }); } function renderCommentSummary(c: Comment | undefined): Description { if (!c) { return []; } return renderCommentContent(c.summary); } /** * Compute a map from blockTagName to list of comment descriptions. */ function getCommentBlockTags(c: Comment | undefined): { [key: string]: Description[]; } { if (!c) { return {}; } const result: { [key: string]: Description[] } = {}; for (const tag of c.blockTags) { const tagType = tag.tag.slice(1); if (!(tagType in result)) { result[tagType] = []; } const content: Description = []; if (tag.name) { // If the tag has a name field, add it as a DescriptionName content.push({ type: "name", text: tag.name, }); } content.push(...renderCommentContent(tag.content)); result[tagType].push(content); } return result; } /** * The type returned by most methods on the converter. * * A pair, an optional TopLevel and an optional list of additional reflections * to convert. */ type ConvertResult = [ TopLevelIR | undefined, DeclarationReflection[] | undefined, ]; /** * We generate some "synthetic parameters" when destructuring parameters. It * would be possible to convert directly to our IR but it causes some code * duplication. Instead, we keep track of the subset of fields that `paramToIR` * actually needs here. */ type ParamReflSubset = Pick< ParameterReflection, "comment" | "defaultValue" | "flags" | "name" | "type" >; /** * Main class for creating IR from the ProjectReflection. * * The main toIr logic is a sort of visitor for ReflectionKinds. We don't use * ReflectionVisitor because the division it uses for visitor methods is too * coarse. * * We visit in a breadth-first order, not for any super compelling reason. */ export class Converter { readonly project: ProjectReflection; readonly basePath: string; readonly config: SphinxJsConfig; readonly symbolToType: ReadonlySymbolToType; readonly pathMap: Map; readonly filePathMap: Map< DeclarationReflection | SignatureReflection, Pathname >; readonly documentationRoots: Set; readonly typedocToIRMap: Map; constructor( project: ProjectReflection, basePath: string, config: SphinxJsConfig, symbolToType: ReadonlySymbolToType, ) { this.project = project; this.basePath = basePath; this.config = config; this.symbolToType = symbolToType; this.pathMap = new Map(); this.filePathMap = new Map(); this.documentationRoots = new Set(); this.typedocToIRMap = new Map(); } convertType(type: SomeType, context: TypeContext = TypeContext.none): Type { return convertType( this.basePath, this.pathMap, this.symbolToType, type, context, ); } referenceToXRef(type: ReferenceType): Type { return referenceToXRef( this.basePath, this.pathMap, this.symbolToType, type, ); } computePaths() { this.project.visit( new PathComputer( this.basePath, this.pathMap, this.filePathMap, this.documentationRoots, ), ); } /** * Convert all Reflections. */ convertAll(): TopLevelIR[] { const todo = Array.from(this.project.children!); const result: TopLevelIR[] = []; while (todo.length) { const node = todo.pop()!; const [converted, rest] = this.toIr(node); if (converted) { this.typedocToIRMap.set(node, converted); result.push(converted); } todo.push(...(rest || [])); } return result; } /** * Convert the reflection and return a pair, the conversion result and a list * of descendent Reflections to convert. These descendents are either children * or signatures. * * @param object The reflection to convert * @returns A pair, a possible result IR object, and a list of descendent * Reflections that still need converting. */ toIr(object: DeclarationReflection | SignatureReflection): ConvertResult { // ReflectionKinds that we give no conversion. if ( object.kindOf( ReflectionKind.Module | ReflectionKind.Namespace | // TODO: document enums ReflectionKind.Enum | ReflectionKind.EnumMember | // A ReferenceReflection is when we reexport something. // TODO: should we handle this somehow? ReflectionKind.Reference, ) ) { // TODO: The children of these have no rendered parent in the docs. If // "object" is marked as a documentation_root, maybe the children should // be too? return [undefined, (object as DeclarationReflection).children]; } const kind = ReflectionKind[object.kind]; const convertFunc = `convert${kind}` as keyof this; if (!this[convertFunc]) { throw new Error(`No known converter for kind ${kind}`); } // @ts-ignore const result: ConvertResult = this[convertFunc](object); if (this.documentationRoots.has(object) && result[0]) { result[0].documentation_root = true; } return result; } // Reflection visitor methods convertFunction(func: DeclarationReflection): ConvertResult { return [this.functionToIR(func), func.children]; } convertMethod(func: DeclarationReflection): ConvertResult { return [this.functionToIR(func), func.children]; } convertConstructor(func: DeclarationReflection): ConvertResult { return [this.functionToIR(func), func.children]; } convertVariable(v: DeclarationReflection): ConvertResult { if (!v.type) { throw new Error(`Type of ${v.name} is undefined`); } let type: Type; if (v.comment?.modifierTags.has("@hidetype")) { type = []; } else { type = this.convertType(v.type); } const result: Attribute = { ...this.memberProps(v), ...this.topLevelProperties(v), readonly: false, kind: "attribute", type, }; return [result, v.children]; } /** * Return the unambiguous pathnames of implemented interfaces or extended * classes. */ relatedTypes( cls: DeclarationReflection, kind: "extendedTypes" | "implementedTypes", ): Type[] { const origTypes = cls[kind] || []; const result: Type[] = []; for (const t of origTypes) { if (t.type !== "reference") { continue; } result.push(this.referenceToXRef(t)); } return result; } convertClass(cls: DeclarationReflection): ConvertResult { const [constructor_, members] = this.constructorAndMembers(cls); const result: Class = { constructor_, members, supers: this.relatedTypes(cls, "extendedTypes"), is_abstract: cls.flags.isAbstract, interfaces: this.relatedTypes(cls, "implementedTypes"), type_params: this.typeParamsToIR(cls.typeParameters), ...this.topLevelProperties(cls), kind: "class", }; return [result, cls.children]; } convertInterface(cls: DeclarationReflection): ConvertResult { const [_, members] = this.constructorAndMembers(cls); const result: Interface = { members, supers: this.relatedTypes(cls, "extendedTypes"), type_params: this.typeParamsToIR(cls.typeParameters), ...this.topLevelProperties(cls), kind: "interface", }; return [result, cls.children]; } convertProperty(prop: DeclarationReflection): ConvertResult { if ( prop.type?.type === "reflection" && prop.type.declaration.kindOf(ReflectionKind.TypeLiteral) && prop.type.declaration.signatures?.length ) { // Render {f: () => void} like {f(): void} // TODO: unclear if this is the right behavior. Maybe there should be a // way to pick? const functionIR = this.functionToIR(prop.type.declaration); // Preserve the property's own documentation if it exists functionIR.description = renderCommentSummary(prop.comment); // Preserve the optional flag from the original property functionIR.is_optional = prop.flags.isOptional; return [functionIR, []]; } let type: Type; if (prop.comment?.modifierTags.has("@hidetype")) { // We should probably also be able to hide the type of a thing with a // function type literal type... type = []; } else { type = this.convertType(prop.type!); } const result: Attribute = { type, ...this.memberProps(prop), ...this.topLevelProperties(prop), description: renderCommentSummary(prop.comment), readonly: prop.flags.isReadonly, kind: "attribute", }; return [result, prop.children]; } /** * An Accessor is a thing with a getter or a setter. It should look exactly * like a Property in the rendered docs since the distinction is an * implementation detail. * * Specifically: * 1. an Accessor with a getter but no setter should be rendered as a readonly * Property. * 2. an Accessor with a getter and a setter should be rendered as a * read/write Property * 3. Not really sure what to do with an Accessor with a setter and no getter. * That's kind of weird. */ convertAccessor(prop: DeclarationReflection): ConvertResult { let type: SomeType; let sig: SignatureReflection; if (prop.getSignature) { // There's no signature to speak of for a getter: only a return type. sig = prop.getSignature; type = sig.type!; } else { if (!prop.setSignature) { throw new Error("???"); } // ES6 says setters have exactly 1 param. sig = prop.setSignature; type = sig.parameters![0].type!; } // If there's no setter say it's readonly const readonly = !prop.setSignature; const result: Attribute = { type: this.convertType(type), readonly, ...this.memberProps(prop), ...this.topLevelProperties(prop), kind: "attribute", }; result.description = renderCommentSummary(sig.comment); return [result, prop.children]; } convertClassChild(child: DeclarationReflection): IRFunction | Attribute { if ( !child.kindOf( ReflectionKind.Accessor | ReflectionKind.Constructor | ReflectionKind.Method | ReflectionKind.Property, ) ) { throw new TypeError( "Expected an Accessor, Constructor, Method, or Property", ); } // Should we assert that the "descendants" component is empty? return this.toIr(child)[0] as IRFunction | Attribute; } /** * Return the IR for the constructor and other members of a class or * interface. * * In TS, a constructor may have multiple (overloaded) type signatures but * only one implementation. (Same with functions.) So there's at most 1 * constructor to return. Return None for the constructor if it is inherited * or implied rather than explicitly present in the class. * * @param refl Class or Interface * @returns A tuple of (constructor Function, list of other members) */ constructorAndMembers( refl: DeclarationReflection, ): [IRFunction | null, (IRFunction | Attribute)[]] { let constructor: IRFunction | null = null; const members: (IRFunction | Attribute)[] = []; for (const child of refl.children || []) { if (child.inheritedFrom) { continue; } if (child.kindOf(ReflectionKind.Constructor)) { // This really, really should happen exactly once per class. constructor = this.functionToIR(child); constructor.returns = []; continue; } members.push(this.convertClassChild(child)); } return [constructor, members]; } /** * Compute common properties for all class members. */ memberProps(refl: DeclarationReflection): Member { return { is_abstract: refl.flags.isAbstract, is_optional: refl.flags.isOptional, is_static: refl.flags.isStatic, is_private: refl.flags.isPrivate, }; } /** * Compute common properties for all TopLevels. */ topLevelProperties( refl: DeclarationReflection | SignatureReflection, ): TopLevel { const path = this.pathMap.get(refl); const filePath = this.filePathMap.get(refl)!; if (!path) { throw new Error(`Missing path for ${refl.name}`); } const block_tags = getCommentBlockTags(refl.comment); let deprecated: Description | boolean = block_tags["deprecated"]?.[0] || false; if (deprecated && deprecated.length === 0) { deprecated = true; } return { name: refl.name, path, deppath: filePath.join(""), filename: "", description: renderCommentSummary(refl.comment), modifier_tags: Array.from(refl.comment?.modifierTags || []), block_tags, deprecated, examples: block_tags["example"] || [], properties: [], see_alsos: [], exported_from: filePath, line: refl.sources?.[0].line || null, documentation_root: false, }; } /** * We want to document a destructured argument as if it were several separate * arguments. This finds complex inline object types in the arguments list of * a function and "destructures" them into separately documented arguments. * * E.g., a function * * /** * * @param options * * @destructure options * *./ * function f({x , y } : { * /** The x value *./ * x : number, * /** The y value *./ * y : string * }){ ... } * * should be documented like: * * options.x (number) The x value * options.y (number) The y value */ _destructureParam(param: ParameterReflection): ParamReflSubset[] { const type = param.type; if (type?.type !== "reflection") { throw new Error("Unexpected"); } const decl = type.declaration; const children = decl.children!; // Sort destructured parameter by order in the type declaration in the // source file. Before we sort they are in alphabetical order by name. Maybe // we should have a way to pick the desired behavior? There are three // reasonable orders: // // 1. alphabetical by name // 2. In order of the @options.b annotations // 3. In order of their declarations in the type // // This does order 3 children.sort( ({ sources: a }, { sources: b }) => a![0].line - b![0].line || a![0].character - b![0].character, ); const result: ParamReflSubset[] = []; for (const child of children) { result.push({ name: param.name + "." + child.name, type: child.type, comment: child.comment, defaultValue: undefined, flags: child.flags, }); } return result; } _destructureParams(sig: SignatureReflection): ParamReflSubset[] { const result = []; // Destructure a parameter if it's type is a reflection and it is requested // with @destructure or _shouldDestructureArg. const destructureTargets = sig.comment ?.getTags("@destructure") .flatMap((tag) => tag.content[0].text.split(" ")); const shouldDestructure = (p: ParameterReflection) => { if (p.type?.type !== "reflection") { return false; } if (destructureTargets?.includes(p.name)) { return true; } const shouldDestructure = this.config.shouldDestructureArg; return shouldDestructure && shouldDestructure(p); }; for (const p of sig.parameters || []) { if (shouldDestructure(p)) { result.push(...this._destructureParam(p)); } else { result.push(p); } } return result; } /** * Convert a signature parameter */ paramToIR(param: ParamReflSubset): Param { let type: Type = []; if (param.type) { type = this.convertType(param.type); } let description = renderCommentSummary(param.comment); if (description.length === 0 && param.type?.type === "reflection") { // If the parameter type is given as the typeof something else, use the // description from the target? // TODO: isn't this a weird thing to do here? I think we should remove it? description = renderCommentSummary( param.type.declaration?.signatures?.[0].comment, ); } return { name: param.name, has_default: !!param.defaultValue, default: param.defaultValue || NO_DEFAULT, is_variadic: param.flags.isRest, description, type, }; } /** * Convert callables: Function, Method, and Constructor. * @param func * @returns */ functionToIR(func: DeclarationReflection): IRFunction { // There's really nothing in the function itself; all the interesting bits // are in the 'signatures' property. We support only the first signature at // the moment, because to do otherwise would create multiple identical // pathnames to the same function, which would cause the suffix tree to // raise an exception while being built. An eventual solution might be to // store the signatures in a one-to- many attr of Functions. const first_sig = func.signatures![0]; // Should always have at least one // Make sure name matches, can be different in case this comes from // isAnonymousTypeLiteral returning true. first_sig.name = func.name; const params = this._destructureParams(first_sig); let returns: Return[] = []; let is_async = false; // We want to suppress the return type for constructors (it's technically // correct that it returns a class instance but it looks weird). // Also hide explicit void return type. const voidReturnType = func.kindOf(ReflectionKind.Constructor) || !first_sig.type || (first_sig.type.type === "intrinsic" && first_sig.type.name === "void"); let type_params = this.typeParamsToIR(first_sig.typeParameters); if (func.kindOf(ReflectionKind.Constructor)) { // I think this is wrong // TODO: remove it type_params = this.typeParamsToIR( (func.parent as DeclarationReflection).typeParameters, ); } const topLevel = this.topLevelProperties(first_sig); if (!voidReturnType && first_sig.type) { // Compute return comment and return annotation. const returnType = this.convertType(first_sig.type); const description = topLevel.block_tags.returns?.[0] || []; returns = [{ type: returnType, description }]; // Put async in front of the function if it returns a Promise. // Question: Is there any important difference between an actual async // function and a non-async one that returns a Promise? is_async = first_sig.type.type === "reference" && first_sig.type.name === "Promise"; } return { ...topLevel, ...this.memberProps(func), is_async, params: params?.map(this.paramToIR.bind(this)) || [], type_params, returns, exceptions: [], kind: "function", }; } typeParamsToIR( typeParams: TypeParameterReflection[] | undefined, ): TypeParam[] { return typeParams?.map((typeParam) => this.typeParamToIR(typeParam)) || []; } typeParamToIR(typeParam: TypeParameterReflection): TypeParam { const extends_ = typeParam.type ? this.convertType(typeParam.type, TypeContext.referenceTypeArgument) : null; return { name: typeParam.name, extends: extends_, description: renderCommentSummary(typeParam.comment), }; } convertTypeAlias(ty: DeclarationReflection): ConvertResult { let type; if (ty.type) { type = this.convertType(ty.type); } else { // Handle this change: // https://github.com/TypeStrong/typedoc/commit/ca94f7eaecf90c25d6377e20c405626817de1e26#diff-14759d25b74ca53aee4558d0e26c85eee3c13484ea3ccdf28872b906829ef6f8R380-R390 type = convertTypeLiteral( this.basePath, this.pathMap, this.symbolToType, ty, ); } const ir: TopLevelIR = { ...this.topLevelProperties(ty), kind: "typeAlias", type, type_params: this.typeParamsToIR(ty.typeParameters), }; return [ir, ty.children]; } } ================================================ FILE: sphinx_js/js/convertType.ts ================================================ import { ArrayType, ConditionalType, DeclarationReflection, IndexedAccessType, InferredType, IntersectionType, IntrinsicType, LiteralType, MappedType, NamedTupleMember, OptionalType, PredicateType, QueryType, ReferenceType, ReflectionKind, ReflectionType, RestType, SignatureReflection, SomeType, TemplateLiteralType, TupleType, TypeContext, TypeOperatorType, TypeVisitor, UnionType, UnknownType, } from "typedoc"; import { Type, TypeXRefExternal, TypeXRefInternal, intrinsicType, } from "./ir.js"; import { parseFilePath } from "./convertTopLevel.js"; import { ReadonlySymbolToType } from "./redirectPrivateAliases.js"; /** * Convert types into a list of strings and XRefs. * * Most visitor nodes should be similar to the implementation of getTypeString * on the same type. */ class TypeConverter implements TypeVisitor { private readonly basePath: string; // For resolving XRefs. private readonly reflToPath: ReadonlyMap< DeclarationReflection | SignatureReflection, string[] >; private readonly symbolToType: ReadonlySymbolToType; constructor( basePath: string, reflToPath: ReadonlyMap< DeclarationReflection | SignatureReflection, string[] >, symbolToType: ReadonlySymbolToType, ) { this.basePath = basePath; this.reflToPath = reflToPath; this.symbolToType = symbolToType; } /** * Helper for inserting type parameters */ addTypeArguments( type: { typeArguments?: SomeType[] | undefined }, l: Type, ): Type { if (!type.typeArguments || type.typeArguments.length === 0) { return l; } l.push("<"); for (const arg of type.typeArguments) { l.push(...arg.visit(this)); l.push(", "); } l.pop(); l.push(">"); return l; } /** * Convert the type, maybe add parentheses */ convert(type: SomeType, context: TypeContext): Type { const result = type.visit(this); if (type.needsParenthesis(context)) { result.unshift("("); result.push(")"); } return result; } conditional(type: ConditionalType): Type { return [ ...this.convert(type.checkType, TypeContext.conditionalCheck), " extends ", ...this.convert(type.extendsType, TypeContext.conditionalExtends), " ? ", ...this.convert(type.trueType, TypeContext.conditionalTrue), " : ", ...this.convert(type.falseType, TypeContext.conditionalFalse), ]; } indexedAccess(type: IndexedAccessType): Type { return [ ...this.convert(type.objectType, TypeContext.indexedObject), "[", ...this.convert(type.indexType, TypeContext.indexedIndex), "]", ]; } inferred(type: InferredType): Type { if (type.constraint) { return [ `infer ${type.name} extends `, ...this.convert(type.constraint, TypeContext.inferredConstraint), ]; } return [`infer ${type.name}`]; } intersection(type: IntersectionType): Type { const result: Type = []; for (const elt of type.types) { result.push(...this.convert(elt, TypeContext.intersectionElement)); result.push(" & "); } result.pop(); return result; } intrinsic(type: IntrinsicType): Type { return [intrinsicType(type.name)]; } literal(type: LiteralType): Type { if (type.value === null) { return [intrinsicType("null")]; } return [JSON.stringify(type.value)]; } mapped(type: MappedType): Type { const read = { "+": "readonly ", "-": "-readonly ", "": "", }[type.readonlyModifier ?? ""]; const opt = { "+": "?", "-": "-?", "": "", }[type.optionalModifier ?? ""]; const parts: Type = [ "{ ", read, "[", type.parameter, " in ", ...this.convert(type.parameterType, TypeContext.mappedParameter), ]; if (type.nameType) { parts.push( " as ", ...this.convert(type.nameType, TypeContext.mappedName), ); } parts.push( "]", opt, ": ", ...this.convert(type.templateType, TypeContext.mappedTemplate), " }", ); return parts; } optional(type: OptionalType): Type { return [ ...this.convert(type.elementType, TypeContext.optionalElement), "?", ]; } predicate(type: PredicateType): Type { // Consider using typedoc's representation for this instead of this custom // string. return [ intrinsicType("boolean"), " (typeguard for ", ...type.targetType!.visit(this), ")", ]; } query(type: QueryType): Type { return [ "typeof ", ...this.convert(type.queryType, TypeContext.queryTypeTarget), ]; } /** * If it's a reference to a private type alias, replace it with a reflection. * Otherwise return undefined. */ convertPrivateReferenceToReflection(type: ReferenceType): Type | undefined { if (type.reflection) { const refl = type.reflection as DeclarationReflection; // If it's private, we don't really want to emit an XRef to it. In the // typedocPlugin.ts we tried to calculate Reflections for these, so now // we try to look it up. I couldn't get the line+column numbers to match // up so in this case we index on file name and reference name. // Another place where we incorrectly handle merged declarations const src = refl?.sources?.[0]; if (!src) { return undefined; } const newTarget = this.symbolToType.get( `${src.fullFileName}:${refl.name}`, ); if (newTarget) { // TODO: this doesn't handle parentheses correctly. return newTarget.visit(this); } return undefined; } if (!type.symbolId) { throw new Error("This should not happen"); } // See if this refers to a private type. In that case we should inline the // type reflection rather than referring to the non-exported name. Ideally // we should key on position rather than name (the same file can have // multiple private types with the same name potentially). But it doesn't // seem to be working. const newTarget = this.symbolToType.get( `${type.symbolId.fileName}:${type.name}`, ); if (newTarget) { // TODO: this doesn't handle parentheses correctly. return newTarget.visit(this); } return undefined; } /** * Convert a reference type to either an XRefExternal or an XRefInternal. It * works on things that `convertPrivateReferenceToReflection` but it will * throw an error if the type `isIntentionallyBroken`. * * This logic is also used for relatedTypes for classes (extends and * implements). * TODO: handle type arguments in extends and implements. */ convertReferenceToXRef(type: ReferenceType): Type { if (type.isIntentionallyBroken()) { throw new Error("Bad type"); } if (type.reflection) { const path = this.reflToPath.get( type.reflection as DeclarationReflection, ); if (!path) { throw new Error( `Broken internal xref to ${type.reflection?.toStringHierarchy()}`, ); } const xref: TypeXRefInternal = { name: type.name, path, type: "internal", }; return this.addTypeArguments(type, [xref]); } if (!type.symbolId) { throw new Error("This shouldn't happen"); } const path = parseFilePath(type.symbolId?.fileName ?? "", this.basePath); if (path.includes("node_modules/")) { // External reference const xref: TypeXRefExternal = { name: type.name, package: type.package!, qualifiedName: type.symbolId.qualifiedName || null, sourcefilename: type.symbolId.fileName || null, type: "external", }; return this.addTypeArguments(type, [xref]); } else { // TODO: I'm not sure that it's right to generate an internal xref here. // We need better test coverage for this code path. const xref: TypeXRefInternal = { name: type.name, path, type: "internal", }; return this.addTypeArguments(type, [xref]); } } reference(type: ReferenceType): Type { // if we got a reflection use that. It's not all that clear how to deal // with type arguments here though... const res = this.convertPrivateReferenceToReflection(type); if (res) { return res; } if (type.isIntentionallyBroken()) { // If it's intentionally broken, don't add an xref. It's probably a type // parameter. return this.addTypeArguments(type, [type.name]); } else { return this.convertReferenceToXRef(type); } } reflection(type: ReflectionType): Type { if (type.declaration.kindOf(ReflectionKind.TypeLiteral)) { return this.convertTypeLiteral(type.declaration); } if (type.declaration.kindOf(ReflectionKind.Constructor)) { const result = this.convertSignature(type.declaration.signatures![0]); result.unshift("{new "); result.push("}"); return result; } if (type.declaration.kindOf(ReflectionKind.FunctionOrMethod)) { return this.convertSignature(type.declaration.signatures![0]); } throw new Error("Not implemented"); } rest(type: RestType): Type { return ["...", ...this.convert(type.elementType, TypeContext.restElement)]; } templateLiteral(type: TemplateLiteralType): Type { return [ "`", type.head, ...type.tail.flatMap(([type, text]) => { return [ "${", ...this.convert(type, TypeContext.templateLiteralElement), "}", text, ]; }), "`", ]; } tuple(type: TupleType): Type { const result: Type = []; for (const elt of type.elements) { result.push(...this.convert(elt, TypeContext.tupleElement)); result.push(", "); } result.pop(); result.unshift("["); result.push("]"); return result; } namedTupleMember(type: NamedTupleMember): Type { const result: Type = [`${type.name}${type.isOptional ? "?" : ""}: `]; result.push(...this.convert(type.element, TypeContext.tupleElement)); return result; } typeOperator(type: TypeOperatorType): Type { return [ type.operator, " ", ...this.convert(type.target, TypeContext.typeOperatorTarget), ]; } union(type: UnionType): Type { const result: Type = []; for (const elt of type.types) { result.push(...this.convert(elt, TypeContext.unionElement)); result.push(" | "); } result.pop(); return result; } unknown(type: UnknownType): Type { // I'm not sure how we get here: generally nobody explicitly annotates // unknown, maybe it's inferred sometimes? return [type.name]; } array(t: ArrayType): Type { const res = this.convert(t.elementType, TypeContext.arrayElement); res.push("[]"); return res; } convertSignature(sig: SignatureReflection): Type { const result: Type = ["("]; for (const param of sig.parameters || []) { result.push(param.name + ": "); result.push(...(param.type?.visit(this) || [])); result.push(", "); } if (sig.parameters?.length) { result.pop(); } result.push(") => "); if (sig.type) { result.push(...sig.type.visit(this)); } else { result.push(intrinsicType("void")); } return result; } convertTypeLiteral(lit: DeclarationReflection): Type { if (lit.signatures) { return this.convertSignature(lit.signatures[0]); } const result: Type = ["{ "]; // lit.indexSignature for 0.25.x, lit.indexSignatures for 0.26.0 and later. // @ts-ignore const index_sig = lit.indexSignature ?? lit.indexSignatures?.[0]; if (index_sig) { if (index_sig.parameters?.length !== 1) { throw new Error("oops"); } const key = index_sig.parameters[0]; // There's no exact TypeContext for indexedAccess b/c typedoc doesn't // render it like this. mappedParameter and mappedTemplate look quite // similar: // [k in mappedParam]: mappedTemplate // vs // [k: keyType]: valueType const keyType = this.convert(key.type!, TypeContext.mappedParameter); const valueType = this.convert( index_sig.type!, TypeContext.mappedTemplate, ); result.push("[", key.name, ": "); result.push(...keyType); result.push("]", ": "); result.push(...valueType); result.push("; "); } for (const child of lit.children || []) { result.push(child.name); if (child.flags.isOptional) { result.push("?: "); } else { result.push(": "); } result.push(...(child.type?.visit(this) || [])); result.push("; "); } result.push("}"); return result; } } export function convertType( basePath: string, reflToPath: ReadonlyMap< DeclarationReflection | SignatureReflection, string[] >, symbolToType: ReadonlySymbolToType, type: SomeType, context: TypeContext = TypeContext.none, ): Type { const typeConverter = new TypeConverter(basePath, reflToPath, symbolToType); return typeConverter.convert(type, context); } export function convertTypeLiteral( basePath: string, reflToPath: ReadonlyMap< DeclarationReflection | SignatureReflection, string[] >, symbolToType: ReadonlySymbolToType, type: DeclarationReflection, ): Type { const typeConverter = new TypeConverter(basePath, reflToPath, symbolToType); return typeConverter.convertTypeLiteral(type); } export function referenceToXRef( basePath: string, reflToPath: ReadonlyMap< DeclarationReflection | SignatureReflection, string[] >, symbolToType: ReadonlySymbolToType, type: ReferenceType, ): Type { const converter = new TypeConverter(basePath, reflToPath, symbolToType); return converter.convertReferenceToXRef(type); } ================================================ FILE: sphinx_js/js/importHooks.mjs ================================================ async function tryResolve(specifier, context, nextResolve) { try { return await nextResolve(specifier, context); } catch (e) { if (e.code !== "ERR_MODULE_NOT_FOUND") { // Unusual error let it propagate throw e; } } } // An import hook to pick up packages in the node_modules that typedoc is // installed into export async function resolve(specifier, context, nextResolve) { // Take an `import` or `require` specifier and resolve it to a URL. const origURL = context.parentURL; const fallbackURL = `file:${process.env["TYPEDOC_NODE_MODULES"]}/`; for (const parentURL of [origURL, fallbackURL]) { context.parentURL = parentURL; const res = await tryResolve(specifier, context, nextResolve); context.parentURL = origURL; if (res) { return res; } } // If we get here, this will throw an error. return nextResolve(specifier, context); } ================================================ FILE: sphinx_js/js/ir.ts ================================================ // Define the types for our IR. Must match the cattrs+json serialization // format from ir.py export type TypeXRefIntrinsic = { name: string; type: "intrinsic"; }; export function intrinsicType(name: string): TypeXRefIntrinsic { return { name, type: "intrinsic", }; } export type TypeXRefInternal = { name: string; path: string[]; type: "internal"; }; export type TypeXRefExternal = { name: string; package: string; sourcefilename: string | null; qualifiedName: string | null; type: "external"; }; export type TypeXRef = TypeXRefExternal | TypeXRefInternal | TypeXRefIntrinsic; export type Type = (string | TypeXRef)[]; export type DescriptionName = { text: string; type: "name"; }; export type DescriptionText = { text: string; type: "text"; }; export type DescriptionCode = { code: string; type: "code"; }; export type DescriptionItem = | DescriptionName | DescriptionText | DescriptionCode; export type Description = DescriptionItem[]; export type Pathname = string[]; export type NoDefault = { _no_default: true }; export const NO_DEFAULT: NoDefault = { _no_default: true }; export type Member = { is_abstract: boolean; is_optional: boolean; is_static: boolean; is_private: boolean; }; export type TypeParam = { name: string; extends: Type | null; description: Description; }; export type Param = { name: string; description: Description; is_variadic: boolean; has_default: boolean; default: string | NoDefault; type: Type; }; export type Return = { type: Type; description: Description; }; export type Module = { filename: string; deppath: string; path: Pathname; line: number; attributes: TopLevel[]; functions: IRFunction[]; classes: Class[]; }; export type TopLevel = { name: string; path: Pathname; filename: string; deppath: string; description: Description; modifier_tags: string[]; block_tags: { [key: string]: Description[] }; line: number | null; deprecated: Description | boolean; examples: Description[]; see_alsos: string[]; properties: Attribute[]; exported_from: Pathname | null; documentation_root: boolean; }; export type Attribute = TopLevel & Member & { type: Type; readonly: boolean; kind: "attribute"; }; export type IRFunction = TopLevel & Member & { is_async: boolean; params: Param[]; returns: Return[]; type_params: TypeParam[]; kind: "function"; exceptions: never[]; }; export type _MembersAndSupers = { members: (IRFunction | Attribute)[]; supers: Type[]; }; export type Interface = TopLevel & _MembersAndSupers & { type_params: TypeParam[]; kind: "interface"; }; export type Class = TopLevel & _MembersAndSupers & { constructor_: IRFunction | null; is_abstract: boolean; interfaces: Type[]; type_params: TypeParam[]; kind: "class"; }; export type TypeAlias = TopLevel & { kind: "typeAlias"; type: Type; type_params: TypeParam[]; }; export type TopLevelIR = Attribute | IRFunction | Class | Interface | TypeAlias; ================================================ FILE: sphinx_js/js/main.ts ================================================ import { writeFile } from "fs/promises"; import { ExitError, run } from "./cli.ts"; async function main() { const start = Date.now(); const args = process.argv.slice(2); let app, result; try { [app, result] = await run(args); } catch (e) { if (e instanceof ExitError) { return e.code; } throw e; } const space = app.options.getValue("pretty") ? "\t" : ""; const res = JSON.stringify([result, app.extraData], null, space); const json = app.options.getValue("json"); await writeFile(json, res); app.logger.info(`JSON written to ${json}`); app.logger.verbose(`JSON rendering took ${Date.now() - start}ms`); return 0; } process.exit(await main()); ================================================ FILE: sphinx_js/js/package.json ================================================ { "name": "sphinx_js", "type": "module", "dependencies": { "tsx": "^4.9.0", "typedoc": "^0.25.13" }, "devDependencies": { "@types/node": "^20.12.7" } } ================================================ FILE: sphinx_js/js/redirectPrivateAliases.ts ================================================ /** * This is very heavily inspired by typedoc-plugin-missing-exports. * * The goal isn't to document the missing exports, but rather to remove them * from the documentation of actually exported stuff. If someone says: * * ``` * type MyPrivateAlias = ... * * function f(a: MyPrivateAlias) { * * } * ``` * * Then the documentation for f should document the value of MyPrivateAlias. We * create a ReflectionType for each missing export and stick them in a * SymbolToType map which we add to the application. In renderType.ts, if we * have a reference type we check if it's in the SymbolToType map and if so we * can use the reflection in place of the reference. * * More or less unrelatedly, we also add the --sphinxJsConfig option to the * options parser so we can pass the sphinxJsConfig on the command line. */ import { Application, Context, Converter, DeclarationReflection, ProjectReflection, ReferenceType, Reflection, ReflectionKind, SomeType, } from "typedoc"; import ts from "typescript"; // Map from the Symbol that is the target of the broken reference to the type // reflection that it should be replaced by. Depending on whether the reference // type holds a symbolId or a reflection, we use fileName:position or // fileName:symbolName as the key (respectively). We could always use the // symbolName but the position is more specific. type SymbolToTypeKey = `${string}:${number}` | `${string}:${string}`; export type SymbolToType = Map; export type ReadonlySymbolToType = ReadonlyMap; const ModuleLike: ReflectionKind = ReflectionKind.Project | ReflectionKind.Module; function getOwningModule(context: Context): Reflection { let refl = context.scope; // Go up the reflection hierarchy until we get to a module while (!refl.kindOf(ModuleLike)) { refl = refl.parent!; } return refl; } /** * @param app The typedoc app * @returns The type reference redirect table to be used in renderType.ts */ export function redirectPrivateTypes(app: Application): ReadonlySymbolToType { const referencedSymbols = new Map>(); const knownPrograms = new Map(); const symbolToType: SymbolToType = new Map<`${string}:${number}`, SomeType>(); app.converter.on( Converter.EVENT_CREATE_DECLARATION, (context: Context, refl: Reflection) => { // TypeDoc 0.26 doesn't fire EVENT_CREATE_DECLARATION for project // We need to ensure the project has a program attached to it, so // do that when the first declaration is created. if (knownPrograms.size === 0) { knownPrograms.set(refl.project, context.program); } if (refl.kindOf(ModuleLike)) { knownPrograms.set(refl, context.program); } }, ); const tsdocVersion = app.toString().split(" ")[1]; let is28: boolean; if ( tsdocVersion.startsWith("0.25") || tsdocVersion.startsWith("0.26") || tsdocVersion.startsWith("0.27") ) { is28 = false; } else if (tsdocVersion.startsWith("0.28")) { is28 = true; } else { throw new Error(`Typedoc version ${tsdocVersion} not supported`); } let getReflectionFromSymbol = is28 ? // @ts-ignore (context: Context, s: ts.Symbol) => context.getReflectionFromSymbol(s) : (context: Context, s: ts.Symbol) => // @ts-ignore context.project.getReflectionFromSymbol(s); /** * Get the set of ts.symbols referenced from a ModuleReflection or * ProjectReflection if there is only one file. */ function getReferencedSymbols(owningModule: Reflection): Set { let set = referencedSymbols.get(owningModule); if (set) { return set; } set = new Set(); referencedSymbols.set(owningModule, set); return set; } function discoverMissingExports( owningModule: Reflection, context: Context, ): ts.Symbol[] { // An export is missing if it was referenced and is not contained in the // documented const referenced = getReferencedSymbols(owningModule); return Array.from(referenced).filter((s) => { const refl = getReflectionFromSymbol(context, s); return ( !refl || refl.flags.isPrivate || refl?.comment?.modifierTags.has("@hidden") ); }); } // @ts-ignore const patchTarget: { createSymbolReference: ( symbol: ts.Symbol, context: Context, name: string, ) => ReferenceType; } = is28 ? Context.prototype : ReferenceType; const origCreateSymbolReference = patchTarget.createSymbolReference; patchTarget.createSymbolReference = function ( symbol: ts.Symbol, context: Context, name: string, ) { const owningModule = getOwningModule(context); getReferencedSymbols(owningModule).add(symbol); return origCreateSymbolReference.call(this, symbol, context, name); }; function onResolveBegin(context: Context): void { const modules: (DeclarationReflection | ProjectReflection)[] = context.project.getChildrenByKind(ReflectionKind.Module); if (modules.length === 0) { // Single entry point, just target the project. modules.push(context.project); } for (const mod of modules) { const program = knownPrograms.get(mod); if (!program) continue; // Nasty hack here that will almost certainly break in future TypeDoc versions. context.setActiveProgram(program); const missing = discoverMissingExports(mod, context); for (const name of missing) { const decl = name.declarations![0]; if (decl.getSourceFile().fileName.includes("node_modules")) { continue; } // TODO: maybe handle things other than TypeAliases? if (ts.isTypeAliasDeclaration(decl)) { const sf = decl.getSourceFile(); const fileName = sf.fileName; const converted = context.converter.convertType(context, decl.type); // Ideally we should be able to key on position rather than file and // name but I couldn't figure out how. symbolToType.set(`${fileName}:${decl.name.getText()}`, converted); } } context.setActiveProgram(void 0); } } app.converter.on(Converter.EVENT_RESOLVE_BEGIN, onResolveBegin); return symbolToType; } ================================================ FILE: sphinx_js/js/registerImportHook.mjs ================================================ import { register } from "node:module"; register("./importHooks.mjs", import.meta.url); ================================================ FILE: sphinx_js/js/sphinxJsConfig.ts ================================================ import { Application, DeclarationReflection, ParameterReflection, ProjectReflection, } from "typedoc"; import { TopLevel } from "./ir.ts"; export type SphinxJsConfig = { shouldDestructureArg?: (param: ParameterReflection) => boolean; preConvert?: (app: Application) => Promise; postConvert?: ( app: Application, project: ProjectReflection, typedocToIRMap: ReadonlyMap, ) => Promise; }; ================================================ FILE: sphinx_js/js/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "noEmit": true, "allowImportingTsExtensions": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true } } ================================================ FILE: sphinx_js/js/typedocPatches.ts ================================================ /** Declare some extra stuff we monkeypatch on to typedoc */ declare module "typedoc" { export interface TypeDocOptionMap { sphinxJsConfig: string; } export interface Application { extraData: { [key: string]: any; }; } } ================================================ FILE: sphinx_js/js/typedocPlugin.ts ================================================ /** * Typedoc plugin which adds --sphinxJsConfig option */ // TODO: we don't seem to resolve imports correctly in this file, but it works // to do a dynamic import. Figure out why. export async function load(app: any): Promise { // @ts-ignore const typedoc = await import("typedoc"); app.options.addDeclaration({ name: "sphinxJsConfig", help: "[typedoc-plugin-sphinx-js]: the sphinx-js config", type: typedoc.ParameterType.String, }); } ================================================ FILE: sphinx_js/jsdoc.py ================================================ """JavaScript analyzer Analyzers run jsdoc or typedoc or whatever, squirrel away their output, and then lazily constitute IR objects as requested. """ import pathlib import subprocess from collections import defaultdict from collections.abc import Callable, Sequence from errno import ENOENT from json import dumps, load from os.path import join, normpath, relpath, sep, splitext from tempfile import TemporaryFile from typing import Any, Literal, TypedDict from sphinx.application import Sphinx from sphinx.errors import SphinxError from .analyzer_utils import ( Command, cache_to_file, is_explicitly_rooted, search_node_modules, ) from .ir import ( NO_DEFAULT, Attribute, Class, DescriptionCode, Exc, Function, Param, Pathname, Return, TopLevel, ) from .parsers import PathVisitor, path_and_formal_params from .suffix_tree import SuffixTree class JsDocCode(TypedDict, total=False): paramnames: list[str] class Meta(TypedDict, total=False): path: str filename: str lineno: int code: JsDocCode class JsdocType(TypedDict, total=False): names: list[str] class Doclet(TypedDict, total=False): name: str comment: str undocumented: bool access: str scope: str meta: Meta longname: str memberof: str description: str type: JsdocType classdesc: str exceptions: list["Doclet"] returns: list["Doclet"] examples: list[Any] see_alsos: list[Any] properties: list["Doclet"] params: list["Doclet"] variable: bool class Analyzer: """A runner of a langauge-specific static analysis tool and translator of the results to our IR """ def __init__(self, json: list[Doclet], base_dir: str): """Index and squirrel away the JSON for later lazy conversion to IR objects. :arg json: The loaded JSON output from jsdoc :arg base_dir: Resolve paths in the JSON relative to this directory. This must be an absolute pathname. """ self._base_dir = base_dir # 2 doclets are made for classes, and they are largely redundant: one # for the class itself and another for the constructor. However, the # constructor one gets merged into the class one and is intentionally # marked as undocumented, even if it isn't. See # https://github.com/jsdoc3/jsdoc/issues/1129. doclets = [ doclet for doclet in json if doclet.get("comment") and not doclet.get("undocumented") ] # Build table for lookup by name, which most directives use: self._doclets_by_path: SuffixTree[Doclet] = SuffixTree() self._doclets_by_path.add_many( (full_path_segments(d, base_dir), d) for d in doclets ) # Build lookup table for autoclass's :members: option. This will also # pick up members of functions (inner variables), but it will instantly # filter almost all of them back out again because they're # undocumented. We index these by unambiguous full path. Then, when # looking them up by arbitrary name segment, we disambiguate that first # by running it through the suffix tree above. Expect trouble due to # jsdoc's habit of calling things (like ES6 class methods) # "" in the memberof field, even though they have names. # This will lead to multiple methods having each other's members. But # if you don't have same-named inner functions or inner variables that # are documented, you shouldn't have trouble. self._doclets_by_class = defaultdict(lambda: []) for d in doclets: of = d.get("memberof") if of: # speed optimization segments = full_path_segments(d, base_dir, longname_field="memberof") self._doclets_by_class[tuple(segments)].append(d) @classmethod def from_disk( cls, abs_source_paths: list[str], app: Sphinx, base_dir: str ) -> "Analyzer": json = jsdoc_output( getattr(app.config, "jsdoc_cache", None), abs_source_paths, base_dir, app.confdir, getattr(app.config, "jsdoc_config_path", None), ) return cls(json, base_dir) def get_object( self, path_suffix: list[str], as_type: Literal["function", "class", "attribute"] ) -> TopLevel: """Return the IR object with the given path suffix. If helpful, use the ``as_type`` hint, which identifies which autodoc directive the user called. """ # Design note: Originally, I had planned to eagerly convert all the # doclets to the IR. But it's hard to tell unambiguously what kind # each doclet is, at least in the case of jsdoc. If instead we lazily # convert each doclet as it's referenced by an autodoc directive, we # can use the hint we previously did: the user saying "this is a # function (by using autofunction on it)", "this is a class", etc. # Additionally, being lazy lets us avoid converting unused doclets # altogether. try: doclet_as_whatever = { "function": self._doclet_as_function, "class": self._doclet_as_class, "attribute": self._doclet_as_attribute, }[as_type] except KeyError: raise NotImplementedError("Unknown autodoc directive: auto%s" % as_type) doclet, full_path = self._doclets_by_path.get_with_path(path_suffix) return doclet_as_whatever(doclet, full_path) def _doclet_as_class(self, doclet: Doclet, full_path: Sequence[str]) -> Class: # This is an instance method so it can get at the base dir. members: list[Function | Attribute] = [] for member_doclet in self._doclets_by_class[tuple(full_path)]: kind = member_doclet.get("kind") member_full_path = full_path_segments(member_doclet, self._base_dir) # Typedefs should still fit into function-shaped holes: doclet_as_whatever: ( Callable[[Doclet, list[str]], Function] | Callable[[Doclet, list[str]], Attribute] ) = ( self._doclet_as_function if (kind == "function" or kind == "typedef") else self._doclet_as_attribute ) member = doclet_as_whatever(member_doclet, member_full_path) members.append(member) return Class( description=doclet.get("classdesc", ""), supers=[], # Could implement for JS later. exported_from=None, # Could implement for JS later. is_abstract=False, interfaces=[], # Right now, a class generates several doclets, all but one of # which are marked as undocumented. In the one that's left, most of # the fields are about the default constructor: constructor_=self._doclet_as_function(doclet, full_path), members=members, **top_level_properties(doclet, full_path, self._base_dir), ) def _doclet_as_function(self, doclet: Doclet, full_path: Sequence[str]) -> Function: return Function( description=description(doclet), exported_from=None, is_abstract=False, is_optional=False, is_static=is_static(doclet), is_async=False, is_private=is_private(doclet), exceptions=exceptions_to_ir(doclet.get("exceptions", [])), returns=returns_to_ir(doclet.get("returns", [])), params=params_to_ir(doclet), **top_level_properties(doclet, full_path, self._base_dir), ) def _doclet_as_attribute( self, doclet: Doclet, full_path: Sequence[str] ) -> Attribute: return Attribute( description=description(doclet), exported_from=None, is_abstract=False, is_optional=False, is_static=False, is_private=is_private(doclet), type=get_type(doclet), **top_level_properties(doclet, full_path, self._base_dir), ) def is_private(doclet: Doclet) -> bool: return doclet.get("access") == "private" def is_static(doclet: Doclet) -> bool: return doclet.get("scope") == "static" def full_path_segments( d: Doclet, base_dir: str, longname_field: Literal["longname", "memberof"] = "longname", ) -> list[str]: """Return the full, unambiguous list of path segments that points to an entity described by a doclet. Example: ``['./', 'dir/', 'dir/', 'file.', 'object.', 'object#', 'object']`` :arg d: The doclet :arg base_dir: Absolutized value of the root_for_relative_js_paths option :arg longname_field: The field to look in at the top level of the doclet for the long name of the object to emit a path to """ meta = d["meta"] rel = relpath(meta["path"], base_dir) rel = "/".join(rel.split(sep)) rooted_rel = rel if is_explicitly_rooted(rel) else "./%s" % rel # Building up a string and then parsing it back down again is probably # not the fastest approach, but it means knowledge of path format is in # one place: the parser. path = "{}/{}.{}".format( rooted_rel, splitext(meta["filename"])[0], d[longname_field] ) return PathVisitor().visit( # type:ignore[no-any-return] path_and_formal_params["path"].parse(path) ) @cache_to_file(lambda cache, *args: cache) def jsdoc_output( cache: str | None, abs_source_paths: list[str], base_dir: str, sphinx_conf_dir: str | pathlib.Path, config_path: str | None = None, ) -> list[Doclet]: jsdoc = search_node_modules("jsdoc", "jsdoc/jsdoc.js", sphinx_conf_dir) command = Command("node") command.add(jsdoc) command.add("-X", *abs_source_paths) if config_path: command.add("-c", normpath(join(sphinx_conf_dir, config_path))) # Use a temporary file to handle large output volume. JSDoc defaults to # utf8-encoded output. with TemporaryFile(mode="w+b") as temp: try: subprocess.run( command.make(), cwd=sphinx_conf_dir, stdout=temp, encoding="utf8" ) except OSError as exc: if exc.errno == ENOENT: raise SphinxError( '%s was not found. Install it using "npm install -g jsdoc".' % command.program ) else: raise # Once output is finished, move back to beginning of file and load it: temp.seek(0) try: return load(temp) # type:ignore[no-any-return] except ValueError: raise SphinxError( "jsdoc found no JS files in the directories %s. Make sure js_source_path is set correctly in conf.py. It is also possible (though unlikely) that jsdoc emitted invalid JSON." % abs_source_paths ) def format_default_according_to_type_hints( value: Any, declared_types: str | None, first_type_is_string: bool ) -> Any: """Return the default value for a param, formatted as a string ready to be used in a formal parameter list. JSDoc is a mess at extracting default values. It can unambiguously extract only a few simple types from the function signature, and ambiguity is even more rife when extracting from doclets. So we use any declared types to resolve the ambiguity. :arg value: The extracted value, which may be of the right or wrong type :arg declared_types: A list of types declared in the doclet for this param. For example ``{string|number}`` would yield ['string', 'number']. :arg first_type_is_string: Whether the first declared type for this param is string, which we use as a signal that any string-typed default value in the JSON is legitimately string-typed rather than some arrow function or something just encased in quotes because they couldn't think what else to do. Thus, if you want your ambiguously documented default like ``@param {string|Array} [foo=[]]`` to be treated as a string, make sure "string" comes first. """ if isinstance(value, str): # JSDoc threw it to us as a string in the JSON. if declared_types and not first_type_is_string: # It's a spurious string, like ``() => 5`` or a variable name. # Let it through verbatim. return value else: # It's a real string. return dumps(value) # Escape any contained quotes. else: # It came in as a non-string. if first_type_is_string: # It came in as an int, null, or bool, and we have to # convert it back to a string. return f'"{dumps(value)}"' else: # It's fine as the type it is. return dumps(value) def description(obj: Doclet) -> str: return obj.get("description", "") def get_type(props: Doclet) -> str | None: """Given an arbitrary object from a jsdoc-emitted JSON file, go get the ``type`` property, and return the textual rendering of the type, possibly a union like ``Foo | Bar``, or None if we don't know the type.""" names = props.get("type", {}).get("names", []) return "|".join(names) if names else None def top_level_properties( doclet: Doclet, full_path: Sequence[str], base_dir: str ) -> dict[str, Any]: """Extract information common to complex entities, and return it as a dict. Specifically, pull out the information needed to parametrize TopLevel's constructor. """ return dict( name=doclet["name"], path=Pathname(full_path), filename=doclet["meta"]["filename"], deppath=relpath( join(doclet["meta"]["path"], doclet["meta"]["filename"]), base_dir ), # description's source varies depending on whether the doclet is a # class, so it gets filled out elsewhere. line=doclet["meta"]["lineno"], deprecated=doclet.get("deprecated", False), examples=[ [DescriptionCode("```js\n" + x + "\n```")] for x in doclet.get("examples", []) ], see_alsos=doclet.get("see", []), properties=properties_to_ir(doclet.get("properties", [])), ) def properties_to_ir(properties: list[Doclet]) -> list[Attribute]: """Turn jsdoc-emitted properties JSON into a list of Properties.""" return [ Attribute( type=get_type(p), name=p["name"], # We can get away with setting null values for these # because we never use them for anything: path=Pathname([]), filename="", deppath="", description=description(p), line=0, deprecated=False, examples=[], see_alsos=[], properties=[], exported_from=None, is_abstract=False, is_optional=False, is_static=False, is_private=False, ) for p in properties ] def first_type_is_string(type: JsdocType) -> bool: type_names = type.get("names", []) return bool(type_names) and type_names[0] == "string" def params_to_ir(doclet: Doclet) -> list[Param]: """Extract the parameters of a function or class, and return a list of Param instances. Formal param fallback philosophy: 1. If the user puts a formal param list in the RST explicitly, use that. 2. Else, if they've @param'd anything, show just those args. This gives the user full control from the code, so they can use autoclass without having to manually write each function signature in the RST. 3. Else, extract a formal param list from the meta field, which will lack descriptions. Param list: * Don't show anything without a description or at least a type. It adds nothing. Our extraction to IR thus follows our formal param philosophy, and the renderer caps it off by checking for descriptions and types while building the param+description list. :arg doclet: A JSDoc doclet representing a function or class """ ret = [] # First, go through the explicitly documented params: for p in doclet.get("params", []): type = get_type(p) default = p.get("defaultvalue", NO_DEFAULT) formatted_default = ( NO_DEFAULT if default is NO_DEFAULT else format_default_according_to_type_hints( default, type, first_type_is_string(p.get("type", {})) ) ) ret.append( Param( name=p["name"], description=description(p), has_default=default is not NO_DEFAULT, default=formatted_default, is_variadic=p.get("variable", False), type=get_type(p), ) ) # Use params from JS code if there are no documented @params. if not ret: ret = [Param(name=p) for p in doclet["meta"]["code"].get("paramnames", [])] return ret def exceptions_to_ir(exceptions: list[Doclet]) -> list[Exc]: """Turn jsdoc's JSON-formatted exceptions into a list of Exceptions.""" return [Exc(type=get_type(e), description=description(e)) for e in exceptions] def returns_to_ir(returns: list[Doclet]) -> list[Return]: return [Return(type=get_type(r), description=description(r)) for r in returns] ================================================ FILE: sphinx_js/parsers.py ================================================ from re import sub from parsimonious import Grammar, NodeVisitor path_and_formal_params = Grammar( r""" path_and_formal_params = path formal_params # Invalid JS symbol names and wild-and-crazy placement of slashes later in # the path (after the FS path is over) will be caught at name-resolution # time. # # Note that "." is a non-separator char only when used in ./ and ../ # prefixes. path = relative_dir* middle_segments name # A name is a series of non-separator (not ~#/.), non-(, and backslashed # characters. name = ~r"(?:[^(/#~.\\]|\\.)+" sep = ~r"[#~/.]" relative_dir = "./" / "../" middle_segments = name_and_sep* name_and_sep = name sep formal_params = ~r".*" """ ) class PathVisitor(NodeVisitor): # type:ignore[type-arg] grammar = path_and_formal_params def visit_path_and_formal_params(self, node, children): return children def visit_name(self, node, children): # This, for better or worse, also makes Python string escape sequences, # like \n for newline, work. return _backslash_unescape(node.text) def visit_sep(self, node, children): return node.text def visit_path(self, node, children): relative_dirs, middle_segments, name = children segments = relative_dirs[:] segments.extend(middle_segments) segments.append(name) return segments def visit_name_and_sep(self, node, children): """Concatenate name and separator into one string.""" return "".join(x for x in children) def visit_formal_params(self, node, children): return node.text def visit_relative_dir(self, node, children): """Return './' or '../'.""" return node.text def visit_cur_dir(self, node, children): return node.text def visit_middle_segments(self, node, children): return children def generic_visit(self, node, visited_children): """``relative_dir*`` has already turned each item of ``children`` into './' or '../'. Just pass the list of those through.""" return visited_children def _backslash_unescape(str): """Return a string with backslash escape sequences replaced with their literal meanings. Don't respect any of the conventional \n, \v, \t, etc. Keep it simple. Keep it safe. """ return sub(r"\\(.)", lambda match: match.group(1), str) ================================================ FILE: sphinx_js/py.typed ================================================ ================================================ FILE: sphinx_js/renderers.py ================================================ import textwrap from collections.abc import Callable, Iterable, Iterator, Sequence from functools import partial from re import sub from typing import Any, Literal, Protocol, TypeVar from docutils import nodes from docutils.nodes import Node from docutils.parsers.rst import Directive from docutils.parsers.rst import Parser as RstParser from docutils.statemachine import StringList from jinja2 import Environment, PackageLoader from sphinx import addnodes from sphinx import version_info as sphinx_version_info from sphinx.application import Sphinx from sphinx.config import Config from sphinx.errors import SphinxError from sphinx.ext.autosummary import autosummary_table, extract_summary from sphinx.util import logging, rst from sphinx.util.docutils import switch_source_input from sphinx_js import ir from .analyzer_utils import dotted_path from .ir import ( Attribute, Class, DescriptionName, DescriptionText, Exc, Function, Interface, Module, Param, Pathname, Return, TopLevel, Type, TypeAlias, TypeParam, TypeXRef, TypeXRefInternal, ) from .jsdoc import Analyzer as JsAnalyzer from .parsers import PathVisitor from .suffix_tree import SuffixAmbiguous, SuffixNotFound from .typedoc import Analyzer as TsAnalyzer Analyzer = TsAnalyzer | JsAnalyzer logger = logging.getLogger(__name__) def new_document_from_parent( source_path: str, parent_doc: nodes.document, line: int | None = None ) -> nodes.document: """Create a new document that inherits the parent's settings and reporter.""" settings = parent_doc.settings reporter = parent_doc.reporter doc = nodes.document(settings, reporter, source=source_path) doc.note_source(source_path, -1) # Store line number for sphinx_js_type_role to use doc.sphinx_js_source_line = line # type: ignore[attr-defined] return doc def sort_attributes_first_then_by_path(obj: TopLevel) -> Any: """Return a sort key for IR objects.""" match obj: case Attribute(_): idx = 0 case Function(_): idx = 1 case Class(_) | Interface(_): idx = 2 return idx, obj.path.segments def _members_to_include_inner( members: Iterable[TopLevel], include: list[str], ) -> list[TopLevel]: """Return the members that should be included (before excludes and access specifiers are taken into account). This will either be the ones explicitly listed after the ``:members:`` option, in that order; all members of the class; or listed members with remaining ones inserted at the placeholder "*". """ if not include: # Specifying none means listing all. return sorted(members, key=sort_attributes_first_then_by_path) included_set = set(include) # If the special name * is included in the list, include all other # members, in sorted order. if "*" in included_set: star_index = include.index("*") sorted_not_included_members = sorted( (m for m in members if m.name not in included_set), key=sort_attributes_first_then_by_path, ) not_included = [m.name for m in sorted_not_included_members] include = include[:star_index] + not_included + include[star_index + 1 :] included_set.update(not_included) # Even if there are 2 members with the same short name (e.g. a # static member and an instance one), keep them both. This # prefiltering step should make the below sort less horrible, even # though I'm calling index(). included_members = [m for m in members if m.name in included_set] # sort()'s stability should keep same-named members in the order # JSDoc spits them out in. included_members.sort(key=lambda m: include.index(m.name)) return included_members def members_to_include( members: Iterable[TopLevel], include: list[str], exclude: list[str], should_include_private: bool, ) -> Iterator[TopLevel]: for member in _members_to_include_inner(members, include): if member.name in exclude: continue if not should_include_private and getattr(member, "is_private", False): continue yield member def unwrapped(text: str) -> str: """Return the text with line wrapping removed.""" return sub(r"[ \t]*[\r\n]+[ \t]*", " ", text) def render_description(description: ir.Description) -> str: """Construct a single comment string from a fancy object.""" if isinstance(description, str): return description content = [] prev = "" for s in description: if isinstance(s, DescriptionName): prev = s.text content.append(prev + "\n") continue if isinstance(s, DescriptionText): prev = s.text content.append(prev) continue # code if s.code.startswith("```") and s.code.count("\n") >= 1: # A code pen first_line, rest = s.code.split("\n", 1) rest = rest.removesuffix("```") code_type = first_line.removeprefix("```") start = f".. code-block:: {code_type}\n\n" codeblock = textwrap.indent(rest, " " * 4) end = "\n\n" content.append("\n" + start + codeblock + end) continue if s.code.startswith("``"): # Sphinx-style escaped, leave it alone. content.append(s.code) continue if prev.endswith(":"): # A sphinx role, leave it alone content.append(s.code) continue if prev.endswith(" ") and not s.code.endswith(">`"): # Used single uptick with code, put double upticks content.append(f"`{s.code}`") continue content.append(s.code) return "".join(content) R = TypeVar("R", bound="Renderer") class HasDepPath(Protocol): deppath: str | None class Renderer: _type_xref_formatter: Callable[[TypeXRef], str] # We turn the in the analyzer tests because it # makes a big mess. _add_span: bool _partial_path: list[str] _explicit_formal_params: str _content: list[str] | StringList _options: dict[str, Any] def _parse_path(self, arg: str) -> None: # content, arguments, options, app: all need to be accessible to # template_vars, so we bring them in on construction and stow them away # on the instance so calls to template_vars don't need to concern # themselves with what it needs. ( self._partial_path, self._explicit_formal_params, ) = PathVisitor().parse(arg) def __init__( self, directive: Directive, app: Sphinx, arguments: list[str], content: list[str] | StringList | None = None, options: dict[str, Any] | None = None, ): self._add_span = True # Fix crash when calling eval_rst with CommonMarkParser: if not hasattr(directive.state.document.settings, "tab_width"): directive.state.document.settings.tab_width = 8 self._directive = directive self._app = app self._set_type_xref_formatter(app.config.ts_type_xref_formatter) self._parse_path(arguments[0]) self._content = content or StringList() self._options = options or {} @classmethod def from_directive(cls: type[R], directive: Directive, app: Sphinx) -> R: """Return one of these whose state is all derived from a directive. This is suitable for top-level calls but not for when a renderer is being called from a different renderer, lest content and such from the outer directive be duplicated in the inner directive. :arg directive: The associated Sphinx directive :arg app: The Sphinx global app object. Some methods need this. """ return cls( directive, app, arguments=directive.arguments, content=directive.content, options=directive.options, ) def _set_type_xref_formatter( self, formatter: Callable[[Config, TypeXRef], str] | None ) -> None: if formatter: self._type_xref_formatter = partial(formatter, self._app.config) return def default_type_xref_formatter(xref: TypeXRef) -> str: return xref.name self._type_xref_formatter = default_type_xref_formatter def get_object(self) -> HasDepPath: raise NotImplementedError def dependencies(self) -> set[str]: """Return a set of path(s) to the file(s) that the IR object rendered by this renderer is from. Each path is absolute or relative to `root_for_relative_js_paths`. """ try: obj = self.get_object() if obj.deppath: return set([obj.deppath]) except SphinxError as exc: logger.exception("Exception while retrieving paths for IR object: %s" % exc) return set([]) def rst_nodes(self) -> list[Node]: raise NotImplementedError class JsRenderer(Renderer): """Abstract superclass for renderers of various sphinx-js directives Provides an inversion-of-control framework for rendering and bridges us from the hidden, closed-over JsDirective subclasses to top-level classes that can see and use each other. Handles parsing of a single, all-consuming argument that consists of a JS/TS entity reference and an optional formal parameter list. """ _renderer_type: Literal["function", "class", "attribute"] _template: str def _template_vars(self, name: str, obj: TopLevel) -> dict[str, Any]: raise NotImplementedError def lookup_object( self, partial_path: list[str], renderer_type: Literal["function", "class", "attribute"] = "attribute", ) -> TopLevel: try: analyzer: Analyzer = ( self._app._sphinxjs_analyzer # type:ignore[attr-defined] ) obj = analyzer.get_object(partial_path, renderer_type) return obj except SuffixNotFound as exc: raise SphinxError( 'No documentation was found for object "%s" or any path ending with that.' % "".join(exc.segments) ) except SuffixAmbiguous as exc: raise SphinxError( 'More than one object matches the path suffix "{}". Candidate paths have these segments in front: {}'.format( "".join(exc.segments), exc.next_possible_keys ) ) def get_object(self) -> TopLevel: """Return the IR object rendered by this renderer.""" return self.lookup_object(self._partial_path, self._renderer_type) def rst_nodes(self) -> list[Node]: """Render into RST nodes a thing shaped like a function, having a name and arguments. Fill in args, docstrings, and info fields from stored JSDoc output. """ obj = self.get_object() rst = self.rst( self._partial_path, obj, use_short_name="short-name" in self._options ) # Parse the RST into docutils nodes with a fresh doc, and return # them. Use the directive's source location for error messages. source, line = self._directive.state_machine.get_source_and_line( self._directive.lineno ) doc = new_document_from_parent( source or "", self._directive.state.document, line ) RstParser().parse(rst, doc) return doc.children def rst_for(self, obj: TopLevel) -> str: renderer_class: type match obj: case Attribute(_) | TypeAlias(_): renderer_class = AutoAttributeRenderer case Function(_): renderer_class = AutoFunctionRenderer case Class(_) | Interface(_): renderer_class = AutoClassRenderer case _: raise RuntimeError("This shouldn't happen...") renderer = renderer_class( self._directive, self._app, arguments=["dummy"], options={"members": ["*"]} ) return renderer.rst([obj.name], obj, use_short_name=False) def rst( self, partial_path: list[str], obj: TopLevel, use_short_name: bool = False ) -> str: """Return rendered RST about an entity with the given name and IR object.""" dotted_name = partial_path[-1] if use_short_name else dotted_path(partial_path) # Render to RST using Jinja: env = Environment(loader=PackageLoader("sphinx_js", "templates")) template = env.get_template(self._template) result = template.render(**self._template_vars(dotted_name, obj)) result = result.strip() had_blank = False lines = [] for line in result.splitlines(): if line.strip(): had_blank = False lines.append(line.rstrip()) elif not had_blank: lines.append("") had_blank = True result = "\n".join(lines) + "\n" return result def _type_params(self, obj: Function | Class | TypeAlias | Interface) -> str: if not obj.type_params: return "" return "<{}>".format(", ".join(tp.name for tp in obj.type_params)) def _formal_params(self, obj: Function) -> str: """Return the JS function params, looking first to any explicit params written into the directive and falling back to those in comments or JS code. Return a ReST-escaped string ready for substitution into the template. """ if self._explicit_formal_params: return self._explicit_formal_params formals = [] used_names = set() for param in obj.params: # Turn "@param p2.subProperty" into just p2. We wouldn't want to # add subproperties to the flat formal param list: name = param.name.split(".")[0] # Add '...' to the parameter name if it's a variadic argument if param.is_variadic: name = "..." + name if name not in used_names: # We don't rst.escape() anything here, because, empirically, # the js:function directive (or maybe directive params in # general) automatically ignores markup constructs in its # parameter (though not its contents). formals.append( name if not param.has_default else f"{name}={param.default}" ) used_names.add(name) return "({})".format(", ".join(formals)) def render_type(self, type: Type, escape: bool = False, bold: bool = True) -> str: if not type: return "" if isinstance(type, str): if bold: type = "**%s**" % type if escape: type = rst.escape(type) return type it = iter(type) def strs() -> Iterator[str]: for elem in it: if isinstance(elem, str): yield elem else: xref.append(elem) return res = [] while True: xref: list[TypeXRef] = [] s = "".join(strs()) if escape: s = rst.escape(s) if s: res.append(s) if not xref: break res.append(self.render_xref(xref[0], escape)) joined = r"\ ".join(res) if self._add_span: return f":sphinx_js_type:`{rst.escape(joined)}`" return joined def render_xref(self, s: TypeXRef, escape: bool = False) -> str: obj = None if isinstance(s, TypeXRefInternal): try: obj = self.lookup_object(s.path) # Stick the kind on the xref so that the formatter will know what # xref role to emit. I'm not sure how to compute this earlier. It's # convenient to do it here. s.kind = type(obj).__name__.lower() except SphinxError: # This sometimes happens on the code path in # convertReferenceToXRef when we generate an xref internal from # a symbolId. That code path is probably entirely wrong. # TODO: fix and add test coverage. pass result = self._type_xref_formatter(s) if escape: result = rst.escape(result) return result def _return_formatter(self, return_: Return) -> tuple[list[str], str]: """Derive heads and tail from ``@returns`` blocks.""" tail = [] if return_.type: tail.append(self.render_type(return_.type, escape=False)) if return_.description: tail.append(render_description(return_.description)) return ["returns"], " -- ".join(tail) def _type_param_formatter(self, tparam: TypeParam) -> tuple[list[str], str] | None: v = tparam.name descr = render_description(tparam.description) if tparam.extends: descr += " (extends " + self.render_type(tparam.extends) + ")" heads = ["typeparam", v] return heads, descr def _param_formatter(self, param: Param) -> tuple[list[str], str] | None: """Derive heads and tail from ``@param`` blocks.""" if not param.type and not param.description: # There's nothing worth saying about this param. return None heads = ["param"] heads.append(param.name) tail = render_description(param.description) return heads, tail def _param_type_formatter(self, param: Param) -> tuple[list[str], str] | None: """Generate types for function parameters specified in field.""" if not param.type: return None heads = ["type", param.name] tail = self.render_type(param.type) return heads, tail def _exception_formatter(self, exception: Exc) -> tuple[list[str], str]: """Derive heads and tail from ``@throws`` blocks.""" heads = ["throws"] if exception.type: heads.append(self.render_type(exception.type, bold=False)) tail = render_description(exception.description) return heads, tail def _fields(self, obj: TopLevel) -> Iterator[tuple[list[str], str]]: """Return an iterable of "info fields" to be included in the directive, like params, return values, and exceptions. Each field consists of a tuple ``(heads, tail)``, where heads are words that go between colons (as in ``:param string href:``) and tail comes after. """ FIELD_TYPES: list[tuple[str, Callable[[Any], tuple[list[str], str] | None]]] = [ ("type_params", self._type_param_formatter), ("params", self._param_formatter), ("params", self._param_type_formatter), ("properties", self._param_formatter), ("properties", self._param_type_formatter), ("exceptions", self._exception_formatter), ("returns", self._return_formatter), ] for collection_attr, callback in FIELD_TYPES: for instance in getattr(obj, collection_attr, []): result = callback(instance) if not result: continue heads, tail = result # If there are line breaks in the tail, the RST parser will # end the field list prematurely. # # TODO: Instead, indent multi-line tails juuuust right, and # we can enjoy block-level constructs within tails: # https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#field-lists. yield [rst.escape(h) for h in heads], unwrapped(tail) class AutoFunctionRenderer(JsRenderer): _template = "function.rst" _renderer_type = "function" def _template_vars(self, name: str, obj: Function) -> dict[str, Any]: # type: ignore[override] deprecated = obj.deprecated if not isinstance(deprecated, bool): deprecated = render_description(deprecated) return dict( name=name, type_params=self._type_params(obj), params=self._formal_params(obj), fields=self._fields(obj), description=render_description(obj.description), examples=[render_description(x) for x in obj.examples], deprecated=deprecated, is_optional=obj.is_optional, is_static=obj.is_static, is_async=obj.is_async, see_also=obj.see_alsos, content="\n".join(self._content), ) class AutoClassRenderer(JsRenderer): _template = "class.rst" _renderer_type = "class" def _template_vars(self, name: str, obj: Class | Interface) -> dict[str, Any]: # type: ignore[override] # TODO: At the moment, we pull most fields (params, returns, # exceptions, etc.) off the constructor only. We could pull them off # the class itself too in the future. if not isinstance(obj, Class) or not obj.constructor_: # One way or another, it has no constructor, so make a blank one to # keep from repeating this long test for every constructor-using # line in the dict() call: constructor = Function( name="", path=Pathname([]), filename="", deppath=None, description="", line=0, deprecated=False, examples=[], see_alsos=[], properties=[], exported_from=None, is_abstract=False, is_optional=False, is_static=False, is_async=False, is_private=False, type_params=obj.type_params, params=[], exceptions=[], returns=[], ) else: constructor = obj.constructor_ return dict( name=name, params=self._formal_params(constructor), type_params=self._type_params(obj), fields=self._fields(constructor), examples=[render_description(ex) for ex in constructor.examples], deprecated=constructor.deprecated, see_also=constructor.see_alsos, exported_from=obj.exported_from, class_comment=render_description(obj.description), is_abstract=isinstance(obj, Class) and obj.is_abstract, interfaces=[self.render_type(x) for x in obj.interfaces] if isinstance(obj, Class) else [], is_interface=isinstance(obj, Interface), supers=[self.render_type(x) for x in obj.supers], constructor_comment=render_description(constructor.description), content="\n".join(self._content), members=self._members_of( obj, include=self._options["members"], exclude=self._options.get("exclude-members", set()), should_include_private="private-members" in self._options, ) if "members" in self._options else "", ) def _members_of( self, obj: Class | Interface, include: list[str], exclude: list[str], should_include_private: bool, ) -> str: """Return RST describing the members of a given class. :arg obj Class: The class we're documenting :arg include: List of names of members to include. If empty, include all. :arg exclude: Set of names of members to exclude :arg should_include_private: Whether to include private members """ return "\n\n".join( self.rst_for(member) for member in members_to_include( obj.members, include, exclude, should_include_private ) ) class AutoAttributeRenderer(JsRenderer): _template = "attribute.rst" _renderer_type = "attribute" def _template_vars(self, name: str, obj: Attribute | TypeAlias) -> dict[str, Any]: # type: ignore[override] is_optional = False ty = self.render_type(obj.type) if isinstance(obj, Attribute): is_optional = obj.is_optional if obj.readonly: ty = "readonly " + ty type_params = "" is_type_alias = isinstance(obj, TypeAlias) fields: Iterator[tuple[list[str], str]] = iter([]) if isinstance(obj, TypeAlias): type_params = self._type_params(obj) fields = self._fields(obj) return dict( name=name, is_type_alias=is_type_alias, type_params=type_params, fields=fields, description=render_description(obj.description), deprecated=obj.deprecated, is_optional=is_optional, see_also=obj.see_alsos, examples=[render_description(ex) for ex in obj.examples], type=ty, content="\n".join(self._content), ) _SECTION_ORDER = ["type_aliases", "attributes", "functions", "interfaces", "classes"] class AutoModuleRenderer(JsRenderer): def _parse_path(self, arg: str) -> None: # content, arguments, options, app: all need to be accessible to # template_vars, so we bring them in on construction and stow them away # on the instance so calls to template_vars don't need to concern # themselves with what it needs. self._explicit_formal_params = "" self._partial_path = arg.split("/") def get_object(self) -> Module: # type:ignore[override] analyzer: Analyzer = self._app._sphinxjs_analyzer # type:ignore[attr-defined] assert isinstance(analyzer, TsAnalyzer) return analyzer._modules_by_path.get(self._partial_path) def rst_for_group(self, objects: Iterable[TopLevel]) -> list[str]: return [ self.rst_for(obj) for obj in objects if "@omitFromAutoModule" not in obj.modifier_tags ] def rst( # type:ignore[override] self, partial_path: list[str], obj: Module, use_short_name: bool = False, ) -> str: rst: list[Sequence[str]] = [] rst.append([f".. js:module:: {''.join(partial_path)}"]) for group_name in _SECTION_ORDER: rst.append(self.rst_for_group(getattr(obj, group_name))) return "\n\n".join(["\n\n".join(r) for r in rst if r]) class AutoSummaryRenderer(Renderer): def _parse_path(self, arg: str) -> None: # content, arguments, options, app: all need to be accessible to # template_vars, so we bring them in on construction and stow them away # on the instance so calls to template_vars don't need to concern # themselves with what it needs. self._explicit_formal_params = "" self._partial_path = arg.split("/") def get_object(self) -> Module: analyzer: Analyzer = self._app._sphinxjs_analyzer # type:ignore[attr-defined] assert isinstance(analyzer, TsAnalyzer) return analyzer._modules_by_path.get(self._partial_path) def rst_nodes(self) -> list[Node]: module = self.get_object() pkgname = "".join(self._partial_path) result: list[Node] = [] for group_name in _SECTION_ORDER: group_objects = getattr(module, group_name) if not group_objects: continue n = nodes.container() n += self.format_heading(group_name.replace("_", " ").title() + ":") table_items = self.get_summary_table(pkgname, group_objects) n += self.format_table(table_items) n["classes"] += ["jssummarytable", group_name] result.append(n) return result def format_heading(self, text: str) -> Node: """Make a section heading. This corresponds to the rst: "**Heading:**" autodocsumm uses headings like that, so this will match that style. """ heading = nodes.paragraph("") strong = nodes.strong("") strong.append(nodes.Text(text)) heading.append(strong) return heading def extract_summary(self, descr: str) -> str: """Wrapper around autosummary extract_summary that is easier to use. It seems like colons need escaping for some reason. """ colon_esc = "esccolon\\\xafhoa:" # extract_summary seems to have trouble if there are Sphinx # directives in descr descr, _, _ = descr.partition("\n..") document = self._directive.state.document # In Sphinx 9.x, extract_summary takes document.settings directly # instead of the document object. doc_or_settings: Any if sphinx_version_info >= (9, 0): doc_or_settings = document.settings else: doc_or_settings = document return extract_summary( [descr.replace(":", colon_esc)], doc_or_settings ).replace(colon_esc, ":") def get_sig(self, obj: TopLevel) -> str: """If the object is a function, get its signature (as figured by JsDoc)""" if isinstance(obj, ir.Function): return AutoFunctionRenderer( self._directive, self._app, arguments=["dummy"] )._formal_params(obj) else: return "" def get_summary_row(self, pkgname: str, obj: TopLevel) -> tuple[str, str, str]: """Get the summary table row for obj. The output is designed to be input to format_table. The link name needs to be set up so that :any:`link_name` makes a link to the actual API docs for this object. """ display_name = obj.name prefix = "**async** " if getattr(obj, "is_async", False) else "" qualifier = "any" link_name = pkgname + "." + display_name main = f"{prefix}:{qualifier}:`{display_name} <{link_name}>`" if slink := obj.block_tags.get("summaryLink"): main = render_description(slink[0]) sig = self.get_sig(obj) summary = self.extract_summary(render_description(obj.description)) return (main, sig, summary) def get_summary_table( self, pkgname: str, group: Iterable[TopLevel] ) -> list[tuple[str, str, str]]: """Get the data for a summary tget_summary_tableable. Return value is set up to be an argument of format_table. """ return [self.get_summary_row(pkgname, obj) for obj in group] # This following method is copied almost verbatim from autosummary # (where it is called get_table). # # We have to change the value of one string: qualifier = 'obj ==> # qualifier = 'any' # https://github.com/sphinx-doc/sphinx/blob/6.0.x/sphinx/ext/autosummary/__init__.py#L375 def format_table(self, items: list[tuple[str, str, str]]) -> list[Node]: """Generate a proper list of table nodes for autosummary:: directive. *items* is a list produced by :meth:`get_items`. """ table_spec = addnodes.tabular_col_spec() table_spec["spec"] = r"\X{1}{2}\X{1}{2}" table = autosummary_table("") real_table = nodes.table("", classes=["longtable"]) table.append(real_table) group = nodes.tgroup("", cols=2) real_table.append(group) group.append(nodes.colspec("", colwidth=10)) group.append(nodes.colspec("", colwidth=90)) body = nodes.tbody("") group.append(body) def append_row(column_texts: list[tuple[str, str]]) -> None: row = nodes.row("") source, line = self._directive.state_machine.get_source_and_line() assert source assert line for [text, cls] in column_texts: node = nodes.paragraph("") vl = StringList() vl.append(text, "%s:%d:" % (source, line)) with switch_source_input(self._directive.state, vl): self._directive.state.nested_parse(vl, 0, node) try: if isinstance(node[0], nodes.paragraph): node = node[0] except IndexError: pass entry = nodes.entry("", node) entry["classes"].append(cls) row.append(entry) body.append(row) for name, sig, summary in items: # The body of this loop is changed from copied code. if "nosignatures" not in self._options: sig = rst.escape(sig) if sig: sig = f"**{sig}**" name = rf"{name}\ {sig}" append_row([(name, "name"), (summary, "summary")]) return [table_spec, table] ================================================ FILE: sphinx_js/suffix_tree.py ================================================ from collections.abc import Iterable, Sequence from typing import ( Any, Generic, TypedDict, TypeVar, ) T = TypeVar("T") # In Python 3.10: Cannot inherit from TypedDict and Generic. class _Tree(TypedDict, total=False): value: Any subtree: dict[str, "_Tree"] class SuffixTree(Generic[T]): """A suffix tree in which you can use anything hashable as a path segment and anything at all as a value """ def __init__(self) -> None: #: Internal structure is like... :: #: #: Tree = {value?: Any, #: subtree?: {segmentFoo: Tree, segmentBar: Tree, ...}} # #: A Tree can have a value key, a subtree key, or both. Subtree dicts #: always have at least 1 key. Every subtree has at least one value, #: directly or indirectly. ``self._tree`` itself is a Tree. self._tree: _Tree = {} def add(self, unambiguous_segments: Sequence[str], value: T) -> None: """Add an item to the tree. :arg unambiguous_segments: A list of path segments that correspond uniquely to the stored value :arg value: Any value you want to fetch by path """ tree = self._tree for seg in reversed(unambiguous_segments): tree = tree.setdefault("subtree", {}).setdefault(seg, {}) if "value" in tree: raise PathTaken(unambiguous_segments) else: tree["value"] = value def add_many( self, segments_and_values: Iterable[tuple[Sequence[str], Any]] ) -> None: """Add a batch of items to the tree all at once, and collect any errors. :arg segments_and_values: An iterable of (unambiguous segment path, value) If any of the segment paths are duplicates, raise PathsTaken. """ conflicts = [] for segs, value in segments_and_values: try: self.add(segs, value) except PathTaken as conflict: conflicts.append(conflict.segments) if conflicts: raise PathsTaken(conflicts) def get_with_path(self, segments: Sequence[str]) -> tuple[T, Sequence[str]]: """Return the value stored at a path ending in the given segments, along with the full path found. :arg segments list: A possibly ambiguous suffix of a path If no paths are found with the given suffix, raise SuffixNotFound. If multiple are found, raise SuffixAmbiguous. This is true even if the given suffix is a full path to a value: we want to flag the ambiguity to the user, even if it might sometimes be more convenient to assume the intention was to present a full path. """ # Keep walking down subtrees (returning NotFound if failed) until we # run out of segs: tree = self._tree for seg in reversed(segments): try: tree = tree["subtree"][seg] except KeyError: raise SuffixNotFound(segments) # If there's only a value there, return it: if "value" in tree: if "subtree" in tree: raise SuffixAmbiguous( segments, list(tree["subtree"].keys()), or_ends_here=True ) return tree["value"], segments # Else if there's a subtree, follow its 1-key subtrees forever, since # there's no ambiguity there: additional_segments = [] while len(tree.get("subtree", {})) == 1: only_key = next(iter(tree["subtree"].keys())) tree = tree["subtree"][only_key] additional_segments.append(only_key) # If we arrived at a spot with multiple possibilities, yell: if len(tree.get("subtree", {})) > 1: raise SuffixAmbiguous(segments, list(tree["subtree"].keys())) # Otherwise, return the found value. There must always be a value here # because add() always eventually adds (or finds) a value after a chain # of subtrees. If there were multiple subtrees from here, we would have # raised SuffixAmbiguous above. If there were a single one, we would # have followed it. So, since subtrees always eventually terminate in a # value, we must be at one now. return tree["value"], (list(reversed(additional_segments)) + list(segments)) def get(self, segments: Sequence[str]) -> T: return self.get_with_path(segments)[0] class SuffixError(Exception): def __init__(self, segments: Sequence[str]): self.segments = segments _message: str def __str__(self) -> str: return self._message % "".join(self.segments) class PathTaken(SuffixError): """Attempted to add a suffix that was already in the tree.""" _message = "Attempted to add a path already in the suffix tree: %s." class PathsTaken(Exception): """One or more JS objects had the same paths. Rolls up multiple PathTaken exceptions for mass reporting. """ def __init__(self, conflicts: list[Sequence[str]]) -> None: """ :arg conflicts: A list of paths, each given as a list of segments """ self.conflicts = conflicts def __str__(self) -> str: return ( "Your code contains multiple documented objects at each of " "these paths:\n\n" + "\n ".join("".join(c) for c in self.conflicts) + "\n\n" "We won't know which one you're " "talking about." ) class SuffixNotFound(SuffixError): """No keys ended in the given suffix.""" _message = "No path found ending in %s." class SuffixAmbiguous(SuffixError): """There were multiple keys found ending in the suffix.""" def __init__( self, segments: Sequence[str], next_possible_keys: list[str], or_ends_here: bool = False, ) -> None: super().__init__(segments) self.next_possible_keys = next_possible_keys self.or_ends_here = or_ends_here def __str__(self) -> str: ends_here_msg = ( " Or it could end without any of them." if self.or_ends_here else "" ) return "Ambiguous path: {} could continue as any of {}.{}".format( "".join(self.segments), self.next_possible_keys, ends_here_msg, ) ================================================ FILE: sphinx_js/templates/attribute.rst ================================================ {% import 'common.rst' as common %} {% if is_type_alias -%} .. js:typealias:: {{ name }}{{ type_params }} {%- else -%} .. js:attribute:: {{ name }}{{ '?' if is_optional else '' }} {%- endif %} {{ common.deprecated(deprecated)|indent(3) }} {% if type -%} .. rst-class:: js attribute type type: {{ type|indent(3) }} {%- endif %} {% if description -%} {{ description|indent(3) }} {%- endif %} {% if is_type_alias -%} {{ common.fields(fields) | indent(3) }} {%- endif %} {{ common.examples(examples)|indent(3) }} {{ content|indent(3) }} {{ common.see_also(see_also)|indent(3) }} ================================================ FILE: sphinx_js/templates/class.rst ================================================ {% import 'common.rst' as common %} {% if is_interface -%} .. js:interface:: {{ name }}{{ type_params }}{{ params }} {%- else -%} .. js:class:: {{ name }}{{ type_params }}{{ params }} {%- endif %} {{ common.deprecated(deprecated)|indent(3) }} {% if class_comment -%} {{ class_comment|indent(3) }} {%- endif %} {% if is_abstract -%} *abstract* {%- endif %} {{ common.exported_from(exported_from)|indent(3) }} {% if supers -%} **Extends:** {% for super in supers -%} - {{ super }} {% endfor %} {%- endif %} {% if interfaces -%} **Implements:** {% for interface in interfaces -%} - {{ interface }} {% endfor %} {%- endif %} {% if constructor_comment -%} {{ constructor_comment|indent(3) }} {%- endif %} {{ common.fields(fields) | indent(3) }} {{ common.examples(examples)|indent(3) }} {{ content|indent(3) }} {% if members -%} {{ members|indent(3) }} {%- endif %} {{ common.see_also(see_also)|indent(3) }} ================================================ FILE: sphinx_js/templates/common.rst ================================================ {% macro deprecated(message) %} {% if message -%} .. note:: Deprecated {%- if message is string -%}: {{ message }}{% else %}.{% endif -%} {%- endif %} {% endmacro %} {% macro examples(items) %} {% for example in items %} .. admonition:: Example {{ example|indent(3) }} {% endfor %} {% endmacro %} {% macro see_also(items) %} {% if items -%} .. seealso:: {% for reference in items -%} - :any:`{{ reference }}` {% endfor %} {%- endif %} {% endmacro %} {% macro exported_from(pathname) %} {% if pathname -%} *exported from* :js:mod:`{{ pathname.dotted() }}` {%- endif %} {% endmacro %} {% macro fields(items) %} {% for heads, tail in items -%} :{{ heads|join(' ') }}: {{ tail }} {% endfor %} {% endmacro %} ================================================ FILE: sphinx_js/templates/function.rst ================================================ {% import 'common.rst' as common %} .. js:function:: {{ name }}{{ '?' if is_optional else '' }}{{ type_params }}{{ params }} {% if is_static -%} :static: {% endif %} {%- if is_async -%} :async: {% endif %} {{ common.deprecated(deprecated)|indent(3) }} {% if description -%} {{ description|indent(3) }} {%- endif %} {{ common.fields(fields) | indent(3) }} {{ common.examples(examples)|indent(3) }} {{ content|indent(3) }} {{ common.see_also(see_also)|indent(3) }} ================================================ FILE: sphinx_js/typedoc.py ================================================ """Converter from TypeDoc output to IR format""" import os import pathlib import re import subprocess from collections.abc import Iterable, Iterator, Sequence from errno import ENOENT from functools import cache from json import load from operator import attrgetter from pathlib import Path from tempfile import NamedTemporaryFile from typing import Any, Literal from sphinx.application import Sphinx from sphinx.errors import SphinxError from . import ir from .analyzer_utils import Command, search_node_modules from .suffix_tree import SuffixTree __all__ = ["Analyzer"] MIN_TYPEDOC_VERSION = (0, 25, 0) @cache def typedoc_version_info(typedoc: str) -> tuple[tuple[int, ...], tuple[int, ...]]: result = subprocess.run( [typedoc, "--version"], capture_output=True, encoding="utf8", check=True, ) lines = result.stdout.strip().splitlines() m = re.search(r"TypeDoc ([0-9]+\.[0-9]+\.[0-9]+)", lines[0]) assert m typedoc_version = tuple(int(x) for x in m.group(1).split(".")) m = re.search(r"TypeScript ([0-9]+\.[0-9]+\.[0-9]+)", lines[1]) assert m typescript_version = tuple(int(x) for x in m.group(1).split(".")) return typedoc_version, typescript_version def version_to_str(t: Sequence[int]) -> str: return ".".join(str(x) for x in t) def typedoc_output( abs_source_paths: Sequence[str], base_dir: str, sphinx_conf_dir: str | pathlib.Path, typedoc_config_path: str | None, tsconfig_path: str | None, ts_sphinx_js_config: str | None, ) -> tuple[list[ir.TopLevelUnion], dict[str, Any]]: """Return the loaded JSON output of the TypeDoc command run over the given paths.""" typedoc = search_node_modules("typedoc", "typedoc/bin/typedoc", sphinx_conf_dir) typedoc_version, _ = typedoc_version_info(typedoc) if typedoc_version < MIN_TYPEDOC_VERSION: raise RuntimeError( f"Typedoc version {version_to_str(typedoc_version)} is too old, minimum required is {version_to_str(MIN_TYPEDOC_VERSION)}" ) env = os.environ.copy() env["TYPEDOC_NODE_MODULES"] = str(Path(typedoc).parents[3].resolve()) command = Command("npx") command.add("tsx@4.15.8") dir = Path(__file__).parent.resolve() / "js" command.add("--tsconfig", str(dir / "tsconfig.json")) command.add("--import", str(dir / "registerImportHook.mjs")) command.add(str(dir / "main.ts")) if ts_sphinx_js_config: command.add("--sphinxJsConfig", ts_sphinx_js_config) command.add("--entryPointStrategy", "expand") if typedoc_config_path: typedoc_config_path = str( (Path(sphinx_conf_dir) / typedoc_config_path).absolute() ) command.add("--options", typedoc_config_path) if tsconfig_path: tsconfig_path = str((Path(sphinx_conf_dir) / tsconfig_path).absolute()) command.add("--tsconfig", tsconfig_path) command.add("--basePath", base_dir) command.add("--excludePrivate", "false") with NamedTemporaryFile(mode="w+b", delete=False) as temp: command.add("--json", temp.name, *abs_source_paths) try: subprocess.run(command.make(), check=True, env=env) except OSError as exc: if exc.errno == ENOENT: raise SphinxError( '%s was not found. Install it using "npm install -g typedoc".' % command.program ) else: raise # typedoc emits a valid JSON file even if it finds no TS files in the dir: json_ir, extra_data = load(temp) return ir.json_to_ir(json_ir), extra_data class Analyzer: _objects_by_path: SuffixTree[ir.TopLevel] _modules_by_path: SuffixTree[ir.Module] _extra_data: dict[str, Any] def __init__( self, objects: Sequence[ir.TopLevel], extra_data: dict[str, Any], base_dir: str ) -> None: self._extra_data = extra_data self._base_dir = base_dir self._objects_by_path = SuffixTree() self._objects_by_path.add_many((obj.path.segments, obj) for obj in objects) modules = self._create_modules(objects) self._modules_by_path = SuffixTree() self._modules_by_path.add_many((obj.path.segments, obj) for obj in modules) def get_object( self, path_suffix: Sequence[str], as_type: Literal["function", "class", "attribute"] = "function", ) -> ir.TopLevel: """Return the IR object with the given path suffix. :arg as_type: Ignored """ return self._objects_by_path.get(path_suffix) @classmethod def from_disk( cls, abs_source_paths: Sequence[str], app: Sphinx, base_dir: str ) -> "Analyzer": json, extra_data = typedoc_output( abs_source_paths, base_dir=base_dir, sphinx_conf_dir=app.confdir, typedoc_config_path=app.config.jsdoc_config_path, tsconfig_path=app.config.jsdoc_tsconfig_path, ts_sphinx_js_config=app.config.ts_sphinx_js_config, ) return cls(json, extra_data, base_dir) def _get_toplevel_objects( self, ir_objects: Sequence[ir.TopLevel] ) -> Iterator[tuple[ir.TopLevel, str, str]]: for obj in ir_objects: if not obj.documentation_root: continue assert obj.deppath yield (obj, obj.deppath, obj.kind) def _create_modules(self, ir_objects: Sequence[ir.TopLevel]) -> Iterable[ir.Module]: """Search through the doclets generated by JsDoc and categorize them by summary section. Skip docs labeled as "@private". """ modules = {} singular_kind_to_plural_kind = { "class": "classes", "interface": "interfaces", "function": "functions", "attribute": "attributes", "typeAlias": "type_aliases", } for obj, path, kind in self._get_toplevel_objects(ir_objects): pathparts = path.split("/") for i in range(len(pathparts) - 1): pathparts[i] += "/" if path not in modules: modules[path] = ir.Module( filename=path, deppath=path, path=ir.Pathname(pathparts), line=1 ) mod = modules[path] getattr(mod, singular_kind_to_plural_kind[kind]).append(obj) for mod in modules.values(): mod.attributes = sorted(mod.attributes, key=attrgetter("name")) mod.functions = sorted(mod.functions, key=attrgetter("name")) mod.classes = sorted(mod.classes, key=attrgetter("name")) mod.interfaces = sorted(mod.interfaces, key=attrgetter("name")) mod.type_aliases = sorted(mod.type_aliases, key=attrgetter("name")) return modules.values() ================================================ FILE: tests/__init__.py ================================================ from pytest import register_assert_rewrite register_assert_rewrite("tests.testing") ================================================ FILE: tests/conftest.py ================================================ import os import sys from pathlib import Path import sphinx SPHINX_VERSION = tuple(int(x) for x in sphinx.__version__.split(".")) sys.path.append(str(Path(__file__).parent)) import pytest if "SPHINX_JS_NODE_MODULES" not in os.environ: for p in [Path(sys.prefix), Path(__file__).parents[1]]: p = p / "node_modules" if p.exists(): os.environ["SPHINX_JS_NODE_MODULES"] = str(p) break else: raise RuntimeError("Couldn't find node_modules") from sphinx_js.analyzer_utils import search_node_modules from sphinx_js.typedoc import typedoc_version_info TYPEDOC = search_node_modules("typedoc", "typedoc/bin/typedoc", "") TYPEDOC_VERSION = typedoc_version_info(TYPEDOC)[0] pytest_plugins = "sphinx.testing.fixtures" # Exclude 'roots' dirs for pytest test collector collect_ignore = ["roots"] @pytest.fixture(scope="session") def rootdir(): rootdir = Path(__file__).parent.resolve() / "roots" if SPHINX_VERSION < (7, 0, 0): from sphinx.testing.path import path return path(rootdir) return rootdir ================================================ FILE: tests/roots/test-incremental_js/a.js ================================================ /** * Class doc. */ class ClassA { /** * Static. */ static noUseOfThis() {} /** * Here. */ methodA() {} } ================================================ FILE: tests/roots/test-incremental_js/a.rst ================================================ module a ======== .. js:autoclass:: ClassA ================================================ FILE: tests/roots/test-incremental_js/a_b.rst ================================================ modules a and b =============== .. js:autofunction:: ClassA#methodA .. js:autofunction:: ClassB#methodB ================================================ FILE: tests/roots/test-incremental_js/b.rst ================================================ module b ======== .. js:autoclass:: ClassB ================================================ FILE: tests/roots/test-incremental_js/conf.py ================================================ extensions = ["sphinx_js"] # Minimal stuff needed for Sphinx to work: source_suffix = ".rst" master_doc = "index" author = "Nick Alexander" exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] js_source_path = [".", "inner"] root_for_relative_js_paths = "." # Temp directories on macOS have internal directories starting with # "_", running afoul of https://github.com/jsdoc/jsdoc/issues/1328. jsdoc_config_path = "jsdoc.json" ================================================ FILE: tests/roots/test-incremental_js/index.rst ================================================ Table of Contents ----------------- .. toctree:: a b a_b unrelated ================================================ FILE: tests/roots/test-incremental_js/inner/b.js ================================================ /** * Class doc. */ class ClassB { /** * Static. */ static noUseOfThis() {} /** * Here. */ methodB() {} } ================================================ FILE: tests/roots/test-incremental_js/jsdoc.json ================================================ { "source": { "includePattern": ".+\\.js(doc|x)?$" } } ================================================ FILE: tests/roots/test-incremental_js/unrelated.rst ================================================ unrelated ========= ================================================ FILE: tests/roots/test-incremental_ts/a.rst ================================================ module a ======== .. js:autoclass:: ClassA ================================================ FILE: tests/roots/test-incremental_ts/a.ts ================================================ export class ClassA { constructor() {} /** * Here. */ methodA() {} } ================================================ FILE: tests/roots/test-incremental_ts/a_b.rst ================================================ modules a and b =============== .. js:autofunction:: ClassA#methodA .. js:autofunction:: ClassB#methodB ================================================ FILE: tests/roots/test-incremental_ts/b.rst ================================================ module b ======== .. js:autoclass:: ClassB ================================================ FILE: tests/roots/test-incremental_ts/conf.py ================================================ extensions = ["sphinx_js"] # Minimal stuff needed for Sphinx to work: source_suffix = ".rst" master_doc = "index" author = "Nick Alexander" exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] js_language = "typescript" js_source_path = [".", "inner"] root_for_relative_js_paths = "." # Temp directories on macOS have internal directories starting with # "_", running afoul of https://github.com/jsdoc/jsdoc/issues/1328. jsdoc_config_path = "tsconfig.json" ================================================ FILE: tests/roots/test-incremental_ts/index.rst ================================================ Table of Contents ----------------- .. toctree:: a b a_b unrelated ================================================ FILE: tests/roots/test-incremental_ts/inner/b.ts ================================================ export class ClassB { constructor() {} /** * Here. */ methodB() {} } ================================================ FILE: tests/roots/test-incremental_ts/tsconfig.json ================================================ { "compilerOptions": { "target": "es6", "module": "commonjs", "moduleResolution": "node" } } ================================================ FILE: tests/roots/test-incremental_ts/unrelated.rst ================================================ unrelated ========= ================================================ FILE: tests/sphinxJsConfig.ts ================================================ export const config = { shouldDestructureArg: (param) => param.name === "destructureThisPlease", }; ================================================ FILE: tests/test.ts ================================================ import assert from "node:assert"; import { test, suite, before } from "node:test"; import { run } from "../sphinx_js/js/cli"; import { TopLevelIR, Type } from "../sphinx_js/js/ir"; import { Application } from "typedoc"; function joinType(t: Type): string { return t.map((x) => (typeof x === "string" ? x : x.name)).join(""); } function resolveFile(path: string): string { return import.meta.dirname + "/" + path; } suite("types.ts", async () => { let app: Application; let results: TopLevelIR[]; let map: Map; before(async () => { [app, results] = await run([ "--sphinxJsConfig", resolveFile("sphinxJsConfig.ts"), "--entryPointStrategy", "expand", "--tsconfig", resolveFile("test_typedoc_analysis/source/tsconfig.json"), "--basePath", resolveFile("test_typedoc_analysis/source"), resolveFile("test_typedoc_analysis/source/types.ts"), ]); map = new Map(results.map((res) => [res.name, res])); }); function getObject(name: string): TopLevelIR { const obj = map.get(name); assert(obj); return obj; } suite("basic", async () => { for (const [obj_name, type_name] of [ ["bool", "boolean"], ["num", "number"], ["str", "string"], ["array", "number[]"], ["genericArray", "number[]"], ["tuple", "[string, number]"], ["color", "Color"], ["unk", "unknown"], ["whatever", "any"], ["voidy", "void"], ["undef", "undefined"], ["nully", "null"], ["nev", "never"], ["obj", "object"], ["sym", "symbol"], ]) { await test(obj_name, () => { const obj = getObject(obj_name); assert.strictEqual(obj.kind, "attribute"); assert.strictEqual(joinType(obj.type), type_name); }); } }); await test("named_interface", () => { const obj = getObject("interfacer"); assert.strictEqual(obj.kind, "function"); assert.deepStrictEqual(obj.params[0].type, [ { name: "Interface", path: ["./", "types.", "Interface"], type: "internal", }, ]); }); await test("interface_readonly_member", () => { const obj = getObject("Interface"); assert.strictEqual(obj.kind, "interface"); const readOnlyNum = obj.members[0]; assert.strictEqual(readOnlyNum.kind, "attribute"); assert.strictEqual(readOnlyNum.name, "readOnlyNum"); assert.deepStrictEqual(readOnlyNum.type, [ { name: "number", type: "intrinsic" }, ]); }); await test("array", () => { const obj = getObject("overload"); assert.strictEqual(obj.kind, "function"); assert.deepStrictEqual(obj.params[0].type, [ { name: "string", type: "intrinsic" }, "[]", ]); }); await test("literal_types", () => { const obj = getObject("certainNumbers"); assert.strictEqual(obj.kind, "attribute"); assert.deepStrictEqual(obj.type, [ { name: "CertainNumbers", path: ["./", "types.", "CertainNumbers"], type: "internal", }, ]); }); await test("private_type_alias_1", () => { const obj = getObject("typeIsPrivateTypeAlias1"); assert.strictEqual(obj.kind, "attribute"); assert.deepStrictEqual(joinType(obj.type), "{ a: number; b: string; }"); }); await test("private_type_alias_2", () => { const obj = getObject("typeIsPrivateTypeAlias2"); assert.strictEqual(obj.kind, "attribute"); assert.deepStrictEqual(joinType(obj.type), "{ a: number; b: string; }"); }); }); ================================================ FILE: tests/test_build_js/source/code.js ================================================ /** * Return the ratio of the inline text length of the links in an element to * the inline text length of the entire element. * * @param {Node} node - Something of a single type * @throws {PartyError|FartyError} Something with multiple types and a line * that wraps * @returns {Number} What a thing */ function linkDensity(node) { const length = node.flavors.get("paragraphish").inlineLength; const lengthWithoutLinks = inlineTextLength( node.element, (element) => element.tagName !== "A", ); return (length - lengthWithoutLinks) / length; } /** * Class doc. */ class ContainingClass { /** * Constructor doc. * * @arg ho A thing */ constructor(ho) { /** * A var */ this.someVar = 4; } /** * Here. * @protected */ someMethod(hi) {} /** * Setting this also frobs the frobnicator. */ get bar() { return this._bar; } set bar(baz) { this._bar = _bar; } /** * Another. */ anotherMethod() {} /** * More. */ yetAnotherMethod() {} /** * Private thing. * @private */ secret() {} } // We won't add any new members to this class, because it would break some tests. /** Closed class. */ class ClosedClass { /** * Public thing. */ publical() {} /** * Public thing 2. */ publical2() {} /** * Public thing 3. */ publical3() {} } /** Non-alphabetical class. */ class NonAlphabetical { /** Fun z. */ z() {} /** Fun a. */ a() {} } /** * This doesn't emit a paramnames key in meta.code. * @class */ const NoParamnames = {}; /** Thing to be shadowed in more_code.js */ function shadow() {} /** * @typedef {Object} TypeDefinition * @property {Number} width - width in pixels */ /** * Some global callback * @callback requestCallback * @param {number} responseCode */ /** * JSDoc example tag * * @example * // This is the example. * exampleTag(); */ function exampleTag() {} /** * JSDoc example tag for class * * @example * // This is the example. * new ExampleClass(); */ class ExampleClass {} /** * JSDoc example tag for attribute * * @example * // This is the example. * console.log(ExampleAttribute); */ const ExampleAttribute = null; /** * @param {number} p1 * @param {Object} p2 * @param {string} p2.foo * @param {string} p2.bar */ function destructuredParams(p1, { foo, bar }) {} /** * @param a_ Snorf * @param {type_} b Borf_ * @returns {rtype_} Dorf_ */ function injection() {} /** * @param {function} [func=() => 5] * @param [str=a string with " quote] * @param {string} [strNum=42] * @param {string} [strBool=true] * @param [num=5] * @param [nil=null] */ function defaultsDocumentedInDoclet(func, str, strNum, strBool, num, nil) {} /** * @param [num] * @param [str] * @param [bool] * @param [nil] */ function defaultsDocumentedInCode( num = 5, str = "true", bool = true, nil = null, ) {} /** * Variadic parameter * @param a * @param args */ function variadicParameter(a, ...args) {} /** @deprecated */ function deprecatedFunction() {} /** @deprecated don't use anymore */ function deprecatedExplanatoryFunction() {} /** @deprecated */ const DeprecatedAttribute = null; /** @deprecated don't use anymore */ const DeprecatedExplanatoryAttribute = null; /** @deprecated */ class DeprecatedClass {} /** @deprecated don't use anymore */ class DeprecatedExplanatoryClass {} /** * @see DeprecatedClass * @see deprecatedFunction * @see DeprecatedAttribute */ function seeFunction() {} /** * @see DeprecatedClass * @see deprecatedFunction * @see DeprecatedAttribute */ const SeeAttribute = null; /** * @see DeprecatedClass * @see deprecatedFunction * @see DeprecatedAttribute */ class SeeClass {} /** * @arg fnodeA {Node|Fnode} */ function union(fnodeA) {} /** * Once upon a time, there was a large bear named Sid. Sid wore green pants * with blue stripes and pink polka dots. * * * List! * * @param a A is the first letter of the Roman alphabet. It is used in such * illustrious words as aardvark and artichoke. * @param b Next param, which should be part of the same field list */ function longDescriptions(a, b) {} /** * Class doc. */ class SimpleClass { /** * Static. * * @see nonStaticMethod */ static staticMethod() {} /** * Non-static member. * * @see staticMethod */ nonStaticMethod() {} } ================================================ FILE: tests/test_build_js/source/docs/autoattribute.rst ================================================ .. js:autoattribute:: ContainingClass#someVar ================================================ FILE: tests/test_build_js/source/docs/autoattribute_deprecated.rst ================================================ .. js:autoattribute:: DeprecatedAttribute .. js:autoattribute:: DeprecatedExplanatoryAttribute ================================================ FILE: tests/test_build_js/source/docs/autoattribute_example.rst ================================================ .. js:autoattribute:: ExampleAttribute ================================================ FILE: tests/test_build_js/source/docs/autoattribute_see.rst ================================================ .. js:autoattribute:: SeeAttribute ================================================ FILE: tests/test_build_js/source/docs/autoclass.rst ================================================ .. js:autoclass:: ContainingClass ================================================ FILE: tests/test_build_js/source/docs/autoclass_alphabetical.rst ================================================ .. js:autoclass:: NonAlphabetical :members: ================================================ FILE: tests/test_build_js/source/docs/autoclass_deprecated.rst ================================================ .. js:autoclass:: DeprecatedClass .. js:autoclass:: DeprecatedExplanatoryClass ================================================ FILE: tests/test_build_js/source/docs/autoclass_example.rst ================================================ .. js:autoclass:: ExampleClass ================================================ FILE: tests/test_build_js/source/docs/autoclass_exclude_members.rst ================================================ .. js:autoclass:: ClosedClass :members: :exclude-members: publical2, publical3 ================================================ FILE: tests/test_build_js/source/docs/autoclass_members.rst ================================================ .. js:autoclass:: ContainingClass :members: ================================================ FILE: tests/test_build_js/source/docs/autoclass_members_list.rst ================================================ .. js:autoclass:: ClosedClass :members: publical3, publical ================================================ FILE: tests/test_build_js/source/docs/autoclass_members_list_star.rst ================================================ .. js:autoclass:: ContainingClass :members: bar, *, someMethod ================================================ FILE: tests/test_build_js/source/docs/autoclass_no_paramnames.rst ================================================ Make sure we don't have KeyErrors on naked, memberless objects labeled as classes: .. js:autoclass:: NoParamnames ================================================ FILE: tests/test_build_js/source/docs/autoclass_private_members.rst ================================================ .. js:autoclass:: ContainingClass :members: :private-members: ================================================ FILE: tests/test_build_js/source/docs/autoclass_see.rst ================================================ .. js:autoclass:: SeeClass ================================================ FILE: tests/test_build_js/source/docs/autofunction_callback.rst ================================================ .. js:autofunction:: requestCallback ================================================ FILE: tests/test_build_js/source/docs/autofunction_defaults_code.rst ================================================ .. js:autofunction:: defaultsDocumentedInCode ================================================ FILE: tests/test_build_js/source/docs/autofunction_defaults_doclet.rst ================================================ .. js:autofunction:: defaultsDocumentedInDoclet ================================================ FILE: tests/test_build_js/source/docs/autofunction_deprecated.rst ================================================ .. js:autofunction:: deprecatedFunction .. js:autofunction:: deprecatedExplanatoryFunction ================================================ FILE: tests/test_build_js/source/docs/autofunction_destructured_params.rst ================================================ .. js:autofunction:: destructuredParams ================================================ FILE: tests/test_build_js/source/docs/autofunction_example.rst ================================================ .. js:autofunction:: exampleTag ================================================ FILE: tests/test_build_js/source/docs/autofunction_explicit.rst ================================================ .. js:autofunction:: linkDensity(snorko, borko[, forko]) Things are ``neat``. Off the beat. * Sweet * Fleet ================================================ FILE: tests/test_build_js/source/docs/autofunction_long.rst ================================================ .. js:autofunction:: ContainingClass#someMethod ================================================ FILE: tests/test_build_js/source/docs/autofunction_minimal.rst ================================================ .. js:autofunction:: linkDensity ================================================ FILE: tests/test_build_js/source/docs/autofunction_see.rst ================================================ .. js:autofunction:: seeFunction ================================================ FILE: tests/test_build_js/source/docs/autofunction_short.rst ================================================ .. js:autofunction:: ContainingClass#someMethod :short-name: ================================================ FILE: tests/test_build_js/source/docs/autofunction_static.rst ================================================ .. js:autoclass:: SimpleClass :members: ================================================ FILE: tests/test_build_js/source/docs/autofunction_typedef.rst ================================================ .. js:autofunction:: TypeDefinition ================================================ FILE: tests/test_build_js/source/docs/autofunction_variadic.rst ================================================ .. js:autofunction:: variadicParameter ================================================ FILE: tests/test_build_js/source/docs/avoid_shadowing.rst ================================================ .. js:autofunction:: more_code.shadow ================================================ FILE: tests/test_build_js/source/docs/conf.py ================================================ extensions = ["sphinx_js"] # Minimal stuff needed for Sphinx to work: source_suffix = ".rst" master_doc = "index" author = "Erik Rose" exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] ================================================ FILE: tests/test_build_js/source/docs/getter_setter.rst ================================================ .. js:autoattribute:: ContainingClass#bar ================================================ FILE: tests/test_build_js/source/docs/index.rst ================================================ ================================================ FILE: tests/test_build_js/source/docs/injection.rst ================================================ .. js:autofunction:: injection ================================================ FILE: tests/test_build_js/source/docs/union.rst ================================================ .. js:autofunction:: union ================================================ FILE: tests/test_build_js/source/docs/unwrapped.rst ================================================ .. js:autofunction:: longDescriptions ================================================ FILE: tests/test_build_js/source/more_code.js ================================================ // This file is processed after code.js and so tends to shadow things. /** Another thing named shadow, to threaten to shadow the one in code.js */ function shadow() {} ================================================ FILE: tests/test_build_js/test_build_js.py ================================================ from sphinx import __version__ as sphinx_version from tests.testing import SphinxBuildTestCase SPHINX_VERSION = tuple(int(part) for part in sphinx_version.split(".")) # NOTE(willkg): This is the version of Sphinx that removes trailing " --" from # :params: lines when there is no description. SPHINX_7_3_0 = (7, 3, 0) class Tests(SphinxBuildTestCase): """Tests which require our big JS Sphinx tree to be built. Yes, it's too coupled. Many of these are renderer tests, but some indirectly test JS analysis. These latter are left over from when JS was the only supported language and had its assumptions coded into the renderers. """ def test_autofunction_minimal(self): """Make sure we render correctly and pull the params out of the JS code when only the function name is provided.""" self._file_contents_eq( "autofunction_minimal", "linkDensity(node)" + DESCRIPTION + FIELDS ) def test_autofunction_explicit(self): """Make sure any explicitly provided params override the ones from the code, and make sure any explicit arbitrary RST content gets preserved.""" self._file_contents_eq( "autofunction_explicit", "linkDensity(snorko, borko[, forko])" + DESCRIPTION + FIELDS + CONTENT, ) def test_autofunction_short(self): """Make sure the ``:short-name:`` option works.""" self._file_contents_eq("autofunction_short", "someMethod(hi)\n\n Here.\n") def test_autofunction_long(self): """Make sure instance methods get converted to dotted notation which indexes better in Sphinx.""" self._file_contents_eq( "autofunction_long", "ContainingClass.someMethod(hi)\n\n Here.\n" ) def test_autofunction_typedef(self): """Make sure @typedef uses can be documented with autofunction.""" self._file_contents_eq( "autofunction_typedef", "TypeDefinition()\n\n Arguments:\n * **width** (**Number**) -- width in pixels\n", ) def test_autofunction_callback(self): """Make sure @callback uses can be documented with autofunction.""" self._file_contents_eq( "autofunction_callback", "requestCallback(responseCode)\n\n Some global callback\n\n Arguments:\n * **responseCode** (**number**)\n", ) def test_autofunction_example(self): """Make sure @example tags can be documented with autofunction.""" self._file_contents_eq( "autofunction_example", "exampleTag()\n\n" " JSDoc example tag\n\n" " Example:\n\n" " // This is the example.\n" " exampleTag();\n", ) def test_autofunction_destructured_params(self): """Make sure that all documented params appears in the function definition.""" self._file_contents_eq( "autofunction_destructured_params", "destructuredParams(p1, p2)\n\n" " Arguments:\n" " * **p1** (**number**)\n\n" " * **p2** (**Object**)\n\n" " * **p2.foo** (**string**)\n\n" " * **p2.bar** (**string**)\n", ) def test_autofunction_defaults_in_doclet(self): """Make sure param default values appear in the function definition, when defined in JSDoc.""" self._file_contents_eq( "autofunction_defaults_doclet", 'defaultsDocumentedInDoclet(func=() => 5, str="a string with \\" quote", strNum="42", strBool="true", num=5, nil=null)\n\n' " Arguments:\n" " * **func** (**function**)\n\n" " * **strNum** (**string**)\n\n" " * **strBool** (**string**)\n", ) def test_autofunction_defaults_in_code(self): """Make sure param default values appear in the function definition, when defined in code.""" self._file_contents_eq( "autofunction_defaults_code", 'defaultsDocumentedInCode(num=5, str="true", bool=true, nil=null)\n', ) def test_autofunction_variadic(self): """Make sure variadic parameters are rendered as ellipses.""" self._file_contents_eq( "autofunction_variadic", "variadicParameter(a, ...args)\n\n Variadic parameter\n", ) def test_autofunction_deprecated(self): """Make sure @deprecated tags can be documented with autofunction.""" self._file_contents_eq( "autofunction_deprecated", "deprecatedFunction()\n\n" " Note:\n\n" " Deprecated.\n\n" "deprecatedExplanatoryFunction()\n\n" " Note:\n\n" " Deprecated: don't use anymore\n", ) def test_autofunction_see(self): """Make sure @see tags work with autofunction.""" self._file_contents_eq( "autofunction_see", "seeFunction()\n\n" " See also:\n\n" ' * "DeprecatedClass"\n\n' ' * "deprecatedFunction"\n\n' ' * "DeprecatedAttribute"\n', ) def test_autofunction_static(self): """Make sure the static function gets its prefix ``static``.""" self._file_contents_eq( "autofunction_static", "class SimpleClass()\n\n" " Class doc.\n" "\n" " SimpleClass.nonStaticMethod()\n" "\n" " Non-static member.\n" "\n" " See also:\n" "\n" ' * "staticMethod"\n' "\n" " static SimpleClass.staticMethod()\n" "\n" " Static.\n" "\n" " See also:\n" "\n" ' * "nonStaticMethod"\n', ) def test_autoclass(self): """Make sure classes show their class comment and constructor comment.""" contents = self._file_contents("autoclass") assert "Class doc." in contents assert "Constructor doc." in contents def test_autoclass_members(self): """Make sure classes list their members if ``:members:`` is specified. Make sure it shows both functions and attributes and shows getters and setters as if they are attributes. Make sure it doesn't show private members. """ self._file_contents_eq( "autoclass_members", "class ContainingClass(ho)\n\n" " Class doc.\n" "\n" " Constructor doc.\n" "\n" " Arguments:\n" " * **ho** -- A thing\n" "\n" " ContainingClass.bar\n" "\n" " Setting this also frobs the frobnicator.\n" "\n" " ContainingClass.someVar\n" "\n" " A var\n" "\n" " ContainingClass.anotherMethod()\n" "\n" " Another.\n" "\n" " ContainingClass.someMethod(hi)\n" "\n" " Here.\n" "\n" " ContainingClass.yetAnotherMethod()\n" "\n" " More.\n", ) def test_autoclass_members_list(self): """Make sure including a list of names after ``members`` limits it to those names and follows the order you specify.""" self._file_contents_eq( "autoclass_members_list", "class ClosedClass()\n\n Closed class.\n\n ClosedClass.publical3()\n\n Public thing 3.\n\n ClosedClass.publical()\n\n Public thing.\n", ) def test_autoclass_members_list_star(self): """Make sure including ``*`` in a list of names after ``members`` includes the rest of the names in the normal order at that point.""" self._file_contents_eq( "autoclass_members_list_star", "class ContainingClass(ho)\n" "\n" " Class doc.\n" "\n" " Constructor doc.\n" "\n" " Arguments:\n" " * **ho** -- A thing\n" "\n" " ContainingClass.bar\n" "\n" " Setting this also frobs the frobnicator.\n" "\n" " ContainingClass.someVar\n" "\n" " A var\n" "\n" " ContainingClass.anotherMethod()\n" "\n" " Another.\n" "\n" " ContainingClass.yetAnotherMethod()\n" "\n" " More.\n" "\n" " ContainingClass.someMethod(hi)\n" "\n" " Here.\n", ) def test_autoclass_alphabetical(self): """Make sure members sort alphabetically when not otherwise specified.""" self._file_contents_eq( "autoclass_alphabetical", "class NonAlphabetical()\n\n Non-alphabetical class.\n\n NonAlphabetical.a()\n\n Fun a.\n\n NonAlphabetical.z()\n\n Fun z.\n", ) def test_autoclass_private_members(self): """Make sure classes list their private members if ``:private-members:`` is specified.""" contents = self._file_contents("autoclass_private_members") assert "secret()" in contents def test_autoclass_exclude_members(self): """Make sure ``exclude-members`` option actually excludes listed members.""" contents = self._file_contents("autoclass_exclude_members") assert "publical()" in contents assert "publical2" not in contents assert "publical3" not in contents def test_autoclass_example(self): """Make sure @example tags can be documented with autoclass.""" self._file_contents_eq( "autoclass_example", "class ExampleClass()\n\n" " JSDoc example tag for class\n\n" " Example:\n\n" " // This is the example.\n" " new ExampleClass();\n", ) def test_autoclass_deprecated(self): """Make sure @deprecated tags can be documented with autoclass.""" self._file_contents_eq( "autoclass_deprecated", "class DeprecatedClass()\n\n" " Note:\n\n" " Deprecated.\n\n" "class DeprecatedExplanatoryClass()\n\n" " Note:\n\n" " Deprecated: don't use anymore\n", ) def test_autoclass_see(self): """Make sure @see tags work with autoclass.""" self._file_contents_eq( "autoclass_see", "class SeeClass()\n\n" " See also:\n\n" ' * "DeprecatedClass"\n\n' ' * "deprecatedFunction"\n\n' ' * "DeprecatedAttribute"\n', ) def test_autoattribute(self): """Make sure ``autoattribute`` works.""" self._file_contents_eq("autoattribute", "ContainingClass.someVar\n\n A var\n") def test_autoattribute_example(self): """Make sure @example tags can be documented with autoattribute.""" self._file_contents_eq( "autoattribute_example", "ExampleAttribute\n\n" " JSDoc example tag for attribute\n\n" " Example:\n\n" " // This is the example.\n" " console.log(ExampleAttribute);\n", ) def test_autoattribute_deprecated(self): """Make sure @deprecated tags can be documented with autoattribute.""" self._file_contents_eq( "autoattribute_deprecated", "DeprecatedAttribute\n\n" " Note:\n\n" " Deprecated.\n\n" "DeprecatedExplanatoryAttribute\n\n" " Note:\n\n" " Deprecated: don't use anymore\n", ) def test_autoattribute_see(self): """Make sure @see tags work with autoattribute.""" self._file_contents_eq( "autoattribute_see", "SeeAttribute\n\n" " See also:\n\n" ' * "DeprecatedClass"\n\n' ' * "deprecatedFunction"\n\n' ' * "DeprecatedAttribute"\n', ) def test_getter_setter(self): """Make sure ES6-style getters and setters can be documented.""" self._file_contents_eq( "getter_setter", "ContainingClass.bar\n\n Setting this also frobs the frobnicator.\n", ) def test_no_shadowing(self): """Make sure we can disambiguate objects of the same name.""" self._file_contents_eq( "avoid_shadowing", "more_code.shadow()\n\n Another thing named shadow, to threaten to shadow the one in\n code.js\n", ) def test_restructuredtext_injection(self): """Make sure param names and types are escaped and cannot be interpreted as RestructuredText. Descriptions should not be escaped; it is a feature to be able to use RST markup there. """ self._file_contents_eq( "injection", "injection(a_, b)\n\n" " Arguments:\n" " * **a_** -- Snorf\n\n" " * **b** (**type_**) -- >>Borf_<<\n\n" " Returns:\n" " **rtype_** -- >>Dorf_<<\n", ) def test_union_types(self): """Make sure union types render into RST non-wonkily. The field was rendering into text as this before:: * **| Fnode fnodeA** (*Node*) -- I don't know what RST was thinking, but it got sane again when we switched from " | " as the union separator back to "|". """ assert "* **fnodeA** (**Node|Fnode**)" in self._file_contents("union") def test_field_list_unwrapping(self): """Ensure the tails of field lists have line breaks and leading whitespace removed. Otherwise, the RST parser decides the field list is over, leading to mangled markup. """ self._file_contents_eq( "unwrapped", "longDescriptions(a, b)\n" "\n" " Once upon a time, there was a large bear named Sid. Sid wore green\n" " pants with blue stripes and pink polka dots.\n" "\n" # Also assert that line breaks in the description are preserved: " * List!\n" "\n" " Arguments:\n" " * **a** -- A is the first letter of the Roman alphabet. It is\n" " used in such illustrious words as aardvark and artichoke.\n" "\n" " * **b** -- Next param, which should be part of the same field\n" " list\n", ) DESCRIPTION = """ Return the ratio of the inline text length of the links in an element to the inline text length of the entire element.""" FIELDS = """ Arguments: * **node** (**Node**) -- Something of a single type Throws: **PartyError|FartyError** -- Something with multiple types and a line that wraps Returns: **Number** -- What a thing """ # Oddly enough, the text renderer renders these bullets with a blank line # between, but the HTML renderer does make them a single list. CONTENT = """ Things are "neat". Off the beat. * Sweet * Fleet """ ================================================ FILE: tests/test_build_ts/source/class.ts ================================================ /** * A definition of a class */ export class ClassDefinition { field: string; /** * ClassDefinition constructor * @param simple A parameter with a simple type */ constructor(simple: number) {} /** * This is a method without return type * @param simple A parameter with a simple type */ method1(simple: number): void {} /** * This is a method (should be before method 'method1', but after fields) */ anotherMethod() {} } export interface Interface {} export abstract class ClassWithSupersAndInterfacesAndAbstract extends ClassDefinition implements Interface { /** I construct. */ constructor() { super(8); } } export interface InterfaceWithSupers extends Interface {} export class ExportedClass { constructor() {} } export class ConstructorlessClass {} export interface OptionalThings { /** * foop should preserve this documentation */ foop?(): void; /** * boop should preserve this documentation */ boop?: boolean; /** * noop should preserve this documentation */ noop?: () => void; } /** * Words words words * @param a An optional thing * @returns The result */ export function blah(a: OptionalThings): ConstructorlessClass { return 0 as ConstructorlessClass; } export function thunk(b: typeof blah) {} /** * A problematic self referential function * @param b troublemaker */ export function selfReferential(b: typeof selfReferential) {} /** * @deprecated since v20! */ export function deprecatedFunction1() {} /** * @deprecated */ export function deprecatedFunction2() {} /** * @example This is an example. * @example This is another example. * ```py * Something python * ``` */ export function exampleFunction() {} export async function asyncFunction() {} export class Iterable { *[Symbol.iterator](): Iterator { yield 1; yield 2; yield 3; return "abc"; } } export function predicate(c): c is ConstructorlessClass { return false; } /** * This shouldn't crash the renderer: * ```things``` */ export function weirdCodeInDescription() {} /** * `abc `_ */ export function spinxLinkInDescription() {} export class GetSetDocs { readonly x: number; y: number; /** * Getter with comment */ get a() { return 7; } /** * Setter with comment */ set b(x: number) {} } export class Base { /** Some docs for f */ f() {} get a() { return 7; } } export class Extension extends Base { /** Some docs for g */ g() {} } ================================================ FILE: tests/test_build_ts/source/docs/async_function.rst ================================================ .. js:autofunction:: asyncFunction ================================================ FILE: tests/test_build_ts/source/docs/autoclass_class_with_interface_and_supers.rst ================================================ .. js:autoclass:: ClassWithSupersAndInterfacesAndAbstract ================================================ FILE: tests/test_build_ts/source/docs/autoclass_constructorless.rst ================================================ .. js:autoclass:: ConstructorlessClass ================================================ FILE: tests/test_build_ts/source/docs/autoclass_exported.rst ================================================ .. js:autoclass:: ExportedClass ================================================ FILE: tests/test_build_ts/source/docs/autoclass_interface_optionals.rst ================================================ .. js:autoclass:: OptionalThings :members: ================================================ FILE: tests/test_build_ts/source/docs/autoclass_star.rst ================================================ .. js:autoclass:: ClassDefinition :members: method1, * ================================================ FILE: tests/test_build_ts/source/docs/automodule.rst ================================================ .. js:automodule:: module ================================================ FILE: tests/test_build_ts/source/docs/autosummary.rst ================================================ .. js:autosummary:: module ================================================ FILE: tests/test_build_ts/source/docs/conf.py ================================================ extensions = ["sphinx_js"] # Minimal stuff needed for Sphinx to work: source_suffix = ".rst" master_doc = "index" author = "Erik Rose" exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] jsdoc_config_path = "../typedoc.json" jsdoc_tsconfig_path = "../tsconfig.json" js_language = "typescript" from sphinx.util import rst from sphinx_js.ir import TypeXRef, TypeXRefInternal def ts_type_xref_formatter(config, xref: TypeXRef) -> str: if isinstance(xref, TypeXRefInternal): name = rst.escape(xref.name) return f":js:{xref.kind}:`{name}`" else: return xref.name ================================================ FILE: tests/test_build_ts/source/docs/deprecated.rst ================================================ .. js:autofunction:: deprecatedFunction1 .. js:autofunction:: deprecatedFunction2 ================================================ FILE: tests/test_build_ts/source/docs/example.rst ================================================ .. js:autofunction:: exampleFunction ================================================ FILE: tests/test_build_ts/source/docs/getset.rst ================================================ .. js:autoclass:: GetSetDocs :members: ================================================ FILE: tests/test_build_ts/source/docs/index.rst ================================================ .. js:autoclass:: ClassDefinition :members: The link from the superclass in autoclass_class_with_interface_and_supers.rst will point here. .. js:autoclass:: Interface :members: Same here. .. js:autofunction:: weirdCodeInDescription ================================================ FILE: tests/test_build_ts/source/docs/inherited_docs.rst ================================================ .. js:autoclass:: Base :members: .. js:autoclass:: Extension :members: ================================================ FILE: tests/test_build_ts/source/docs/predicate.rst ================================================ .. js:autofunction:: predicate ================================================ FILE: tests/test_build_ts/source/docs/sphinx_link_in_description.rst ================================================ .. js:autofunction:: spinxLinkInDescription ================================================ FILE: tests/test_build_ts/source/docs/symbol.rst ================================================ .. js:autoclass:: Iterable :members: ================================================ FILE: tests/test_build_ts/source/docs/xrefs.rst ================================================ .. js:autofunction:: blah blah .. js:autofunction:: thunk Xrefs in the function type of the argument ================================================ FILE: tests/test_build_ts/source/empty.ts ================================================ // Test that we don't crash on a file with no exports ================================================ FILE: tests/test_build_ts/source/module.ts ================================================ /** * The thing. */ export const a = 7; /** * Clutches the bundle */ export async function f() {} export function z(a: number, b: typeof q): number { return a; } /** * This is a summary. This is more info. */ export class A { async f() {} [Symbol.iterator]() {} g(a: number): number { return a + 1; } } /** * An instance of class A */ export let aInstance: A; /** * @typeParam T Description of T */ export class Z { x: T; constructor(a: number, b: number) {} z() {} } export let zInstance: Z; /** * Another thing. */ export const q = { a: "z29", b: 76 }; /** * Documentation for the interface I */ export interface I {} /** * An instance of the interface */ export let interfaceInstance: I = {}; /** * A super special type alias * @typeParam T The whatsit */ export type TestTypeAlias = { a: T }; export type TestTypeAlias2 = { a: number }; /** * Omit from automodule and send summary link somewhere else * @omitFromAutoModule * @summaryLink :js:typealias:`TestTypeAlias3 ` */ export type TestTypeAlias3 = { a: number }; export let t: TestTypeAlias; export let t2: TestTypeAlias2; /** * A function with a type parameter! * * We'll refer to ourselves: :js:func:`~module.functionWithTypeParam` * * @typeParam T The type parameter * @typeParam S Another type param * @param z A Z of T * @returns The x field of z */ export function functionWithTypeParam(z: Z): T { return z.x; } ================================================ FILE: tests/test_build_ts/source/tsconfig.json ================================================ { "compilerOptions": { "target": "es6", "module": "commonjs", "moduleResolution": "node" } } ================================================ FILE: tests/test_build_ts/source/typedoc.json ================================================ {} ================================================ FILE: tests/test_build_ts/test_build_ts.py ================================================ from textwrap import dedent from bs4 import BeautifulSoup, Tag from tests.testing import SphinxBuildTestCase class TestTextBuilder(SphinxBuildTestCase): """Tests which require our big TS Sphinx tree to be built (as text)""" def test_autoclass_constructor(self): """Make sure class constructor comes before methods.""" contents = self._file_contents("index") pos_cstrct = contents.index("ClassDefinition constructor") pos_method = contents.index("ClassDefinition.method1") assert pos_method > pos_cstrct, ( "Constructor appears after method in " + contents ) def test_autoclass_order(self): """Make sure fields come before methods.""" contents = self._file_contents("index") pos_field = contents.index("ClassDefinition.field") pos_method2 = contents.index("ClassDefinition.anotherMethod") pos_method = contents.index("ClassDefinition.method1") assert pos_field < pos_method2 < pos_method, ( "Methods and fields are not in right order in " + contents ) def test_autoclass_star_order(self): """Make sure fields come before methods even when using ``*``.""" contents = self._file_contents("autoclass_star") pos_method = contents.index("ClassDefinition.method1") pos_field = contents.index("ClassDefinition.field") pos_method2 = contents.index("ClassDefinition.anotherMethod") assert pos_method < pos_field < pos_method2, ( "Methods and fields are not in right order in " + contents ) def test_abstract_extends_and_implements(self): """Make sure the template correctly represents abstract classes, classes with superclasses, and classes that implement interfaces. These are all TypeScript-specific features. """ # The quotes around ClassDefinition must be some weird decision in # Sphinx's text output. I don't care if they go away in a future # version of Sphinx. It doesn't affect the HTML output. self._file_contents_eq( "autoclass_class_with_interface_and_supers", "class ClassWithSupersAndInterfacesAndAbstract()\n" "\n" " A definition of a class\n\n" " *abstract*\n" "\n" ' *exported from* "class"\n' "\n" " **Extends:**\n" ' * "ClassDefinition"\n' "\n" " **Implements:**\n" ' * "Interface"\n' "\n" " I construct.\n", ) def test_exported_from(self): """Make sure classes say where they were exported from. I'm divided on whether this is even useful. Maybe people should just specify full.path.Names in the js:autoclass directives if they want to surface that info. """ self._file_contents_eq( "autoclass_exported", 'class ExportedClass()\n\n *exported from* "class"\n', ) def test_constructorless_class(self): """Make sure constructorless classes don't crash the renderer.""" self._file_contents_eq( "autoclass_constructorless", 'class ConstructorlessClass()\n\n *exported from* "class"\n', ) def test_optional_members(self): """Make sure optional attributes and functions of interfaces get question marks sticking out of them.""" self._file_contents_eq( "autoclass_interface_optionals", "interface OptionalThings\n" "\n" ' *exported from* "class"\n' "\n" " OptionalThings.boop?\n" "\n" " type: boolean\n" "\n" " boop should preserve this documentation\n" "\n" " OptionalThings.foop?()\n" "\n" " foop should preserve this documentation\n" "\n" " OptionalThings.noop?()\n" "\n" " noop should preserve this documentation\n", ) def test_deprecated(self): self._file_contents_eq( "deprecated", dedent( """\ deprecatedFunction1() Note: Deprecated: since v20! deprecatedFunction2() Note: Deprecated. """ ), ) def test_example(self): self._file_contents_eq( "example", dedent( """\ exampleFunction() Example: This is an example. Example: This is another example. Something python """ ), ) def test_async(self): self._file_contents_eq( "async_function", dedent( """\ async asyncFunction() Returns: Promise """ ), ) def test_symbol(self): expected = dedent( """\ class Iterable() *exported from* "class" Iterable.[Symbol․iterator]() Returns: Iterator """ ) self._file_contents_eq("symbol", expected) def test_predicate(self): self._file_contents_eq( "predicate", dedent( """\ predicate(c) Arguments: * **c** (any) Returns: boolean (typeguard for "ConstructorlessClass") """ ), ) def test_get_set(self): self._file_contents_eq( "getset", dedent( """\ class GetSetDocs() *exported from* "class" GetSetDocs.a type: readonly number Getter with comment GetSetDocs.b type: number Setter with comment GetSetDocs.x type: readonly number GetSetDocs.y type: number """ ), ) def test_inherited_docs(self): # Check that we aren't including documentation entries from the base class self._file_contents_eq( "inherited_docs", dedent( """\ class Base() *exported from* "class" Base.a type: readonly number Base.f() Some docs for f class Extension() *exported from* "class" **Extends:** * "Base" Extension.g() Some docs for g """ ), ) def test_automodule(self): self._file_contents_eq( "automodule", dedent( """\ module.TestTypeAlias type: { a: T; } A super special type alias Type parameters: **T** -- The whatsit (extends "A") module.TestTypeAlias2 type: { a: number; } module.a type: 7 The thing. module.aInstance type: "A" An instance of class A module.interfaceInstance type: "I" An instance of the interface module.q type: { a: string; b: number; } Another thing. module.t type: "TestTypeAlias"<"A"> module.t2 type: "TestTypeAlias2" module.zInstance type: "Z"<"A"> async module.f() Clutches the bundle Returns: Promise module.functionWithTypeParam(z) A function with a type parameter! We'll refer to ourselves: "functionWithTypeParam()" Type parameters: **T** -- The type parameter (extends "A") Arguments: * **z** ("Z") -- A Z of T Returns: T -- The x field of z module.z(a, b) Arguments: * **a** (number) * **b** ({ a: string; b: number; }) Returns: number interface module.I Documentation for the interface I *exported from* "module" class module.A() This is a summary. This is more info. *exported from* "module" A.[Symbol․iterator]() async A.f() Returns: Promise A.g(a) Arguments: * **a** (number) Returns: number class module.Z(a, b) *exported from* "module" Type parameters: **T** -- Description of T (extends "A") Arguments: * **a** (number) * **b** (number) Z.x type: T Z.z() """ ), ) class TestHtmlBuilder(SphinxBuildTestCase): """Tests which require an HTML build of our Sphinx tree, for checking links""" builder = "html" def test_extends_links(self): """Make sure superclass mentions link to their definitions.""" assert 'href="index.html#ClassDefinition"' in self._file_contents( "autoclass_class_with_interface_and_supers" ) def test_implements_links(self): """Make sure implemented interfaces link to their definitions.""" assert 'href="index.html#Interface"' in self._file_contents( "autoclass_class_with_interface_and_supers" ) def test_extends_type_param_links(self): """Make sure implemented interfaces link to their definitions.""" soup = BeautifulSoup(self._file_contents("automodule"), "html.parser") z = soup.find(id="module.Z") assert z assert z.parent t = z.parent.find_all(class_="sphinx_js-type") s: Tag = t[0] href: Tag = list(s.children)[0] assert href.name == "a" assert href.get_text() == "A" assert href.attrs["class"] == ["reference", "internal"] assert href.attrs["title"] == "module.A" assert href.attrs["href"] == "#module.A" def test_xrefs(self): soup = BeautifulSoup(self._file_contents("xrefs"), "html.parser") def get_links(id): return soup.find(id=id).parent.find_all("a") links = get_links("blah") href = links[1] assert href.attrs["class"] == ["reference", "internal"] assert href.attrs["href"] == "autoclass_interface_optionals.html#OptionalThings" assert href.attrs["title"] == "OptionalThings" assert next(href.children).name == "code" assert href.get_text() == "OptionalThings" href = links[2] assert href.attrs["class"] == ["reference", "internal"] assert ( href.attrs["href"] == "autoclass_constructorless.html#ConstructorlessClass" ) assert href.get_text() == "ConstructorlessClass" thunk_links = get_links("thunk") assert thunk_links[1].get_text() == "OptionalThings" assert thunk_links[2].get_text() == "ConstructorlessClass" def test_sphinx_link_in_description(self): soup = BeautifulSoup( self._file_contents("sphinx_link_in_description"), "html.parser" ) href = soup.find(id="spinxLinkInDescription").parent.find_all("a")[1] assert href.get_text() == "abc" assert href.attrs["href"] == "http://example.com" def test_sphinx_js_type_class(self): soup = BeautifulSoup(self._file_contents("async_function"), "html.parser") href = soup.find_all(class_="sphinx_js-type") assert len(href) == 1 assert href[0].get_text() == "Promise" def test_autosummary(self): soup = BeautifulSoup(self._file_contents("autosummary"), "html.parser") attrs = soup.find(class_="attributes") rows = list(attrs.find_all("tr")) assert len(rows) == 7 href = rows[0].find("a") assert href.get_text() == "a" assert href["href"] == "automodule.html#module.a" assert rows[0].find(class_="summary").get_text() == "The thing." href = rows[1].find("a") assert href.get_text() == "aInstance" assert href["href"] == "automodule.html#module.aInstance" assert rows[1].find(class_="summary").get_text() == "An instance of class A" funcs = soup.find(class_="functions") rows = list(funcs.find_all("tr")) assert len(rows) == 3 row0 = list(rows[0].children) NBSP = "\xa0" assert row0[0].get_text() == f"async{NBSP}f()" href = row0[0].find("a") assert href.get_text() == "f" assert href["href"] == "automodule.html#module.f" assert rows[0].find(class_="summary").get_text() == "Clutches the bundle" row1 = list(rows[2].children) assert row1[0].get_text() == "z(a, b)" href = row1[0].find("a") assert href.get_text() == "z" assert href["href"] == "automodule.html#module.z" classes = soup.find(class_="classes") assert classes.find(class_="summary").get_text() == "This is a summary." interfaces = soup.find(class_="interfaces") assert ( interfaces.find(class_="summary").get_text() == "Documentation for the interface I" ) type_aliases = soup.find(class_="type_aliases") assert type_aliases assert ( type_aliases.find(class_="summary").get_text() == "A super special type alias" ) rows = list(type_aliases.find_all("tr")) assert len(rows) == 3 href = rows[2].find("a") assert href.get_text() == "TestTypeAlias3" assert href["href"] == "automodule.html#module.TestTypeAlias" ================================================ FILE: tests/test_build_xref_none/source/docs/conf.py ================================================ extensions = ["sphinx_js"] js_language = "typescript" js_source_path = ["../main.ts"] root_for_relative_js_paths = "../" suppress_warnings = ["config.cache"] def ts_type_xref_formatter(config, xref): """Always return an invalid :js:None: role to test error propagation.""" return f":js:None:`{xref.name}`" ================================================ FILE: tests/test_build_xref_none/source/docs/index.rst ================================================ An extra line so we can test whether the error points to the right line. Another extra line. .. js:autoattribute:: thing ================================================ FILE: tests/test_build_xref_none/source/main.ts ================================================ /** A simple variable to document. */ export let thing: number; ================================================ FILE: tests/test_build_xref_none/test_build_xref_none.py ================================================ """ RST errors from ts_type_xref_formatter should cause build failures. The test conf.py uses a formatter that always returns :js:None:`...`, which is an invalid RST role. This should cause the build to fail. We also test that the error message points to the right location. """ import io from contextlib import redirect_stderr from pathlib import Path from sphinx.cmd.build import main as sphinx_main def test_build_fails_with_invalid_role(tmp_path: Path): """Build must fail when ts_type_xref_formatter emits an invalid RST role.""" docs_dir = str(Path(__file__).parent / "source" / "docs") stderr = io.StringIO() with redirect_stderr(stderr): result = sphinx_main([docs_dir, "-b", "text", "-W", "-E", str(tmp_path)]) output = stderr.getvalue() assert result != 0, "Expected build failure due to invalid :js:None: role" assert "index.rst:4" in output, f"Expected error at index.rst:4, got: {output}" ================================================ FILE: tests/test_common_mark/source/code.js ================================================ /** * Foo. */ function foo() {} ================================================ FILE: tests/test_common_mark/source/docs/conf.py ================================================ from recommonmark.transform import AutoStructify extensions = ["recommonmark", "sphinx.ext.mathjax", "sphinx_js"] source_suffix = [".rst", ".md"] master_doc = "index" author = "Jam Risser" exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] def setup(app): app.add_config_value( "recommonmark_config", { "auto_toc_tree_section": "Content", "enable_auto_doc_ref": True, "enable_auto_toc_tree": True, "enable_eval_rst": True, "enable_inline_math": True, "enable_math": True, }, True, ) app.add_transform(AutoStructify) ================================================ FILE: tests/test_common_mark/source/docs/index.md ================================================ ```eval_rst .. js:autofunction:: foo ``` ================================================ FILE: tests/test_common_mark/test_common_mark.py ================================================ from tests.testing import SphinxBuildTestCase class Tests(SphinxBuildTestCase): def test_build_success(self): """Mostly just test that the build doesn't crash, which is what used to happen before we added the tab_width workaround. """ self._file_contents_eq("index", "foo()\n\n Foo.\n") ================================================ FILE: tests/test_dot_dot_paths/source/code.js ================================================ /** * Bar function */ function bar(node) {} ================================================ FILE: tests/test_dot_dot_paths/source/docs/conf.py ================================================ extensions = ["sphinx_js"] # Minimal stuff needed for Sphinx to work: source_suffix = ".rst" master_doc = "index" author = "Erik Rose" exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] root_for_relative_js_paths = "./" ================================================ FILE: tests/test_dot_dot_paths/source/docs/index.rst ================================================ .. js:autofunction:: bar ================================================ FILE: tests/test_dot_dot_paths/test_dot_dot_paths.py ================================================ from tests.testing import SphinxBuildTestCase class Tests(SphinxBuildTestCase): def test_dot_dot(self): """Make sure the build doesn't explode with a parse error on the "../more.bar" path that is constructed when the JSDoc doclets are imbibed. Also make sure it render correctly afterward. """ self._file_contents_eq("index", "bar(node)\n\n Bar function\n") ================================================ FILE: tests/test_incremental.py ================================================ """Test incremental builds.""" import warnings from pathlib import Path import pytest from sphinx.environment import CONFIG_NEW, CONFIG_OK try: from sphinx.util.console import strip_colors except ImportError: from sphinx.testing.util import strip_escseq as strip_colors def build(app): """Build the given app, collecting docnames read and written (resolved). Returns a tuple (status text, [reads], [writes]), with reads and writes sorted for convenience. """ reads = set([]) writes = set([]) def source_read(app, docname, source): reads.add(docname) def doctree_resolved(app, doctree, docname): writes.add(docname) source_read_id = app.connect("source-read", source_read) doctree_resolved_id = app.connect("doctree-resolved", doctree_resolved) try: with warnings.catch_warnings(): warnings.filterwarnings(action="ignore", category=DeprecationWarning) app.build() finally: app.disconnect(source_read_id) app.disconnect(doctree_resolved_id) return ( strip_colors(app._status.getvalue()), list(sorted(reads)), list(sorted(writes)), ) def do_test(app, extension="js"): # Clean build. assert app.env.config_status == CONFIG_NEW status, reads, writes = build(app) assert reads == ["a", "a_b", "b", "index", "unrelated"] assert writes == ["a", "a_b", "b", "index", "unrelated"] # Incremental build, no config changed and no files changed. assert app.env.config_status == CONFIG_OK status, reads, writes = build(app) assert reads == [] assert writes == [] # Incremental build, one file changed. a_js = Path(app.srcdir) / f"a.{extension}" a_js.write_text(a_js.read_text() + "\n\n") assert app.env.config_status == CONFIG_OK status, reads, writes = build(app) # FIXME: re-enable the rest of this test! return assert reads == ["a", "a_b"] # Note that the transitive dependency 'index' is written. assert writes == ["a", "a_b", "index"] # Incremental build, the other file changed. b_js = Path(app.srcdir) / "inner" / f"b.{extension}" b_js.write_text(b_js.read_text() + "\n\n") assert app.env.config_status == CONFIG_OK status, reads, writes = build(app) assert reads == ["a_b", "b"] # Note that the transitive dependency 'index' is written. assert writes == ["a_b", "b", "index"] # We must use a "real" builder since the `dummy` builder does not track changed # files. @pytest.mark.sphinx("html", testroot="incremental_js") def test_incremental_js(make_app, app_params): args, kwargs = app_params app = make_app(*args, freshenv=True, **kwargs) do_test(app, extension="js") @pytest.mark.xfail(reason="TODO: fix me!") @pytest.mark.sphinx("html", testroot="incremental_ts") def test_incremental_ts(make_app, app_params): args, kwargs = app_params app = make_app(*args, freshenv=True, **kwargs) do_test(app, extension="ts") ================================================ FILE: tests/test_init.py ================================================ import pytest from sphinx.errors import SphinxError from sphinx_js import root_or_fallback def test_relative_path_root(): """Make sure the computation of the root path for relative JS entity pathnames is right.""" # Fall back to the only source path if not specified. assert root_or_fallback(None, ["a"]) == "a" with pytest.raises(SphinxError): root_or_fallback(None, ["a", "b"]) assert root_or_fallback("smoo", ["a"]) == "smoo" ================================================ FILE: tests/test_ir.py ================================================ from inspect import getmembers from json import dumps, loads import pytest from sphinx_js.ir import ( Attribute, DescriptionCode, DescriptionName, DescriptionText, Function, Param, Pathname, Return, TopLevel, TypeXRefExternal, TypeXRefInternal, converter, json_to_ir, ) def test_default(): """Accessing ``.default`` on a Param having a default should return the default value.""" p = Param(name="fred", has_default=True, default="boof") assert p.default == "boof" def test_missing_default(): """Constructing a Param with ``has_default=True`` but without a ``default`` value should raise an error.""" with pytest.raises(ValueError): Param(name="fred", has_default=True) top_level_base = TopLevel( name="blah", block_tags={}, deppath="x", deprecated=False, description=[], examples=[], exported_from=Pathname([]), filename="", line=7, modifier_tags=[], path=Pathname([]), properties=[], see_alsos=[], kind="", ) tl_dict = {k: v for k, v in getmembers(top_level_base) if not k.startswith("_")} del tl_dict["kind"] attribute_base = Attribute( **tl_dict, is_abstract=False, is_optional=False, is_static=False, is_private=False, readonly=False, type=[], ) attr_dict = {k: v for k, v in getmembers(attribute_base) if not k.startswith("_")} def attr_with(**kwargs): return Attribute(**(attr_dict | kwargs)) function_base = Function( **tl_dict, is_abstract=False, is_optional=False, is_static=False, is_private=False, is_async=False, params=[], exceptions=[], returns=[], ) func_dict = {k: v for k, v in getmembers(function_base) if not k.startswith("_")} def func_with(**kwargs): return Function(**(func_dict | kwargs)) # Check that we can successfully serialize and desrialize IR. @pytest.mark.parametrize( "x", [ attr_with(), attr_with(type="a string"), attr_with( type=[ TypeXRefInternal("xx", ["a", "b"]), "x", TypeXRefExternal("blah", "pkg", "sfn", "qn"), ] ), attr_with( deprecated=True, ), attr_with( deprecated="a string", ), attr_with( deprecated=[ DescriptionName("name"), DescriptionText("xx"), DescriptionCode("yy"), ], ), func_with(), func_with(params=[Param(name="fred", has_default=True, default="boof")]), func_with( params=[Param(name="fred", has_default=False)], returns=[ Return( type=[TypeXRefInternal("a", [])], description=[DescriptionText("x")] ) ], ), ], ) def test_ir_serialization(x): l = [x] s = converter.unstructure(l) s2 = loads(dumps(s)) assert s == s2 l2 = json_to_ir(s2) assert l2 == l ================================================ FILE: tests/test_jsdoc_analysis/source/class.js ================================================ /** * This is a long description that should not be unwrapped. Once day, I was * walking down the street, and a large, green, polka-dotted grand piano fell * from the 23rd floor of an apartment building. * * @example Example in class */ class Foo { /** * Constructor doc. * * @arg ho A thing * @example Example in constructor */ constructor(ho) {} /** * Setting this also frobs the frobnicator. */ get bar() { return this._bar; } set bar(baz) { this._bar = _bar; } /** * Private method. * * @private */ secret() {} } ================================================ FILE: tests/test_jsdoc_analysis/source/function.js ================================================ /** * Determine any of type, note, score, and element using a callback. This * overrides any previous call. * * The callback should return... * * * An optional :term:`subscore` * * A type (required on ``dom(...)`` rules, defaulting to the input one on * ``type(...)`` rules) * * @param {String} bar Which bar * @param baz * @return {Number} How many things * there are * @exception ExplosionError It went boom. */ function foo(bar, baz = 8) {} ================================================ FILE: tests/test_jsdoc_analysis/test_jsdoc.py ================================================ from sphinx_js.ir import ( Attribute, DescriptionCode, Exc, Function, Param, Pathname, Return, ) from sphinx_js.jsdoc import full_path_segments from tests.testing import JsDocTestCase def test_doclet_full_path(): """Sanity-check full_path_segments(), including throwing it a non-.js filename.""" doclet = { "meta": { "filename": "utils.jsm", "path": "/boogie/smoo/Checkouts/fathom", }, "longname": "best#thing~yeah", } assert full_path_segments(doclet, "/boogie/smoo/Checkouts") == [ "./", "fathom/", "utils.", "best#", "thing~", "yeah", ] class TestFunction(JsDocTestCase): file = "function.js" def test_top_level_and_function(self): """Test Function (and thus also TopLevel) analysis. This also includes exceptions, returns, params, and default values. """ function = self.analyzer.get_object(["foo"], "function") assert function == Function( name="foo", path=Pathname(["./", "function.", "foo"]), filename="function.js", deppath="function.js", # Line breaks and indentation should be preserved: description=( "Determine any of type, note, score, and element using a callback. This\n" "overrides any previous call.\n" "\n" "The callback should return...\n" "\n" "* An optional :term:`subscore`\n" "* A type (required on ``dom(...)`` rules, defaulting to the input one on\n" " ``type(...)`` rules)" ), line=17, deprecated=False, examples=[], see_alsos=[], properties=[], is_private=False, exported_from=None, is_abstract=False, is_optional=False, is_static=False, is_async=False, params=[ Param( name="bar", description="Which bar", has_default=False, is_variadic=False, type="String", ), Param( name="baz", description="", has_default=True, default="8", is_variadic=False, type=None, ), ], exceptions=[Exc(type=None, description="ExplosionError It went boom.")], returns=[ Return( type="Number", # Line breaks and indentation should be preserved: description="How many things\n there are", ) ], ) class TestClass(JsDocTestCase): file = "class.js" def test_class(self): """Test Class analysis, including members, attributes, and privacy.""" cls = self.analyzer.get_object(["Foo"], "class") assert cls.name == "Foo" assert cls.path == Pathname(["./", "class.", "Foo"]) assert cls.filename == "class.js" assert ( cls.description == "This is a long description that should not be unwrapped. Once day, I was\nwalking down the street, and a large, green, polka-dotted grand piano fell\nfrom the 23rd floor of an apartment building." ) # Not ideal, as it refers to the constructor, but we'll allow it assert cls.line in ( 8, # jsdoc 4.0.0 15, # jsdoc 3.6.3 ) # We ignore examples and other fields from the class doclet so far. This could change someday. assert cls.examples == [[DescriptionCode(code="```js\nExample in class\n```")]] # Members: getter, private_method = cls.members # default constructor not included here assert isinstance(private_method, Function) assert private_method.name == "secret" assert private_method.path == Pathname(["./", "class.", "Foo#", "secret"]) assert private_method.description == "Private method." assert private_method.is_private is True assert isinstance(getter, Attribute) assert getter.name == "bar" assert getter.path == Pathname(["./", "class.", "Foo#", "bar"]) assert getter.filename == "class.js" assert getter.description == "Setting this also frobs the frobnicator." # Constructor: constructor = cls.constructor_ assert constructor.name == "Foo" assert constructor.path == Pathname( ["./", "class.", "Foo"] ) # Same path as class. This might differ in different languages. assert constructor.filename == "class.js" assert constructor.description == "Constructor doc." assert constructor.examples == [ [DescriptionCode(code="```js\nExample in class\n```")] ] assert constructor.params == [ Param( name="ho", description="A thing", has_default=False, is_variadic=False, type=None, ) ] ================================================ FILE: tests/test_parsers.py ================================================ from sphinx_js.parsers import PathVisitor, path_and_formal_params def test_escapes(): r"""Make sure escapes work right and Python escapes like \n don't work.""" assert PathVisitor().parse(r"d\.i:\nr/ut\\ils.max(whatever)") == [ [r"d.i:nr/", r"ut\ils.", "max"], "(whatever)", ] def test_relative_dirs(): """Make sure all sorts of relative-dir prefixes result in proper path segment arrays.""" assert PathVisitor().visit(path_and_formal_params["path"].parse("./hi")) == [ "./", "hi", ] assert PathVisitor().visit(path_and_formal_params["path"].parse("../../hi")) == [ "../", "../", "hi", ] ================================================ FILE: tests/test_paths.py ================================================ import subprocess from pathlib import Path import pytest from conftest import TYPEDOC_VERSION from sphinx.errors import SphinxError from sphinx_js.analyzer_utils import search_node_modules @pytest.fixture(autouse=True) def clear_node_modules_env(monkeypatch): monkeypatch.delenv("SPHINX_JS_NODE_MODULES") @pytest.fixture def global_install(tmp_path_factory, monkeypatch): tmpdir = tmp_path_factory.mktemp("my_program_global") my_program = tmpdir / "my_program" my_program.write_text("") my_program.chmod(0o777) monkeypatch.setenv("PATH", str(tmpdir), prepend=":") return tmpdir @pytest.fixture def no_local_install(tmp_path_factory): my_program = tmp_path_factory.mktemp("my_program_local") working_dir = my_program / "a" / "b" / "c" return working_dir my_prog_path = Path("my_program/sub/bin.js") @pytest.fixture def local_install(no_local_install): working_dir = no_local_install bin_path = working_dir.parents[1] / "node_modules" / my_prog_path bin_path.parent.mkdir(parents=True) bin_path.write_text("") return (working_dir, bin_path) @pytest.fixture def env_install(monkeypatch): env_path = Path("/a/b/c") monkeypatch.setenv("SPHINX_JS_NODE_MODULES", str(env_path)) return env_path / my_prog_path def test_global(global_install, no_local_install): # If no env or local, use global working_dir = no_local_install assert search_node_modules("my_program", my_prog_path, working_dir) == str( global_install / "my_program" ) def test_node_modules1(global_install, local_install): # If local and global, use local [working_dir, bin_path] = local_install assert search_node_modules("my_program", my_prog_path, working_dir) == str(bin_path) def test_node_modules2(local_install): # If local only, use local [working_dir, bin_path] = local_install assert search_node_modules("my_program", my_prog_path, working_dir) == str(bin_path) def test_env1(env_install): # If env only, use env assert search_node_modules("my_program", my_prog_path, "/x/y/z") == str(env_install) def test_env2(env_install, local_install, global_install): # If env, local, and global, use env [working_dir, _] = local_install assert search_node_modules("my_program", my_prog_path, working_dir) == str( env_install ) def test_err(): with pytest.raises( SphinxError, match='my_program was not found. Install it using "npm install my_program"', ): search_node_modules("my_program", my_prog_path, "/a/b/c") @pytest.mark.xfail(reason="Isn't working right now. Not sure why.") def test_global_install(tmp_path_factory, monkeypatch): tmpdir = tmp_path_factory.mktemp("global_root") tmpdir2 = tmp_path_factory.mktemp("blah") monkeypatch.setenv("npm_config_prefix", str(tmpdir)) monkeypatch.setenv("PATH", str(tmpdir / "bin"), prepend=":") version = ".".join(str(x) for x in TYPEDOC_VERSION) subprocess.run(["npm", "i", "-g", f"typedoc@{version}", "typescript"]) typedoc = search_node_modules("typedoc", "typedoc/bin/typedoc", str(tmpdir2)) monkeypatch.setenv("TYPEDOC_NODE_MODULES", str(Path(typedoc).parents[3])) dir = Path(__file__).parents[1].resolve() / "sphinx_js/js" res = subprocess.run( [ "npx", "tsx@4.15.8", "--import", dir / "registerImportHook.mjs", dir / "main.ts", "--version", ], capture_output=True, encoding="utf8", ) print(res.stdout) print(res.stderr) res.check_returncode() assert f"TypeDoc {version}" in res.stdout ================================================ FILE: tests/test_renderers.py ================================================ from textwrap import dedent, indent from typing import Any import pytest from sphinx.util import rst from sphinx_js.ir import ( Attribute, Class, DescriptionCode, DescriptionText, Exc, Function, Interface, Module, Param, Return, TypeAlias, TypeParam, TypeXRefExternal, TypeXRefInternal, ) from sphinx_js.renderers import ( AutoAttributeRenderer, AutoFunctionRenderer, AutoModuleRenderer, render_description, ) def setindent(txt): return indent(dedent(txt), " " * 3) def test_render_description(): assert render_description( [ DescriptionText(text="Code 1 had "), DescriptionCode(code="`single ticks around it`"), DescriptionText(text=".\nCode 2 has "), DescriptionCode(code="``double ticks around it``"), DescriptionText(text=".\nCode 3 has a :sphinx:role:"), DescriptionCode(code="`before it`"), DescriptionText(text=".\n\n"), DescriptionCode(code="```js\nA JS code pen!\n```"), DescriptionText(text="\nAnd some closing words."), ] ) == dedent( """\ Code 1 had ``single ticks around it``. Code 2 has ``double ticks around it``. Code 3 has a :sphinx:role:`before it`. .. code-block:: js A JS code pen! And some closing words.""" ) def ts_xref_formatter(config, xref): if isinstance(xref, TypeXRefInternal): name = rst.escape(xref.name) return f":js:{xref.kind}:`{name}`" else: return xref.name def make_renderer(cls): class _app: class config: ts_type_xref_formatter = ts_xref_formatter renderer = cls.__new__(cls) renderer._app = _app renderer._explicit_formal_params = None renderer._content = [] renderer._set_type_xref_formatter(ts_xref_formatter) renderer._add_span = False return renderer @pytest.fixture() def function_renderer(): def lookup_object(self, partial_path: list[str]): return self.objects[partial_path[-1]] renderer = make_renderer(AutoFunctionRenderer) renderer.lookup_object = lookup_object.__get__(renderer) renderer.objects = {} return renderer @pytest.fixture() def attribute_renderer(): return make_renderer(AutoAttributeRenderer) @pytest.fixture() def auto_module_renderer(): renderer = make_renderer(AutoModuleRenderer) class directive: class state: class document: class settings: pass renderer._directive = directive return renderer @pytest.fixture() def function_render(function_renderer) -> Any: def function_render(partial_path=None, use_short_name=False, objects=None, **args): if objects is None: objects = {} if not partial_path: partial_path = ["blah"] function_renderer.objects = objects return function_renderer.rst( partial_path, make_function(**args), use_short_name ) return function_render @pytest.fixture() def attribute_render(attribute_renderer) -> Any: def attribute_render(partial_path=None, use_short_name=False, **args): if not partial_path: partial_path = ["blah"] return attribute_renderer.rst( partial_path, make_attribute(**args), use_short_name ) return attribute_render @pytest.fixture() def type_alias_render(attribute_renderer) -> Any: def type_alias_render(partial_path=None, use_short_name=False, **args): if not partial_path: partial_path = ["blah"] return attribute_renderer.rst( partial_path, make_type_alias(**args), use_short_name ) return type_alias_render @pytest.fixture() def auto_module_render(auto_module_renderer) -> Any: def auto_module_render(partial_path=None, use_short_name=False, **args): if not partial_path: partial_path = ["blah"] return auto_module_renderer.rst( partial_path, make_module(**args), use_short_name ) return auto_module_render top_level_dict = dict( name="", path=[], filename="", deppath="", description="", line=0, deprecated="", examples=[], see_alsos=[], properties=[], exported_from=None, ) member_dict = dict( is_abstract=False, is_optional=False, is_static=False, is_private=False, ) members_and_supers_dict = dict(members=[], supers=[]) class_dict = ( top_level_dict | members_and_supers_dict | dict(constructor_=None, is_abstract=False, interfaces=[], type_params=[]) ) interface_dict = top_level_dict | members_and_supers_dict | dict(type_params=[]) function_dict = ( top_level_dict | member_dict | dict( is_async=False, params=[], exceptions=[], returns=[], ) ) attribute_dict = top_level_dict | member_dict | dict(type="") type_alias_dict = top_level_dict | dict(type="", type_params=[]) module_dict = dict( filename="", deppath=None, path=[], line=0, attributes=[], functions=[], classes=[], interfaces=[], type_aliases=[], ) def make_class(**args): return Class(**(class_dict | args)) def make_interface(**args): return Interface(**(interface_dict | args)) def make_function(**args): return Function(**(function_dict | args)) def make_attribute(**args): return Attribute(**(attribute_dict | args)) def make_type_alias(**args): return TypeAlias(**(type_alias_dict | args)) def make_module(**args): return Module(**(module_dict | args)) DEFAULT_RESULT = ".. js:function:: blah()\n" def test_func_render_simple(function_render): assert function_render() == DEFAULT_RESULT def test_func_render_shortnames(function_render): assert function_render(["a.", "b.", "c"]) == ".. js:function:: a.b.c()\n" assert ( function_render(["a.", "b.", "c"], use_short_name=True) == ".. js:function:: c()\n" ) def test_func_render_flags(function_render): # is_abstract is ignored? Maybe only makes sense if it is a class method?? # TODO: look into this. assert function_render(is_abstract=True) == DEFAULT_RESULT assert function_render(is_optional=True) == ".. js:function:: blah?()\n" assert function_render(is_static=True) == ".. js:function:: blah()\n :static:\n" assert function_render(is_async=True) == ".. js:function:: blah()\n :async:\n" assert ( function_render(is_async=True, is_static=True) == ".. js:function:: blah()\n :static:\n :async:\n" ) assert function_render(is_private=True) == DEFAULT_RESULT def test_func_render_description(function_render): assert function_render( description="this is a description" ) == DEFAULT_RESULT + setindent( """ this is a description """, ) def test_func_render_params(function_render): assert function_render( description="this is a description", params=[Param("a", description="a description")], ) == dedent( """\ .. js:function:: blah(a) this is a description :param a: a description """ ) assert function_render( description="this is a description", params=[Param("a", description="a description"), Param("b", "b description")], ) == dedent( """\ .. js:function:: blah(a, b) this is a description :param a: a description :param b: b description """ ) def test_func_render_returns(function_render): assert function_render( params=[Param("a", description="a description"), Param("b", "b description")], returns=[Return("number", "first thing"), Return("string", "second thing")], ) == dedent( """\ .. js:function:: blah(a, b) :param a: a description :param b: b description :returns: **number** -- first thing :returns: **string** -- second thing """ ) def test_func_render_type_params(function_render): assert function_render( params=[Param("a", type="T"), Param("b", type="S")], type_params=[ TypeParam("T", "number", "a type param"), TypeParam("S", "", "second type param"), ], ) == dedent( """\ .. js:function:: blah(a, b) :typeparam T: a type param (extends **number**) :typeparam S: second type param :param a: :param b: :type a: **T** :type b: **S** """ ) def test_render_xref(function_renderer: AutoFunctionRenderer): function_renderer.objects["A"] = make_class() assert ( function_renderer.render_type([TypeXRefInternal(name="A", path=["a.", "A"])]) == ":js:class:`A`" ) function_renderer.objects["A"] = make_type_alias() assert ( function_renderer.render_type([TypeXRefInternal(name="A", path=["a.", "A"])]) == ":js:typealias:`A`" ) function_renderer.objects["A"] = make_interface() assert ( function_renderer.render_type([TypeXRefInternal(name="A", path=["a.", "A"])]) == ":js:interface:`A`" ) assert ( function_renderer.render_type( [TypeXRefInternal(name="A", path=["a.", "A"]), "[]"] ) == r":js:interface:`A`\ []" ) xref_external = TypeXRefExternal("A", "blah", "a.ts", "a.A") assert function_renderer.render_type([xref_external]) == "A" res = [] def xref_render(config, val): res.append([config, val]) kind = None if isinstance(val, TypeXRefInternal): kind = val.kind return f"{val.package}::{val.name}::{kind}" function_renderer._set_type_xref_formatter(xref_render) assert function_renderer.render_type([xref_external]) == "blah::A::None" assert res[0][0] == function_renderer._app.config assert res[0][1] == xref_external def test_func_render_param_type(function_render): assert function_render( description="this is a description", params=[Param("a", description="a description", type="xxx")], ) == dedent( """\ .. js:function:: blah(a) this is a description :param a: a description :type a: **xxx** """ ) assert function_render( objects={"A": make_type_alias()}, params=[ Param( "a", description="a description", type=[TypeXRefInternal(name="A", path=["a.", "A"])], ) ], ) == dedent( """\ .. js:function:: blah(a) :param a: a description :type a: :js:typealias:`A` """ ) def test_func_render_param_options(function_render): assert ( function_render( params=[ Param( "a", has_default=True, default="5", ) ], ) == ".. js:function:: blah(a=5)\n" ) assert function_render( params=[ Param( "a", is_variadic=True, ) ], ) == dedent(".. js:function:: blah(...a)\n") def test_func_render_param_exceptions(function_render): assert function_render( description="this is a description", exceptions=[Exc("TypeError", "")] ) == dedent( """\ .. js:function:: blah() this is a description :throws TypeError: """ ) def test_func_render_callouts(function_render): assert function_render(deprecated=True) == DEFAULT_RESULT + setindent( """ .. note:: Deprecated. """, ) assert function_render(deprecated="v0.24") == DEFAULT_RESULT + setindent( """ .. note:: Deprecated: v0.24 """, ) assert function_render(see_alsos=["see", "this too"]) == DEFAULT_RESULT + setindent( """ .. seealso:: - :any:`see` - :any:`this too` """, ) def test_all(function_render): assert function_render( description="description", params=[Param("a", "xx")], deprecated=True, exceptions=[Exc("TypeError", "")], examples=["ex1"], see_alsos=["see"], ) == dedent( """\ .. js:function:: blah(a) .. note:: Deprecated. description :param a: xx :throws TypeError: .. admonition:: Example ex1 .. seealso:: - :any:`see` """ ) def test_examples(function_render): assert function_render(examples=["ex1", "ex2"]) == DEFAULT_RESULT + setindent( """ .. admonition:: Example ex1 .. admonition:: Example ex2 """, ) assert function_render( examples=[[DescriptionText(text="This is another example.\n")]] ) == DEFAULT_RESULT + setindent( """ .. admonition:: Example This is another example. """ ) assert function_render( examples=[ [DescriptionCode(code="```ts\nThis is an example.\n```")], [ DescriptionText(text="This is another example.\n"), DescriptionCode(code="```py\nSomething python\n```"), ], ] ) == DEFAULT_RESULT + setindent( """ .. admonition:: Example .. code-block:: ts This is an example. .. admonition:: Example This is another example. .. code-block:: py Something python """ ) def test_type_alias(type_alias_render): assert type_alias_render() == ".. js:typealias:: blah\n" assert type_alias_render( type="number", description="my great type alias!" ) == dedent( """\ .. js:typealias:: blah .. rst-class:: js attribute type type: **number** my great type alias! """ ) assert type_alias_render( type="string | T", type_params=[TypeParam("T", extends="number", description="ABC")], description="With a type parameter", ) == dedent( """\ .. js:typealias:: blah .. rst-class:: js attribute type type: **string | T** With a type parameter :typeparam T: ABC (extends **number**) """ ) def test_auto_module_render(auto_module_render): assert auto_module_render() == ".. js:module:: blah" assert auto_module_render( functions=[ make_function( name="f", description="this is a description", params=[Param("a", description="a description")], ), make_function(name="g"), ], attributes=[make_attribute(name="x", type="any"), make_attribute(name="y")], type_aliases=[ make_type_alias(name="S"), make_type_alias(name="T"), # Check that we omit stuff marked with @omitFromAutoModule make_type_alias(name="U", modifier_tags=["@omitFromAutoModule"]), ], ) == dedent( """\ .. js:module:: blah .. js:typealias:: S .. js:typealias:: T .. js:attribute:: x .. rst-class:: js attribute type type: **any** .. js:attribute:: y .. js:function:: f(a) this is a description :param a: a description .. js:function:: g() """ ) ================================================ FILE: tests/test_suffix_tree.py ================================================ from pytest import raises from sphinx_js.suffix_tree import SuffixAmbiguous, SuffixNotFound, SuffixTree def test_things(): s = SuffixTree() s.add(["./", "dir/", "utils.", "max"], 1) s.add(["./", "dir/", "footils.", "max"], 2) s.add(["./", "dir/", "footils.", "hacks"], 3) assert s.get(["hacks"]) == 3 assert s.get_with_path(["footils.", "max"]) == ( 2, ["./", "dir/", "footils.", "max"], ) with raises(SuffixNotFound): s.get(["quacks.", "max"]) with raises(SuffixAmbiguous): s.get(["max"]) def test_full_path(): """Looking up a full path should not crash.""" s = SuffixTree() s.add(["./", "dir/", "footils.", "jacks"], 4) assert s.get_with_path(["./", "dir/", "footils.", "jacks"]) == ( 4, ["./", "dir/", "footils.", "jacks"], ) def test_terminal_insertion(): """A non-terminal segment should be able to later be made terminal.""" s = SuffixTree() s.add(["a", "b"], 5) s.add(["b"], 4) with raises(SuffixAmbiguous): s.get("b") def test_ambiguous_even_if_full_path(): """Even full paths should be considered ambiguous if there are paths that have them as suffixes.""" s = SuffixTree() s.add(["a", "b"], 5) s.add(["q", "a", "b"], 6) with raises(SuffixAmbiguous): s.get(["a", "b"]) def test_ambiguous_paths_reported(): """Make sure SuffixAmbiguous gives a good explanation.""" s = SuffixTree() s.add(["q", "b", "c"], 5) s.add(["r", "b", "c"], 6) try: s.get(["b", "c"]) except SuffixAmbiguous as exc: assert exc.next_possible_keys == ["q", "r"] assert not exc.or_ends_here def test_value_ambiguity(): """If we're at the point of following single-subtree links, make sure we throw SuffixAmbiguous if we encounter a value and a subtree at a given point (b in this case).""" s = SuffixTree() s.add(["a", "b", "c"], 5) s.add(["b", "c"], 6) try: assert s.get(["c"]) except SuffixAmbiguous as exc: assert exc.next_possible_keys == ["b"] assert exc.or_ends_here ================================================ FILE: tests/test_testing.py ================================================ from tests.testing import NO_MATCH, dict_where def test_dict_where(): json = {"hi": "there", "more": {"mister": "zangler", "and": "friends"}} assert dict_where(json, mister="zangler") == {"mister": "zangler", "and": "friends"} assert dict_where(json, mister="zangler", fee="foo") == NO_MATCH assert dict_where(json, hi="there") == json ================================================ FILE: tests/test_typedoc_analysis/source/exports.ts ================================================ export interface Blah { a: number; b: string; } ================================================ FILE: tests/test_typedoc_analysis/source/nodes.ts ================================================ export class Superclass { method() {} } export interface SuperInterface {} export interface Interface extends SuperInterface {} export interface InterfaceWithMembers { callableProperty(): void; } /** * An empty subclass */ export abstract class EmptySubclass extends Superclass implements Interface {} export abstract class EmptySubclass2 extends Promise implements Interface {} export const topLevelConst = 3; /** * @param a Some number * @param b Some strings * @return The best number */ export function func(a: number = 1, ...b: string[]): number { return 4; } export class ClassWithProperties { static someStatic: number; someOptional?: number; private somePrivate: number; /** * This is totally normal! */ someNormal: number; constructor(a: number) {} get gettable(): number { return 5; } set settable(value: string) {} } export class Indexable { [id: string]: any; // smoketest } // Test that we don't fail on a reexport export { Blah } from "./exports"; /** * A super special type alias * @typeparam T The whatsit */ export type TestTypeAlias = 1 | 2 | T; ================================================ FILE: tests/test_typedoc_analysis/source/subdir/pathSegments.ts ================================================ /** * Function */ export function foo(): void { /** * An inner function */ function inner(): void {} } foo.adHocInner = "innerValue"; /** * Foo class */ export class Foo { /** * Static member */ static staticMember = 8; /** * Num instance var */ numInstanceVar: number; /** * Weird var */ "weird#Var": number; /** * Constructor */ constructor(num: number) { this.numInstanceVar = num; } /** * Method */ someMethod(): void {} /** * Static method */ static staticMethod(): void {} /** * Getter */ get getter(): number { return 5; } /** * Setter */ set setter(n: number) {} } export interface Face { /** * Interface property */ moof: string; } export namespace SomeSpace { /** * Namespaced number */ export const spacedNumber = 4; } ================================================ FILE: tests/test_typedoc_analysis/source/tsconfig.json ================================================ { "compilerOptions": { "target": "es6", "module": "commonjs", "moduleResolution": "node" } } ================================================ FILE: tests/test_typedoc_analysis/source/types.ts ================================================ // Basic types: https://www.typescriptlang.org/docs/handbook/basic-types.html import { Blah } from "./exports"; export enum Color { Red = 1, Green = 2, } export let bool: boolean; export let num: number; export let str: string; export let array: number[]; export let genericArray: Array; export let tuple: [string, number]; export let color: Color; export let unk: unknown; export let whatever: any; export let voidy: void; export let undef: undefined; export let nully: null; export let nev: never; export let obj: object; export let sym: symbol; // Interfaces (https://www.typescriptlang.org/docs/handbook/interfaces.html) export interface Interface { readonly readOnlyNum: number; [someProp: number]: string; // Just a smoketest for now. (IOW, make sure the analysis engine doesn't crash on it.) We'll need more work to handle members with no names. } export function interfacer(a: Interface) {} export interface FunctionInterface { (thing: string, ding: number): boolean; // just a smoketest for now } // Functions. Basic function types are covered by ConvertNodeTests.test_function. export function noThis(this: void) { // smoketest } // Make sure multi-signature functions don't crash us: export function overload(x: string[]): number; export function overload(x: number): number; export function overload(x): any {} // Literal types (https://www.typescriptlang.org/docs/handbook/literal-types.html) export type CertainNumbers = 1 | 2 | 4; export let certainNumbers: CertainNumbers = 2; // Unions and intersections (https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html) export let union: number | string | Color = Color.Red; export interface FooHaver { foo: string; } export interface BarHaver { bar: string; } export let intersection: FooHaver & BarHaver; // Generics (https://www.typescriptlang.org/docs/handbook/generics.html) export function aryIdentity(things: T[]): T[] { console.log(things.length); return things; } export class GenericNumber { add: (x: T, y: T) => T; } // Generic constraints: export interface Lengthwise { length: number; } /** * @typeParam T - the identity type */ export function constrainedIdentity(arg: T): T { return arg; } /** * @typeParam T - The type of the object * @typeParam K - The type of the key */ export function getProperty(obj: T, key: K) { return obj[key]; } export class A {} export function create1(c: { new (x: number): A }): A { return new c(7); } export function create2(c: { new (): T }): T { return new c(); } /** * @typeParam S - The type we contain */ export class ParamClass { constructor() {} } // Utility types (https://www.typescriptlang.org/docs/handbook/utility-types.html) // Most type references in here have ResolvedReferences. These test cases are // for "SymbolReference". See typedoc/src/lib/models/types.ts // Partial should generate a SymbolReference that we turn into a // TypeXRefExternal export let partial: Partial; // Blah should generate a SymbolReference that we turn into a // TypeXRefInternal export let internalSymbolReference: Blah; // Complex: nested nightmares that show our ability to handle compound typing constructs export function objProps( a: { label: string }, b: { label: string; [key: number]: string }, ) {} export let option: { a: number; b?: string }; /** * Code 1 had `single ticks around it`. * Code 2 has ``double ticks around it``. * Code 3 has a :sphinx:role:`before it`. * * ```js * A JS code pen! * ``` * And some closing words. */ export function codeInDescription() {} /** * An example with destructured args 1 * * @param options * @param options.a - The 'a' string. * @param options.b - The 'b' string. * @destructure options */ export function destructureTest({ a, b }: { b: { c: string }; a: string }) {} /** * An example with destructured args 2 * * @param options * @destructure options */ export function destructureTest2({ a, b, }: { /** The 'b' object. */ b: { c: string }; /** The 'a' string. */ a: string; }) {} /** * An example with no destructured args 3 * * @param options - The options. * @param options.a - The 'a' string. * @param options.b - The 'b' string. */ export function destructureTest3({ a, b }: { a: string; b: { c: string } }) {} /** * A test for should_destructure_arg 4 */ export function destructureTest4(destructureThisPlease: { /** The 'a' string. */ a: string; }) {} /** * An example with a function as argument * * @param a - A number reducer. */ export function funcArg(a: (b: number, c: number) => number) {} export function namedTupleArg(namedTuple: [key: string, value: any]) {} export let queryType: typeof A; export let typeOperatorType: keyof A; type PrivateTypeAlias1 = { a: number; b: string }; // Should expand the private type alias export let typeIsPrivateTypeAlias1: PrivateTypeAlias1; /** @private */ export type PrivateTypeAlias2 = { a: number; b: string }; // Should expand the private type alias export let typeIsPrivateTypeAlias2: PrivateTypeAlias2; /** * Some comment * @hidetype */ export let hiddenType: number; export class HasHiddenTypeMember { /** * Some comment * @hidetype */ hasHiddenType: number; } export let restType: [...number[]]; export let indexedAccessType: FunctionInterface["length"]; export type ConditionalType = T extends A ? 1 : 2; export type InferredType = T extends Promise ? S : T; export type keys = "A" | "B" | "C"; export type MappedType1 = { [property in keys]: number }; export type MappedType2 = { -readonly [property in keys]?: number }; export type MappedType3 = { readonly [property in keys]-?: number }; export type TemplateLiteral = `${number}: ${string}`; export type OptionalType = [number?]; /** * @hidetype * @omitFromAutoModule * @destructure a.b * @summaryLink :role:`target` */ export type CustomTags = {}; ================================================ FILE: tests/test_typedoc_analysis/test_typedoc_analysis.py ================================================ from copy import copy, deepcopy import pytest from sphinx_js.ir import ( Attribute, Class, Description, DescriptionCode, DescriptionText, Function, Interface, Param, Pathname, Return, Type, TypeAlias, TypeParam, TypeXRef, TypeXRefExternal, TypeXRefInternal, TypeXRefIntrinsic, ) from sphinx_js.renderers import AutoClassRenderer, AutoFunctionRenderer from tests.testing import NO_MATCH, TypeDocAnalyzerTestCase, TypeDocTestCase, dict_where def join_type(t: Type) -> str: if not t: return "" if isinstance(t, str): return t return "".join(e.name if isinstance(e, TypeXRef) else e for e in t) def join_description(t: Description) -> str: if not t: return "" if isinstance(t, str): return t return "".join(e.code if isinstance(e, DescriptionCode) else e.text for e in t) class TestPathSegments(TypeDocTestCase): """Make sure ``make_path_segments() `` works on all its manifold cases.""" files = ["subdir/pathSegments.ts"] def commented_object(self, comment, **kwargs): """Return the object from ``json`` having the given comment short-text.""" comment = [DescriptionText(text=comment)] return dict_where(self.json, description=comment, **kwargs) def commented_object_path(self, comment, **kwargs): """Return the path segments of the object with the given comment.""" obj = self.commented_object(comment, **kwargs) if obj is NO_MATCH: raise RuntimeError(f'No object found with the comment "{comment}".') return obj.path.segments # type:ignore[attr-defined] def test_class(self): assert self.commented_object_path("Foo class") == [ "./", "subdir/", "pathSegments.", "Foo", ] def test_instance_property(self): assert self.commented_object_path("Num instance var") == [ "./", "subdir/", "pathSegments.", "Foo#", "numInstanceVar", ] def test_static_property(self): assert self.commented_object_path("Static member") == [ "./", "subdir/", "pathSegments.", "Foo.", "staticMember", ] def test_interface_property(self): assert self.commented_object_path("Interface property") == [ "./", "subdir/", "pathSegments.", "Face.", "moof", ] def test_weird_name(self): """Make sure property names that themselves contain delimiter chars like #./~ get their pathnames built correctly.""" assert self.commented_object_path("Weird var") == [ "./", "subdir/", "pathSegments.", "Foo#", "weird#Var", ] def test_getter(self): assert self.commented_object_path("Getter") == [ "./", "subdir/", "pathSegments.", "Foo#", "getter", ] def test_setter(self): assert self.commented_object_path("Setter") == [ "./", "subdir/", "pathSegments.", "Foo#", "setter", ] def test_method(self): assert self.commented_object_path("Method") == [ "./", "subdir/", "pathSegments.", "Foo#", "someMethod", ] def test_static_method(self): """Since ``make_path_segments()`` looks at the inner Call Signature, make sure the flags (which determine staticness) are on the node we expect.""" assert self.commented_object_path("Static method") == [ "./", "subdir/", "pathSegments.", "Foo.", "staticMethod", ] def test_constructor(self): # Pass the kindString so we're sure to find the signature (which is # what convert_nodes() passes to make_path_segments()) rather than the # constructor itself. They both have the same comments. # # Constructors get a #. They aren't static; they can see ``this``. assert self.commented_object_path("Constructor") == [ "./", "subdir/", "pathSegments.", "Foo#", "constructor", ] def test_function(self): assert self.commented_object_path("Function") == [ "./", "subdir/", "pathSegments.", "foo", ] @pytest.mark.xfail( reason="Test approach doesn't work anymore and broken by typedoc v0.20" ) def test_relative_paths(self): """Make sure FS path segments are emitted if ``base_dir`` doesn't directly contain the code.""" assert self.commented_object_path("Function") == [ "./", "test_typedoc_analysis/", "source/", "subdir/", "pathSegments.", "foo", ] def test_namespaced_var(self): """Make sure namespaces get into the path segments.""" assert self.commented_object_path("Namespaced number") == [ "./", "subdir/", "pathSegments.", "SomeSpace.", "spacedNumber", ] class TestConvertNode(TypeDocAnalyzerTestCase): """Test all the branches of ``convert_node()`` by analyzing every kind of TypeDoc JSON object.""" files = ["nodes.ts", "exports.ts"] def test_class1(self): """Test that superclasses, implemented interfaces, abstractness, and nonexistent constructors, members, and top-level attrs are surfaced.""" # Make sure is_abstract is sometimes false: super = self.analyzer.get_object(["Superclass"]) assert isinstance(super, Class) assert not super.is_abstract # There should be a single member representing method(): (method,) = super.members assert isinstance(method, Function) assert method.name == "method" # Class-specific attrs: subclass = self.analyzer.get_object(["EmptySubclass"]) assert isinstance(subclass, Class) assert subclass.constructor_ is None assert subclass.is_abstract assert subclass.interfaces == [ [TypeXRefInternal("Interface", ["./", "nodes.", "Interface"])] ] subclass2 = self.analyzer.get_object(["EmptySubclass2"]) assert isinstance(subclass2, Class) assert join_type(subclass2.supers[0]) == "Promise" # _MembersAndSupers attrs: assert subclass.supers == [ [TypeXRefInternal("Superclass", ["./", "nodes.", "Superclass"])] ] assert subclass.members == [] # TopLevel attrs. This should cover them for other kinds of objs as # well (if node structures are the same across object kinds), since we # have the filling of them factored out. assert subclass.name == "EmptySubclass" assert subclass.path == Pathname(["./", "nodes.", "EmptySubclass"]) assert subclass.description == [DescriptionText("An empty subclass")] assert subclass.deprecated is False assert subclass.examples == [] assert subclass.see_alsos == [] assert subclass.properties == [] assert subclass.exported_from == Pathname(["./", "nodes"]) def test_interface(self): """Test that interfaces get indexed and have their supers exposed. Members and top-level properties should be covered in test_class() assuming node structure is the same as for classes. """ interface = self.analyzer.get_object(["Interface"]) assert isinstance(interface, Interface) assert interface.supers == [ [TypeXRefInternal("SuperInterface", ["./", "nodes.", "SuperInterface"])] ] def test_interface_function_member(self): """Make sure function-like properties are understood.""" obj = self.analyzer.get_object(["InterfaceWithMembers"]) assert isinstance(obj, Interface) prop = obj.members[0] assert isinstance(prop, Function) assert prop.name == "callableProperty" def test_variable(self): """Make sure top-level consts and vars are found.""" const = self.analyzer.get_object(["topLevelConst"]) assert isinstance(const, Attribute) assert const.type == ["3"] def test_function(self): """Make sure Functions, Params, and Returns are built properly for top-level functions. This covers a few simple function typing cases as well. """ func = self.analyzer.get_object(["func"]) assert isinstance(func, Function) assert func.params == [ Param( name="a", description=[DescriptionText("Some number")], has_default=True, is_variadic=False, type=[TypeXRefIntrinsic("number")], default="1", ), Param( name="b", description=[DescriptionText("Some strings")], has_default=False, is_variadic=True, type=[TypeXRefIntrinsic("string"), "[]"], ), ] assert func.exceptions == [] assert func.returns == [ Return( type=[TypeXRefIntrinsic("number")], description=[DescriptionText("The best number")], ) ] def test_constructor(self): """Make sure constructors get attached to classes and analyzed into Functions. The rest of their analysis should share a code path with functions. """ cls = self.analyzer.get_object(["ClassWithProperties"]) assert isinstance(cls, Class) assert isinstance(cls.constructor_, Function) def test_properties(self): """Make sure properties are hooked onto classes and expose their flags.""" cls = self.analyzer.get_object(["ClassWithProperties"]) assert isinstance(cls, Class) # The properties are on the class and are Attributes: assert ( len( [ m for m in cls.members if isinstance(m, Attribute) and m.name in ["someStatic", "someOptional", "somePrivate", "someNormal"] ] ) == 4 ) # The unique things about properties (over and above Variables) are set # right: def get_prop(delim: str, val: str) -> Attribute: res = self.analyzer.get_object(["ClassWithProperties" + delim, val]) assert isinstance(res, Attribute) return res assert get_prop(".", "someStatic").is_static assert get_prop("#", "someOptional").is_optional assert get_prop("#", "somePrivate").is_private normal_property = get_prop("#", "someNormal") assert ( not normal_property.is_optional and not normal_property.is_static and not normal_property.is_abstract and not normal_property.is_private ) def test_getter(self): """Test that we represent getters as Attributes and find their return types.""" getter = self.analyzer.get_object(["gettable"]) assert isinstance(getter, Attribute) assert getter.type == [TypeXRefIntrinsic("number")] def test_setter(self): """Test that we represent setters as Attributes and find the type of their 1 param.""" setter = self.analyzer.get_object(["settable"]) assert isinstance(setter, Attribute) assert setter.type == [TypeXRefIntrinsic("string")] def test_type_alias(self): alias = self.analyzer.get_object(["TestTypeAlias"]) assert isinstance(alias, TypeAlias) assert join_description(alias.description) == "A super special type alias" assert join_type(alias.type) == "1 | 2 | T" assert alias.type_params == [TypeParam(name="T", extends=None, description=[])] class TestTypeName(TypeDocAnalyzerTestCase): """Make sure our rendering of TypeScript types into text works.""" files = ["types.ts"] def test_basic(self): """Test intrinsic types.""" for obj_name, type_name in [ ("bool", "boolean"), ("num", "number"), ("str", "string"), ("array", "number[]"), ("genericArray", "number[]"), ("tuple", "[string, number]"), ("color", "Color"), ("unk", "unknown"), ("whatever", "any"), ("voidy", "void"), ("undef", "undefined"), ("nully", "null"), ("nev", "never"), ("obj", "object"), ("sym", "symbol"), ]: obj = self.analyzer.get_object([obj_name]) assert isinstance(obj, Attribute) assert join_type(obj.type) == type_name def test_named_interface(self): """Make sure interfaces can be referenced by name.""" obj = self.analyzer.get_object(["interfacer"]) assert isinstance(obj, Function) assert obj.params[0].type == [ TypeXRefInternal(name="Interface", path=["./", "types.", "Interface"]) ] def test_interface_readonly_member(self): """Make sure the readonly modifier doesn't keep us from computing the type of a property.""" obj = self.analyzer.get_object(["Interface"]) assert isinstance(obj, Interface) read_only_num = obj.members[0] assert isinstance(read_only_num, Attribute) assert read_only_num.name == "readOnlyNum" assert read_only_num.type == [TypeXRefIntrinsic("number")] def test_array(self): """Make sure array types are rendered correctly. As a bonus, make sure we grab the first signature of an overloaded function. """ obj = self.analyzer.get_object(["overload"]) assert isinstance(obj, Function) assert obj.params[0].type == [TypeXRefIntrinsic("string"), "[]"] def test_literal_types(self): """Make sure a thing of a named literal type has that type name attached.""" obj = self.analyzer.get_object(["certainNumbers"]) assert isinstance(obj, Attribute) assert obj.type == [ TypeXRefInternal( name="CertainNumbers", path=["./", "types.", "CertainNumbers"] ) ] def test_unions(self): """Make sure unions get rendered properly.""" obj = self.analyzer.get_object(["union"]) assert isinstance(obj, Attribute) assert obj.type == [ TypeXRefIntrinsic("number"), " | ", TypeXRefIntrinsic("string"), " | ", TypeXRefInternal(name="Color", path=["./", "types.", "Color"]), ] def test_intersection(self): obj = self.analyzer.get_object(["intersection"]) assert isinstance(obj, Attribute) assert obj.type == [ TypeXRefInternal(name="FooHaver", path=["./", "types.", "FooHaver"]), " & ", TypeXRefInternal(name="BarHaver", path=["./", "types.", "BarHaver"]), ] def test_generic_function(self): """Make sure type params appear in args and return types.""" obj = self.analyzer.get_object(["aryIdentity"]) assert isinstance(obj, Function) T = ["T", "[]"] assert obj.params[0].type == T assert obj.returns[0].type == T def test_generic_member(self): """Make sure members of a class have their type params taken into account.""" obj = self.analyzer.get_object(["add"]) assert isinstance(obj, Function) assert obj.name == "add" assert len(obj.params) == 2 T = ["T"] assert obj.params[0].type == T assert obj.params[1].type == T assert obj.returns[0].type == T def test_constrained_by_interface(self): """Make sure ``extends SomeInterface`` constraints are rendered.""" obj = self.analyzer.get_object(["constrainedIdentity"]) assert isinstance(obj, Function) T = ["T"] assert obj.params[0].type == T assert obj.returns[0].type == T assert obj.type_params[0] == TypeParam( name="T", extends=[ TypeXRefInternal(name="Lengthwise", path=["./", "types.", "Lengthwise"]) ], description=[DescriptionText("the identity type")], ) def test_constrained_by_key(self): """Make sure ``extends keyof SomeObject`` constraints are rendered.""" obj = self.analyzer.get_object(["getProperty"]) assert isinstance(obj, Function) assert obj.params[0].name == "obj" assert join_type(obj.params[0].type) == "T" assert join_type(obj.params[1].type) == "K" # TODO? # assert obj.returns[0].type == "" assert obj.type_params[0] == TypeParam( name="T", extends=None, description=[DescriptionText("The type of the object")], ) tp = copy(obj.type_params[1]) tp.extends = join_type(tp.extends) assert tp == TypeParam( name="K", extends="string | number | symbol", description=[DescriptionText("The type of the key")], ) # TODO: this part maybe belongs in a unit test for the renderer or something a = AutoFunctionRenderer.__new__(AutoFunctionRenderer) a._add_span = False a._set_type_xref_formatter(None) a._explicit_formal_params = None # type:ignore[attr-defined] a._content = [] rst = a.rst([obj.name], obj) rst = rst.replace("\\", "").replace(" ", " ") assert ":typeparam T: The type of the object" in rst assert ( ":typeparam K: The type of the key (extends string | number | symbol)" in rst ) def test_class_constrained(self): # TODO: this may belong somewhere else obj = self.analyzer.get_object(["ParamClass"]) assert isinstance(obj, Class) tp = copy(obj.type_params[0]) tp.extends = join_type(tp.extends) assert tp == TypeParam( name="S", extends="number[]", description=[DescriptionText("The type we contain")], ) a = AutoClassRenderer.__new__(AutoClassRenderer) a._set_type_xref_formatter(None) a._explicit_formal_params = None # type:ignore[attr-defined] a._add_span = False a._content = [] a._options = {} rst = a.rst([obj.name], obj) rst = rst.replace("\\ ", "").replace("\\", "").replace(" ", " ") assert ":typeparam S: The type we contain (extends number[])" in rst def test_constrained_by_constructor(self): """Make sure ``new ()`` expressions and, more generally, per-property constraints are rendered properly.""" obj = self.analyzer.get_object(["create1"]) assert isinstance(obj, Function) assert join_type(obj.params[0].type) == "{new (x: number) => A}" obj = self.analyzer.get_object(["create2"]) assert isinstance(obj, Function) assert join_type(obj.params[0].type) == "{new () => T}" def test_utility_types(self): """Test that a representative one of TS's utility types renders. Partial should generate a SymbolReference that we turn into a TypeXRefExternal """ obj = self.analyzer.get_object(["partial"]) assert isinstance(obj, Attribute) t = deepcopy(obj.type) assert t s = t[0] assert isinstance(s, TypeXRefExternal) s.sourcefilename = "xxx" assert t == [ TypeXRefExternal("Partial", "typescript", "xxx", "Partial"), "<", TypeXRefIntrinsic("string"), ">", ] def test_internal_symbol_reference(self): """ Blah should generate a SymbolReference that we turn into a TypeXRefInternal """ obj = self.analyzer.get_object(["internalSymbolReference"]) assert isinstance(obj, Attribute) assert obj.type == [ TypeXRefInternal(name="Blah", path=["./", "exports"], type="internal") ] def test_constrained_by_property(self): obj = self.analyzer.get_object(["objProps"]) assert isinstance(obj, Function) assert obj.params[0].type == [ "{ ", "label", ": ", TypeXRefIntrinsic("string"), "; ", "}", ] assert ( join_type(obj.params[1].type) == "{ [key: number]: string; label: string; }" ) def test_optional_property(self): """Make sure optional properties render properly.""" obj = self.analyzer.get_object(["option"]) assert isinstance(obj, Attribute) assert join_type(obj.type) == "{ a: number; b?: string; }" def test_code_in_description(self): obj = self.analyzer.get_object(["codeInDescription"]) assert obj.description == [ DescriptionText(text="Code 1 had "), DescriptionCode(code="`single ticks around it`"), DescriptionText(text=".\nCode 2 has "), DescriptionCode(code="``double ticks around it``"), DescriptionText(text=".\nCode 3 has a :sphinx:role:"), DescriptionCode(code="`before it`"), DescriptionText(text=".\n\n"), DescriptionCode(code="```js\nA JS code pen!\n```"), DescriptionText(text="\nAnd some closing words."), ] def test_destructured(self): obj = self.analyzer.get_object(["destructureTest"]) assert isinstance(obj, Function) # Parameters should be sorted by source position in the type annotation not by name. assert obj.params[0].name == "options.b" assert join_type(obj.params[0].type) == "{ c: string; }" assert obj.params[0].description == [DescriptionText(text="The 'b' string.")] assert obj.params[1].name == "options.a" assert join_type(obj.params[1].type) == "string" assert obj.params[1].description == [DescriptionText(text="The 'a' string.")] obj = self.analyzer.get_object(["destructureTest2"]) assert isinstance(obj, Function) assert obj.params[0].name == "options.b" assert join_type(obj.params[0].type) == "{ c: string; }" assert obj.params[0].description == [DescriptionText(text="The 'b' object.")] assert obj.params[1].name == "options.a" assert join_type(obj.params[1].type) == "string" assert obj.params[1].description == [DescriptionText(text="The 'a' string.")] obj = self.analyzer.get_object(["destructureTest3"]) assert isinstance(obj, Function) assert obj.params[0].name == "options" assert join_type(obj.params[0].type) == "{ a: string; b: { c: string; }; }" obj = self.analyzer.get_object(["destructureTest4"]) assert isinstance(obj, Function) assert obj.params[0].name == "destructureThisPlease.a" assert join_type(obj.params[0].type) == "string" assert obj.params[0].description == [DescriptionText(text="The 'a' string.")] def test_funcarg(self): obj = self.analyzer.get_object(["funcArg"]) assert isinstance(obj, Function) assert obj.params[0].name == "a" assert join_type(obj.params[0].type) == "(b: number, c: number) => number" def test_namedtuplearg(self): obj = self.analyzer.get_object(["namedTupleArg"]) assert isinstance(obj, Function) assert obj.params[0].name == "namedTuple" assert join_type(obj.params[0].type) == "[key: string, value: any]" def test_query(self): obj = self.analyzer.get_object(["queryType"]) assert isinstance(obj, Attribute) assert join_type(obj.type) == "typeof A" def test_type_operator(self): obj = self.analyzer.get_object(["typeOperatorType"]) assert isinstance(obj, Attribute) assert join_type(obj.type) == "keyof A" def test_private_type_alias1(self): obj = self.analyzer.get_object(["typeIsPrivateTypeAlias1"]) assert isinstance(obj, Attribute) assert join_type(obj.type) == "{ a: number; b: string; }" def test_private_type_alias2(self): obj = self.analyzer.get_object(["typeIsPrivateTypeAlias2"]) assert isinstance(obj, Attribute) assert join_type(obj.type) == "{ a: number; b: string; }" def test_hidden_type_top_level(self): obj = self.analyzer.get_object(["hiddenType"]) assert obj.modifier_tags == ["@hidetype"] assert isinstance(obj, Attribute) assert obj.type == [] def test_hidden_type_member(self): obj = self.analyzer.get_object(["HasHiddenTypeMember"]) assert isinstance(obj, Class) assert obj.members member = obj.members[0] assert isinstance(member, Attribute) assert member.type == [] def test_rest_type(self): obj = self.analyzer.get_object(["restType"]) assert isinstance(obj, Attribute) assert join_type(obj.type) == "[...number[]]" def test_indexed_access_type(self): obj = self.analyzer.get_object(["indexedAccessType"]) assert isinstance(obj, Attribute) assert join_type(obj.type) == 'FunctionInterface["length"]' def test_conditional_type(self): obj = self.analyzer.get_object(["ConditionalType"]) assert isinstance(obj, TypeAlias) assert join_type(obj.type) == "T extends A ? 1 : 2" def test_inferred_type(self): obj = self.analyzer.get_object(["InferredType"]) assert isinstance(obj, TypeAlias) assert join_type(obj.type) == "T extends Promise ? S : T" def test_mapped_type(self): obj = self.analyzer.get_object(["MappedType1"]) assert isinstance(obj, TypeAlias) assert join_type(obj.type) == "{ [property in keys]: number }" obj = self.analyzer.get_object(["MappedType2"]) assert isinstance(obj, TypeAlias) assert join_type(obj.type) == "{ -readonly [property in keys]?: number }" obj = self.analyzer.get_object(["MappedType3"]) assert isinstance(obj, TypeAlias) assert join_type(obj.type) == "{ readonly [property in keys]-?: number }" def test_template_literal(self): obj = self.analyzer.get_object(["TemplateLiteral"]) assert isinstance(obj, TypeAlias) assert join_type(obj.type) == "`${number}: ${string}`" def test_custom_tags(self): obj = self.analyzer.get_object(["CustomTags"]) assert isinstance(obj, TypeAlias) assert "@hidetype" in obj.modifier_tags assert "@omitFromAutoModule" in obj.modifier_tags assert [join_description(d) for d in obj.block_tags["summaryLink"]] == [ ":role:`target`" ] assert [join_description(d) for d in obj.block_tags["destructure"]] == ["a.b"] ================================================ FILE: tests/testing.py ================================================ import sys from inspect import getmembers from os.path import dirname, join from shutil import rmtree from sphinx.cmd.build import main as sphinx_main from sphinx_js.jsdoc import Analyzer as JsAnalyzer from sphinx_js.jsdoc import jsdoc_output from sphinx_js.typedoc import Analyzer as TsAnalyzer from sphinx_js.typedoc import typedoc_output class ThisDirTestCase: """A TestCase that knows how to find the directory the subclass is defined in""" @classmethod def this_dir(cls): """Return the path to the dir containing the testcase class.""" # nose does some amazing magic that makes this work even if there are # multiple test modules with the same name: return dirname(sys.modules[cls.__module__].__file__) class SphinxBuildTestCase(ThisDirTestCase): """Base class for tests which require a Sphinx tree to be built and then deleted afterward """ builder = "text" @classmethod def setup_class(cls): """Run Sphinx against the dir adjacent to the testcase.""" cls.docs_dir = join(cls.this_dir(), "source", "docs") # -v for better tracebacks: if sphinx_main( [cls.docs_dir, "-b", cls.builder, "-v", "-E", join(cls.docs_dir, "_build")] ): raise RuntimeError("Sphinx build exploded.") @classmethod def teardown_class(cls): rmtree(join(cls.docs_dir, "_build")) def _file_contents(self, filename): extension = "txt" if self.builder == "text" else "html" with open( join(self.docs_dir, "_build", f"{filename}.{extension}"), encoding="utf8", ) as file: return file.read() def _file_contents_eq(self, filename, expected_contents): __tracebackhide__ = True contents = self._file_contents(filename) # Fix a difference between sphinx v6 and v7 contents = contents.replace(" --\n", "\n") assert contents == expected_contents class JsDocTestCase(ThisDirTestCase): """Base class for tests which analyze a file using JSDoc""" @classmethod def setup_class(cls): """Run the JS analyzer over the JSDoc output.""" source_dir = join(cls.this_dir(), "source") output = jsdoc_output( None, [join(source_dir, cls.file)], source_dir, source_dir ) cls.analyzer = JsAnalyzer(output, source_dir) class TypeDocTestCase(ThisDirTestCase): """Base class for tests which imbibe TypeDoc's output""" @classmethod def setup_class(cls): """Run the TS analyzer over the TypeDoc output.""" cls._source_dir = join(cls.this_dir(), "source") from pathlib import Path config_file = Path(__file__).parent / "sphinxJsConfig.ts" [cls.json, cls.extra_data] = typedoc_output( abs_source_paths=[join(cls._source_dir, file) for file in cls.files], base_dir=cls._source_dir, ts_sphinx_js_config=str(config_file), typedoc_config_path=None, tsconfig_path="tsconfig.json", sphinx_conf_dir=cls._source_dir, ) class TypeDocAnalyzerTestCase(TypeDocTestCase): """Base class for tests which analyze a file using TypeDoc""" @classmethod def setup_class(cls): """Run the TS analyzer over the TypeDoc output.""" super().setup_class() cls.analyzer = TsAnalyzer(cls.json, cls.extra_data, cls._source_dir) NO_MATCH = object() def dict_where(json, already_seen=None, **kwargs): """Return the first object in the given data structure with properties equal to the ones given by ``kwargs``. For example:: >>> dict_where({'hi': 'there', {'mister': 'zangler', 'and': 'friends'}}, mister=zangler) {'mister': 'zangler', 'and': 'friends'} So far, only dicts and lists are supported. Other data structures won't be recursed into. Cycles are avoided. """ def matches_properties(json, **kwargs): """Return the given JSON object iff all the properties and values given by ``kwargs`` are in it. Else, return NO_MATCH.""" for k, v in kwargs.items(): if json.get(k, NO_MATCH) != v: return False return True if already_seen is None: already_seen = set() already_seen.add(id(json)) if isinstance(json, list): for list_item in json: if id(list_item) not in already_seen: match = dict_where(list_item, already_seen, **kwargs) if match is not NO_MATCH: return match elif isinstance(json, dict): if matches_properties(json, **kwargs): return json for v in json.values(): if id(v) not in already_seen: match = dict_where(v, already_seen, **kwargs) if match is not NO_MATCH: return match elif hasattr(type(json), "__attrs_attrs__"): d = dict([k, v] for [k, v] in getmembers(json) if not k.startswith("_")) if matches_properties(d, **kwargs): return json for k, v in d.items(): if k.startswith("_"): continue if id(v) not in already_seen: match = dict_where(v, already_seen, **kwargs) if match is not NO_MATCH: return match else: # We don't know how to match leaf values yet. pass return NO_MATCH