Initial release: Jupyter Notebook Viewer v1.0.0

This commit is contained in:
Chris Teplovs 2026-04-22 17:23:14 -04:00
commit 7fae06cc06
5 changed files with 407 additions and 0 deletions

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cteplovs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

53
README.md Normal file
View file

@ -0,0 +1,53 @@
# Jupyter Notebook Viewer for Obsidian
Renders `.ipynb` (Jupyter notebook) files natively inside Obsidian — markdown cells as prose, code cells with syntax highlighting and execution-count badges, and outputs (text, tables, errors) inline.
## Features
- **Markdown cells** rendered using Obsidian's own renderer — wikilinks, bold, italics, and all standard formatting work
- **Code cells** with Python syntax highlighting and `[N]` execution-count badges
- **Stream output** (`stdout`/`stderr`) displayed as monospace text
- **Rich output** — pandas and polars DataFrame HTML tables rendered with scrollable, theme-aware styling
- **Error tracebacks** with ANSI escape codes stripped for clean display
- **Theme-aware** — all colors use Obsidian CSS variables; works in light mode, dark mode, and any community theme
- **No build step** — plugin is plain JavaScript, no Node.js or npm required
## Installation
### From the Community Plugin Directory
Search for **"Jupyter Notebook Viewer"** in **Settings → Community plugins → Browse**.
### Manual installation
1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](../../releases/latest).
2. Create a folder `<your-vault>/.obsidian/plugins/jupyter-viewer/`.
3. Place the three downloaded files into that folder.
4. Open Obsidian, go to **Settings → Community plugins**, disable Restricted Mode if prompted, then enable **Jupyter Notebook Viewer**.
## Usage
Click any `.ipynb` file in the Obsidian file explorer. It opens in the notebook viewer automatically.
To switch back to raw JSON (e.g., for copying cell contents), right-click the file and choose **Open with → Default editor**.
## Supported output types
| Output type | Rendered as |
|---|---|
| `stream` (stdout) | Monospace preformatted text |
| `stream` (stderr) | Monospace text in error color |
| `execute_result` / `display_data` with `text/html` | Scrollable HTML table |
| `execute_result` / `display_data` with `text/plain` | Monospace preformatted text |
| `error` | Exception header + cleaned traceback |
| `image/png` | Inline image (base64) |
## Compatibility
- Obsidian 1.0.0+
- Desktop only (uses Obsidian's desktop file APIs)
- Jupyter notebook format v4 (nbformat 4)
## License
MIT — see [LICENSE](LICENSE).

166
main.js Normal file
View file

@ -0,0 +1,166 @@
'use strict';
const { Plugin, FileView, MarkdownRenderer } = require('obsidian');
const VIEW_TYPE = 'jupyter-viewer';
// ── Helpers ───────────────────────────────────────────────────────────────────
function normalizeSource(source) {
if (Array.isArray(source)) return source.join('');
if (typeof source === 'string') return source;
return '';
}
function stripAnsi(str) {
return str.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
}
// Returns { type: 'html'|'plain', content } or null
function mimeToHtml(data) {
if (data['text/html']) {
const raw = data['text/html'];
return { type: 'html', content: Array.isArray(raw) ? raw.join('') : raw };
}
if (data['text/plain']) {
const raw = data['text/plain'];
return { type: 'plain', content: Array.isArray(raw) ? raw.join('') : raw };
}
// image/png: not present in current corpus; add here if needed:
// if (data['image/png']) return { type: 'img', content: data['image/png'] };
return null;
}
function renderOutput(output, container) {
const wrap = container.createDiv({ cls: 'jv-output' });
switch (output.output_type) {
case 'stream': {
const text = Array.isArray(output.text)
? output.text.join('')
: (output.text || '');
const pre = wrap.createEl('pre', { cls: 'jv-stream' });
if (output.name === 'stderr') pre.addClass('jv-stderr');
pre.setText(text);
break;
}
case 'execute_result':
case 'display_data': {
const data = output.data || {};
const result = mimeToHtml(data);
if (!result) break;
wrap.addClass('jv-rich-output');
if (result.type === 'html') {
wrap.innerHTML = result.content;
} else {
wrap.createEl('pre', { cls: 'jv-plain-output', text: result.content });
}
break;
}
case 'error': {
const errorWrap = wrap.createDiv({ cls: 'jv-error' });
const header = [output.ename, output.evalue].filter(Boolean).join(': ');
if (header) {
errorWrap.createEl('div', { cls: 'jv-error-header', text: header });
}
const tb = (output.traceback || []).map(stripAnsi).join('\n');
if (tb) {
errorWrap.createEl('pre', { cls: 'jv-traceback', text: tb });
}
break;
}
default:
break;
}
}
async function renderCell(cell, container, plugin, file) {
const source = normalizeSource(cell.source);
if (cell.cell_type === 'markdown') {
if (!source.trim()) return;
const mdWrap = container.createDiv({ cls: 'jv-cell jv-markdown-cell' });
await MarkdownRenderer.renderMarkdown(source, mdWrap, file.path, plugin);
return;
}
if (cell.cell_type === 'code') {
const codeWrap = container.createDiv({ cls: 'jv-cell jv-code-cell' });
const execCount = cell.execution_count;
const badge = execCount != null ? `[${execCount}]` : '[ ]';
codeWrap.createDiv({ cls: 'jv-exec-count', text: badge });
const codeBlock = codeWrap.createDiv({ cls: 'jv-code-source' });
if (source.trim()) {
const fenced = '```python\n' + source + '\n```';
await MarkdownRenderer.renderMarkdown(fenced, codeBlock, file.path, plugin);
}
const outputs = cell.outputs || [];
if (outputs.length > 0) {
const outputWrap = codeWrap.createDiv({ cls: 'jv-outputs' });
for (const output of outputs) {
renderOutput(output, outputWrap);
}
}
return;
}
// raw cells and unknown types — skip silently
}
// ── View ──────────────────────────────────────────────────────────────────────
class JupyterView extends FileView {
constructor(leaf, plugin) {
super(leaf);
this.plugin = plugin;
}
getViewType() {
return VIEW_TYPE;
}
getDisplayText() {
return this.file ? this.file.basename : 'Jupyter Notebook';
}
async onLoadFile(file) {
const container = this.containerEl.children[1];
container.empty();
container.addClass('jv-container');
try {
const raw = await this.app.vault.read(file);
const nb = JSON.parse(raw);
const cells = nb.cells || [];
for (const cell of cells) {
await renderCell(cell, container, this.plugin, file);
}
} catch (err) {
container.createEl('p', {
cls: 'jv-parse-error',
text: `Failed to parse notebook: ${err.message}`,
});
}
}
}
// ── Plugin ────────────────────────────────────────────────────────────────────
class JupyterViewerPlugin extends Plugin {
async onload() {
this.registerView(VIEW_TYPE, (leaf) => new JupyterView(leaf, this));
this.registerExtensions(['ipynb'], VIEW_TYPE);
}
onunload() {
this.app.workspace.detachLeavesOfType(VIEW_TYPE);
}
}
module.exports = JupyterViewerPlugin;

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "jupyter-viewer",
"name": "Jupyter Notebook Viewer",
"version": "1.0.0",
"minAppVersion": "1.0.0",
"description": "Renders .ipynb files natively inside Obsidian.",
"author": "cteplovs",
"authorUrl": "",
"isDesktopOnly": true
}

