import type { NodeInput } from 'mathjslab'; import styles from './batch-shell.styles.scss'; import { appEngine } from '../../appEngine'; import i18n from '../../i18n'; import type WebComponentElement from '../WebComponentElement'; import constructorFactory from '../constructorFactory'; import createElementFactory from '../createElementFactory'; import defineFactory from '../defineFactory'; import keyToPostfix from '../keyToPostfix'; import setContainerFactory from '../setContainerFactory'; import setIdFirstFactory from '../setIdFirstFactory'; import type { BatchCodeEditor } from '../batch-code-editor/batch-code-editor.component'; import type { BatchOutput, BatchOutputItem } from '../batch-output/batch-output.component'; /** * Shadow DOM element map for the batch shell. */ export interface BatchShellElementEntry { root: HTMLElement; title: HTMLElement; description: HTMLElement; languageLabel: HTMLElement; language: HTMLSelectElement; status: HTMLElement; editor: BatchCodeEditor; controls: HTMLElement; run: HTMLButtonElement; clearOutput: HTMLButtonElement; reset: HTMLButtonElement; output: BatchOutput; } export type BatchShellElement = WebComponentElement; export const BatchShellElementEntryKey: (keyof BatchShellElementEntry)[] = [ 'root', 'title', 'description', 'languageLabel', 'language', 'status', 'editor', 'controls', 'run', 'clearOutput', 'reset', 'output', ] as const; /** * Main application shell that coordinates editing, batch execution, and output. */ export class BatchShell extends HTMLElement { public static readonly tagName = 'batch-shell'; public readonly element = {} as BatchShellElement; public static readonly elementFields: (keyof BatchShellElementEntry)[] = BatchShellElementEntryKey; public static readonly elementPostfix = keyToPostfix(BatchShellElementEntryKey); public static readonly null = null as unknown as BatchShell; public static readonly undefined = undefined as unknown as BatchShell; public constructor() { super(); constructorFactory(BatchShell, styles).bind(this)(); this.renderLanguageOptions(); this.setLanguage(); } public set superId(id: string) { super.id = id; } public get superId(): string { return super.id; } public set id(id: string) { this.setId(id); } public get id(): string { return super.id; } public setId: (this: BatchShell, id?: string) => void = setIdFirstFactory(BatchShell).bind(this); public static readonly createElement = createElementFactory(BatchShell); public static readonly define = defineFactory(BatchShell); public set container(element: HTMLElement) { setContainerFactory().bind(this)(element); } public get container(): HTMLElement { return this.element.container; } /** * Register event listeners after the shell is connected. */ public connectedCallback(): void { i18n.addEventListener('languagechange', this.setLanguage); this.element.language.addEventListener('change', this.changeLanguage); this.element.run.addEventListener('click', this.run); this.element.clearOutput.addEventListener('click', this.clearOutput); this.element.reset.addEventListener('click', this.resetSample); } /** * Remove event listeners registered by `connectedCallback`. */ public disconnectedCallback(): void { i18n.removeEventListener('languagechange', this.setLanguage); this.element.language.removeEventListener('change', this.changeLanguage); this.element.run.removeEventListener('click', this.run); this.element.clearOutput.removeEventListener('click', this.clearOutput); this.element.reset.removeEventListener('click', this.resetSample); } /** * Populate the language selector from the i18n service. */ private renderLanguageOptions(): void { this.element.language.replaceChildren(); for (const locale of i18n.locales) { const option = document.createElement('option'); option.value = locale; option.textContent = i18n.languageNames[locale]; this.element.language.append(option); } } /** * Change the shared application language from the selector value. */ private readonly changeLanguage = (): void => { appEngine.setLanguage(this.element.language.value); }; /** * Apply localized shell text and document metadata. */ private readonly setLanguage = (): void => { i18n.applyDocumentLanguage(); this.element.title.textContent = i18n.page.app.title; this.element.description.textContent = i18n.page.app.description; this.element.languageLabel.textContent = i18n.page.shell.languageLabel; this.element.language.setAttribute('aria-label', i18n.page.shell.languageLabel); this.element.language.value = i18n.locale; this.element.controls.setAttribute('aria-label', i18n.page.shell.controlsLabel); this.element.run.textContent = i18n.page.shell.run; this.element.clearOutput.textContent = i18n.page.shell.clearOutput; this.element.reset.textContent = i18n.page.shell.resetSample; if (!this.element.output.hasItems) { this.element.status.textContent = i18n.page.shell.status.ready; } }; /** * Execute all statements in the editor as a batch. */ private readonly run = (): void => { const source = this.element.editor.value; const items: BatchOutputItem[] = []; try { const parsed = this.parseStatements(source); for (const command of parsed.statements) { const tree = appEngine.interpreter.Parse(command); const evaluated = appEngine.interpreter.Evaluate(tree); items.push({ command, html: this.formatResult(tree, evaluated), }); } this.element.output.setItems(items); this.element.status.textContent = i18n.format('shell.status.finished', { count: items.length }); } catch (error) { const message = error instanceof Error ? error.message : String(error); items.push({ command: source.trim(), html: this.escapeHTML(message), error: true }); this.element.output.setItems(items); this.element.status.textContent = i18n.page.shell.status.error; if (appEngine.interpreter.debug) { throw error; } } }; /** * Clear the output panel and restore the ready status. */ private readonly clearOutput = (): void => { this.element.output.clear(); this.element.status.textContent = i18n.page.shell.status.ready; this.element.editor.focus(); }; /** * Restore the starter source code sample. */ private readonly resetSample = (): void => { this.element.editor.value = ['A = [1 2; 3 4];', 'b = [5; 6];', 'x = A \\ b', 'sin(pi / 6)'].join('\n'); this.clearOutput(); }; /** * Split parsed multiline source into executable statement snippets. * * @param input Full editor source. * @returns Extracted statements plus original source lines. */ private parseStatements(input: string): { statements: string[]; lines: string[] } { const statements: string[] = []; const lines = input.split(/\r?\n/); const tree = appEngine.interpreter.Parse(input); for (let i = 0; i < tree.list.length; i++) { const node = tree.list[i]!; if (node.stop.line === node.start.line) { if ((i === 0 || tree.list[i - 1]!.stop.line < node.start.line) && (i === tree.list.length - 1 || tree.list[i + 1]!.start.line > node.start.line)) { statements[i] = lines[node.start.line - 1] ?? ''; } else { statements[i] = (lines[node.start.line - 1] ?? '').substring(node.start.column, node.stop.column + 1); } } else { statements[i] = this.getMultilineStatement(lines, tree.list, i).trim(); } } return { statements: statements.filter((statement) => statement.trim().length > 0), lines }; } /** * Extract one multiline statement from source lines using parser positions. * * @param lines Original source split by line. * @param list Parsed statement nodes. * @param index Statement index to extract. * @returns Source text corresponding to the parsed statement. */ private getMultilineStatement(lines: string[], list: NodeInput[], index: number): string { const node = list[index]!; let result = ''; if (index === 0 || list[index - 1]!.stop.line < node.start.line) { result = `${lines[node.start.line - 1] ?? ''}\n`; } else { result = `${(lines[node.start.line - 1] ?? '').substring(node.start.column)}\n`; } if (node.stop.line > node.start.line + 1) { result += `${lines.slice(node.start.line, node.stop.line - 1).join('\n')}\n`; } if (index === list.length - 1 || list[index + 1]!.start.line > node.start.line) { result += lines[node.stop.line - 1] ?? ''; } else { result += (lines[node.stop.line - 1] ?? '').substring(0, node.stop.column); } return result; } /** * Format one input/result pair as MathML output. * * @param input Parsed input node. * @param evaluated Evaluated result node. * @returns HTML fragment containing MathML markup. */ private formatResult(input: NodeInput, evaluated: NodeInput): string { const inputText = appEngine.interpreter.Unparse(input); const resultText = appEngine.interpreter.Unparse(evaluated); const inputMath = appEngine.interpreter.UnparseMathML(input); const resultMath = appEngine.interpreter.UnparseMathML(evaluated); if (inputText === resultText) { return `
${inputMath}
`; } return `
${inputMath}=${resultMath}
`; } /** * Escape plain text for safe insertion into HTML output. * * @param value Text value to escape. * @returns Escaped HTML string. */ private escapeHTML(value: string): string { const text = document.createTextNode(value); const wrapper = document.createElement('div'); wrapper.append(text); return wrapper.innerHTML; } } BatchShell.define();