support execution of typescript codeblocks

This commit is contained in:
Ben Burns
2025-02-03 23:18:32 +13:00
parent cee094f9b2
commit b4a8e10fc8
14 changed files with 774 additions and 5 deletions
+113
View File
@@ -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
+7
View File
@@ -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
+4 -2
View File
@@ -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:
+6
View File
@@ -0,0 +1,6 @@
> const name: string = "Baron";
> console.log(name);
Baron
> const age: string | number = "???";
> console.log(age);
???
+2
View File
@@ -0,0 +1,2 @@
> console.log("I'm the result!");
I'm not the result...
+53
View File
@@ -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"
```
````
+1
View File
@@ -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
+2 -1
View File
@@ -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'",
]
]
+11 -2
View File
@@ -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"}
+224
View File
@@ -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,
)
+37
View File
@@ -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,
)
+121
View File
@@ -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"<session {session}>" if session else "<anonymous session>"
) + ".ts"
stderr = stderr.replace(f"at file://{filename}", f"at {cleaned_filename}")
# given an error line like: "at <anonymous session>.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<line_number>\d+):(?P<column_number>\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)
+3
View File
@@ -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()
+190
View File
@@ -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 == "<p><strong>Bold!</strong></p>"
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 <anonymous>: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 <anonymous session>.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