/**
* Browser/build notebook rendering; never executes cells.
* @module convert
*/
import { sourceText, parseYaml, readMetadata, get_metadata } from './metadata.mjs';
import { createMarkdown, escapeHtml } from './markdown.mjs';
import { makeDetails } from './convert_util.mjs';
const imageTypes = ['image/svg+xml', 'image/png', 'image/jpeg', 'image/webp', 'image/gif'];
const mimeOrder = ['text/html', 'application/javascript', ...imageTypes, 'text/plain', 'application/json'];
const legacy = {
'#hide': { include: false }, '#hide_input': { echo: false }, '#hide_output': { output: false },
'#collapse_input': { 'code-fold': true }, '#collapse_input_open': { 'code-fold': 'show' },
'#collapse_output': { 'output-fold': true }, '#collapse_output_open': { 'output-fold': 'show' },
'#export': { export: true }
};
function codeOptions(source, diagnose) {
const lines = source.split('\n');
const old = {}, conventional = {};
let count = 0;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('#|')) {
try {
const value = parseYaml(trimmed.slice(2).trim().replace(/^([\w-]+):(?=\S)/, '$1: '));
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected key: value');
for (const [key, item] of Object.entries(value)) {
if (!['echo', 'output', 'include', 'code-fold', 'output-fold'].includes(key)) {
diagnose('unsupported-option', `Rendering does not handle #| ${key}`);
} else if (typeof item !== 'boolean' && !(['code-fold', 'output-fold'].includes(key) && item === 'show')) {
diagnose('invalid-option', `Invalid value for #| ${key}`);
} else conventional[key] = item;
}
} catch (error) { diagnose('invalid-option', error.message); }
} else {
const flags = trimmed.split(/\s+/);
if (!flags.length || !flags.every(flag => Object.hasOwn(legacy, flag))) break;
for (const flag of flags) Object.assign(old, legacy[flag]);
}
count++;
}
return { options: { ...old, ...conventional }, source: lines.slice(count).join('\n') };
}
function context(options = {}, extractAssets = false, notebookName = 'notebook', verbose = false) {
const result = { assets: [], diagnostics: [], pyCode: [] };
let index = 0;
let cellIndex = 0;
const trusted = options.trusted === true;
const diagnose = (code, message) => {
const diagnostic = { code, cell: cellIndex + 1, message };
result.diagnostics.push(diagnostic);
if (verbose) console.warn('ipynb2web:', diagnostic);
};
const shouldExtract = type => extractAssets === true || (Array.isArray(extractAssets) && extractAssets.some(item => {
const name = String(item).toLowerCase();
return [type, type.split('/')[1], { 'image/svg+xml': 'svg', 'image/jpeg': 'jpg', 'application/javascript': 'js', 'text/plain': 'txt' }[type]].includes(name);
}));
const asset = (type, data, encoding) => {
const extension = { 'image/svg+xml': 'svg', 'image/jpeg': 'jpg', 'text/html': 'html', 'application/javascript': 'js' }[type] ?? type.split('/')[1];
const prefix = (typeof notebookName === 'string' ? notebookName : 'notebook').replace(/[^a-zA-Z0-9_-]/g, '_') || 'notebook';
const name = `${prefix}-asset-${++index}.${extension}`;
result.assets.push({ placeholderName: name, data, encoding, type, notebookPrefix: `${prefix}-` });
return `ASSET_PLACEHOLDER_${name}`;
};
const image = bundle => {
if (!bundle || typeof bundle !== 'object') return null;
for (const type of imageTypes) {
if (!Object.hasOwn(bundle, type)) continue;
const data = sourceText(bundle[type]);
if (!data.trim()) { diagnose('invalid-image', `Empty ${type} output`); continue; }
const svg = type === 'image/svg+xml';
if (svg ? !/<svg[\s>]/i.test(data) : !/^[\da-z+/=\s]+$/i.test(data)) {
diagnose('invalid-image', `Invalid ${type} output`); continue;
}
// SVG is an image resource, never active inline DOM, in the default mode.
if (shouldExtract(type)) return asset(type, data, svg ? 'utf8' : 'base64');
return svg ? `data:${type},${encodeURIComponent(data)}` : `data:${type};base64,${data.replace(/\s/g, '')}`;
}
return null;
};
const md = createMarkdown(options, diagnose, image);
const pre = text => `<pre><code>${escapeHtml(text)}</code></pre>`;
const missing = message => {
diagnose('unsupported-output', message);
return `<pre class="ipynb-diagnostic">${escapeHtml(message)}</pre>`;
};
const output = saved => {
if (!saved || typeof saved !== 'object') return missing('Missing saved output');
if (saved.output_type === 'stream') {
if (saved.name === 'stderr') diagnose('stderr', sourceText(saved.text));
return pre(sourceText(saved.text));
}
if (saved.output_type === 'error') {
const message = sourceText(saved.traceback?.join('\n') || `${saved.ename ?? 'Error'}: ${saved.evalue ?? ''}`);
diagnose('saved-error', message);
return pre(message);
}
const bundle = saved.data;
if (!bundle || typeof bundle !== 'object') return missing('Saved output has no MIME data');
if (!trusted && (Object.hasOwn(bundle, 'text/html') || Object.hasOwn(bundle, 'application/javascript'))) {
diagnose('untrusted-output', 'Rich HTML/JavaScript requires the host option trusted: true; using an inert representation');
}
for (const type of mimeOrder) {
if (!Object.hasOwn(bundle, type) || bundle[type] == null) continue;
const data = sourceText(bundle[type]);
if (type === 'text/html' || type === 'application/javascript') {
if (!trusted || !data.trim()) continue;
if (type === 'text/html') {
return shouldExtract(type) ? `<iframe src="${asset(type, data, 'utf8')}" title="Notebook output"></iframe>` : data;
}
// A data URL avoids closing-script sequences corrupting the HTML wrapper.
const url = shouldExtract(type) ? asset(type, data, 'utf8') : `data:text/javascript,${encodeURIComponent(data)}`;
return `<script src="${escapeHtml(url)}"></script>`;
}
if (type.startsWith('image/')) {
const url = image({ [type]: bundle[type] });
if (url) return `<img src="${escapeHtml(url)}" alt="Notebook output">`;
continue;
}
if (type === 'application/json') return pre(JSON.stringify(bundle[type], null, 2));
return pre(data);
}
if (!trusted) {
const rich = bundle['text/html'] ?? bundle['application/javascript'];
if (rich != null) return pre(sourceText(rich));
}
return missing(`Unsupported saved output MIME types: ${Object.keys(bundle).join(', ') || '(none)'}`);
};
const render = (cell, index) => {
cellIndex = index;
const text = sourceText(cell?.source);
if (cell?.cell_type === 'markdown') return md.render(text, { attachments: cell.attachments, docId: `cell-${index + 1}` });
if (cell?.cell_type === 'raw') return pre(text);
if (cell?.cell_type !== 'code') return missing(`Unsupported cell type: ${cell?.cell_type}`);
const parsed = codeOptions(text, diagnose);
const flags = parsed.options;
if (flags.export) result.pyCode.push(parsed.source);
if (flags.include === false) return '';
if (/^\s*%%?\w/.test(parsed.source) && !(cell.outputs?.length)) {
diagnose('unexecuted-magic', 'IPython magic has no saved output; the renderer does not execute it');
}
let input = flags.echo === false || !parsed.source ? '' : pre(parsed.source);
if (input && flags['code-fold']) input = makeDetails(input, flags['code-fold'] === 'show', 'input');
const saved = cell.outputs ?? [];
let outputs = flags.output === false ? '' : Array.isArray(saved) ? saved.map(output).join('\n') : missing('Expected a saved outputs array');
if (outputs && flags['output-fold']) outputs = makeDetails(outputs, flags['output-fold'] === 'show', 'output');
return input + outputs;
};
return { ...result, render };
}
/** Render a parsed notebook, without I/O or execution. Options are host-owned,
* never read from notebook metadata. Returns { meta, content, assets, diagnostics }.
*/
export function renderNotebook(notebook, options = {}) {
if (!notebook || !Array.isArray(notebook.cells)) throw new Error('Invalid notebook: expected cells array');
const { meta, consumed, remainder } = readMetadata(notebook.cells[0]);
if (!Object.hasOwn(meta, 'filename')) meta.filename = options.filename ?? 'notebook';
const ctx = context(options, options.extractAssets, meta.filename, options.verbose);
const content = notebook.cells.map((cell, index) => {
if (index === 0 && consumed) return remainder ? ctx.render({ ...cell, source: remainder }, index) : '';
return ctx.render(cell, index);
}).join('\n');
if (ctx.pyCode.length && !Object.hasOwn(meta, 'pyCode')) meta.pyCode = ctx.pyCode;
return { meta, content, assets: ctx.assets, diagnostics: ctx.diagnostics };
}
/** Fetch and render a notebook. Positional verbose/extractAssets arguments remain
* supported; the fourth argument holds host options such as { trusted: true }.
* Node's historical extensionless localhost:8085 paths remain supported.
*/
export async function nb2json(ipynbPath, verbose = false, extractAssets = false, options = {}) {
if (typeof verbose === 'object' && verbose !== null) {
options = verbose;
verbose = options.verbose ?? false;
extractAssets = options.extractAssets ?? false;
}
let url = String(ipynbPath);
if (typeof window === 'undefined' && !/^[a-z][a-z\d+.-]*:/i.test(url)) {
url = `http://localhost:8085/${url.replace(/^\//, '')}${url.endsWith('.ipynb') ? '' : '.ipynb'}`;
}
const response = await fetch(url);
if (!response.ok) throw new Error(`Notebook fetch failed (${response.status}): ${url}`);
const filename = options.filename ?? (/^(?:data|blob):/i.test(url) ? 'notebook'
: String(ipynbPath).split('/').pop().split(/[?#]/)[0].replace(/\.ipynb$/i, '').toLowerCase().replaceAll(' ', '_'));
return renderNotebook(await response.json(), { ...options, verbose, extractAssets, filename });
}
// Low-level compatibility export. Use renderNotebook to receive assets/diagnostics.
export function convertNb(cells, meta = {}, verbose = false, extractAssets = false, notebookName = null, options = {}) {
const ctx = context(options, extractAssets, notebookName ?? meta.filename, verbose);
return cells.map(ctx.render);
}
export { get_metadata };