157
styles.css Normal file
View file

@ -0,0 +1,157 @@
/* ── Container ──────────────────────────────────────────────────────────────── */
.jv-container {
padding: 1.5rem 2rem;
max-width: 900px;
margin: 0 auto;
font-family: var(--font-text);
}
/* ── Cells ───────────────────────────────────────────────────────────────────── */
.jv-cell {
margin-bottom: 1.25rem;
}
/* Markdown cells render as normal prose — no border needed */
.jv-markdown-cell {
line-height: var(--line-height-normal);
}
/* Code cells: 2-column grid — exec badge | source — outputs span both */
.jv-code-cell {
display: grid;
grid-template-columns: 3rem 1fr;
background-color: var(--background-secondary);
border-left: 3px solid var(--color-blue);
border-radius: 4px;
overflow: hidden;
}
.jv-exec-count {
grid-column: 1;
grid-row: 1;
padding: 0.6rem 0.25rem 0;
color: var(--text-muted);
font-size: 0.72rem;
font-family: var(--font-monospace);
text-align: center;
line-height: 1.4;
user-select: none;
}
.jv-code-source {
grid-column: 2;
grid-row: 1;
min-width: 0; /* prevent grid blowout */
}
/* Strip the extra margin/background from Obsidian's rendered code blocks */
.jv-code-source pre {
margin: 0;
border-radius: 0;
background: transparent;
}
.jv-code-source pre code {
font-size: 0.85rem;
}
/* ── Outputs ─────────────────────────────────────────────────────────────────── */
.jv-outputs {
grid-column: 1 / -1;
border-top: 1px solid var(--background-modifier-border);
}
.jv-output {
padding: 0.4rem 0.75rem;
}
/* Stream (stdout / stderr) */
.jv-stream {
font-family: var(--font-monospace);
font-size: 0.83rem;
color: var(--text-normal);
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
.jv-stderr {
color: var(--text-error);
}
/* Rich output: pandas / polars DataFrame tables */
.jv-rich-output {
overflow-x: auto;
font-size: 0.83rem;
}
.jv-rich-output table {
border-collapse: collapse;
color: var(--text-normal);
}
.jv-rich-output th,
.jv-rich-output td {
border: 1px solid var(--background-modifier-border);
padding: 3px 8px;
text-align: right;
}
.jv-rich-output th {
background-color: var(--background-secondary-alt);
font-weight: 600;
text-align: center;
}
/* Hide the embedded <style scoped> blocks from pandas/polars they override
our theme-aware table rules and break dark mode */
.jv-rich-output style {
display: none;
}
/* Plain-text execute_result */
.jv-plain-output {
font-family: var(--font-monospace);
font-size: 0.83rem;
color: var(--text-normal);
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
/* ── Error outputs ───────────────────────────────────────────────────────────── */
.jv-error {
padding: 0.25rem 0;
}
.jv-error-header {
font-family: var(--font-monospace);
font-size: 0.88rem;
font-weight: 700;
color: var(--text-error);
padding: 0.2rem 0;
}
.jv-traceback {
font-family: var(--font-monospace);
font-size: 0.78rem;
color: var(--text-muted);
white-space: pre-wrap;
word-break: break-all;
background-color: var(--background-secondary);
padding: 0.5rem;
border-radius: 4px;
margin: 0.25rem 0 0 0;
}
/* ── Parse error ─────────────────────────────────────────────────────────────── */
.jv-parse-error {
color: var(--text-error);
font-family: var(--font-monospace);
padding: 1rem;
}