diff --git a/app/components/CodeMirrorEditorDemo.tsx b/app/components/CodeMirrorEditorDemo.tsx
index 96c558f..d1697fa 100644
--- a/app/components/CodeMirrorEditorDemo.tsx
+++ b/app/components/CodeMirrorEditorDemo.tsx
@@ -19,6 +19,15 @@ def greet(name: str) -> str:
return f"Hello, {name}!"
print(greet("nteract"))`,
+ ipython: `# IPython example with magics and shell commands
+%matplotlib inline
+%time x = sum(range(1000000))
+
+!pip install pandas
+
+import pandas as pd
+df = pd.DataFrame({"a": [1, 2, 3]})
+df.head?`,
markdown: `# Markdown Example
This is a **bold** statement and this is *italic*.
@@ -123,6 +132,7 @@ export function CodeMirrorEditorDemo({
className="text-sm bg-background border border-border rounded px-2 py-1"
>
+
diff --git a/registry.json b/registry.json
index 0adac32..fdf8579 100644
--- a/registry.json
+++ b/registry.json
@@ -401,7 +401,7 @@
"name": "codemirror-editor",
"type": "registry:component",
"title": "CodeMirror Editor",
- "description": "A CodeMirror 6 editor component for notebook cells with syntax highlighting, key bindings, and language support for Python, Markdown, SQL, HTML, JavaScript, TypeScript, and JSON.",
+ "description": "A CodeMirror 6 editor component for notebook cells with syntax highlighting, key bindings, and language support for Python, IPython (with magics and shell commands), Markdown, SQL, HTML, JavaScript, TypeScript, and JSON.",
"dependencies": [
"@codemirror/view",
"@codemirror/state",
@@ -439,6 +439,11 @@
"type": "registry:component",
"target": "components/editor/themes.ts"
},
+ {
+ "path": "registry/editor/ipython.ts",
+ "type": "registry:component",
+ "target": "components/editor/ipython.ts"
+ },
{
"path": "registry/editor/index.ts",
"type": "registry:component",
diff --git a/registry/editor/codemirror-editor.tsx b/registry/editor/codemirror-editor.tsx
index 92c85d8..43c0e42 100644
--- a/registry/editor/codemirror-editor.tsx
+++ b/registry/editor/codemirror-editor.tsx
@@ -19,7 +19,11 @@ import {
import { cn } from "@/lib/utils";
import { defaultExtensions } from "./extensions";
-import { getLanguageExtension, type SupportedLanguage } from "./languages";
+import {
+ getIPythonExtension,
+ getLanguageExtension,
+ type SupportedLanguage,
+} from "./languages";
import { darkTheme, isDarkMode, lightTheme, type ThemeMode } from "./themes";
export interface CodeMirrorEditorRef {
@@ -140,10 +144,13 @@ export const CodeMirrorEditor = forwardRef<
};
}, [theme]);
- const langExtension = useMemo(
- () => getLanguageExtension(language),
- [language],
- );
+ // For IPython, detect cell magics and switch to appropriate language mode
+ const langExtension = useMemo(() => {
+ if (language === "ipython") {
+ return getIPythonExtension(value).extension;
+ }
+ return getLanguageExtension(language);
+ }, [language, value]);
// Determine which theme to use
const themeExtension = useMemo(() => {
diff --git a/registry/editor/index.ts b/registry/editor/index.ts
index cb685d1..ac0c68e 100644
--- a/registry/editor/index.ts
+++ b/registry/editor/index.ts
@@ -10,9 +10,19 @@ export {
minimalSetup,
notebookEditorTheme,
} from "./extensions";
+export {
+ CELL_MAGIC_LANGUAGES,
+ detectCellMagic,
+ getCellMagicLanguage,
+ ipythonHighlighting,
+ ipythonIndent,
+ ipythonStyles,
+ ipythonStylesDark,
+} from "./ipython";
export {
detectLanguage,
fileExtensionToLanguage,
+ getIPythonExtension,
getLanguageExtension,
languageDisplayNames,
type SupportedLanguage,
diff --git a/registry/editor/ipython.ts b/registry/editor/ipython.ts
new file mode 100644
index 0000000..0ff63f8
--- /dev/null
+++ b/registry/editor/ipython.ts
@@ -0,0 +1,267 @@
+import { indentService } from "@codemirror/language";
+import { RangeSetBuilder } from "@codemirror/state";
+import type { DecorationSet, ViewUpdate } from "@codemirror/view";
+import { Decoration, EditorView, ViewPlugin } from "@codemirror/view";
+
+/**
+ * IPython syntax highlighting extension for CodeMirror 6
+ *
+ * Highlights IPython-specific syntax on top of standard Python:
+ * - Shell commands: !ls, !pip install
+ * - Line magics: %time, %run script.py
+ * - Cell magics: %%bash, %%javascript (first line only)
+ * - Help operators: object?, object??
+ *
+ * Note: For cell magics (%%), only the first line is decorated.
+ * The rest of the cell should use the appropriate language mode.
+ * Use detectCellMagic() and CELL_MAGIC_LANGUAGES to determine
+ * the correct language for cell magic content.
+ */
+
+// Decoration marks for IPython syntax
+const shellMark = Decoration.mark({ class: "cm-ipython-shell" });
+const magicMark = Decoration.mark({ class: "cm-ipython-magic" });
+const cellMagicMark = Decoration.mark({ class: "cm-ipython-cell-magic" });
+const helpMark = Decoration.mark({ class: "cm-ipython-help" });
+
+// Patterns for IPython syntax (applied to line content)
+const CELL_MAGIC_PATTERN = /^(%%[a-zA-Z_]\w*)/;
+const LINE_MAGIC_PATTERN = /^(%[a-zA-Z_]\w*)/;
+const SHELL_PATTERN = /^(!)/;
+const HELP_PATTERN = /(\?\??)$/;
+
+/**
+ * Custom indentation service for IPython.
+ *
+ * Prevents auto-indent after:
+ * - Line magics (%time, %matplotlib, etc.)
+ * - Shell commands (!pip, !ls, etc.)
+ * - Cell magic declarations (%%bash, %%html, etc.)
+ *
+ * These lines don't follow Python's indentation rules,
+ * so we reset to column 0 after them.
+ */
+export const ipythonIndent = indentService.of((context, pos) => {
+ // Get the previous line
+ const line = context.state.doc.lineAt(pos);
+ if (line.number <= 1) return null; // Let Python handle first line
+
+ const prevLine = context.state.doc.line(line.number - 1);
+ const prevText = prevLine.text.trim();
+
+ // After line magic, shell command, or cell magic - don't auto-indent
+ if (
+ LINE_MAGIC_PATTERN.test(prevText) ||
+ SHELL_PATTERN.test(prevText) ||
+ CELL_MAGIC_PATTERN.test(prevText)
+ ) {
+ return 0;
+ }
+
+ // Let Python's indentation handle everything else
+ return null;
+});
+
+/**
+ * Mapping of cell magic names to language identifiers.
+ * Use with getLanguageExtension() from languages.ts
+ */
+export const CELL_MAGIC_LANGUAGES: Record = {
+ // HTML/SVG
+ html: "html",
+ HTML: "html",
+ svg: "html",
+ SVG: "html",
+ // JavaScript
+ javascript: "javascript",
+ js: "javascript",
+ // TypeScript
+ typescript: "typescript",
+ ts: "typescript",
+ // SQL
+ sql: "sql",
+ SQL: "sql",
+ // Markdown
+ markdown: "markdown",
+ md: "markdown",
+ // JSON
+ json: "json",
+ // Shell (falls back to plain since we don't have shell lang)
+ bash: "plain",
+ sh: "plain",
+ // Python (stays as Python)
+ python: "python",
+ python3: "python",
+ // Others without specific support fall back to plain
+};
+
+/**
+ * Detect cell magic from content.
+ * Cell magics must be on the first line.
+ *
+ * @param content - The editor content
+ * @returns The magic name (without %%) if found, null otherwise
+ *
+ * @example
+ * detectCellMagic("%%html\nHello
") // "html"
+ * detectCellMagic("%time x = 1") // null (line magic, not cell magic)
+ * detectCellMagic("print('hello')") // null
+ */
+export function detectCellMagic(content: string): string | null {
+ const firstLine = content.split("\n")[0].trim();
+ const match = firstLine.match(/^%%([a-zA-Z_]\w*)/);
+ return match ? match[1] : null;
+}
+
+/**
+ * Get the language identifier for a cell magic.
+ *
+ * @param magic - The cell magic name (without %%)
+ * @returns The language identifier to use with getLanguageExtension(),
+ * or "plain" for unsupported magics
+ *
+ * @example
+ * getCellMagicLanguage("html") // "html"
+ * getCellMagicLanguage("bash") // "plain" (no shell support)
+ * getCellMagicLanguage("unknown") // "plain"
+ */
+export function getCellMagicLanguage(magic: string): string {
+ return CELL_MAGIC_LANGUAGES[magic] ?? "plain";
+}
+
+class IPythonHighlighter {
+ decorations: DecorationSet;
+
+ constructor(view: EditorView) {
+ this.decorations = this.buildDecorations(view);
+ }
+
+ update(update: ViewUpdate) {
+ if (update.docChanged || update.viewportChanged) {
+ this.decorations = this.buildDecorations(update.view);
+ }
+ }
+
+ buildDecorations(view: EditorView): DecorationSet {
+ const builder = new RangeSetBuilder();
+ const doc = view.state.doc;
+
+ // Check if we're in cell magic mode (first line is %%)
+ const firstLineText = doc.line(1).text.trim();
+ const isInCellMagicMode = CELL_MAGIC_PATTERN.test(firstLineText);
+
+ for (const { from, to } of view.visibleRanges) {
+ const startLine = doc.lineAt(from).number;
+ const endLine = doc.lineAt(to).number;
+
+ for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
+ const line = doc.line(lineNum);
+ const lineText = line.text;
+ const trimmedText = lineText.trimStart();
+ const leadingSpaces = lineText.length - trimmedText.length;
+ const lineStart = line.from + leadingSpaces;
+
+ // Cell magic on first line - decorate it
+ if (lineNum === 1) {
+ const cellMagicMatch = trimmedText.match(CELL_MAGIC_PATTERN);
+ if (cellMagicMatch) {
+ builder.add(
+ lineStart,
+ lineStart + cellMagicMatch[1].length,
+ cellMagicMark,
+ );
+ continue;
+ }
+ }
+
+ // If we're in cell magic mode, don't apply IPython decorations
+ // to subsequent lines (they belong to the cell magic's language)
+ if (isInCellMagicMode && lineNum > 1) {
+ continue;
+ }
+
+ // Line magic (%magic)
+ const lineMagicMatch = trimmedText.match(LINE_MAGIC_PATTERN);
+ if (lineMagicMatch) {
+ builder.add(
+ lineStart,
+ lineStart + lineMagicMatch[1].length,
+ magicMark,
+ );
+ continue;
+ }
+
+ // Shell command (!command)
+ const shellMatch = trimmedText.match(SHELL_PATTERN);
+ if (shellMatch) {
+ builder.add(lineStart, line.to, shellMark);
+ continue;
+ }
+
+ // Help operator (object? or object??)
+ const helpMatch = lineText.match(HELP_PATTERN);
+ if (helpMatch && helpMatch.index !== undefined) {
+ const helpStart = line.from + helpMatch.index;
+ const helpEnd = helpStart + helpMatch[1].length;
+ builder.add(helpStart, helpEnd, helpMark);
+ }
+ }
+ }
+
+ return builder.finish();
+ }
+}
+
+/**
+ * CodeMirror extension that adds IPython syntax highlighting.
+ *
+ * For cells with cell magics (%%html, %%bash, etc.), only the first
+ * line is decorated. Use detectCellMagic() and getLanguageExtension()
+ * to set the appropriate language for the cell content.
+ */
+export function ipythonHighlighting() {
+ return ViewPlugin.fromClass(IPythonHighlighter, {
+ decorations: (v) => v.decorations,
+ });
+}
+
+/**
+ * Light theme styles for IPython syntax
+ */
+export const ipythonStyles = EditorView.theme({
+ ".cm-ipython-shell": {
+ color: "#0550ae",
+ },
+ ".cm-ipython-magic": {
+ color: "#6639ba",
+ },
+ ".cm-ipython-cell-magic": {
+ color: "#6639ba",
+ fontWeight: "bold",
+ },
+ ".cm-ipython-help": {
+ color: "#0969da",
+ },
+});
+
+/**
+ * Dark theme styles for IPython syntax
+ */
+export const ipythonStylesDark = EditorView.theme(
+ {
+ ".cm-ipython-shell": {
+ color: "#79c0ff",
+ },
+ ".cm-ipython-magic": {
+ color: "#d2a8ff",
+ },
+ ".cm-ipython-cell-magic": {
+ color: "#d2a8ff",
+ fontWeight: "bold",
+ },
+ ".cm-ipython-help": {
+ color: "#58a6ff",
+ },
+ },
+ { dark: true },
+);
diff --git a/registry/editor/languages.ts b/registry/editor/languages.ts
index cb27383..e6adf33 100644
--- a/registry/editor/languages.ts
+++ b/registry/editor/languages.ts
@@ -4,13 +4,25 @@ import { json } from "@codemirror/lang-json";
import { markdown } from "@codemirror/lang-markdown";
import { python } from "@codemirror/lang-python";
import { sql } from "@codemirror/lang-sql";
+import { indentUnit } from "@codemirror/language";
import type { Extension } from "@codemirror/state";
+import {
+ CELL_MAGIC_LANGUAGES,
+ detectCellMagic,
+ getCellMagicLanguage,
+ ipythonHighlighting,
+ ipythonIndent,
+ ipythonStyles,
+ ipythonStylesDark,
+} from "./ipython";
+
/**
* Supported languages for the CodeMirror editor
*/
export type SupportedLanguage =
| "python"
+ | "ipython"
| "markdown"
| "sql"
| "html"
@@ -22,10 +34,22 @@ export type SupportedLanguage =
/**
* Get the CodeMirror language extension for a given language
*/
+// PEP 8 specifies 4-space indentation for Python
+const pythonIndent = indentUnit.of(" ");
+
export function getLanguageExtension(language: SupportedLanguage): Extension {
switch (language) {
case "python":
- return python();
+ return [python(), pythonIndent];
+ case "ipython":
+ return [
+ python(),
+ pythonIndent,
+ ipythonIndent,
+ ipythonHighlighting(),
+ ipythonStyles,
+ ipythonStylesDark,
+ ];
case "markdown":
return markdown();
case "sql":
@@ -43,11 +67,70 @@ export function getLanguageExtension(language: SupportedLanguage): Extension {
}
}
+/**
+ * Get the language extension for IPython content, detecting cell magics.
+ *
+ * If the content starts with a cell magic (e.g., %%html, %%bash),
+ * returns the appropriate language extension for that magic.
+ * Otherwise returns the standard IPython extension.
+ *
+ * @param content - The editor content to analyze
+ * @returns Object with language extension and detected cell magic (if any)
+ *
+ * @example
+ * // Cell magic - returns HTML language
+ * getIPythonExtension("%%html\nHello
")
+ * // { extension: html(), cellMagic: "html", language: "html" }
+ *
+ * // No cell magic - returns IPython
+ * getIPythonExtension("%time x = sum(range(100))")
+ * // { extension: [python(), ...], cellMagic: null, language: "ipython" }
+ */
+export function getIPythonExtension(content: string): {
+ extension: Extension;
+ cellMagic: string | null;
+ language: SupportedLanguage;
+} {
+ const magic = detectCellMagic(content);
+
+ if (magic) {
+ const langId = getCellMagicLanguage(magic);
+ const language = (
+ langId in languageDisplayNames ? langId : "plain"
+ ) as SupportedLanguage;
+
+ // For cell magics, use the target language but add IPython decoration
+ // for the first line (the %%magic declaration)
+ const baseExtension = getLanguageExtension(language);
+ return {
+ extension: [
+ baseExtension,
+ ipythonHighlighting(),
+ ipythonStyles,
+ ipythonStylesDark,
+ ],
+ cellMagic: magic,
+ language,
+ };
+ }
+
+ // No cell magic - use standard IPython mode
+ return {
+ extension: getLanguageExtension("ipython"),
+ cellMagic: null,
+ language: "ipython",
+ };
+}
+
+// Re-export cell magic utilities for consumers
+export { CELL_MAGIC_LANGUAGES, detectCellMagic, getCellMagicLanguage };
+
/**
* Language display names for UI
*/
export const languageDisplayNames: Record = {
python: "Python",
+ ipython: "IPython",
markdown: "Markdown",
sql: "SQL",
html: "HTML",
@@ -62,6 +145,7 @@ export const languageDisplayNames: Record = {
*/
export const fileExtensionToLanguage: Record = {
".py": "python",
+ ".ipy": "ipython",
".md": "markdown",
".markdown": "markdown",
".sql": "sql",