From b4a8e10fc8618f27733177be3092a181f89e9eed Mon Sep 17 00:00:00 2001 From: Ben Burns <803016+benjamincburns@users.noreply.github.com> Date: Mon, 3 Feb 2025 23:18:32 +1300 Subject: [PATCH] support execution of typescript codeblocks --- .github/workflows/ci.yml | 113 +++++++++++ .github/workflows/release.yml | 7 + README.md | 6 +- docs/snippets/usage/multiple.tscon | 6 + docs/snippets/usage/source.tscon | 2 + docs/usage/typescript.md | 53 +++++ mkdocs.yml | 1 + pyproject.toml | 3 +- src/markdown_exec/__init__.py | 13 +- src/markdown_exec/formatters/jupyter.py | 224 +++++++++++++++++++++ src/markdown_exec/formatters/tscon.py | 37 ++++ src/markdown_exec/formatters/typescript.py | 121 +++++++++++ src/markdown_exec/mkdocs_plugin.py | 3 + tests/test_typescript.py | 190 +++++++++++++++++ 14 files changed, 774 insertions(+), 5 deletions(-) create mode 100644 docs/snippets/usage/multiple.tscon create mode 100644 docs/snippets/usage/source.tscon create mode 100644 docs/usage/typescript.md create mode 100644 src/markdown_exec/formatters/jupyter.py create mode 100644 src/markdown_exec/formatters/tscon.py create mode 100644 src/markdown_exec/formatters/typescript.py create mode 100644 tests/test_typescript.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f72fec7..353926f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ env: LC_ALL: en_US.utf-8 PYTHONIOENCODING: UTF-8 PYTHON_VERSIONS: "" + JUPYTER_PLATFORM_DIRS: 1 jobs: @@ -32,6 +33,9 @@ jobs: - name: Set up Graphviz uses: ts-graphviz/setup-graphviz@v1 + - name: Set up Deno + uses: denoland/setup-deno@v2 + - name: Setup Python uses: actions/setup-python@v5 with: @@ -46,6 +50,59 @@ jobs: - name: Install dependencies run: make setup + - name: Install Deno Jupyter Kernel + run: deno jupyter --install + + - name: Fix deno path on macOS + if: runner.os == 'macOS' + run: | + source .venv/bin/activate + export JUPYTER_PLATFORM_DIRS=0 + + # ensure jupyter can find the kernel config using the old path structure + jupyter kernelspec list | grep -q deno || exit 1 + + ORIG_JUPYTER_KERNEL_PATH="$(jupyter --data-dir)/kernels" + + export JUPYTER_PLATFORM_DIRS=1 + + # make the data directory for the new path structure + mkdir -p "$(jupyter --data-dir)/kernels" + + cp -r "$ORIG_JUPYTER_KERNEL_PATH/deno" "$(jupyter --data-dir)/kernels" + + # fail if jupyter still can't find the kernel config + jupyter kernelspec list | grep -q deno + + deactivate + + - name: Fix deno path on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + .\.venv\bin\activate.ps1 + $env:JUPYTER_PLATFORM_DIRS = "0" + + # ensure jupyter can find the kernel config using the old path structure + $kernelCheck = jupyter kernelspec list | Select-String -Pattern "deno" + if (-not $kernelCheck) { exit 1 } + + $origKernelPath = Join-Path (jupyter --data-dir) "kernels" + + $env:JUPYTER_PLATFORM_DIRS = "1" + + # make the data directory for the new path structure + $newKernelPath = Join-Path (jupyter --data-dir) "kernels" + New-Item -ItemType Directory -Force -Path $newKernelPath + + Copy-Item -Path (Join-Path $origKernelPath "deno") -Destination $newKernelPath -Recurse -Force + + # fail if jupyter still can't find the kernel config + $kernelCheck = jupyter kernelspec list | Select-String -Pattern "deno" + if (-not $kernelCheck) { exit 1 } + + deactivate + - name: Check if the documentation builds correctly run: make check-docs @@ -118,6 +175,9 @@ jobs: python-version: ${{ matrix.python-version }} allow-prereleases: true + - name: Set up Deno + uses: denoland/setup-deno@v2 + - name: Setup uv uses: astral-sh/setup-uv@v3 with: @@ -130,5 +190,58 @@ jobs: UV_RESOLUTION: ${{ matrix.resolution }} run: make setup + - name: Install Deno Jupyter Kernel + run: deno jupyter --install + + - name: Fix deno path on macOS + if: runner.os == 'macOS' + run: | + source .venv/bin/activate + export JUPYTER_PLATFORM_DIRS=0 + + # ensure jupyter can find the kernel config using the old path structure + jupyter kernelspec list | grep -q deno || exit 1 + + ORIG_JUPYTER_KERNEL_PATH="$(jupyter --data-dir)/kernels" + + export JUPYTER_PLATFORM_DIRS=1 + + # make the data directory for the new path structure + mkdir -p "$(jupyter --data-dir)/kernels" + + cp -r "$ORIG_JUPYTER_KERNEL_PATH/deno" "$(jupyter --data-dir)/kernels" + + # fail if jupyter still can't find the kernel config + jupyter kernelspec list | grep -q deno + + deactivate + + - name: Fix deno path on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + .\.venv\bin\activate.ps1 + $env:JUPYTER_PLATFORM_DIRS = "0" + + # ensure jupyter can find the kernel config using the old path structure + $kernelCheck = jupyter kernelspec list | Select-String -Pattern "deno" + if (-not $kernelCheck) { exit 1 } + + $origKernelPath = Join-Path (jupyter --data-dir) "kernels" + + $env:JUPYTER_PLATFORM_DIRS = "1" + + # make the data directory for the new path structure + $newKernelPath = Join-Path (jupyter --data-dir) "kernels" + New-Item -ItemType Directory -Force -Path $newKernelPath + + Copy-Item -Path (Join-Path $origKernelPath "deno") -Destination $newKernelPath -Recurse -Force + + # fail if jupyter still can't find the kernel config + $kernelCheck = jupyter kernelspec list | Select-String -Pattern "deno" + if (-not $kernelCheck) { exit 1 } + + deactivate + - name: Run the test suite run: make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e347718..06e2bd6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push permissions: contents: write +env: + JUPYTER_PLATFORM_DIRS: 1 + jobs: release: runs-on: ubuntu-latest @@ -20,6 +23,10 @@ jobs: python-version: "3.12" - name: Setup uv uses: astral-sh/setup-uv@v3 + - name: Set up Deno + uses: denoland/setup-deno@v2 + - name: Install Deno Jupyter Kernel + run: deno jupyter --install - name: Build dists if: github.repository_owner == 'pawamoy-insiders' run: uv tool run --from build pyproject-build diff --git a/README.md b/README.md index e41c8b7..84bb4ba 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ Markdown( "format": formatter, } # ...one fence for each language we support: - # bash, console, md, markdown, py, python, pycon, sh, tree + # bash, console, md, markdown, py, python, pycon, sh, tree, + # typescript, ts, tscon ] } } @@ -65,7 +66,8 @@ markdown_extensions: validator: !!python/name:markdown_exec.validator format: !!python/name:markdown_exec.formatter # ...one fence for each language we support: - # bash, console, md, markdown, py, python, pycon, sh, tree + # bash, console, md, markdown, py, python, pycon, sh, tree, typescript, ts, + # tscon ``` ...or in MkDocs configuration file, as a plugin: diff --git a/docs/snippets/usage/multiple.tscon b/docs/snippets/usage/multiple.tscon new file mode 100644 index 0000000..85df0f6 --- /dev/null +++ b/docs/snippets/usage/multiple.tscon @@ -0,0 +1,6 @@ +> const name: string = "Baron"; +> console.log(name); +Baron +> const age: string | number = "???"; +> console.log(age); +??? diff --git a/docs/snippets/usage/source.tscon b/docs/snippets/usage/source.tscon new file mode 100644 index 0000000..eee751e --- /dev/null +++ b/docs/snippets/usage/source.tscon @@ -0,0 +1,2 @@ +> console.log("I'm the result!"); +I'm not the result... diff --git a/docs/usage/typescript.md b/docs/usage/typescript.md new file mode 100644 index 0000000..2eba599 --- /dev/null +++ b/docs/usage/typescript.md @@ -0,0 +1,53 @@ +# TypeScript + +## Regular TypeScript + +TypeScript code is executed via a Jupyter kernel. Markdown Exec will attempt to +use any kernel that lists TypeScript as a supported language, however +Markdown Exec is tested using the Jupyter kernel provided by the Deno project. +See Deno's [installation instructions](https://docs.deno.com/runtime/getting_started/installation/) +and [Jupyter kernel docs](https://docs.deno.com/runtime/reference/cli/jupyter/#quickstart) +for more information. + +### Output capturing + +Outputs are captured just as they are in jupyter notebooks. + +````md exec="1" source="tabbed-left" tabs="Markdown|Rendered" +```typescript exec="1" +console.log("**Hello world!**"); +``` +```` + +### Type checking + +TypeScript code blocks will be type checked using Deno's type checker. + +````md exec="1" source="tabbed-left" tabs="Markdown|Rendered" +```typescript exec="yes" returncode="1" +const x: string = 1; +``` +```` + +## TypeScript REPL console code + +Code blocks syntax-highlighted with the `tscon` identifier are also supported. +These code blocks will be pre-processed to keep only the lines +starting with `> `, and the chevron (prompt) will be removed from these lines, +so we can execute them. Continuation lines starting with `... ` are also supported +(and the ellipses will be removed as well). + +````md exec="1" source="tabbed-left" tabs="Markdown|Rendered" +```tscon exec="1" source="console" +--8<-- "usage/source.tscon" +``` +```` + +It also means that multiple blocks of instructions will be concatenated, +as well as their output: + +````md exec="1" source="tabbed-left" tabs="Markdown|Rendered" +```tscon exec="1" source="console" +--8<-- "usage/multiple.tscon" +``` +```` diff --git a/mkdocs.yml b/mkdocs.yml index 8cf7dd2..21bf6d1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,6 +23,7 @@ nav: - usage/index.md - Python: usage/python.md - Pyodide: usage/pyodide.md + - TypeScript: usage/typescript.md - Shell: usage/shell.md - Tree: usage/tree.md - Gallery: gallery.md diff --git a/pyproject.toml b/pyproject.toml index 5b6dccb..37a9b88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ + "jupyter-client>=8.6.3", "pymdown-extensions>=9", ] @@ -124,4 +125,4 @@ dev = [ "plotly>=6.0; python_version == '3.12'", "pandas>=2.2; python_version == '3.12'", "chalk-diagrams>=0.2.2; python_version == '3.12'", -] \ No newline at end of file +] diff --git a/src/markdown_exec/__init__.py b/src/markdown_exec/__init__.py index 9ffaf14..cb187e2 100644 --- a/src/markdown_exec/__init__.py +++ b/src/markdown_exec/__init__.py @@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from markdown import Markdown -from markdown_exec.formatters.base import default_tabs +from markdown_exec.formatters.base import ExecutionError, default_tabs from markdown_exec.formatters.bash import _format_bash from markdown_exec.formatters.console import _format_console from markdown_exec.formatters.markdown import _format_markdown @@ -24,6 +24,8 @@ from markdown_exec.formatters.pyodide import _format_pyodide from markdown_exec.formatters.python import _format_python from markdown_exec.formatters.sh import _format_sh from markdown_exec.formatters.tree import _format_tree +from markdown_exec.formatters.tscon import _format_tscon +from markdown_exec.formatters.typescript import _format_typescript __all__: list[str] = ["formatter", "validator"] @@ -40,6 +42,9 @@ formatters = { "pyodide": _format_pyodide, "sh": _format_sh, "tree": _format_tree, + "ts": _format_typescript, + "typescript": _format_typescript, + "tscon": _format_tscon, } # negative look behind: matches only if | (pipe) if not preceded by \ (backslash) @@ -65,6 +70,7 @@ def validator( Returns: Success or not. """ + # print(f"language: {language}") exec_value = language in MARKDOWN_EXEC_AUTO or _to_bool(inputs.pop("exec", "no")) if language not in {"tree", "pyodide"} and not exec_value: return False @@ -124,7 +130,10 @@ def formatter( HTML contents. """ fmt = formatters.get(language, lambda source, **kwargs: source) - return fmt(code=source, md=md, **options) # type: ignore[operator] + try: + return fmt(code=source, md=md, **options) # type: ignore[operator] + except Exception as e: + raise ExecutionError(e) from e falsy_values = {"", "no", "off", "false", "0"} diff --git a/src/markdown_exec/formatters/jupyter.py b/src/markdown_exec/formatters/jupyter.py new file mode 100644 index 0000000..0fadfd4 --- /dev/null +++ b/src/markdown_exec/formatters/jupyter.py @@ -0,0 +1,224 @@ +"""Formatter for executing code using Jupyter kernels.""" + +from __future__ import annotations + +import functools +import os +import platform +import queue +from typing import TYPE_CHECKING, Any, Callable + +from jupyter_client import KernelManager +from jupyter_client.kernelspec import KernelSpecManager + +from markdown_exec.formatters.base import ExecutionError, base_format +from markdown_exec.rendering import code_block + +if TYPE_CHECKING: + from jupyter_client.blocking.client import BlockingKernelClient + +# Store kernel managers by session and kernel name +_kernel_managers: dict[str, dict[tuple[str | None, str], KernelManager]] = {} +_kernel_clients: dict[str, dict[tuple[str | None, str], BlockingKernelClient]] = {} + + +def _get_available_kernels() -> dict[str, dict[str, Any]]: + """Get information about all available Jupyter kernels. + + Returns: + A dictionary mapping kernel names to their information including: + - display_name: Human readable name + - language: The programming language + - language_version: Version of the language (if available) + - language_info: Additional language information + """ + ksm = KernelSpecManager() + # Find all available kernel specs + kernel_specs = ksm.get_all_specs() + + # Get detailed information for each kernel + return { + name: { + "display_name": spec["spec"]["display_name"], + "languages": [ + spec["spec"]["language"], + *(spec["spec"].get("aliases", [])), + ], + "language_version": spec["spec"].get("language_version", None), + "language_info": spec["spec"].get("language_info", {}), + } + for name, spec in kernel_specs.items() + } + +_language_to_kernel: dict[str, str] = {} + +def _get_kernel_for_language(language: str) -> str: + """Get the best matching kernel for a given language name. + + Args: + language: Name of the programming language (case insensitive) + + Returns: + The kernel name to use, or None if no matching kernel is found + """ + if language in _language_to_kernel: + return _language_to_kernel[language] + language = language.lower() + kernels = _get_available_kernels() + for kernel in kernels: + if language in [name.lower() for name in kernels[kernel]["languages"]]: + _language_to_kernel[language] = kernel + return kernel + + no_kernel_msg = f"No jupyter kernel found that supports language '{language}'." + + if language == "typescript": + no_kernel_msg += "Please install the deno jupyter kernel by running `deno jupyter install`." + if os.environ.get("JUPYTER_PLATFORM_DIRS", "") and (platform.system() == "Darwin" or platform.system() == "Windows"): + no_kernel_msg += ("You may need additional install steps when running" + "with JUPYTER_PLATFORM_DIRS=1. See " + "https://github.com/denoland/deno/issues/27984 for more info.") + else: + no_kernel_msg += "Available kernels:\n" + "\n".join( + f"- {info['language']}: {name} ({info['display_name']})" + for name, info in kernels.items() + ) + raise ExecutionError(no_kernel_msg) + + +def _get_kernel_manager_and_client( + language: str, + session: str | None = None, +) -> tuple[KernelManager, BlockingKernelClient]: + """Get or create a kernel manager and client for the given kernel name and session.""" + kernel_name = _get_kernel_for_language(language) + key = (session, kernel_name) + + if language not in _kernel_managers: + _kernel_managers[language] = {} + if language not in _kernel_clients: + _kernel_clients[language] = {} + if key not in _kernel_managers[language]: + km = KernelManager(kernel_name=kernel_name) + km.start_kernel() + _kernel_managers[language][key] = km + km = _kernel_managers[language][key] + if key not in _kernel_clients[language]: + kc = km.client() + kc.start_channels() + _kernel_clients[language][key] = kc + kc = _kernel_clients[language][key] + return km, kc + +def _shutdown_kernels() -> None: + for clients in _kernel_clients.values(): + for kc in clients.values(): + kc.stop_channels() + _kernel_clients.clear() + for managers in _kernel_managers.values(): + for km in managers.values(): + km.shutdown_kernel() + _kernel_managers.clear() + +# Because this is a language agnostic runner, it has an extra initial language +# argument. This makes it easy to use functools.partial to create a language +# specific runner when needed. +def _run_jupyter( + language: str, + code: str, + returncode: int | None = None, + session: str | None = None, + id: str | None = None, # noqa: A002, ARG001 + **extra: str, +) -> str: + """Execute code using a Jupyter kernel.""" + is_named_session = session is not None and session != "" + + # Get kernel manager and client + km, kc = _get_kernel_manager_and_client(language, session) + + kc.wait_for_ready() + # Send code for execution + msg_id = kc.execute(code, store_history=is_named_session) + + # Collect output + outputs = [] + error_outputs = [] + execution_completed = False + + while not execution_completed: + try: + # Check for shell messages (execution replies) + shell_msg = kc.get_shell_msg(timeout=0.1) + if shell_msg["parent_header"].get("msg_id") == msg_id: + execution_completed = True + + if shell_msg["content"]["status"] == "error": + if ( + shell_msg["content"]["traceback"] is not None + and len(shell_msg["content"]["traceback"]) > 0 + ): + if shell_msg["content"]["traceback"][0] == "Stack trace:": + error_outputs.extend( + shell_msg["content"]["traceback"][1:], + ) + else: + error_outputs.extend(shell_msg["content"]["traceback"]) + break + # note: abort is deprecated, but some older kernels may still send it + # it doesn't send error info, however - so we'll need to populate a fake abort error instead + if shell_msg["content"]["status"] == "abort": + error_outputs.append("AbortError: Execution aborted") + break + except queue.Empty: + pass + + try: + # nested while here so we consume all iopub messages for this execution before moving on. + # this lets us consume all pending messages after execution has completed before exiting the loop + while True: + # Check for iopub messages (output) + iopub_msg = kc.get_iopub_msg(timeout=0.1) + if iopub_msg["parent_header"].get("msg_id") != msg_id: + continue + + msg_type = iopub_msg["header"]["msg_type"] + content = iopub_msg["content"] + + if msg_type == "stream": + outputs.append(content["text"]) + elif msg_type in ("execute_result", "display_data"): + # TODO: these can contain other MIME types (including HTML) - do we want to support them? + outputs.append(str(content["data"].get("text/plain", ""))) + elif msg_type == "error": + # ignore these because we already got it from the shell message + pass + + except queue.Empty: + # No messages received in timeout period, check if kernel is still alive + if not km.is_alive(): + raise ExecutionError( + "Error: Kernel died during execution", + ) from None + continue + + # Check for errors + if error_outputs: + if returncode: + return "\n".join(error_outputs) + raise ExecutionError( + code_block(language, "\n".join(error_outputs), **extra), + ) + + return "\n".join(outputs) + + + +def _jupyter_formatter(language: str, runner: Callable[[str], str] | None = None, **kwargs: Any) -> str: + """Format and execute code using Jupyter kernels.""" + return base_format( + language=language, + run=runner or functools.partial(_run_jupyter, language), + **kwargs, + ) + diff --git a/src/markdown_exec/formatters/tscon.py b/src/markdown_exec/formatters/tscon.py new file mode 100644 index 0000000..260c8b3 --- /dev/null +++ b/src/markdown_exec/formatters/tscon.py @@ -0,0 +1,37 @@ +"""Formatter for executing `tscon` code.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from markdown_exec.formatters.base import base_format +from markdown_exec.formatters.typescript import _run_typescript +from markdown_exec.logger import get_logger + +if TYPE_CHECKING: + from markupsafe import Markup + +logger = get_logger(__name__) + + +def _transform_source(code: str) -> tuple[str, str]: + typescript_lines = [] + tscon_lines = [] + for line in code.split("\n"): + if line.startswith("> "): + tscon_lines.append(line) + typescript_lines.append(line[2:]) + elif line.startswith("... "): + tscon_lines.append(line) + typescript_lines.append(line[4:]) + typescript_code = "\n".join(typescript_lines) + return typescript_code, "\n".join(tscon_lines) + + +def _format_tscon(**kwargs: Any) -> Markup: + return base_format( + language="tscon", + run=_run_typescript, + transform_source=_transform_source, + **kwargs, + ) diff --git a/src/markdown_exec/formatters/typescript.py b/src/markdown_exec/formatters/typescript.py new file mode 100644 index 0000000..a9b8a0d --- /dev/null +++ b/src/markdown_exec/formatters/typescript.py @@ -0,0 +1,121 @@ +"""Formatter for executing TypeScript code.""" + +from __future__ import annotations + +import functools +import os +import re +import shutil +import subprocess +import tempfile + +from markdown_exec.formatters.base import ExecutionError +from markdown_exec.formatters.jupyter import _jupyter_formatter, _run_jupyter +from markdown_exec.rendering import code_block + +_sessions: dict[str, list[str]] = {} + + +def _clean_typecheck_error(filename: str, stderr: str, session: str | None) -> str: + cleaned_filename = ( + f"" if session else "" + ) + ".ts" + stderr = stderr.replace(f"at file://{filename}", f"at {cleaned_filename}") + + # given an error line like: "at .ts:1:5", subtract the number of lines of old code from the line number + # so the line number aligns with the one in the code block + old_lines = 0 if not session else len(_sessions[session]) + + cleaned_lines = [] + for line in stderr.split("\n"): + if match := re.match( + r"^(\s*at .*:)(?P\d+):(?P\d+)$", + line, + ): + line_number = int(match.group("line_number")) - old_lines + column_number = int(match.group("column_number")) + cleaned_lines.append(f"{match.group(1)}{line_number}:{column_number}") + else: + cleaned_lines.append(line) + return "\n".join(cleaned_lines) + + +def _check_types( + code_history: list[str], + session: str | None, + returncode: int | None = None, + **extra: str, +) -> str | None: + code = "\n".join(code_history) + env = {**os.environ, "NO_COLOR": "1"} + deno_path = shutil.which("deno") + + if not deno_path: + raise ExecutionError( + "Deno not found. Please install it from " + "https://deno.land/manual/getting_started/installation. Be sure to " + "also install the deno jupyter kernel by running `deno jupyter " + "install`.", + ) + + with tempfile.NamedTemporaryFile( + suffix=".ts", + delete=True, + encoding="utf-8", + mode="w", + ) as file: + file.write(code) + file.flush() + with subprocess.Popen( # noqa: S603 + [deno_path, "check", "--quiet", file.name], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + encoding="utf-8", + text=True, + cwd=os.getcwd(), + ) as check_proc: + check_proc.wait() + stderr = check_proc.stderr.read() if check_proc.stderr else "" + if check_proc.returncode != 0: + if returncode: + return code_block( + "typescript", + _clean_typecheck_error(file.name, stderr, session), + **extra, + ) + raise ExecutionError( + code_block( + "typescript", + _clean_typecheck_error(file.name, stderr, session), + **extra, + ), + ) + return None + + +def _get_code_history(session: str | None, code: str) -> list[str]: + if session is None or session == "": + return code.split("\n") + + _sessions[session] = _sessions.get(session, []) + code.split("\n") + + return _sessions[session] + + +def _run_typescript( + code: str, + returncode: int | None = None, + session: str | None = None, + id: str | None = None, # noqa: A002 + **extra: str, +) -> str: + code_history = _get_code_history(session, code) + type_check_errors = _check_types(code_history, session, returncode, **extra) + if type_check_errors: + return type_check_errors + return _run_jupyter("typescript", code, returncode, session, id, **extra) + + +_format_typescript = functools.partial(_jupyter_formatter, "typescript", _run_typescript) diff --git a/src/markdown_exec/mkdocs_plugin.py b/src/markdown_exec/mkdocs_plugin.py index 1919966..7d4ad2a 100644 --- a/src/markdown_exec/mkdocs_plugin.py +++ b/src/markdown_exec/mkdocs_plugin.py @@ -14,6 +14,7 @@ from mkdocs.plugins import BasePlugin from mkdocs.utils import write_file from markdown_exec import formatter, formatters, validator +from markdown_exec.formatters.jupyter import _shutdown_kernels from markdown_exec.logger import patch_loggers from markdown_exec.rendering import MarkdownConverter, markdown_config @@ -128,6 +129,8 @@ class MarkdownExecPlugin(BasePlugin[MarkdownExecPluginConfig]): else: os.environ["MKDOCS_CONFIG_DIR"] = self.mkdocs_config_dir + _shutdown_kernels() + def _add_asset(self, config: MkDocsConfig, asset_file: str, asset_type: str) -> None: asset_filename = f"assets/_markdown_exec_{asset_file}" asset_content = Path(__file__).parent.joinpath(asset_file).read_text() diff --git a/tests/test_typescript.py b/tests/test_typescript.py new file mode 100644 index 0000000..accbd2f --- /dev/null +++ b/tests/test_typescript.py @@ -0,0 +1,190 @@ +"""Tests for the TypeScript formatter.""" + +from textwrap import dedent + +import pytest +from markdown import Markdown + +from markdown_exec.formatters.base import ExecutionError +from markdown_exec.rendering import code_block + + +def _expect_error(md: Markdown, error_text: str, actual_html: str) -> None: + """Utility function to check that the error text is formatted as expected.""" + expected_exception = ExecutionError(code_block("typescript", error_text)) + expected_html = md.convert(str(expected_exception)) + assert expected_html == actual_html + + +def test_output_markdown(md: Markdown) -> None: + """Test that output from code is rendered as Markdown.""" + html = md.convert( + dedent( + """ + ```typescript exec="yes" + console.log("**Bold!**") + ``` + """, + ), + ) + assert html == "

