Skip to main content

yggdrasil/components/code_runner/
runner.rs

1//! CodeRunner 组件实现:可编辑源码 + 运行按钮 + SSE 流式输出 + xterm.js 终端。
2//!
3//! 标题栏:语言、「可运行」、改过后的「已修改 / 还原」、「圆点 + 文字」运行状态、
4//! Vim 开关、复制与运行(窄屏把 Vim、复制、还原收进「⋯」菜单)。编辑器挂载前显示
5//! 服务端高亮好的代码。
6//!
7//! 分包边界:本文件的 [`CodeRunner`] 是外壳,留在调用方所在的包——静态代码占位、标题栏、
8//! 容器、输出区与 `use_near_viewport` 激活判断;激活后的编辑器 + 终端 + 执行逻辑抽成
9//! `body::code_runner_body`(`#[leptos::lazy]`),在 `<Suspense>` 内经
10//! `Suspend::new(async move { code_runner_body(..).await })` 加载。外壳与分包只通过
11//! `Send + Sync` 的信号通信(状态信号与命令信号,见 `execution`),JS 句柄全在分包里。
12//!
13//! 激活门控(P2):挂载后并不立即加载 CodeMirror、分包或创建编辑器实例;容器接近
14//! 视口(约一屏提前量),或用户点击静态代码 / 运行按钮时,才触发库加载、分包加载与
15//! 实例创建(`use_near_viewport` 单向激活,滚出视口不销毁)。激活前容器渲染服务端
16//! 高亮占位(`highlighted`,SSR 与 hydration 输出一致;`.code-runner-static`);用户在
17//! 编辑器就绪前点击「运行」时保留意图(pending_run),就绪后自动执行一次。
18//! xterm 输出终端保持「输出出现后才加载」的既有门控,激活绝不触发代码执行。
19//!
20//! 输出渲染:分包在输出区的 `NodeRef` 元素上调用 `xterm::create(...)`
21//! 挂载 xterm.js 终端(输出专用,无 stdin),SSE stdout/stderr 事件实时写入。
22//! SSE 不可用时降级到轮询 get_exec_result,整段写入终端(writeAll)。
23
24mod body;
25#[cfg(target_arch = "wasm32")]
26mod editor;
27mod execution;
28mod state;
29#[cfg(target_arch = "wasm32")]
30mod stream;
31#[cfg(target_arch = "wasm32")]
32mod terminal;
33
34use crate::api::code_runner::ExecRequest;
35use crate::bridges::library::{use_browser_library, BrowserLibrary, LibraryLoadError};
36use crate::components::skeletons::atoms::SkeletonBox;
37use crate::components::ui::{
38    Button, ButtonSize, ButtonVariant, Icon, IconName, Menu, MenuItem, Tooltip,
39};
40use crate::infra::runner_config::ResourceLimits;
41use leptos::prelude::*;
42
43use body::{code_runner_body, RunnerBody};
44use execution::Execution;
45
46/// Vim 偏好在 localStorage 中的键,所有代码块共用。
47#[cfg(target_arch = "wasm32")]
48const VIM_STORAGE_KEY: &str = "yggdrasil-code-runner-vim";
49
50/// Fixed local output for callers that demonstrate the runner without execution.
51#[derive(Clone, PartialEq)]
52pub struct RunnerExample {
53    pub stdout: String,
54    pub stderr: String,
55    pub status: String,
56    pub error: String,
57}
58
59/// 代码运行器组件。
60///
61/// Props:
62/// - `source`:初始源码(首次挂载用于初始化编辑器;之后编辑器内容是唯一真源,
63///   与它不同即为「已修改」,「还原」写回它)。
64/// - `language`:语言标识(python / node 等)。
65/// - `overrides`:可选资源限制覆盖(可传 `ResourceLimits`、`Option<ResourceLimits>` 或省略)。
66/// - `instance_id`:实例在父级片段序列中的索引,用作 CodeMirror 容器 id 后缀。
67///   必须是 **SSR/hydration 确定性**的值(如父组件遍历片段的索引)——容器 id 在 SSR 与
68///   水合两端必须一致,`create()` 才能在 hydration 后找到 SSR 渲染的容器元素。
69/// - `highlighted`:服务端高亮好的代码 HTML,编辑器挂载前显示;缺省时显示纯文本源码
70///   (可传 `String`、`Option<String>` 或省略)。
71/// - `example_output`:图鉴播放作者保存的输出,不实际运行(可传 `RunnerExample`、
72///   `Option<RunnerExample>` 或省略)。
73#[component]
74pub fn CodeRunner(
75    #[prop(into)] source: String,
76    #[prop(into)] language: String,
77    #[prop(optional, into)] overrides: MaybeProp<ResourceLimits>,
78    instance_id: usize,
79    #[prop(optional, into)] highlighted: MaybeProp<String>,
80    #[prop(optional, into)] example_output: MaybeProp<RunnerExample>,
81) -> impl IntoView {
82    let overrides = overrides.get_untracked();
83    let highlighted = highlighted.get_untracked();
84    let example_output = example_output.get_untracked();
85
86    let execution = Execution::new(example_output.as_ref());
87    let show_output = execution.show_output;
88    let has_output = execution.has_output;
89    let state = execution.state;
90    let running = Memo::new(move |_| state.with(|state| state.is_running()));
91    let terminal_library = use_browser_library(BrowserLibrary::Xterm, move || show_output.get());
92    // 编辑器内容的唯一真源;初始化为 prop 值。
93    let source_signal = RwSignal::new(source.clone());
94    let dirty = {
95        let original = source.clone();
96        Memo::new(move |_| source_signal.with(|current| *current != original))
97    };
98    // 最近一次运行用的是不是改过的代码,决定输出区标题。
99    let ran_modified = RwSignal::new(false);
100    // 复制结果:Some(true) 已复制,Some(false) 失败(如非安全上下文),None 默认。
101    let copied = RwSignal::new(None::<bool>);
102
103    // 编辑器与终端容器:id 由确定性 prop 派生(SSR 与 hydration 一致;视口观察、
104    // 样式选择器和脚本按 id 查找),挂载点经 NodeRef 把元素交给 `create`。
105    let container_id = format!("code-runner-{instance_id}");
106    let output_container_id = format!("code-runner-output-{instance_id}");
107    let editor_mount = NodeRef::<leptos::html::Div>::new();
108    let output_mount = NodeRef::<leptos::html::Div>::new();
109
110    // 激活门控:容器接近视口(底部提前约一屏)或用户主动点击占位代码 / 运行按钮
111    // 时置 true。单向激活,滚出视口不销毁已建实例;IntersectionObserver 不可用时
112    // near 保持 false,由上述交互入口兜底激活。SSR 端恒为 false:占位与 hydration 首帧
113    // 结构一致,可见性只在 hydration 后的 Effect 里生效。
114    let near_viewport = crate::hooks::use_near_viewport(container_id.clone(), "0px 0px 100% 0px");
115    let activated = RwSignal::new(false);
116    Effect::new(move |_| {
117        if near_viewport.get() {
118            activated.set(true);
119        }
120    });
121
122    // CodeMirror 库只在激活后加载;多实例共享 JS 侧 pending-map 去重,仍只发一次脚本请求。
123    let editor_library = use_browser_library(BrowserLibrary::CodeMirror, move || activated.get());
124
125    // 用户在编辑器就绪前的运行意图;编辑器就绪后由下方 pending Effect 自动执行一次。
126    let pending_run = RwSignal::new(false);
127
128    // 编辑器是否已挂载就绪:SSR 与 hydration 完成前为 false,显示高亮占位。
129    let editor_ready = RwSignal::new(false);
130    // 编辑器/终端实例创建失败(库已加载但 create 报错或返回空):提示并允许重试。
131    let editor_failed = RwSignal::new(false);
132    let terminal_failed = RwSignal::new(false);
133    // 点「重试」自增,分包里的两个挂载 Effect 订阅它而重跑;已有句柄的那个早退。
134    let mount_retry = RwSignal::new(0_u32);
135    let vim_mode: RwSignal<Option<String>> = RwSignal::new(None);
136    // 「还原」命令:分包里的编辑器 Effect 取走并整体替换编辑器内容。
137    let editor_command: RwSignal<Option<String>> = RwSignal::new(None);
138
139    // Vim 偏好存在本机,默认开启;没有偏好时,触屏设备默认关闭。
140    // 首屏与 hydration 都按默认开启渲染,挂载后再读本机偏好,避免两端结构不一致。
141    let vim_enabled = RwSignal::new(true);
142    #[cfg(target_arch = "wasm32")]
143    let example_mode = example_output.is_some();
144    #[cfg(target_arch = "wasm32")]
145    Effect::new(move |_| {
146        let preferred = web_sys::window().and_then(|window| {
147            let stored = (!example_mode)
148                .then(|| window.local_storage().ok().flatten())
149                .flatten()
150                .and_then(|storage| storage.get_item(VIM_STORAGE_KEY).ok().flatten());
151            match stored {
152                Some(val) => Some(val == "true"),
153                None => window
154                    .match_media("(pointer: coarse)")
155                    .ok()
156                    .flatten()
157                    .map(|query| !query.matches()),
158            }
159        });
160        if let Some(enabled) = preferred.filter(|enabled| !enabled) {
161            vim_enabled.set(enabled);
162        }
163    });
164
165    let toggle_vim = Callback::new(move |()| {
166        let next = !vim_enabled.get_untracked();
167        vim_enabled.set(next);
168        #[cfg(target_arch = "wasm32")]
169        if !example_mode {
170            if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten())
171            {
172                let _ = storage.set_item(VIM_STORAGE_KEY, &next.to_string());
173            }
174        }
175    });
176
177    let run_example = example_output.clone();
178    let run_language = language.clone();
179    let run = Callback::new(move |()| {
180        if running.get_untracked() {
181            return;
182        }
183        if !editor_ready.get_untracked() {
184            // 用户操作早于编辑器就绪(未激活或库/分包加载中):保留意图并立即触发
185            // 激活,就绪后由 pending Effect 自动执行一次,不静默丢弃点击、不
186            // 执行空代码或重复执行。
187            pending_run.set(true);
188            activated.set(true);
189            return;
190        }
191        if let Some(example) = &run_example {
192            execution.show_example(example);
193        } else {
194            ran_modified.set(dirty.get_untracked());
195            execution.start(ExecRequest {
196                language: run_language.clone(),
197                source: source_signal.get_untracked(),
198                overrides: overrides.clone(),
199            });
200        }
201    });
202
203    // 编辑器就绪后兑现激活前的运行意图:先清标志再启动,保证只执行一次。
204    // 读取 running 建立订阅并避免在已有执行未结束时重复启动。
205    Effect::new(move |_| {
206        if editor_ready.get() && pending_run.get() && !running.get() {
207            pending_run.set(false);
208            run.run(());
209        }
210    });
211
212    // 意图已保留:编辑器激活/加载期间按钮显示加载态;库加载失败时不伪装加载,
213    // 由 LibraryLoadError 重试恢复。
214    let awaiting_editor = move || {
215        let library_failed = matches!(editor_library.read_state(), Some(Err(_)));
216        pending_run.get() && !editor_ready.get() && !library_failed && !editor_failed.get()
217    };
218
219    let revert_source = source.clone();
220    let revert = Callback::new(move |()| {
221        editor_command.set(Some(revert_source.clone()));
222        source_signal.set(revert_source.clone());
223    });
224    let copy = Callback::new(move |()| {
225        #[cfg(target_arch = "wasm32")]
226        {
227            let text = source_signal.get_untracked();
228            leptos::task::spawn_local(async move {
229                let ok = crate::utils::js::copy_text(&text).await;
230                let _ = copied.try_set(Some(ok));
231                crate::utils::time::sleep_ms(2000).await;
232                let _ = copied.try_set(None);
233            });
234        }
235    });
236
237    let example_mode_label = example_output.is_some();
238    let copy_label = move || match copied.get() {
239        Some(true) => "已复制",
240        Some(false) => "复制失败",
241        None => "复制代码",
242    };
243    let run_label = move || {
244        if example_mode_label {
245            "播放示例"
246        } else if state.with(|state| state.is_finished()) {
247            "再次运行"
248        } else {
249            "运行"
250        }
251    };
252    let output_label = move || {
253        if example_mode_label {
254            "输出示例"
255        } else if ran_modified.get() {
256            "输出 · 修改后的代码"
257        } else {
258            "输出"
259        }
260    };
261    let menu_items = Signal::derive(move || {
262        let mut items = vec![MenuItem::new("copy", copy_label()).icon(IconName::Copy)];
263        if dirty.get() {
264            items.push(MenuItem::new("revert", "还原").icon(IconName::RotateCcw));
265        }
266        items.push(
267            MenuItem::new(
268                "vim",
269                if vim_enabled.get() {
270                    "关闭 Vim 键位"
271                } else {
272                    "开启 Vim 键位"
273                },
274            )
275            .icon(IconName::Keyboard)
276            .separated(),
277        );
278        items
279    });
280    let placeholder_html = highlighted.unwrap_or_else(|| crate::utils::html::escape_html(&source));
281    // 编辑器把末尾换行显示成一个空行,占位按同样的行数撑高。
282    let line_count = source.split('\n').count().max(1);
283    let gutter_digits = line_count.to_string().len();
284    let gutter_text: String = (1..=line_count).map(|n| format!("{n}\n")).collect();
285    let placeholder_style =
286        format!("--runner-lines: {line_count}; --runner-digits: {gutter_digits};");
287    let language_label = language.clone();
288
289    let body_args = RunnerBody {
290        language,
291        editor_mount,
292        output_mount,
293        example_output: example_output.clone(),
294        source_signal,
295        vim_enabled,
296        vim_mode,
297        run,
298        activated,
299        editor_ready,
300        editor_failed,
301        terminal_failed,
302        mount_retry,
303        editor_command,
304        editor_library,
305        terminal_library,
306        execution,
307        resolved_theme: crate::theme::use_resolved_theme(),
308    };
309
310    view! {
311        <figure class="runner" data-tone=move || state.with(|state| state.tone().as_str())>
312            <div class="runner-head">
313                <div class="runner-head-start">
314                    <span class="runner-lang">{language_label}</span>
315                    <span class="runner-badge">"可运行"</span>
316                    {move || {
317                        dirty
318                            .get()
319                            .then(|| {
320                                view! {
321                                    <span class="runner-dirty">"已修改"</span>
322                                    <button
323                                        class="runner-revert runner-wide"
324                                        type="button"
325                                        on:click=move |_| revert.run(())
326                                    >
327                                        "还原"
328                                    </button>
329                                }
330                            })
331                    }}
332                </div>
333                <div class="runner-head-end">
334                    <span
335                        class=move || {
336                            if running.get() { "runner-status is-live" } else { "runner-status" }
337                        }
338                        role="status"
339                        aria-live="polite"
340                    >
341                        {move || state.with(|state| state.label())}
342                    </span>
343                    <button
344                        class="runner-vim runner-wide"
345                        type="button"
346                        aria-pressed=move || vim_enabled.get().to_string()
347                        title="Vim 键位,偏好保存在本机"
348                        on:click=move |_| toggle_vim.run(())
349                    >
350                        "Vim"
351                    </button>
352                    <span class="runner-wide">
353                        <Tooltip tip=Signal::derive(move || copy_label().to_string())>
354                            <button
355                                class="btn btn-sm btn-icon btn-ghost"
356                                type="button"
357                                aria-label=copy_label
358                                on:click=move |_| copy.run(())
359                            >
360                                {move || {
361                                    view! {
362                                        <Icon
363                                            name=if copied.get() == Some(true) {
364                                                IconName::Check
365                                            } else {
366                                                IconName::Copy
367                                            }
368                                            size=15
369                                        />
370                                    }
371                                }}
372                            </button>
373                        </Tooltip>
374                    </span>
375                    <span class="runner-narrow">
376                        <Menu
377                            label="更多操作"
378                            items=menu_items
379                            on_select=Callback::new(move |id: &'static str| match id {
380                                "copy" => copy.run(()),
381                                "revert" => revert.run(()),
382                                "vim" => toggle_vim.run(()),
383                                _ => {}
384                            })
385                        />
386                    </span>
387                    <Button
388                        variant=ButtonVariant::Primary
389                        size=ButtonSize::Sm
390                        icon=IconName::Play
391                        loading=Signal::derive(move || running.get() || awaiting_editor())
392                        onclick=Callback::new(move |_| run.run(()))
393                    >
394                        <span class="runner-run-label">
395                            <span>{run_label}</span>
396                            <span class="runner-run-reserve" aria-hidden="true">
397                                "再次运行"
398                            </span>
399                        </span>
400                    </Button>
401                </div>
402            </div>
403            <LibraryLoadError library=editor_library/>
404            {move || {
405                editor_failed
406                    .get()
407                    .then(|| {
408                        view! {
409                            <div
410                                role="alert"
411                                class="flex items-center gap-3 p-3 text-sm text-red-500 dark:text-red-400"
412                            >
413                                <span>"编辑器初始化失败。"</span>
414                                <button
415                                    type="button"
416                                    class="underline underline-offset-4"
417                                    on:click=move |_| {
418                                        editor_failed.set(false);
419                                        mount_retry.update(|n| *n += 1);
420                                    }
421                                >
422                                    "重试"
423                                </button>
424                            </div>
425                        }
426                    })
427            }}
428            <div id=container_id node_ref=editor_mount class="runner-editor">
429                // 挂载前显示服务端高亮好的代码,行号与编辑器对齐,挂载时不跳动。
430                // 用绝对定位覆盖在(始终存在的)容器上方,不影响 CodeMirror 在该元素上的挂载。
431                // 点击占位提前激活编辑器,也是 IntersectionObserver 不可用时的交互兜底。
432                {move || {
433                    (!editor_ready.get())
434                        .then(|| {
435                            view! {
436                                <div
437                                    class="runner-placeholder code-runner-static"
438                                    aria-hidden="true"
439                                    title="点击启用编辑"
440                                    on:click=move |_| activated.set(true)
441                                    style=placeholder_style.clone()
442                                >
443                                    <pre class="runner-placeholder-gutter">{gutter_text.clone()}</pre>
444                                    <pre class="runner-placeholder-code">
445                                        <code inner_html=placeholder_html.clone()></code>
446                                    </pre>
447                                </div>
448                            }
449                        })
450                }}
451            </div>
452            // 挂载前按 NORMAL 占位,挂载后跟随编辑器的模式,状态条出现时不跳动。
453            {move || {
454                vim_enabled
455                    .get()
456                    .then(|| {
457                        let mode = if editor_ready.get() {
458                            vim_mode.get().unwrap_or_default()
459                        } else {
460                            String::new()
461                        };
462                        view! { <VimStatus mode/> }
463                    })
464            }}
465            // 输出区:第一次运行时才加载终端,加载期间两行占位。
466            {move || {
467                let output_container_id = output_container_id.clone();
468                show_output
469                    .get()
470                    .then(|| {
471                        view! {
472                            <div class="runner-output">
473                                <LibraryLoadError library=terminal_library/>
474                                {move || {
475                                    terminal_failed
476                                        .get()
477                                        .then(|| {
478                                            view! {
479                                                <div
480                                                    role="alert"
481                                                    class="flex items-center gap-3 p-3 text-sm text-red-500 dark:text-red-400"
482                                                >
483                                                    <span>"终端初始化失败。"</span>
484                                                    <button
485                                                        type="button"
486                                                        class="underline underline-offset-4"
487                                                        on:click=move |_| {
488                                                            terminal_failed.set(false);
489                                                            mount_retry.update(|n| *n += 1);
490                                                        }
491                                                    >
492                                                        "重试"
493                                                    </button>
494                                                </div>
495                                            }
496                                        })
497                                }}
498                                <div class="runner-output-head">
499                                    <span>{output_label}</span>
500                                    <div class="runner-output-meta">
501                                        <span class="tabular-nums">
502                                            {move || state.with(|state| state.meta())}
503                                        </span>
504                                        {example_mode_label
505                                            .then(|| {
506                                                view! {
507                                                    <button
508                                                        type="button"
509                                                        on:click=move |_| execution.clear()
510                                                    >
511                                                        "清空"
512                                                    </button>
513                                                }
514                                            })}
515                                    </div>
516                                </div>
517                                <div id=output_container_id node_ref=output_mount class="runner-terminal">
518                                    {move || {
519                                        (running.get() && !has_output.get())
520                                            .then(|| {
521                                                view! {
522                                                    <div class="runner-terminal-skeleton">
523                                                        <SkeletonBox class="h-2.5" style="width: 40%"/>
524                                                        <SkeletonBox class="h-2.5" style="width: 64%"/>
525                                                        {move || {
526                                                            state
527                                                                .with(|state| state.busy_hint())
528                                                                .map(|hint| {
529                                                                    view! { <span class="runner-terminal-hint">{hint}</span> }
530                                                                })
531                                                        }}
532                                                    </div>
533                                                }
534                                            })
535                                    }}
536                                </div>
537                                {move || {
538                                    state
539                                        .with(|state| state.note().map(|note| (note, state.retryable())))
540                                        .map(|(note, retryable)| {
541                                            view! {
542                                                <div class="runner-note">
543                                                    <span>{note}</span>
544                                                    {retryable
545                                                        .then(|| {
546                                                            view! {
547                                                                <button
548                                                                    class="runner-retry"
549                                                                    type="button"
550                                                                    on:click=move |_| run.run(())
551                                                                >
552                                                                    "重试"
553                                                                </button>
554                                                            }
555                                                        })}
556                                                </div>
557                                            }
558                                        })
559                                }}
560                            </div>
561                        }
562                    })
563            }}
564            // 激活后才加载分包:挂载编辑器与终端、接管执行。分包不渲染 DOM。
565            <Suspense fallback=|| ()>
566                {move || {
567                    let args = body_args.clone();
568                    activated
569                        .get()
570                        .then(|| Suspend::new(async move { code_runner_body(args).await }))
571                }}
572            </Suspense>
573        </figure>
574    }
575}
576
577/// Vim 状态条:当前模式与该模式下最常用的键。
578#[component]
579fn VimStatus(mode: String) -> impl IntoView {
580    let (label, hint) = match mode.as_str() {
581        "insert" => ("INSERT", "Esc 回到普通模式 · ⌘↵ 运行"),
582        "visual" => ("VISUAL", "y 复制选区 · Esc 取消"),
583        "replace" => ("REPLACE", "Esc 回到普通模式"),
584        _ => ("NORMAL", "i 插入 · v 选择 · ⌘↵ 运行"),
585    };
586    view! {
587        <div class="runner-vim-status" data-mode=label>
588            <span class="runner-vim-mode">{format!("-- {label} --")}</span>
589            <span class="runner-vim-hint">{hint}</span>
590        </div>
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use leptos::reactive::owner::Owner;
598
599    fn render(view: impl FnOnce() -> AnyView) -> String {
600        Owner::new().with(|| {
601            crate::theme::provide_theme();
602            view().to_html()
603        })
604    }
605
606    #[test]
607    fn inactive_runner_renders_static_code_without_output_or_editor() {
608        let html = render(|| {
609            view! {
610                <CodeRunner
611                    source="print('hi')\nprint(2)"
612                    language="python"
613                    instance_id=1usize
614                    highlighted="<span>hi</span>".to_string()
615                />
616            }
617            .into_any()
618        });
619        // 激活前:静态高亮占位 + 确定性容器 id;没有输出区。
620        assert!(html.contains("code-runner-static"), "{html}");
621        assert!(html.contains(r#"id="code-runner-1""#), "{html}");
622        assert!(html.contains("<span>hi</span>"), "{html}");
623        assert!(html.contains("可运行"), "{html}");
624        assert!(html.contains("NORMAL"), "{html}");
625        assert!(!html.contains("runner-output"), "{html}");
626        assert!(!html.contains("cm-editor"), "{html}");
627    }
628
629    #[test]
630    fn example_runner_shows_saved_output_without_running() {
631        let html = render(|| {
632            view! {
633                <CodeRunner
634                    source="print(1)"
635                    language="python"
636                    instance_id=2usize
637                    example_output=RunnerExample {
638                        stdout: "1\n".into(),
639                        stderr: String::new(),
640                        status: "退出码 0".into(),
641                        error: String::new(),
642                    }
643                />
644            }
645            .into_any()
646        });
647        assert!(html.contains("播放示例"), "{html}");
648        assert!(html.contains("输出示例"), "{html}");
649        assert!(html.contains(r#"id="code-runner-output-2""#), "{html}");
650        assert!(html.contains("清空"), "{html}");
651    }
652
653    #[test]
654    fn placeholder_gutter_counts_lines() {
655        let html = render(|| {
656            view! { <CodeRunner source="a\nb\nc" language="python" instance_id=3usize/> }.into_any()
657        });
658        assert!(
659            html.contains("--runner-lines: 3; --runner-digits: 1;"),
660            "{html}"
661        );
662    }
663}