Source: convert.mjs

/**
 * 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 };