Bold!

" + + +def test_session_persistence(md: Markdown) -> None: + """Test that session data is persisted across code blocks.""" + html = md.convert( + dedent( + """ + ```typescript exec="1" session="a" + let count = 0; + ``` + + ```typescript exec="1" session="a" + count++; + console.log(`Count: ${count}`); + ``` + """, + ), + ) + assert "Count: 1" in html + + +def test_runtime_error(md: Markdown, caplog: pytest.LogCaptureFixture) -> None: + """Test that runtime errors are formatted correctly.""" + html = md.convert( + dedent( + """ + ```typescript exec="1" session="error-test" + throw new Error("Intentional runtime error"); + ``` + """, + ), + ) + + _expect_error( + md, + dedent( + """ + Error: Intentional runtime error + at :1:28 + """, + ), + html, + ) + + assert "Execution of typescript code block exited with errors" in caplog.text + + +def test_type_error(md: Markdown, caplog: pytest.LogCaptureFixture) -> None: + """Test that type errors are formatted correctly.""" + html = md.convert( + dedent( + """ + ```typescript exec="1" + let count: number = "string"; + ``` + """, + ), + ) + + _expect_error( + md, + dedent( + """ + TS2322 [ERROR]: Type 'string' is not assignable to type 'number'. + let count: number = "string"; + ~~~~~ + at .ts:1:5 + + error: Type checking failed. + """, + ), + html, + ) + + assert "Execution of typescript code block exited with errors" in caplog.text + + +def test_console_output(md: Markdown) -> None: + """Test that console output is rendered correctly.""" + html = md.convert( + dedent( + """ + ```typescript exec="1" + console.log("Hello"); + console.error("World"); + ``` + """, + ), + ) + assert "Hello" in html + assert "World" in html + + +def test_session_isolation(md: Markdown) -> None: + """Test that sessions are isolated from each other.""" + html = md.convert( + dedent( + """ + ```typescript exec="1" session="session-a" + let x = 1; + console.log(`A: ${x}`); + ``` + + ```typescript exec="1" session="session-b" + let x = 2; + console.log(`B: ${x}`); + ``` + + ```typescript exec="1" session="session-a" + console.log(`A2: ${x}`); + ``` + """, + ), + ) + assert "A: 1" in html + assert "B: 2" in html + assert "A2: 1" in html + + +def test_partial_execution(md: Markdown) -> None: + """Test that partial execution works.""" + html = md.convert( + dedent( + """ + ```typescript exec="1" session="partial" + let x = 1; + console.log(`THE NUMBER IS ${x}`); + throw new Error("Fail"); + x = 2; + ``` + + ```typescript exec="1" session="partial" + console.log(`THE NUMBER IS ${x}`); + ``` + """, + ), + ) + assert "THE NUMBER IS 1" in html + assert "THE NUMBER IS 2" not in html + + +def test_tscon_multiple_blocks(md: Markdown) -> None: + """Test that multiple blocks of instructions are concatenated, as well as their output.""" + html = md.convert( + dedent( + """ + ```tscon exec="1" + > const name: string = "Baron"; + > console.log(name); + Baron + > const age: string | number = "???"; + > console.log(age); + ??? + ``` + """, + ), + ) + + assert "Baron" in html + assert "???" in html