Compare commits

..

No commits in common. "gh-pages" and "master" have entirely different histories.

84 changed files with 12029 additions and 55 deletions

15
.editorconfig Normal file
View file

@ -0,0 +1,15 @@
; http://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[patches/*.patch]
end_of_line =
insert_final_newline = false
trim_trailing_whitespace = false

13
.eslintignore Normal file
View file

@ -0,0 +1,13 @@
node_modules/
/.*cache/
/.tmp/
/coverage/
/dist/
/packages/*/bin/
/packages/*/coverage/
/packages/*/dist/
/packages/*/lib/
/packages/*/*.js
/scripts/*
/types/
/*.js

195
.eslintrc.js Normal file
View file

@ -0,0 +1,195 @@
const tsconfigs = [
'tsconfig.json',
'*/tsconfig.json',
'packages/*/tsconfig.json',
'packages/*/test/tsconfig.json',
]
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
project: tsconfigs,
},
env: {
es6: true,
node: true,
},
settings: {
'import/resolver': {
// Use eslint-import-resolver-typescript to obey "paths" in tsconfig.json.
typescript: {
directory: tsconfigs,
},
},
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
'standard-with-typescript',
'plugin:import/recommended',
'plugin:import/typescript',
],
rules: {
'comma-dangle': ['error', 'always-multiline'],
'linebreak-style': ['error', 'unix'],
// Changed from error to warn and enabled ignoreEOLComments.
'no-multi-spaces': ['warn', {
ignoreEOLComments: true,
}],
// Changed from error to warn and adjusted options.
'no-multiple-empty-lines': ['warn', {
max: 2,
maxEOF: 1,
maxBOF: 1,
}],
'no-template-curly-in-string': 'off',
// Changed from 'after' to 'before'.
'operator-linebreak': ['error', 'before'],
// Changed from error and all 'never' to warn and switches 'never'.
'padded-blocks': ['warn', {
switches: 'never',
}],
// Changed from 'as-needed' to 'consistent-as-needed'.
'quote-props': ['error', 'consistent-as-needed'],
// Import
// Some packages have wrong type declarations.
'import/default': 'off',
'import/newline-after-import': 'warn',
// This rule disallows using both wildcard and selective imports from the same module.
'import/no-duplicates': 'off',
// Some packages have it wrong in type declarations (e.g. katex, marked).
'import/no-named-as-default-member': 'off',
// TypeScript
// Changed options.
'@typescript-eslint/ban-types': ['error', {
// Allow to use {} and object - they are actually useful.
types: {
'{}': false,
'object': false,
},
extendDefaults: true,
}],
'@typescript-eslint/class-literal-property-style': ['error', 'fields'],
// Changed from error to off.
'@typescript-eslint/consistent-type-definitions': 'off',
// Changed from error to off.
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-member-accessibility': ['error', {
accessibility: 'no-public',
overrides: {
parameterProperties: 'off',
},
}],
// Changed from warn to error and adjusted options.
'@typescript-eslint/explicit-module-boundary-types': ['error', {
allowArgumentsExplicitlyTypedAsAny: true,
}],
'@typescript-eslint/indent': ['error', 2, {
SwitchCase: 1,
VariableDeclarator: 1,
outerIIFEBody: 1,
MemberExpression: 1,
// Changed parameters from 1 to off.
FunctionDeclaration: { parameters: 'off', body: 1 },
// Changed parameters from 1 to off.
FunctionExpression: { parameters: 'off', body: 1 },
// Changed arguments from 1 to off.
CallExpression: { arguments: 'off' },
ArrayExpression: 1,
ObjectExpression: 1,
ImportDeclaration: 1,
// Changed from false to true.
flatTernaryExpressions: true,
ignoreComments: false,
}],
// Changed from error to warn.
'@typescript-eslint/lines-between-class-members': 'warn',
// Changed delimiter for type literals from none to comma.
// The reason is just aesthetic symmetry with object literals.
'@typescript-eslint/member-delimiter-style': ['error', {
multiline: { delimiter: 'comma', requireLast: true },
singleline: { delimiter: 'comma', requireLast: false },
overrides: {
interface: {
multiline: { delimiter: 'none' },
},
},
}],
'@typescript-eslint/member-ordering': 'warn',
// Changed from warn to off.
'@typescript-eslint/no-explicit-any': 'off',
// Changed from error to warn.
'@typescript-eslint/no-extra-semi': 'warn',
// It disallows using void even in valid cases.
'@typescript-eslint/no-invalid-void-type': 'off',
// Changed from error to warn.
'@typescript-eslint/no-namespace': 'warn',
// Changed from error to warn.
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-require-imports': 'error',
// Changed from error to warn.
'@typescript-eslint/no-unsafe-assignment': 'warn',
// Changed from error to warn.
'@typescript-eslint/no-unsafe-member-access': 'warn',
// Disabled in favour of the next rule.
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars-experimental': 'error',
// Changed options.
'@typescript-eslint/no-use-before-define': ['error', {
functions: false,
typedefs: false,
}],
'@typescript-eslint/prefer-for-of': 'warn',
// Changed from error to warn.
'@typescript-eslint/prefer-includes': 'warn',
// Changed from error to warn.
'@typescript-eslint/prefer-regexp-exec': 'warn',
'@typescript-eslint/prefer-string-starts-ends-with': 'warn',
// It has too many false positives.
'@typescript-eslint/restrict-template-expressions': 'off',
// Changed from error to off.
'@typescript-eslint/strict-boolean-expressions': 'off',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
// Changed from error to warn and adjusted options.
'@typescript-eslint/unbound-method': ['warn', {
ignoreStatic: true,
}],
},
overrides: [
{
files: ['*.test.ts?(x)'],
rules: {
// Allow to format arrays for parametrized tests as tables.
'array-bracket-spacing': 'off',
'comma-dangle': ['error', {
arrays: 'always-multiline',
objects: 'always-multiline',
imports: 'always-multiline',
exports: 'always-multiline',
// Changed to not require comma in a multiline expect().
functions: 'only-multiline',
}],
'object-curly-spacing': 'off',
'no-multi-spaces': 'off',
'standard/array-bracket-even-spacing': 'off',
// Allow spaces inside expect( foo ).
'space-in-parens': 'off',
// jest.mock() must be above imports.
'import/first': 'off',
'@typescript-eslint/comma-spacing': 'off',
// False positive on expect() functions.
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-return': 'warn',
},
},
],
}

3
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,3 @@
# These are supported funding model platforms
github: jirutka

68
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,68 @@
name: CI
on:
- push
- pull_request
jobs:
test:
name: Test on Node.js ${{ matrix.node-version }}
runs-on: ubuntu-20.04
strategy:
matrix:
node-version: [10, 12, 13, 14]
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0 # fetch all history to make `git describe` work
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- run: yarn install
- run: yarn build
- run: yarn bundle
- run: yarn test
- run: yarn lint
publish:
name: Publish to npmjs and GitHub Releases
needs: [test]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0 # fetch all history to make `git describe` work
- run: sudo apt-get install asciidoctor pandoc
- uses: actions/setup-node@v2
with:
node-version: 14
registry-url: https://registry.npmjs.org
- run: yarn install
- name: Generate source tarball
run: ./scripts/create-src-tarball dist/ipynb2html-${GITHUB_REF/refs\/tags\//}-src.tar.gz
- run: yarn build
- run: yarn bundle
- name: Publish packages to npmjs
run: yarn publish-all --non-interactive
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Upload tarballs to Releases
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
fail_on_unmatched_files: true
files: |
dist/*.tar.gz
packages/ipynb2html-cli/dist/*.tar.gz
packages/ipynb2html-cli/dist/*.zip

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
/coverage/
/dist/
/packages/*/coverage/
/packages/*/dist/
/packages/*/lib/
/packages/*/README.md
/.*cache/
/.tmp/
node_modules/
.eslintcache
.tsbuildinfo
tsconfig.tsbuildinfo
*.log

18
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,18 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
// List of extensions which should be recommended for users of this workspace.
"recommendations": [
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"gamunu.vscode-yarn",
"Orta.vscode-jest",
"ryanluker.vscode-coverage-gutters",
"shtian.jest-snippets-standard"
],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": [
"ms-vscode.vscode-typescript-tslint-plugin"
]
}

15
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible Node.js debug attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "attach",
"name": "Attach by Process ID",
"processId": "${command:PickProcess}",
"protocol": "inspector"
}
]
}

36
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,36 @@
{
"editor.insertSpaces": true,
"editor.tabSize": 2,
"eslint.packageManager": "yarn",
"eslint.validate": [
"javascript",
"typescript"
],
"files.encoding": "utf8",
"files.eol": "\n",
"files.exclude": {
"**/.tsbuildinfo": true,
},
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
"flow.enabled": false,
"javascript.format.insertSpaceAfterConstructor": true,
"javascript.format.insertSpaceBeforeFunctionParenthesis": true,
"javascript.format.semicolons": "remove",
"javascript.preferences.quoteStyle": "single",
"search.exclude": {
"**/node_modules": true,
"**/dist": true
},
"typescript.format.insertSpaceAfterConstructor": true,
"typescript.format.insertSpaceBeforeFunctionParenthesis": true,
"typescript.format.semicolons": "remove",
"typescript.preferences.quoteStyle": "single",
"typescript.tsdk": "./node_modules/typescript/lib",
}

23
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,23 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"type": "yarn",
"task": "build",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"type": "yarn",
"task": "test",
"group": {
"kind": "test",
"isDefault": true
}
}
]
}

2
.yarnrc Normal file
View file

@ -0,0 +1,2 @@
version-git-message "Release version %s"
version-sign-git-tag true

1
CNAME
View file

@ -1 +0,0 @@
ipynb.js.org

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
The MIT License
Copyright 2019-2020 Jakub Jirutka <jakub@jirutka.cz>.
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.

272
README.adoc Normal file
View file

@ -0,0 +1,272 @@
= Jupyter Notebook to HTML
:npm-name: ipynb2html
:gh-name: jirutka/{npm-name}
:gh-branch: master
:version: 0.3.0
:ansiup-version: 5.0.1
:hljs-version: 10.7.3
:katex-version: 0.13.11
:marked-version: 2.0.7
:vs-marketplace-uri: https://marketplace.visualstudio.com/items?itemName=
ifdef::env-github[]
image:https://github.com/{gh-name}/workflows/CI/badge.svg[CI Status, link=https://github.com/{gh-name}/actions?query=workflow%3A%22CI%22]
endif::env-github[]
{npm-name} is a converter (renderer) of the https://nbformat.readthedocs.io/en/stable/[Jupyter Notebook Format] 4.0+ to static HTML.
It works both in Node.js and browser environment.
== Packages
This repository contains the following packages, all published on https://www.npmjs.com/[npm].
=== {npm-name}-core
ifdef::env-github[]
image:https://img.shields.io/npm/v/{npm-name}-core.svg[Version on npm, link="https://www.npmjs.org/package/{npm-name}-core"]
image:https://img.shields.io/bundlephobia/min/{npm-name}-core.svg[Minified bundle size, link="https://bundlephobia.com/result?p={npm-name}-core"]
endif::env-github[]
This package provides the converter itself and some utilities with *no dependencies*.
You have to provide your own syntax highlighter and Markdown, math and ANSI sequences renderer; or not, if you dont need them.
=== {npm-name}
ifdef::env-github[]
image:https://img.shields.io/npm/v/{npm-name}.svg[Version on npm, link="https://www.npmjs.org/package/{npm-name}"]
image:https://img.shields.io/bundlephobia/min/{npm-name}.svg[Minified bundle size, link="https://bundlephobia.com/result?p={npm-name}"]
endif::env-github[]
This package builds on the {npm-name}-core and provides a complete, ready-to-go renderer configured with:
* https://github.com/markedjs/marked[marked] as Markdown renderer,
* https://github.com/KaTeX/KaTeX[KaTeX] as math renderer,
* https://github.com/IonicaBizau/anser[anser] as ANSI sequences renderer,
* https://github.com/highlightjs/highlight.js[highlight.js] as syntax highlighter.
It also provides a reference stylesheet which you can find in `dist/notebook.min.css` (or non-minified link:packages/{npm-name}/styles/notebook.css[`styles/notebook.css`]).
=== {npm-name}-cli
ifdef::env-github[]
image:https://img.shields.io/npm/v/{npm-name}-cli.svg[Version on npm, link="https://www.npmjs.org/package/{npm-name}-cli"]
image:https://img.shields.io/bundlephobia/min/{npm-name}-cli.svg[Minified bundle size, link="https://bundlephobia.com/result?p={npm-name}-cli"]
endif::env-github[]
This package provides a CLI interface for {npm-name}.
ifndef::npm-readme[]
== Installation
All the <<Packages, packages>> can be installed using `npm` or `yarn` from https://www.npmjs.com/[npmjs.com].
=== Standalone CLI Tool
{npm-name}-cli is also provided as a single minified JavaScript with all the external dependencies bundled in.
It requires only Node.js (version 10 or newer) to be installed on the system.
* https://github.com/{gh-name}/releases/download/v{version}/{npm-name}-cli-v{version}.tar.gz[{npm-name}-cli-v{version}.tar.gz]
* https://github.com/{gh-name}/releases/download/v{version}/{npm-name}-cli-v{version}.zip[{npm-name}-cli-v{version}.zip]
The archive also contains source maps (useful for debugging).
endif::[]
== Usage
=== CLI
[source, subs="+attributes"]
{npm-name} notebook.ipynb notebook.html
Run `{npm-name} --help` for more information.
=== Node.js (server-side)
To render HTML in Node.js (server-side rendering), you need some (fake) DOM implementation.
The recommended one is https://github.com/redom/nodom/[nodom] -- its lightweight, https://bundlephobia.com/result?p=nodom[small], doesnt have any external dependencies and {npm-name} is tested against it.
However, you can choose any other if you like.
[source, subs="+attributes"]
npm install {npm-name} nodom
[source, js, subs="+attributes"]
----
import * as fs from 'fs'
import * as ipynb from '{npm-name}'
import { Document } from 'nodom'
const renderNotebook = ipynb.createRenderer(new Document())
const notebook = JSON.parse(fs.readFileSync('./example.ipynb', 'utf8'))
console.log(renderNotebook(notebook).outerHTML)
----
=== Browser (client-side)
You have basically two options how to use {npm-name} in the browser: use the browser bundles provided in the {npm-name} package, or build your own bundle (using e.g. https://rollupjs.org[Rollup] or https://webpack.js.org/[webpack]).
The provided bundles are in UMD format (AMD, CommonJS and IIFE in one file), so they should work in all environments (old and modern browsers, Node.js).
They are transpiled and have injected https://github.com/zloirock/core-js/[core-js] polyfills to be compatible with browsers that have https://browserl.ist/?q=%3E0.5%25%2C+Firefox+ESR%2C+not+dead[>0.5% global coverage, Firefox ESR, and not dead browsers].
==== Full Bundle
`{npm-name}-full.min.js` is a self-contained bundle with all the external dependencies included (marked, KaTeX, Anser and Highlight.js).
You can link it from https://www.jsdelivr.com/[jsDelivr CDN], for example:
[source, html, subs="+attributes"]
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/{npm-name}@{version}/dist/notebook.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@{katex-version}/dist/katex.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@{hljs-version}/build/styles/default.min.css" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/{npm-name}@{version}/dist/{npm-name}-full.min.js" crossorigin="anonymous"></script>
</head>
...
</html>
The bundle exposes global variable `{npm-name}`:
[source, js, subs="+attributes"]
const element = {npm-name}.render(notebook)
document.body.appendChild(element)
{npm-name} also provides function `autoRender` that renders each notebook on the page embedded (as JSON) inside `<script type="application/x-ipynb+json">\...</script>`.footnote:[Dont forget to escape HTML special characters: `<`, `>`, and `&`.]
[source, html, subs="+attributes"]
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/{npm-name}@{version}/dist/notebook.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@{katex-version}/dist/katex.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@{hljs-version}/build/styles/default.min.css" crossorigin="anonymous">
<script defer src="https://cdn.jsdelivr.net/npm/{npm-name}@{version}/dist/{npm-name}-full.min.js" crossorigin="anonymous"
onload="{npm-name}.autoRender();"></script>
</head>
<body>
<main>
<script type="application/x-ipynb+json">
{
"cells": [ ... ],
"metadata": { ... },
"nbformat": 4,
"nbformat_minor": 3
}
</script>
</main>
</body>
<html>
==== Slim Bundle
`{npm-name}.min.js` contains only {npm-name} and {npm-name}-core code (plus polyfills).
If you load marked, KaTeX, AnsiUp, and Highlight.js in the page, you will get the same functionality as with `{npm-name}-full.min.js`:
[source, html, subs="+attributes"]
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@{katex-version}/dist/katex.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@{hljs-version}/build/styles/default.min.css" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/marked@{marked-version}/marked.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/ansi_up@{ansiup-version}/ansi_up.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@{hljs-version}/build/highlight.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@{katex-version}/dist/katex.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/{npm-name}@{version}/dist/{npm-name}.min.js" crossorigin="anonymous"></script>
</head>
...
</html>
Or you may use any other implementations and provide them to the `{npm-name}.createRenderer` function.
All of them are optional, but you usually need at least a Markdown renderer.
ifndef::npm-readme[]
== Development
=== System Requirements
* https://nodejs.org[NodeJS] 10.13+
* https://pandoc.org[Pandoc] and https://asciidoctor.org[Asciidoctor] (used only for converting README.adoc to Markdown for npmjs)
=== Used Tools
* https://www.typescriptlang.org[TypeScript] the language
* https://github.com/cevek/ttypescript[ttypescript] wrapper for `tsc` allowing to use custom AST transformers
* https://yarnpkg.com[yarn] for dependencies management and building
* https://eslint.org[ESLint] for linting JS/TypeScript code
* https://jestjs.io[Jest] for testing
* https://rollupjs.org[Rollup] for building single-file bundles
=== How to Start
. Clone this repository:
[source, subs="+attributes"]
git clone https://github.com/{gh-name}.git
. Install Yarn (if you dont have it already):
[source]
npm install -g yarn
. Install all JS dependencies:
[source]
yarn install
. Build the project:
[source]
yarn build
. Run tests and generate code coverage:
[source]
yarn test
. Run linter:
[source]
yarn lint
IMPORTANT: Keep in mind that JS sources are located in the `src` directories; `lib` directories contains transpiled code (created after running `yarn build`)!
=== Visual Studio Code
If you use Visual Studio Code, you should install the following extensions:
* link:{vs-marketplace-uri}ryanluker.vscode-coverage-gutters[Coverage Gutters]
* link:{vs-marketplace-uri}EditorConfig.EditorConfig[EditorConfig for VS Code]
* link:{vs-marketplace-uri}dbaeumer.vscode-eslint[ESLint]
* link:{vs-marketplace-uri}Orta.vscode-jest[Jest] (and link:{vs-marketplace-uri}shtian.jest-snippets-standard[Jest Snippets Standard Style])
* link:{vs-marketplace-uri}gamunu.vscode-yarn[yarn]
endif::[]
== Credits
* The renderer module is originally based on https://github.com/jsvine/notebookjs[notebookjs] 0.4.2 developed by https://github.com/jsvine[Jeremy Singer-Vine] and distributed under the http://opensource.org/licenses/MIT/[MIT License].
* The mathExtractor module is based on https://github.com/jupyter/notebook/blob/6.0.1/notebook/static/notebook/js/mathjaxutils.js[mathjaxutils.js] from the https://github.com/jupyter/notebook[Jupyter Notebook] 6.0.1 distributed under the https://github.com/jupyter/notebook/blob/6.0.1/COPYING.md[Modified BSD License].
== License
This project is licensed under http://opensource.org/licenses/MIT/[MIT License].
For the full text of the license, see the link:LICENSE[LICENSE] file.

323
examples/bundle-full.html Normal file

File diff suppressed because one or more lines are too long

327
examples/bundle.html Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,31 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" lang="en">
<meta name="viewport" content="initial-scale=1">
<title>.ipynb viewer</title>
<link
rel="stylesheet"
href="ipynb-viewer.9bba2c89.css"
crossorigin="anonymous">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/ipynb2html@0.2.0/dist/notebook.min.css"
crossorigin="anonymous">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/katex.min.css"
crossorigin="anonymous">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.15.10/build/styles/default.min.css"
crossorigin="anonymous">
<script defer src="https://cdn.jsdelivr.net/npm/ipynb2html@0.2.0/dist/ipynb2html-full.min.js" crossorigin="anonymous"></script>
<script defer src="ipynb-viewer.bb5d0a15.js" crossorigin="anonymous" onload="init()"></script>
</head>
<body>
<noscript>
This page needs JavaScript. Enable JavaScript in your browser or use a different browser.
</noscript>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

196
jest.config.base.js Normal file
View file

@ -0,0 +1,196 @@
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// Respect "browser" field in package.json when resolving modules
// browser: false,
// The directory where Jest should store its cached dependency information
// cacheDirectory: '/tmp/jest_rt',
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
collectCoverageFrom: [
'<rootDir>/src/**/*.ts',
],
// The directory where Jest should output its coverage files
coverageDirectory: '<rootDir>/coverage',
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// '/node_modules/'
// ],
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// 'json',
// 'text',
// 'lcov',
// 'clover'
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: null,
// A path to a custom dependency extractor
// dependencyExtractor: null,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: null,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: null,
// A set of global variables that need to be available in all test environments
globals: {
'ts-jest': {
compiler: 'ttypescript',
tsConfig: '<rootDir>/test/tsconfig.json',
},
},
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// 'node_modules'
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// 'js',
// 'json',
// 'jsx',
// 'ts',
// 'tsx',
// 'node'
// ],
// A map from regular expressions to module names that allow to stub out resources with a single module
moduleNameMapper: {
'@/(.*)': '<rootDir>/src/$1',
'~/(.*)': `${__dirname}/$1`,
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
preset: 'ts-jest',
// Run tests from one or more projects
// projects: null,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state between every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: null,
// Automatically restore mock state between every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: null,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// '<rootDir>'
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: 'jest-runner',
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
setupFilesAfterEnv: [
`${__dirname}/test/setup.ts`,
],
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: 'node',
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
testMatch: [
'<rootDir>/test/**/*.test.[jt]s?(x)',
],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// '/node_modules/'
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: null,
// This option allows use of a custom test runner
// testRunner: 'jasmine2',
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: 'http://localhost',
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: 'real',
// A map from regular expressions to paths to transformers
// transform: null,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// '/node_modules/'
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: null,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
}

12
jest.config.js Normal file
View file

@ -0,0 +1,12 @@
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
module.exports = {
...require('./jest.config.base'),
// Run tests from one or more projects
projects: [
'<rootDir>',
'<rootDir>/packages/*/',
],
}

72
package.json Normal file
View file

@ -0,0 +1,72 @@
{
"name": "ipynb2html-parent",
"version": "0.3.0",
"private": true,
"scripts": {
"build": "ttsc --build",
"bundle": "wsrun --exclude-missing bundle",
"clean": "rimraf coverage/ dist/ lib/ .eslintcache *.log && wsrun clean",
"lint": "eslint --cache --ext .ts,.tsx,.js .",
"postinstall": "patch-package && run-s build",
"publish-all": "wsrun --serial publish",
"test": "jest --detectOpenHandles --coverage --verbose",
"version": "./scripts/bump-version && git add README.adoc **/package.json",
"watch-ts": "ttsc --build --watch"
},
"engines": {
"node": ">=10.13.0"
},
"devDependencies": {
"@babel/core": "^7.10.3",
"@babel/preset-env": "^7.10.3",
"@rollup/plugin-babel": "^5.0.3",
"@rollup/plugin-commonjs": "^13.0.0",
"@rollup/plugin-node-resolve": "^8.0.1",
"@types/dedent": "^0.7.0",
"@types/jest": "^24.9.1",
"@types/node": "^10.17.25",
"@typescript-eslint/eslint-plugin": "^3.3.0",
"@typescript-eslint/parser": "^3.3.0",
"arrify": "^2.0.1",
"common-path-prefix": "^3.0.0",
"core-js": "^3.6.5",
"csso-cli": "^3.0.0",
"dedent": "^0.7.0",
"eslint": "^7.3.0",
"eslint-config-standard-with-typescript": "^18.0.2",
"eslint-import-resolver-typescript": "^2.0.0",
"eslint-plugin-import": "^2.21.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.0",
"fs-extra": "^8.1.0",
"jest": "^25.5.4",
"jest-chain": "^1.1.5",
"node-html-parser": "^1.2.19",
"nodom": "^2.3.0",
"npm-run-all": "^4.1.5",
"patch-package": "^6.2.2",
"postinstall-postinstall": "^2.1.0",
"rimraf": "^3.0.2",
"rollup": "^2.17.1",
"rollup-plugin-add-git-msg": "^1.1.0",
"rollup-plugin-executable": "^1.6.0",
"rollup-plugin-node-externals": "^2.2.0",
"rollup-plugin-node-license": "^0.2.0",
"rollup-plugin-terser": "^6.1.0",
"rollup-plugin-typescript2": "^0.27.1",
"tar": "^5.0.10",
"ts-jest": "^25.5.1",
"ts-node": "^8.10.2",
"ts-transformer-export-default-name": "^0.1.0",
"ts-transformer-inline-file": "^0.1.1",
"ttypescript": "^1.5.10",
"typescript": "~3.9.5",
"wsrun": "^5.2.1",
"yarn-version-bump": "^0.0.3",
"yazl": "^2.5.1"
},
"workspaces": [
"packages/*"
]
}

View file

@ -0,0 +1,2 @@
#!/usr/bin/env node
require('../lib/index').default(process.argv.slice(2))

View file

@ -0,0 +1,54 @@
{
"name": "ipynb2html-cli",
"version": "0.3.0",
"description": "CLI tool for converting Jupyter Notebooks to static HTML",
"author": "Jakub Jirutka <jakub@jirutka.cz>",
"license": "MIT",
"homepage": "https://github.com/jirutka/ipynb2html",
"bugs": "https://github.com/jirutka/ipynb2html/issues",
"repository": {
"type": "git",
"url": "https://github.com/jirutka/ipynb2html.git"
},
"keywords": [
"cli",
"converter",
"html",
"ipython",
"jupyter",
"notebook"
],
"bin": {
"ipynb2html": "bin/ipynb2html"
},
"main": "lib/index.js",
"types": "lib/index.d.ts",
"files": [
"bin",
"lib",
"src"
],
"scripts": {
"build": "ttsc --build",
"bundle": "rollup -c && ./scripts/pack-bundle",
"clean": "rimraf coverage/ dist/ lib/ .eslintcache .tsbuildinfo",
"lint": "PKGDIR=$PWD; cd ../../ && eslint --cache --ext .ts,.tsx,.js $PKGDIR",
"prepublishOnly": "run-s readme2md",
"readme2md": "../../scripts/adoc2md -a npm-readme ../../README.adoc > README.md",
"watch-ts": "ttsc --build --watch"
},
"engines": {
"node": ">=10.13.0"
},
"dependencies": {
"ipynb2html": "0.3.0",
"minimist": "^1.2.6",
"minimist-options": "^4.0.2",
"nodom": "^2.3.0",
"source-map-support": "^0.5.16"
},
"devDependencies": {
"@types/minimist": "^1.2.0",
"@types/source-map-support": "^0.5.1"
}
}

View file

@ -0,0 +1,67 @@
import addGitMsg from 'rollup-plugin-add-git-msg'
import commonjs from '@rollup/plugin-commonjs'
import executable from 'rollup-plugin-executable'
import externals from 'rollup-plugin-node-externals'
import license from 'rollup-plugin-node-license'
import resolve from '@rollup/plugin-node-resolve'
import { terser } from 'rollup-plugin-terser'
import typescript from 'rollup-plugin-typescript2'
import ttypescript from 'ttypescript'
import pkg from './package.json'
export default {
input: 'src/index.ts',
plugins: [
// Transpile TypeScript sources to JS.
typescript({
typescript: ttypescript,
tsconfigOverride: {
compilerOptions: {
module: 'ESNext',
declaration: false,
declarationMap: false,
composite: false,
incremental: true,
},
},
clean: true,
}),
// Make node builtins external.
externals(),
// Resolve node modules.
resolve({
extensions: ['.mjs', '.js', '.ts'],
mainFields: ['jsnext:main', 'module', 'main'],
}),
// Convert CommonJS modules to ES6 modules.
commonjs(),
// Add git tag, commit SHA and build date at top of the file.
addGitMsg({
copyright: [
pkg.author,
'* This project is licensed under the terms of the MIT license.'
].join('\n'),
}),
// Generate table of the bundled packages at top of the file.
license({ format: 'table' }),
// Minify JS.
terser({
ecma: 2018,
output: {
// Preserve comment injected by addGitMsg and license.
comments: RegExp(`(?:\\$\\{${pkg.name}\\}|Bundled npm packages)`),
},
}),
// Make the output file executable.
executable(),
],
output: {
file: 'dist/ipynb2html',
format: 'cjs',
banner: '#!/usr/bin/env node',
exports: 'named',
sourcemap: true,
},
}

View file

@ -0,0 +1,8 @@
#!/bin/sh
set -e
cd "$(dirname "$0")/../dist"
export PATH="$(pwd)/../../../scripts:$PATH"
assemble-license ipynb2html > LICENSE
create-archives ipynb2html-cli ipynb2html ipynb2html.map LICENSE

View file

@ -0,0 +1,134 @@
import fs from 'fs'
import minimist from 'minimist'
import minimistOptions from 'minimist-options'
import { Document } from 'nodom'
import { exit } from 'process'
import { $INLINE_FILE, $INLINE_JSON } from 'ts-transformer-inline-file'
import * as ipynb2html from 'ipynb2html'
import renderPage from './page'
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { version, bugs: bugsUrl } = $INLINE_JSON('../package.json')
const notebookCss = $INLINE_FILE('../../ipynb2html/styles/notebook.css')
const pageCss = $INLINE_FILE('./page.css')
const progName = 'ipynb2html'
const helpMsg = `\
Usage: ${progName} [options] <input> [<output>]
Convert Jupyter Notebook 4.0+ to a static HTML page.
Arguments:
<input> Path of the Jupyter notebook to read, or "-" for STDIN.
<output> Path of the file to write the output HTML into. If not
provided, the output will be written to STDOUT.
Options:
-d --debug Print debug messages.
-s --style <file,...> Comma separated stylesheet(s) to embed into the output
HTML. The stylesheet may be a path to a CSS file,
"@base" for the base ipynb2html style, or "@default"
for the default full page style. Default is @default.
-h --help Show this message and exit.
-V --version Print version and exit.
Exit Codes:
1 Generic error code.
2 Missing required arguments or invalid option.
Please report bugs at <${bugsUrl}>.
`
function logErr (msg: string): void {
console.error(`${progName}: ${msg}`)
}
function arrify <T> (obj: T | T[]): T[] {
return Array.isArray(obj) ? obj : [obj]
}
function parseCliArgs (argv: string[]) {
const opts = minimist(argv, minimistOptions({
debug: { alias: 'd', type: 'boolean' },
style: { alias: 's', type: 'string', default: '@default' },
version: { alias: 'V', type: 'boolean' },
help: { alias: 'h', type: 'boolean' },
arguments: 'string',
stopEarly: true,
unknown: (arg: string) => {
if (arg.startsWith('-')) {
logErr(`Unknown option: ${arg}`)
return exit(2)
} else {
return true
}
},
}))
if (opts.help) {
console.log(helpMsg)
return exit(0)
}
if (opts.version) {
console.log(`${progName} ${version}`)
return exit(0)
}
if (opts._.length < 1 || opts._.length > 2) {
logErr('Invalid number of arguments\n')
console.log(helpMsg)
return exit(2)
}
const [input, output] = opts._
return {
styles: arrify(opts.style).join(',').split(/,\s*/),
debug: opts.debug as boolean,
input: input === '-' ? 0 : input, // 0 = stdin
output,
}
}
function loadStyle (name: string): string {
switch (name) {
case '@base': return notebookCss
case '@default': return pageCss + notebookCss
default: return fs.readFileSync(name, 'utf8')
}
}
export default (argv: string[]): void => {
const opts = parseCliArgs(argv)
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const notebook = JSON.parse(fs.readFileSync(opts.input, 'utf-8'))
const style = opts.styles.map(loadStyle).join('\n')
const title = ipynb2html.readNotebookTitle(notebook) || 'Notebook'
const renderNotebook = ipynb2html.createRenderer(new Document())
const contents = renderNotebook(notebook).outerHTML
const html = renderPage({ contents, title, style })
if (opts.output) {
fs.writeFileSync(opts.output, html)
} else {
console.log(html)
}
} catch (err) {
if (opts.debug) {
console.debug(err)
} else {
logErr((err as Error).message)
}
return exit(1)
}
}

View file

@ -0,0 +1,15 @@
import sourceMapSupport from 'source-map-support'
import cli from './cli'
// Allow to disable sourcemap when running from pkg bundle.
if (!/^(0|disable|false|no|off)$/i.test(process.env.NODE_SOURCEMAP ?? '')) {
sourceMapSupport.install({ environment: 'node' })
}
// If the file is run directly (not required as a module), call CLI.
if (require.main === module) {
cli(process.argv.slice(2))
}
export default cli

View file

@ -0,0 +1,95 @@
html, body {
margin: 0;
padding: 0;
background-color: #dedede;
color: #2a2a2a;
font-family: sans-serif;
}
code,
pre {
font-size: 0.85rem;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 1.6em;
position: relative;
}
h1 {
font-size: 2em;
}
h1 .anchor,
h2 .anchor,
h3 .anchor,
h4 .anchor,
h5 .anchor,
h6 .anchor {
display: block;
width: 1em;
left: -1em;
height: 100%;
position: absolute;
}
h1:hover .anchor,
h2:hover .anchor,
h3:hover .anchor,
h4:hover .anchor,
h5:hover .anchor,
h6:hover .anchor {
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50'%3E%3Cg fill='none' stroke='%23000' stroke-width='3'%3E%3Cpath d='M26.1 22a5.8 5.8 0 00-8.1 0l-9.6 9.5a5.8 5.8 0 008.2 8.2l5.8-5.9'/%3E%3Cpath d='M23.6 28a5.8 5.8 0 008.2 0l9.5-9.5a5.8 5.8 0 00-8.1-8.2l-5.8 5.9'/%3E%3C/g%3E%3C/svg%3E") no-repeat 100% center;
}
table {
border-collapse: collapse;
border: 1px solid #cfcfcf;
}
th {
font-weight: 600;
}
td,
th {
padding: 0.2em 0.4em;
border: 1px solid #cfcfcf;
}
thead tr,
tbody tr:nth-child(even) {
background-color: #f7f7f7;
}
thead tr:hover,
tbody tr:hover {
background-color: #cfcfcf;
}
.nb-notebook {
max-width: 45rem;
margin: 1rem auto;
padding: 3rem 6rem;
background-color: white;
box-shadow: 4px 4px 8px #cfcfcf, -4px -4px 8px #cfcfcf;
}
.nb-output table {
font-size: 0.9em;
}
@media (max-width: 900px) {
.nb-notebook {
margin: 0;
padding-left: 5rem;
padding-right: 3rem;
max-width: none;
box-shadow: none;
}
}
@media (max-width: 768px) {
.nb-notebook {
padding: 1rem 5% 2rem 5%;
}
}

View file

@ -0,0 +1,34 @@
import { version } from 'ipynb2html'
export type Options = {
contents: string,
title: string,
style: string,
}
export default ({ contents, title, style }: Options): string => `\
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1">
<meta name="generator" content="ipynb2html ${version}">
<title>${title}</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@10.7.3/build/styles/default.min.css"
integrity="sha384-s4RLYRjGGbVqKOyMGGwfxUTMOO6D7r2eom7hWZQ6BjK2Df4ZyfzLXEkonSm0KLIQ"
crossorigin="anonymous">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/katex@0.13.11/dist/katex.min.css"
integrity="sha384-Um5gpz1odJg5Z4HAmzPtgZKdTBHZdw8S29IecapCSB31ligYPhHQZMIlWLYQGVoc"
crossorigin="anonymous">
<style>
${style.replace(/\n\n/g, '\n').replace(/\n$/, '').replace(/^/gm, ' ')}
</style>
</head>
<body>
${contents}
</body>
</html>
`

View file

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"tsBuildInfoFile": "./.tsbuildinfo",
"baseUrl": ".",
},
"include": [
"./src",
],
"references": [
{ "path": "../ipynb2html-core" },
{ "path": "../ipynb2html" },
],
}

View file

@ -0,0 +1,7 @@
const pkg = require('./package.json')
module.exports = {
...require('../../jest.config.base'),
name: pkg.name,
displayName: pkg.name,
}

View file

@ -0,0 +1,38 @@
{
"name": "ipynb2html-core",
"version": "0.3.0",
"description": "Convert Jupyter Notebook to static HTML",
"author": "Jakub Jirutka <jakub@jirutka.cz>",
"license": "MIT",
"homepage": "https://github.com/jirutka/ipynb2html",
"bugs": "https://github.com/jirutka/ipynb2html/issues",
"repository": {
"type": "git",
"url": "https://github.com/jirutka/ipynb2html.git"
},
"keywords": [
"converter",
"html",
"ipython",
"jupyter",
"notebook"
],
"main": "lib/index.js",
"types": "lib/index.d.ts",
"files": [
"lib",
"src"
],
"scripts": {
"build": "ttsc --build",
"clean": "rimraf coverage/ lib/ .eslintcache .tsbuildinfo",
"lint": "PKGDIR=$PWD; cd ../../ && eslint --cache --ext .ts,.tsx,.js $PKGDIR",
"prepublishOnly": "run-s readme2md",
"test": "jest --detectOpenHandles --coverage --verbose",
"readme2md": "../../scripts/adoc2md -a npm-readme ../../README.adoc > README.md",
"watch-ts": "ttsc --build --watch"
},
"engines": {
"node": ">=10.13.0"
}
}

View file

@ -0,0 +1,53 @@
type Attributes = { [k: string]: string }
// Definition of the smallest possible subset of the HTMLElement type required
// for this module's function.
export type MinimalElement = {
innerHTML: string,
setAttribute: (name: string, value: string) => void,
appendChild: (child: any) => unknown,
}
export type ElementCreator<TElement = HTMLElement> =
& ((tag: string, classes?: string[], children?: TElement[] | string) => TElement)
& ((tag: string, attrs?: Attributes, children?: TElement[] | string) => TElement)
/**
* Returns a function for building `HTMLElement`s.
*
* @param {function} createElement A function that creates a new `HTMLElement`
* (e.g. `document.createElement.bind(document)`).
* @param {string} classPrefix The prefix to be used for all CSS class names
* except `lang-*`. Default is `nb-`.
* @template TElement Type of the element object that *createElement* produces.
*/
export default <TElement extends MinimalElement> (
createElement: (tag: string) => TElement,
classPrefix = 'nb-',
): ElementCreator<TElement> => {
const prefixClassName = (name: string) => name.startsWith('lang-') ? name : classPrefix + name
return (tag: string, classesOrAttrs?: string[] | Attributes, childrenOrHTML?: TElement[] | string): TElement => {
const el = createElement(tag)
if (Array.isArray(classesOrAttrs)) {
el.setAttribute('class', classesOrAttrs.map(prefixClassName).join(' '))
} else if (classesOrAttrs) {
for (let [key, val] of Object.entries(classesOrAttrs)) {
if (key === 'class') {
val = val.split(' ').map(prefixClassName).join(' ')
}
el.setAttribute(key, val)
}
}
if (Array.isArray(childrenOrHTML)) {
childrenOrHTML.forEach(e => el.appendChild(e))
} else if (childrenOrHTML) {
el.innerHTML = childrenOrHTML
}
return el
}
}

View file

@ -0,0 +1,24 @@
import { ElementCreator } from './elementCreator'
import { DataRenderer } from './renderer'
const extractMathRx = /^\s*<html>\s*<script\s*type="math\/tex(?:;[^"]*)">([\s\S]*)<\/script><\/html>\s*$/
export type Options<TElement = HTMLElement> = {
elementCreator: ElementCreator<TElement>,
mathRenderer: (math: string) => string,
}
/**
* Returns a text/html data renderer with workaround for SageMath (La)TeX output.
*/
export default <TElement> (opts: Options<TElement>): DataRenderer<TElement> => {
const { elementCreator: el, mathRenderer: renderMath } = opts
return (data: string): TElement => {
const math = (extractMathRx.exec(data) ?? [])[1]
return math
? el('div', ['latex-output'], renderMath(math))
: el('div', ['html-output'], data)
}
}

View file

@ -0,0 +1,8 @@
import * as mathExtractor from './mathExtractor'
export { default as createElementCreator, ElementCreator, MinimalElement } from './elementCreator'
export { default as createHtmlRenderer } from './htmlRenderer'
export * from './nbformat'
export { default as NbRenderer, DataRenderer, NbRendererOpts } from './renderer'
export { mathExtractor }
export { default as version } from './version'

View file

@ -0,0 +1,56 @@
const htmlEntities: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
}
type Callable = (...args: any[]) => any
type CallableConstructor = new <T> () => T extends { __call__: Callable }
? T['__call__']
: 'subclass does not implement method __call__'
/* eslint-disable @typescript-eslint/no-unsafe-assignment,
@typescript-eslint/no-unsafe-member-access,
@typescript-eslint/ban-types */
export const CallableInstance: CallableConstructor = function Callable (
this: object,
): Callable {
const func = this.constructor.prototype.__call__ as Callable
const cls = function (...args: any[]) {
return func.apply(cls, args) as unknown
}
Object.setPrototypeOf(cls, this.constructor.prototype)
Object.defineProperties(cls, {
name: {
value: this.constructor.name,
configurable: true,
},
length: {
value: func.length,
configurable: true,
},
})
return cls
} as any
CallableInstance.prototype = Object.create(Function.prototype)
/* eslint-enable @typescript-eslint/no-unsafe-assignment,
@typescript-eslint/no-unsafe-member-access,
@typescript-eslint/ban-types */
/**
* Escapes characters with special meaning in HTML with the corresponding
* HTML entities.
*/
export function escapeHTML (str: string): string {
return str.replace(/[&<>]/g, c => htmlEntities[c])
}
/**
* A function that does nothing but return the parameter supplied to it.
*/
export const identity = <T>(x: T, ..._rest: any[]): T => x

View file

@ -0,0 +1,209 @@
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
// Based on https://github.com/jupyter/notebook/blob/6.0.1/notebook/static/notebook/js/mathjaxutils.js.
// Some magic for deferring mathematical expressions to MathJax by hiding them
// from the Markdown parser.
// Some of the code here is adapted with permission from Davide Cervone under
// the terms of the Apache2 license governing the MathJax project.
// Other minor modifications are also due to StackExchange and are used with
// permission.
export type MathExpression = {
raw: string,
value: string,
displayMode: boolean,
}
// The pattern for math delimiters and special symbols needed for searching for
// math in the text input.
const mathSplitRx = /(\$\$?|\\(?:begin|end)\{[a-z]*\*?\}|\\[{}$]|[{}]|(?:\n\s*)+|\\\\(?:\(|\)|\[|\]))/i
const delimiters = {
'$$': { displayMode: true },
'$': { displayMode: false },
'\\\\[': { displayMode: true },
'\\\\(': { displayMode: false },
}
/**
* Parses the given string that may contain a delimited math expression.
* Use this function to parse extracted chunks from `extractMath()`.
*/
function parseDelimitedMath (raw: string): MathExpression {
const delim = Object.keys(delimiters)
.find(s => raw.startsWith(s)) as keyof typeof delimiters | undefined
if (delim) {
const value = raw.slice(delim.length, -delim.length).trim()
return { raw, value, ...delimiters[delim] }
} else {
return { raw, value: raw, displayMode: true }
}
}
/**
* Escapes dollar characters (`$`) inside in-line codes and fenced code blocks
* found in the given Markdown text.
*
* Except for extreme edge cases, this should catch precisely those pieces of
* the Markdown source that will later be turned into code spans. While
* MathJax will not TeXify code spans, we still have to consider them at this
* point; the following issue has happened several times:
*
* `$foo` and `$bar` are variables. --> <code>$foo ` and `$bar</code> are variables.
*/
function escapeCodes (text: string): string {
const escapeDolar = (match: string) => match.replace(/\$/g, '~D')
return text
.replace(/~/g, '~T')
.replace(/\n(?!\n)/g, '~N')
.replace(/(?:^|[^\\])(`+)[^\n]*?[^`\n]\1(?!`)/gm, escapeDolar)
.replace(/~N/g, '\n')
.replace(/^\s{0,3}(`{3,})(?:.|\n)*?\1/gm, escapeDolar)
}
/**
* Reverts escaping performed by `escapeCodes()`.
*/
function unescapeCodes (text: string): string {
const subs = { T: '~', D: '$' }
return text.replace(/~([TD])/g, (_, char: keyof typeof subs) => subs[char])
}
// - The math is in blocks start through end, so collect it into one block and
// clear the others.
// - Clear the current math positions and store the index of the math, then
// push the math string onto the storage array.
// - The preProcess function is called on all blocks if it has been passed in
function processMath (
preProcess: (str: string) => string,
math: string[], // will be modified in-place
blocks: string[], // will be modified in-place
start: number,
end: number,
): void {
const block = blocks.slice(start, end + 1).join('')
while (end > start) {
blocks[end] = ''
end--
}
// Replace the current block text with a unique tag to find later.
blocks[start] = `@@${math.length + 1}@@`
math.push(preProcess(block))
}
/**
* Extracts delimited math expressions from the given *text*, substitutes them
* with numbered markers and returns a tuple of the modified *text* and an
* array of the extracted expressions.
*
* NOTE: Sequences that looks like our markers (`@@\d+@@`) will be escaped by
* adding a zero (`0`) before the number. They will be unescaped in
* `restoreMath()`.
*/
export function extractMath (text: string): [string, MathExpression[]] {
// - Break up the text into its component parts and search through them for
// math delimiters, braces, line breaks, etc.
// - Math delimiters must match and braces must balance.
// - Don't allow math to pass through a double line break (which will be
// a paragraph).
// Escape things that look like our math markers so we can distinguish them
// later in `restoreMath()`.
text = text.replace(/@@(\d+)@@/g, (_, n) => `@@0${n}@@`)
const hasCodeSpans = text.includes('`')
if (hasCodeSpans) {
text = escapeCodes(text)
}
const unescape = hasCodeSpans ? unescapeCodes : (x: string) => x
const math: string[] = [] // stores math strings for later
// TODO: Test if it works correctly across browsers. The original code uses
// utils.regex_split() based on http://blog.stevenlevithan.com/archives/cross-browser-split.
const blocks = text.replace(/\r\n?/g, '\n').split(mathSplitRx)
let startIdx: number | null = null // the blocks' index where the current math starts
let lastIdx: number | null = null // the blocks' index where the current math ends
let endDelim: string | null = null // end delimiter of the current math expression
let bracesLevel = 0 // nesting level of the braces
for (let i = 1; i < blocks.length; i += 2) {
const block = blocks[i]
if (startIdx) {
// If we are in math, look for the end delimiter, but don't go past
// double line breaks, and and balance braces within the math.
switch (block) {
case endDelim:
if (bracesLevel) {
lastIdx = i
} else {
processMath(unescape, math, blocks, startIdx, i)
startIdx = endDelim = lastIdx = null
}
break
case '{':
bracesLevel++
break
case '}':
if (bracesLevel) { bracesLevel-- }
break
default: if (/\n.*\n/.exec(block)) {
if (lastIdx) {
i = lastIdx
processMath(unescape, math, blocks, startIdx, i)
}
startIdx = endDelim = lastIdx = null
bracesLevel = 0
}
}
} else {
// Look for math start delimiters and when found, set up
// the end delimiter.
switch (block) {
case '$':
case '$$':
startIdx = i
endDelim = block
bracesLevel = 0
break
case '\\\\(':
case '\\\\[':
startIdx = i
endDelim = block.endsWith('(') ? '\\\\)' : '\\\\]'
bracesLevel = 0
break
default: if (block.startsWith('\\begin')) {
startIdx = i
endDelim = '\\end' + block.substr(6)
bracesLevel = 0
}
}
}
}
if (lastIdx) {
processMath(unescape, math, blocks, startIdx ?? 0, lastIdx)
startIdx = endDelim = lastIdx = null
}
return [unescape(blocks.join('')), math.map(parseDelimitedMath)]
}
/**
* Replaces math markers injected by `extractMath()` into the given *text*
* with strings from the given *math* array and unescapes sequences that looks
* like our markers.
*/
export function restoreMath (text: string, math: string[]): string {
return text
.replace(/@@([1-9][0-9]*)@@/g, (_, n) => math[Number(n) - 1] ?? '')
.replace(/@@0(\d+)@@/g, (_, n) => `@@${n}@@`)
}

View file

@ -0,0 +1,259 @@
// These types are based on https://github.com/jupyter/nbformat/blob/b6b5a18e5a40d37f1cc0f71f65108288bdec9bb7/nbformat/v4/nbformat.v4.schema.json.
// This file was originally generated using json-schema-to-typescript 7.0.0 and
// then manually polished.
/* eslint-disable @typescript-eslint/member-ordering */
/** Jupyter Notebook v4.3. */
export interface Notebook {
/** Notebook root-level metadata. */
metadata: NotebookMetadata
/** Notebook format (minor number). Incremented for backward compatible changes to the notebook format. */
nbformat_minor: number
/** Notebook format (major number). Incremented between backwards incompatible changes to the notebook format. */
nbformat: 4
/** Array of cells of the current notebook. */
cells: Cell[]
}
/** Notebook root-level metadata. */
export interface NotebookMetadata {
/** Kernel information. */
kernelspec?: KernelSpec
/** Kernel information. */
language_info?: LanguageInfo
/** Original notebook format (major number) before converting the notebook between versions. This should never be written to a file. */
orig_nbformat?: number
/** The title of the notebook document */
title?: string
/** The author(s) of the notebook document */
authors?: any[]
/** Extra properties. */
[key: string]: any
}
/** Kernel information. */
export interface KernelSpec {
/** Name of the kernel specification. */
name: string
/** Name to display in UI. */
display_name: string
/** Extra properties. */
[key: string]: any
}
/** Kernel information. */
export interface LanguageInfo {
/** The programming language which this kernel runs. */
name: string
/** The codemirror mode to use for code in this language. */
codemirror_mode?: string | { [k: string]: any }
/** The file extension for files in this language. */
file_extension?: string
/** The mimetype corresponding to files in this language. */
mimetype?: string
/** The pygments lexer to use for code in this language. */
pygments_lexer?: string
/** Extra properties. */
[key: string]: any
}
// ---------------------- Input (Cell) types ----------------------- //
export type Cell = RawCell | MarkdownCell | CodeCell
export enum CellType {
Raw = 'raw',
Markdown = 'markdown',
Code = 'code',
}
interface BaseCell {
/** String identifying the type of cell. */
cell_type: CellType
/** Cell-level metadata. */
metadata: CellMetadata
/** Contents of the cell, represented as an array of lines. */
source: MultilineString
}
/** Notebook raw nbconvert cell. */
export interface RawCell extends BaseCell {
/** String identifying the type of cell. */
cell_type: CellType.Raw
/** Cell-level metadata. */
metadata: CellMetadata & {
/** Raw cell metadata format for nbconvert. */
format?: string,
}
/** Media attachments (e.g. inline images), stored as mimebundle keyed by filename. */
attachments?: MediaAttachments
}
/** Notebook markdown cell. */
export interface MarkdownCell extends BaseCell {
/** String identifying the type of cell. */
cell_type: CellType.Markdown
/** Media attachments (e.g. inline images), stored as mimebundle keyed by filename. */
attachments?: MediaAttachments
}
/** Notebook code cell. */
export interface CodeCell extends BaseCell {
/** String identifying the type of cell. */
cell_type: CellType.Code
/** Cell-level metadata. */
metadata: CellMetadata & {
/** Whether the cell's output is collapsed/expanded. */
collapsed?: boolean,
/** Whether the cell's output is scrolled, unscrolled, or autoscrolled. */
scrolled?: true | false | 'auto',
}
/** Execution, display, or stream outputs. */
outputs: Output[]
/** The code cell's prompt number. Will be null if the cell has not been run. */
execution_count: number | null
}
export interface CellMetadata {
/** Official Jupyter Metadata for Raw Cells */
jupyter?: { [k: string]: any }
/**
* The cell's name. If present, must be a non-empty string. Cell names are expected to be unique
* across all the cells in a given notebook. This criterion cannot be checked by the json schema
* and must be established by an additional check.
*/
name?: string
/** The cell's tags. Tags must be unique, and must not contain commas. */
tags?: string[]
/** Extra properties. */
[key: string]: any
}
export interface MediaAttachments {
/** The attachment's data stored as a mimebundle. */
[filename: string]: MimeBundle
}
// ------------------------- Output types ------------------------- //
export type Output = ExecuteResult | DisplayData | StreamOutput | ErrorOutput
export enum OutputType {
ExecuteResult = 'execute_result',
DisplayData = 'display_data',
Stream = 'stream',
Error = 'error',
}
/** Result of executing a code cell. */
export interface ExecuteResult {
/** Type of cell output. */
output_type: OutputType.ExecuteResult
/** A result's prompt number. */
execution_count: number | null
/** A mime-type keyed dictionary of data */
data: MimeBundle
/** Cell output metadata. */
metadata: {
[k: string]: any,
}
}
/** Data displayed as a result of code cell execution. */
export interface DisplayData {
/** Type of cell output. */
output_type: OutputType.DisplayData
/** A mime-type keyed dictionary of data */
data: MimeBundle
/** Cell output metadata. */
metadata: {
[k: string]: any,
}
}
/** Stream output from a code cell. */
export interface StreamOutput {
/** Type of cell output. */
output_type: OutputType.Stream
/** The name of the stream (stdout, stderr). */
name: string
/** The stream's text output, represented as an array of strings. */
text: MultilineString
}
/** Output of an error that occurred during code cell execution. */
export interface ErrorOutput {
/** Type of cell output. */
output_type: OutputType.Error
/** The name of the error. */
ename: string
/** The value, or message, of the error. */
evalue: string
/** The error's traceback, represented as an array of strings. */
traceback: string[]
}
// ------------------------- Misc types ------------------------- //
export interface MimeBundle {
/** mimetype output (e.g. text/plain), represented as either an array of strings or a string. */
[mediaType: string]: MultilineString
}
export type MultilineString = string | string[]

View file

@ -0,0 +1,255 @@
// This code is originally based on notebookjs 0.4.2 distributed under the MIT license.
import { ElementCreator } from './elementCreator'
import { CallableInstance, escapeHTML, identity } from './internal/utils'
import {
Cell,
CellType,
CodeCell,
DisplayData,
ErrorOutput,
ExecuteResult,
MarkdownCell,
Notebook,
Output,
OutputType,
RawCell,
StreamOutput,
} from './nbformat'
export type NbRendererOpts<TElement = HTMLElement> = {
/**
* An object with additional data renderers indexed by a media type.
*/
dataRenderers?: DataRenderers<TElement>,
/**
* An array of the supported MIME types in the priority order. When a cell
* contains multiple representations of the data, the one with the media type
* that has the lowest index in this array will be rendered. The default is
* `Object.keys({ ...dataRenderers, ...builtinRenderers })`.
*/
dataTypesPriority?: string[],
/**
* A function for converting ANSI escape sequences in the given *text* to HTML.
* It gets the text from the cell as-is, without prior escaping, so it must
* escape special characters unsafe for HTML (ansi_up does it implicitly)!
*/
ansiCodesRenderer?: (text: string) => string,
/**
* A function for highlighting the given source *code*, it should return an
* HTML string. It gets the text from the cell as-is, without prior escaping,
* so it must escape special characters unsafe for HTML (highlight.js does it
* implicitly)!
*/
codeHighlighter?: (code: string, lang: string) => string,
/**
* A function for converting the given Markdown source to HTML.
*/
markdownRenderer?: (markup: string) => string,
}
export type DataRenderer<TElement = HTMLElement> = (this: NbRenderer<TElement> | void, data: string) => TElement
type DataRenderers<TElement> = { [mediaType: string]: DataRenderer<TElement> }
function joinText (text: string | string[]): string {
return Array.isArray(text) ? text.map(joinText).join('') : text
}
function coalesceStreams (outputs: Output[]): Output[] {
if (!outputs.length) { return outputs }
let last = outputs[0]
const newOutputs = [last]
for (const output of outputs.slice(1)) {
if (output.output_type === 'stream' && last.output_type === 'stream' && output.name === last.name) {
last.text = last.text.concat(...output.text)
} else {
newOutputs.push(output)
last = output
}
}
return newOutputs
}
function executionCountAttrs ({ execution_count: count }: CodeCell): { [k: string]: string } {
return count ? {
'data-execution-count': String(count),
// Only for backward compatibility with notebook.js.
'data-prompt-number': String(count),
} : {}
}
function notebookLanguage ({ metadata: meta }: Notebook): string {
return meta.language_info?.name ?? 'python'
}
class NbRenderer <TElement> extends CallableInstance<NbRenderer<TElement>> {
readonly el: ElementCreator<TElement>
readonly renderMarkdown: NonNullable<NbRendererOpts['markdownRenderer']>
readonly renderAnsiCodes: NonNullable<NbRendererOpts['ansiCodesRenderer']>
readonly highlightCode: NonNullable<NbRendererOpts['codeHighlighter']>
readonly dataRenderers: DataRenderers<TElement>
readonly dataTypesPriority: string[]
/**
* Creates a Notebook renderer with the given options. The constructed object
* is "callable", i.e. you can treat it as a function.
*
* @example
* const renderer = new NbRenderer(document.createElement.bind(document))
* console.log(renderer(notebook).outerHTML)
*
* @param {ElementCreator} elementCreator The function that will be used for
* building all HTML elements.
* @param {NbRendererOpts} opts The renderer's options.
*/
constructor (elementCreator: ElementCreator<TElement>, opts: NbRendererOpts<TElement> = {}) {
super()
this.el = elementCreator
this.renderMarkdown = opts.markdownRenderer ?? identity
this.renderAnsiCodes = opts.ansiCodesRenderer ?? escapeHTML
this.highlightCode = opts.codeHighlighter ?? escapeHTML
const el2 = (tag: string, classes: string[]) => (data: string) => this.el(tag, classes, data)
const embeddedImageEl = (format: string) => (data: string) => this.el('img', {
class: 'image-output',
src: `data:image/${format};base64,${data.replace(/\n/g, '')}`,
})
// opts.dataRenderers is intentionally included twice; to get the user's
// provided renderers in the default dataTypesPriority before the built-in
// renderers and at the same time allow to override any built-in renderer.
this.dataRenderers = {
...opts.dataRenderers,
'image/png': embeddedImageEl('png'),
'image/jpeg': embeddedImageEl('jpeg'),
'image/svg+xml': el2('div', ['svg-output']),
'text/svg+xml': (data) => this.dataRenderers['image/svg+xml'].call(this, data),
'text/html': el2('div', ['html-output']),
'text/markdown': (data) => this.el('div', ['html-output'], this.renderMarkdown(data)),
'text/latex': el2('div', ['latex-output']),
'application/javascript': el2('script', []),
'text/plain': (data) => this.el('pre', ['text-output'], escapeHTML(data)),
...opts.dataRenderers,
}
this.dataTypesPriority = opts.dataTypesPriority ?? Object.keys(this.dataRenderers)
}
/**
* Renders the given Jupyter *notebook*.
*/
__call__ (notebook: Notebook): TElement {
return this.render(notebook)
}
/**
* Renders the given Jupyter *notebook*.
*/
render (notebook: Notebook): TElement {
const children = notebook.cells.map(cell => this.renderCell(cell, notebook))
return this.el('div', ['notebook'], children)
}
renderCell (cell: Cell, notebook: Notebook): TElement {
switch (cell.cell_type) {
case CellType.Code: return this.renderCodeCell(cell, notebook)
case CellType.Markdown: return this.renderMarkdownCell(cell, notebook)
case CellType.Raw: return this.renderRawCell(cell, notebook)
default: return this.el('div', [], '<!-- Unsupported cell type -->')
}
}
renderMarkdownCell (cell: MarkdownCell, _notebook: Notebook): TElement {
return this.el('section', ['cell', 'markdown-cell'], this.renderMarkdown(joinText(cell.source)))
}
renderRawCell (cell: RawCell, _notebook: Notebook): TElement {
return this.el('section', ['cell', 'raw-cell'], joinText(cell.source))
}
renderCodeCell (cell: CodeCell, notebook: Notebook): TElement {
const source = cell.source.length > 0
? this.renderSource(cell, notebook)
: this.el('div')
const outputs = coalesceStreams(cell.outputs ?? [])
.map(output => this.renderOutput(output, cell))
return this.el('section', ['cell', 'code-cell'], [source, ...outputs])
}
renderSource (cell: CodeCell, notebook: Notebook): TElement {
const lang = notebookLanguage(notebook)
const html = this.highlightCode(joinText(cell.source), lang)
const codeEl = this.el('code', { 'class': `lang-${lang}`, 'data-language': lang }, html)
const preEl = this.el('pre', [], [codeEl])
// Class "input" is for backward compatibility with notebook.js.
const attrs = { ...executionCountAttrs(cell), class: 'source input' }
return this.el('div', attrs, [preEl])
}
renderOutput (output: Output, cell: CodeCell): TElement {
const innerEl = (() => {
switch (output.output_type) {
case OutputType.DisplayData: return this.renderDisplayData(output)
case OutputType.ExecuteResult: return this.renderExecuteResult(output)
case OutputType.Stream: return this.renderStream(output)
case OutputType.Error: return this.renderError(output)
default: return this.el('div', [], '<!-- Unsupported output type -->')
}
})()
const attrs = { ...executionCountAttrs(cell), class: 'output' }
return this.el('div', attrs, [innerEl])
}
renderDisplayData (output: DisplayData): TElement {
const type = this.resolveDataType(output)
if (type) {
return this.renderData(type, joinText(output.data[type]))
}
return this.el('div', ['empty-output'])
}
renderExecuteResult (output: ExecuteResult): TElement {
const type = this.resolveDataType(output)
if (type) {
return this.renderData(type, joinText(output.data[type]))
}
return this.el('div', ['empty-output'])
}
renderError (error: ErrorOutput): TElement {
const html = this.renderAnsiCodes(error.traceback.join('\n'))
// Class "pyerr" is for backward compatibility with notebook.js.
return this.el('pre', ['error', 'pyerr'], html)
}
renderStream (stream: StreamOutput): TElement {
const html = this.renderAnsiCodes(joinText(stream.text))
return this.el('pre', [stream.name], html)
}
renderData (mimeType: string, data: string): TElement {
const render = this.dataRenderers[mimeType]
if (!render) {
throw RangeError(`missing renderer for MIME type: ${mimeType}`)
}
return render.call(this, data)
}
resolveDataType (output: DisplayData | ExecuteResult): string | undefined {
return this.dataTypesPriority.find(type => output.data[type])
}
}
export default NbRenderer

View file

@ -0,0 +1,6 @@
import { $INLINE_JSON } from 'ts-transformer-inline-file'
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { version } = $INLINE_JSON('../package.json')
export default version as string

View file

@ -0,0 +1,112 @@
import { Document } from 'nodom'
import buildElementCreator from '@/elementCreator'
const doc = new Document()
const el1 = (() => {
const el = doc.createElement('p')
el.className = 'fixture'
el.textContent = 'child-1'
return el
})()
const el2 = (() => {
const el = doc.createElement('img')
el.setAttribute('src', 'pic.png')
return el
})()
describe('created function', () => {
const prefix = 'x-'
const makeElement = buildElementCreator(doc.createElement.bind(doc), prefix)
let exp = doc.createElement('div')
describe('with tag', () => {
it('returns an HTMLElement for the specified tag', () => {
expect( makeElement('div') ).toHtmlEqual(exp)
})
describe('and classes', () => {
const tag = 'img'
const classes = ['foo', 'bar']
beforeEach(() => {
exp = doc.createElement(tag)
exp.className = `${prefix}foo ${prefix}bar`
})
it('returns an HTMLElement with the specified classes prefixed', () => {
expect( makeElement(tag, classes) ).toHtmlEqual(exp)
})
it('does not prefix class name starting with lang-', () => {
expect( makeElement(tag, ['lang-js']).className ).toEqual('lang-js')
})
describe('and child elements', () => {
it('returns an HTMLElement with the given children', () => {
exp.appendChild(el1)
exp.appendChild(el2)
expect( makeElement(tag, classes, [el1, el2]) ).toHtmlEqual(exp)
})
})
describe('and inner HTML', () => {
it('returns an HTMLElement with the given inner HTML', () => {
exp.innerHTML = el1.outerHTML
expect( makeElement(tag, classes, el1.outerHTML) ).toHtmlEqual(exp)
})
})
})
describe('and attributes', () => {
const tag = 'img'
const attrs = { src: 'img.png', alt: 'picture' }
beforeEach(() => {
exp = doc.createElement(tag)
for (const [key, val] of Object.entries(attrs)) {
exp.setAttribute(key, val)
}
})
it('returns an HTMLElement with the given attributes', () => {
expect( makeElement('img', attrs) ).toHtmlEqual(exp)
})
it('prefixes items of the class attribute that do not start with lang-', () => {
expect(
makeElement(tag, { src: 'img.png', class: 'foo lang-js' }).className
).toEqual(`${prefix}foo lang-js`)
})
describe('and child elements', () => {
it('returns an HTMLElement with the given children', () => {
exp.appendChild(el1)
exp.appendChild(el2)
expect( makeElement(tag, attrs, [el1, el2]) ).toHtmlEqual(exp)
})
})
describe('and inner HTML', () => {
it('returns an HTMLElement with the given inner HTML', () => {
exp.innerHTML = el1.outerHTML
expect( makeElement(tag, attrs, el1.outerHTML) ).toHtmlEqual(exp)
})
})
})
})
})

View file

@ -0,0 +1,33 @@
import { Document } from 'nodom'
import buildElementCreator from '@/elementCreator'
import htmlRenderer from '@/htmlRenderer'
describe('htmlRenderer', () => {
const doc = new Document()
const elementCreator = buildElementCreator(doc.createElement.bind(doc), '')
const mathRenderer = jest.fn(math => math)
const renderHtml = htmlRenderer({ elementCreator, mathRenderer })
describe('with SageMath\'s embedded TeX', () => {
const math = '\\newcommand{\\Bold}[1]{\\mathbf{#1}}\\Bold{Z}'
const data = `<html><script type="math/tex; mode=display">${math}</script></html>`
it('returns <div class="latex-output">${math}</div>', () => {
expect( renderHtml(data) ).toEqual(elementCreator('div', ['latex-output'], math))
expect( mathRenderer ).toBeCalledWith(math)
})
})
describe('with other HTML', () => {
const data = '<p>Lorem ipsum</p>'
it('returns <div class="html-output">${data}</div>', () => {
expect( renderHtml(data) ).toEqual(elementCreator('div', ['html-output'], data))
expect( mathRenderer ).not.toBeCalled()
})
})
})

View file

@ -0,0 +1,84 @@
import { CallableInstance, escapeHTML, identity } from '@/internal/utils'
describe('CallableInstance', () => {
class FixtureCallable extends CallableInstance<FixtureCallable> {
readonly salutation: string
constructor (salutation: string) {
super()
this.salutation = salutation
}
__call__ (name: string) {
return this.salute(name)
}
salute (name: string) {
return `${this.salutation}, ${name}!`
}
}
describe('subclass', () => {
it('can be instantiated using new', () => {
expect(() => new FixtureCallable('Hello') ).not.toThrow()
})
})
describe('subclass instance', () => {
let instance: FixtureCallable
beforeEach(() => {
instance = new FixtureCallable('Hello')
})
it('is an instance of its class', () => {
expect( instance ).toBeInstanceOf(FixtureCallable)
expect( instance.salutation ).toBe('Hello')
expect( instance.salute('world') ).toBe('Hello, world!')
})
it('is an instance of Function', () => {
expect( instance ).toBeInstanceOf(Function)
})
it('is a typeof function', () => {
expect( typeof instance ).toBe('function')
})
it('has function property "name" that equals the class name', () => {
expect( instance.name ).toBe('FixtureCallable')
})
it('has function property "length" that equals number of arguments of the __call__ method', () => {
expect( instance.length ).toBe(1)
})
it('can be called, redirects to the method __call__', () => {
expect( instance('world') ).toBe('Hello, world!')
expect( instance.apply(null, ['world']) ).toBe('Hello, world!') // eslint-disable-line no-useless-call
expect( instance.call(null, 'world') ).toBe('Hello, world!') // eslint-disable-line no-useless-call
})
})
})
describe('.escapeHTML', () => {
test.each([
/* input | expected */
['&' , '&amp;' ],
['<' , '&lt;' ],
['>' , '&gt;' ],
['Hey >_<! <<&>>', 'Hey &gt;_&lt;! &lt;&lt;&amp;&gt;&gt;'],
])('"%s" -> "%s"', (input, expected) => {
expect( escapeHTML(input) ).toEqual(expected)
})
})
describe('.identity', () => {
it('returns the first given argument', () => {
expect( identity('a', 'b') ).toBe('a')
})
})

View file

@ -0,0 +1,147 @@
import dedent from 'dedent'
import { extractMath, restoreMath } from '@/mathExtractor'
describe('.extractMath', () => {
describe.each([
/* delimiters | displayMode */
['$...$' , false],
['$$...$$' , true ],
['\\\\(...\\\\)', false],
['\\\\[...\\\\]', true ],
] as Array<[string, boolean]>)(
'text with expression delimited using %s', (delimiters, displayMode) => {
describe('on a single line', () => {
const value = 'x = 42'
const raw = delimiters.replace('...', value)
it('extracts and substitutes math expression in the given text', () => {
expect(
extractMath(`Let's define ${raw}.`),
).toEqual(["Let's define @@1@@.", [{ displayMode, raw, value }]])
})
})
describe('on multiple lines', () => {
const value = 'x = 42\ny = 55'
const raw = delimiters.replace('...', `\n ${value}\n`)
it('extracts and substitutes math expression in the given text', () => {
expect(
extractMath(`Let's define ${raw}.`),
).toEqual(["Let's define @@1@@.", [{ displayMode, raw, value }]])
})
})
}
)
describe('text with \\begin{..} ... \\end{..} expression', () => {
const raw = '\\begin{equation}a_{0}+ b_{T}\\end{equation}'
it('extracts and substitutes math expression in the given text', () => {
expect(
extractMath(`Let's define ${raw}.`),
).toEqual(["Let's define @@1@@.", [{ displayMode: true, raw, value: raw }]])
})
})
describe('text with marker-like sequences', () => {
it('escapes @@[0-9]+@@ as @@0[0-9]+@@', () => {
expect(
extractMath('This @@02@@ is not our marker'),
).toEqual(['This @@002@@ is not our marker', []])
})
})
it('ignores math delimiters inside `inline code`', () => {
expect(
extractMath('`$x$` and ``$`x`$`` is a code, $x$ is not'),
).toEqual([
'`$x$` and ``$`x`$`` is a code, @@1@@ is not',
[{ displayMode: false, raw: '$x$', value: 'x' }],
])
})
it('ignores math delimiters inside `inline code` with line breaks', () => {
expect(
extractMath('`$x\n$` and ``\n$`x`$\n`` is a code, `$x$\n\nis` not'),
).toEqual([
'`$x\n$` and ``\n$`x`$\n`` is a code, `@@1@@\n\nis` not',
[{ displayMode: false, raw: '$x$', value: 'x' }],
])
})
it('ignores math delimiters inside ```fenced code blocks```', () => {
const text = dedent`
Some code:
\`\`\`sh
echo $foo $bar
\`\`\`
and $$ x = 42 $$
`
expect( extractMath(text) ).toEqual([
text.replace('$$ x = 42 $$', '@@1@@'),
[{ displayMode: true, raw: '$$ x = 42 $$', value: 'x = 42' }],
])
})
test('complex example', () => {
const eq2 = dedent`
\begin{aligned}
A_k &= \langle k, +\infty), &
B_k &= \{ p \mid p \ \text{is prvočíslo a} \ p < k \}
\end{aligned}
`
const text = dedent`
Define sets for natural $k$
\begin{aligned}
A_k &= \langle k, +\infty), &
B_k &= \{ p \mid p \ \text{is prvočíslo a} \ p < k \}
\end{aligned}
This @@1@@ is not a marker, this \`$x && $y\`
is not a math, but \\(x\\) and $$
x = 42
$$ is.
`
const expected = dedent`
Define sets for natural @@1@@
@@2@@
This @@01@@ is not a marker, this \`$x && $y\`
is not a math, but @@3@@ and @@4@@ is.
`
expect( extractMath(text) ).toEqual([expected, [
{ displayMode: false, raw: '$k$', value: 'k' },
{ displayMode: true, raw: eq2, value: eq2 },
{ displayMode: false, raw: '\\\\(x\\\\)', value: 'x' },
{ displayMode: true, raw: '$$\n x = 42\n$$', value: 'x = 42' },
]])
})
})
describe('.restoreMath', () => {
it('replaces markers with the given strings', () => {
const repl = ['first']
repl[21] = 'second'
expect(
restoreMath("Let's define @@1@@ and @@22@@.", repl),
).toEqual("Let's define first and second.")
})
it('unescapes marker-like sequences', () => {
expect(
restoreMath('This @@001@@ is not our marker, nor @@01@@, but @@1@@ is.', ['this one']),
).toEqual('This @@01@@ is not our marker, nor @@1@@, but this one is.')
})
})

View file

@ -0,0 +1,563 @@
/* eslint-disable @typescript-eslint/unbound-method */
import '~/test/setup' // setupFilesAfterEnv doesn't work here
import arrify from 'arrify'
import { Document, HTMLElement } from 'nodom'
import buildElementCreator from '@/elementCreator'
import NbRenderer, { NbRendererOpts } from '@/renderer'
import { DisplayData, MimeBundle, MultilineString, Notebook } from '@/nbformat'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { Anything } from '~/test/support/matchers/toMatchElement'
import { mockLastResult, mockResults } from '~/test/support/helpers'
import * as fixtures from './support/fixtures/notebook'
const document = new Document()
describe('built renderer', () => {
const elementCreator = buildElementCreator(document.createElement.bind(document), '')
const markdownRenderer = jest.fn(x => `<markdown>${x}</markdown>`)
const ansiCodesRenderer = jest.fn(x => `<ansi>${x}</ansi>`)
const codeHighlighter = jest.fn(x => `<highlight>${x}</highlight>`)
const notebook = fixtures.Notebook
const dataRenderers = {
'text/custom': rendererMock('DisplayData'),
}
const rendererOpts: NbRendererOpts<HTMLElement> = {
ansiCodesRenderer,
codeHighlighter,
markdownRenderer,
dataRenderers,
}
let renderer: NbRenderer<HTMLElement>
beforeEach(() => {
renderer = new NbRenderer(elementCreator, rendererOpts)
})
describe('.render', () => {
beforeEach(() => {
renderer.renderCell = rendererMock('Cell')
})
it('returns div.notebook', () => {
expect( renderer.render({ ...notebook, cells: [] }) ).toHtmlEqual(
<div class="notebook"></div>
)
})
it('returns element with $cells converted using .renderCell() as the children', () => {
const result = renderer.render(notebook)
notebook.cells.forEach((cell, idx) => {
expect( renderer.renderCell ).toHaveBeenNthCalledWith(idx + 1, cell, notebook)
})
expect( result.children ).toHtmlEqual(mockResults(renderer.renderCell))
})
})
describe('.renderCell', () => {
describe.each([
'renderCodeCell', 'renderMarkdownCell', 'renderRawCell',
] as const)('with %s', (funcName) => {
const type = funcName.replace('render', '')
const cell = (fixtures as any)[type]
it(`returns result of calling .${funcName}() with the given cell`, () => {
const expected = stubElement(type)
const rendererFunc = (renderer as any)[funcName] = jest.fn(() => expected)
expect( renderer.renderCell(cell, notebook) ).toBe(expected)
expect( rendererFunc ).toBeCalledWith(cell, notebook)
})
})
describe('with unsupported cell type', () => {
const cell = {
cell_type: 'whatever',
metadata: {},
} as any
it('returns div with comment "Unsupported cell type"', () => {
expect( renderer.renderCell(cell, notebook) ).toHtmlEqual(
<div>
{{__html: '<!-- Unsupported cell type -->' }}
</div>
)
})
})
})
describe('.renderMarkdownCell', () => {
eachMultilineVariant(fixtures.MarkdownCell, 'source', (cell) => {
const source = join(cell.source)
it('returns section.cell.markdown-cell with the $source converted using markdownRenderer() as content', () => {
expect( renderer.renderMarkdownCell(cell, notebook) ).toHtmlEqual(
<section class="cell markdown-cell">
{{__html: mockLastResult(markdownRenderer) }}
</section>
)
expect( markdownRenderer ).toBeCalledWith(source)
})
})
})
describe('.renderRawCell', () => {
eachMultilineVariant(fixtures.RawCell, 'source', (cell) => {
it('returns section.cell.raw-cell with the $source as content', () => {
expect( renderer.renderRawCell(cell, notebook) ).toHtmlEqual(
<section class="cell raw-cell">
{{__html: join(cell.source) }}
</section>
)
})
})
})
describe('.renderCodeCell', () => {
const cell = fixtures.CodeCell
let result: HTMLElement
beforeEach(() => {
renderer.renderSource = rendererMock('Source')
renderer.renderOutput = rendererMock('Output')
result = renderer.renderCodeCell(cell, notebook)
})
it('returns section.cell.code-cell', () => {
expect( result ).toMatchElement(
<section class="cell code-cell"><Anything /></section>
)
})
describe('with non-empty $source', () => {
it('returns element with $source rendered using .renderSource() as children[0]', () => {
expect( renderer.renderSource ).toBeCalledWith(cell, notebook)
expect( result.children[0] ).toHtmlEqual(mockLastResult(renderer.renderSource)!)
})
})
describe('with empty $source', () => {
beforeEach(() => {
result = renderer.renderCodeCell({ ...cell, source: [] }, notebook)
})
it('returns element with empty div as children[0]', () => {
expect( result.children[0] ).toHtmlEqual(
<div></div>
)
})
})
it('returns element with $outputs rendered using .renderOutput() as children[1+]', () => {
cell.outputs.forEach((output, idx) => {
expect( renderer.renderOutput ).toHaveBeenNthCalledWith(idx + 1, output, cell)
})
expect( result.children.slice(1) ).toHtmlEqual(mockResults(renderer.renderOutput))
})
})
describe('.renderSource', () => {
const cell = fixtures.CodeCell
const notebookLang = notebook.metadata.language_info!.name
let result: HTMLElement
beforeEach(() => {
result = renderer.renderSource(cell, notebook)
})
it('returns div > pre > code', () => {
expect( result ).toMatchElement(
<div>
<pre>
<code><Anything /></code>
</pre>
</div>,
{ ignoreAttrs: true }
)
})
it("calls the codeHighlighter() with the notebook's language", () => {
expect( codeHighlighter ).toBeCalledWith(expect.anything(), notebookLang)
})
describe('outer div', () => {
it('has class "source input"', () => {
expect( result.className ).toBe('source input')
})
describe('when the cell has non-null execution_count', () => {
const myCell = { ...cell, execution_count: 2 }
it('has data-execution-count and data-prompt-number attributes', () => {
const result = renderer.renderSource(myCell, notebook)
expect( result.attributes ).toMatchObject({
'data-execution-count': String(myCell.execution_count),
'data-prompt-number': String(myCell.execution_count),
})
})
})
describe('when the cell has null execution_count', () => {
const myCell = { ...cell, execution_count: null }
it('has data-execution-count and data-prompt-number attributes', () => {
const result = renderer.renderSource(myCell, notebook)
expect( result.attributes )
.not.toHaveProperty('data-execution-count')
.not.toHaveProperty('data-prompt-number')
})
})
})
describe('inner code', () => {
let codeEl: HTMLElement
beforeEach(() => {
codeEl = renderer.renderSource(cell, notebook).firstChild!.firstChild as HTMLElement
})
it("has class lang-<lang> where lang is the notebook's language", () => {
expect( codeEl.className ).toBe(`lang-${notebookLang}`)
})
it("has attribute data-language with the notebook's language", () => {
expect( codeEl.getAttribute('data-language') ).toBe(notebookLang)
})
it('has $source converted using codeHighlighter() as the innerHTML', () => {
expect( codeHighlighter ).toBeCalledWith(join(cell.source), expect.anything())
expect( codeEl.innerHTML ).toEqual(mockLastResult(codeHighlighter))
})
})
describe('when the notebook does not have metadata.language_info.name', () => {
const myNotebook: Notebook = { ...notebook, metadata: {} }
const notebookLang = 'python'
it('uses the default language: python', () => {
const result = renderer.renderSource(cell, myNotebook)
const codeEl = result.firstChild!.firstChild as HTMLElement
expect( codeEl.getAttribute('data-language') ).toBe(notebookLang)
expect( codeEl.classList ).toContain(`lang-${notebookLang}`)
expect( codeHighlighter ).toBeCalledWith(expect.anything(), notebookLang)
})
})
})
describe('.renderOutput', () => {
const cell = { ...fixtures.CodeCell, execution_count: null }
describe.each([
'renderDisplayData', 'renderExecuteResult', 'renderStream', 'renderError',
] as const)('with %s output', (funcName) => {
const type = funcName.replace('render', '')
const output = (fixtures as any)[type]
let result: HTMLElement
beforeEach(() => {
renderer[funcName] = rendererMock(type)
result = renderer.renderOutput(output, cell)
})
it('returns div.output', () => {
expect( result ).toMatchElement(
<div class="output"><Anything /></div>
)
})
it(`returns element with the output rendered using .${funcName}() as the only child`, () => {
expect( renderer[funcName] ).toBeCalledWith(output)
expect( result.children ).toHtmlEqual([mockLastResult(renderer[funcName])!])
})
describe('when the cell has non-null execution_count', () => {
const cell = { ...fixtures.CodeCell, execution_count: 2 }
it('returns element with attributes data-execution-count and data-prompt-number', () => {
const result = renderer.renderOutput(output, cell)
expect( result.attributes ).toMatchObject({
'data-execution-count': String(cell.execution_count),
'data-prompt-number': String(cell.execution_count),
})
})
})
})
describe('with unsupported output type', () => {
const output = {
output_type: 'whatever',
} as any
const cell = {
...fixtures.CodeCell,
execution_count: null,
output: [output],
}
it('returns div with comment "Unsupported output type"', () => {
expect( renderer.renderOutput(output, cell) ).toHtmlEqual(
<div class="output">
<div>
{{__html: '<!-- Unsupported output type -->' }}
</div>
</div>
)
})
})
})
describe('.renderDisplayData', () => {
function displayDataWith (data: MimeBundle): DisplayData {
return { ...fixtures.DisplayData, data }
}
function withMimeData (
mimeType: string,
value: MultilineString,
fn: (output: DisplayData, value: MultilineString) => void,
): void {
describe(mimeType, () => {
describe('as a string', () => {
const data = join(value)
fn(displayDataWith({ [mimeType]: data }), data)
})
describe('as an array', () => {
const data = arrify(value)
fn(displayDataWith({ [mimeType]: data }), data)
})
})
}
describe('with single data of unsupported MIME type', () => {
const displayData = displayDataWith({ 'text/non-sense': 'whaat' })
it('returns div.empty-output', () => {
expect( renderer.renderDisplayData(displayData) ).toHtmlEqual(
<div class="empty-output"></div>
)
})
})
describe('with single data of built-in MIME type', () => {
;['image/png', 'image/jpeg'].forEach(mimeType => {
withMimeData(mimeType, ['aW1hZ2Ug\n', 'ZGF0YQ=='], (output) => {
it('returns img.image-output with the data in the src attribute', () => {
expect( renderer.renderDisplayData(output) ).toHtmlEqual(
<img class="image-output" src={`data:${mimeType};base64,aW1hZ2UgZGF0YQ==`}></img>
)
})
})
})
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
;([
/* mimeType | classes */
['image/svg+xml', ['svg-output'] ],
['text/svg+xml' , ['svg-output'] ],
['text/html' , ['html-output'] ],
['text/latex' , ['latex-output']],
] as Array<[string, string[]]>).forEach(([mimeType, classes]) => {
withMimeData(mimeType, '<stub>data</stub>', (output, data) => {
it(`returns div${classes.map(x => `.${x}`)} with the data as content`, () => {
expect( renderer.renderDisplayData(output) ).toHtmlEqual(
<div class={ classes.join(' ') }>
{{__html: join(data) }}
</div>
)
})
})
})
withMimeData('text/markdown', ['Lorem\n', 'ipsum'], (output, data) => {
it('returns div.html-output with the data converted using markdownRenderer() as content', () => {
expect( renderer.renderDisplayData(output) ).toHtmlEqual(
<div class="html-output">
{{__html: mockLastResult(markdownRenderer) }}
</div>
)
expect( markdownRenderer ).toBeCalledWith(join(data))
})
})
withMimeData('text/plain', '>_<', (output) => {
it('returns pre.text-output with html-escaped data', () => {
expect( renderer.renderDisplayData(output) ).toHtmlEqual(
<pre class="text-output">{ '>_<' }</pre>
)
})
})
withMimeData('application/javascript', 'alert("Hello &!")', (output, data) => {
it('returns script with the data', () => {
expect( renderer.renderDisplayData(output) ).toHtmlEqual(
<script>{{__html: join(data) }}</script>
)
})
})
})
describe('with single data of non-built-in MIME type', () => {
withMimeData('text/custom', 'Lorem ipsum', (output, data) => {
it('renders the data using the associated external renderer', () => {
expect( renderer.renderDisplayData(output) ).toHtmlEqual(
mockLastResult(dataRenderers['text/custom'])!
)
expect( dataRenderers['text/custom'] ).toBeCalledWith(join(data))
})
})
})
describe('with multiple data', () => {
const mimeBundle = {
'text/plain': 'Lorem ipsum',
'text/html': '<p>Lorem ipsum</p>',
'text/unknown': '???',
}
const output = displayDataWith(mimeBundle)
it('renders the data of the MIME type with a higher priority', () => {
expect( renderer.renderDisplayData(output) ).toHtmlEqual(
<div class="html-output">
{{__html: mimeBundle['text/html'] }}
</div>
)
})
test('the provided dataRenderers have higher priority than the built-ins', () => {
const mimeBundle = {
'text/custom': '>>Lorem ipsum<<',
'text/html': '<p>Lorem ipsum</p>',
}
const output = displayDataWith(mimeBundle)
expect( renderer.renderDisplayData(output) ).toHtmlEqual(
mockLastResult(dataRenderers['text/custom'])!
)
})
})
describe('when built with external renderer for the built-in type', () => {
const dataRenderer = rendererMock('DisplayData')
beforeEach(() => {
renderer = new NbRenderer(elementCreator, {
...rendererOpts,
dataRenderers: { 'text/plain': dataRenderer },
})
})
it('renders the data using the external renderer instead of the built-in', () => {
const data = 'allons-y!'
const output = displayDataWith({ 'text/plain': [data] })
expect( renderer.renderDisplayData(output) ).toBe(mockLastResult(dataRenderer))
expect( dataRenderer ).toBeCalledWith(data)
})
})
})
describe('.renderError', () => {
const error = fixtures.Error
const traceback = error.traceback.join('\n')
it('returns pre.error.pyerr with inner $traceback converted using ansiCodesRenderer', () => {
expect( renderer.renderError(error) ).toHtmlEqual(
<pre class="error pyerr">
{{__html: mockLastResult(ansiCodesRenderer) }}
</pre>
)
expect( ansiCodesRenderer ).toBeCalledWith(traceback)
})
})
describe('.renderStream', () => {
eachMultilineVariant(fixtures.Stream, 'text', (stream) => {
const text = join(stream.text)
it('returns pre.$name with inner $text converted using ansiCodesRenderer', () => {
expect( renderer.renderStream(stream) ).toHtmlEqual(
<pre class={ stream.name }>
{{__html: mockLastResult(ansiCodesRenderer) }}
</pre>
)
expect( ansiCodesRenderer ).toBeCalledWith(text)
})
})
})
})
function eachMultilineVariant <T extends { [P in K]: MultilineString }, K extends keyof T> (
obj: T,
propName: K,
fn: (obj: T) => void,
): void {
const propValue = obj[propName]
describe(`when ${propName} is an array`,
() => fn({ ...obj, [propName]: arrify(propValue) }))
describe(`when ${propName} is a string`,
() => fn({ ...obj, [propName]: join(propValue) }))
}
const genStubElement = jest.fn((type: string) => {
const id = genStubElement.mock.calls.filter(args => args[0] === type).length
const el = document.createElement('stub')
el.setAttribute('type', type)
el.setAttribute('id', id.toString())
return el
})
function stubElement (type: string): HTMLElement {
return genStubElement(type)
}
function rendererMock (type: string) {
return jest.fn(() => stubElement(type))
}
function join (input: MultilineString): string {
return Array.isArray(input) ? input.join('') : input
}

View file

@ -0,0 +1,101 @@
import * as nb from '@/nbformat'
const { CellType, OutputType } = nb
export const Stream: nb.StreamOutput = {
output_type: OutputType.Stream,
name: 'stdout',
text: [
'foo\n',
'bar',
],
}
export const Error: nb.ErrorOutput = {
output_type: OutputType.Error,
ename: 'Error',
evalue: 'whatever',
traceback: [
'Error',
' at repl:1:7',
' at REPLServer.self.eval (repl.js:110:21)',
],
}
export const ExecuteResult: nb.ExecuteResult = {
output_type: OutputType.ExecuteResult,
data: {
'text/plain': [
'[1, 2]\n',
'[3, 4]',
],
},
execution_count: 1,
metadata: {},
}
export const DisplayData: nb.DisplayData = {
output_type: OutputType.DisplayData,
data: {
'application/x-tex': '\\alpha = 42',
'text/plain': 'α = 42',
},
metadata: {},
}
export const CodeCell: nb.CodeCell = {
cell_type: CellType.Code,
source: 'print("Hello, world!")',
execution_count: 1,
outputs: [
DisplayData,
ExecuteResult,
Stream,
Error,
],
metadata: {},
}
export const RawCell: nb.RawCell = {
cell_type: CellType.Raw,
source: '<p>Allons-y!</p>',
metadata: {
format: 'text/html',
},
}
export const MarkdownCell: nb.MarkdownCell = {
cell_type: CellType.Markdown,
source: [
'# Title\n',
'\n',
'Markdown content',
],
metadata: {
tags: ['test'],
},
}
export const Notebook: nb.Notebook = {
metadata: {
kernelspec: {
display_name: 'Julia 1.2.0',
language: 'julia',
name: 'julia-1.2',
},
language_info: {
file_extension: '.jl',
mimetype: 'application/julia',
name: 'julia',
version: '1.2.0',
},
},
nbformat: 4,
nbformat_minor: 3,
cells: [
RawCell,
MarkdownCell,
CodeCell,
],
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.test.json",
"compilerOptions": {
"baseUrl": ".",
},
"references": [
{ "path": "../" },
],
}

View file

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"tsBuildInfoFile": "./.tsbuildinfo",
"baseUrl": ".",
},
"include": [
"./src",
],
}

View file

@ -0,0 +1,7 @@
const pkg = require('./package.json')
module.exports = {
...require('../../jest.config.base'),
name: pkg.name,
displayName: pkg.name,
}

View file

@ -0,0 +1,65 @@
{
"name": "ipynb2html",
"version": "0.3.0",
"description": "Convert Jupyter Notebook to static HTML",
"author": "Jakub Jirutka <jakub@jirutka.cz>",
"license": "MIT",
"homepage": "https://github.com/jirutka/ipynb2html",
"bugs": "https://github.com/jirutka/ipynb2html/issues",
"repository": {
"type": "git",
"url": "https://github.com/jirutka/ipynb2html.git"
},
"keywords": [
"converter",
"html",
"ipython",
"jupyter",
"notebook"
],
"main": "lib/index.js",
"browser": "dist/ipynb2html.min.js",
"types": "lib/index.d.ts",
"files": [
"dist/ipynb2html.min.js*",
"dist/ipynb2html-full.min.js*",
"dist/notebook.min.css*",
"lib",
"src",
"styles"
],
"scripts": {
"build": "ttsc --build",
"bundle": "rollup -c",
"clean": "rimraf coverage/ dist/ lib/ .eslintcache .tsbuildinfo",
"lint": "PKGDIR=$PWD; cd ../../ && eslint --cache --ext .ts,.tsx,.js $PKGDIR",
"minify-css": "csso styles/notebook.css -o dist/notebook.min.css -s dist/notebook.min.css.map",
"prepublishOnly": "run-p bundle minify-css readme2md",
"test": "jest --detectOpenHandles --coverage --verbose",
"readme2md": "../../scripts/adoc2md -a npm-readme ../../README.adoc > README.md",
"watch-ts": "ttsc --build --watch"
},
"engines": {
"node": ">=10.13.0"
},
"dependencies": {
"anser": "^1.4.9",
"highlight.js": "^10.7.3",
"ipynb2html-core": "0.3.0",
"katex": "^0.13.11",
"marked": "^2.0.7"
},
"devDependencies": {
"@types/katex": "^0.11.0",
"@types/marked": "^2.0.3",
"ansi_up": "^5.0.1"
},
"peerDependencies": {
"nodom": "^2.3.0"
},
"browserslist": [
">0.5%",
"Firefox ESR",
"not dead"
]
}

View file

@ -0,0 +1,109 @@
import addGitMsg from 'rollup-plugin-add-git-msg'
import babel from '@rollup/plugin-babel'
import commonjs from '@rollup/plugin-commonjs'
import license from 'rollup-plugin-node-license'
import resolve from '@rollup/plugin-node-resolve'
import { terser } from 'rollup-plugin-terser'
import ttypescript from 'ttypescript'
import typescript from 'rollup-plugin-typescript2'
import pkg from './package.json'
const extensions = ['.mjs', '.js', '.ts']
const globals = {
'anser': 'anser',
'highlight.js': 'hljs',
'katex': 'katex',
'marked': 'marked',
}
const plugins = [
// Transpile TypeScript sources to JS.
typescript({
typescript: ttypescript,
tsconfigOverride: {
compilerOptions: {
target: 'ES5',
module: 'ESNext',
declaration: false,
declarationMap: false,
composite: false,
incremental: true,
},
},
clean: true,
}),
// Resolve node modules.
resolve({
extensions,
mainFields: ['browser', 'module', 'main'],
}),
// Convert CommonJS modules to ES6 modules.
commonjs(),
// Transpile all sources for older browsers and inject needed polyfills.
babel({
babelrc: false,
babelHelpers: 'bundled',
// To avoid Babel injecting core-js polyfills into core-js.
exclude: [/node_modules\/core-js\//],
extensions,
presets: [
[
'@babel/env', {
corejs: 3,
debug: false,
modules: false,
useBuiltIns: 'usage', // inject polyfills
// targets: reads from "browserslist" in package.json
},
],
],
}),
// Add git tag, commit SHA and build date at top of the file.
addGitMsg({
copyright: [
pkg.author,
'* This project is licensed under the terms of the MIT license.'
].join('\n'),
}),
// Generate table of the bundled packages at top of the file.
license({ format: 'table' }),
]
const output = (filename, extra = {}) => [false, true].map(minify => ({
name: pkg.name,
file: `${filename}${minify ? '.min.js' : '.js'}`,
format: 'umd',
sourcemap: true,
plugins: [
// Minify JS when building .min.js file.
minify && terser({
ecma: 5,
output: {
// Preserve comment injected by addGitMsg and license.
comments: RegExp(`(?:\\$\\{${pkg.name}\\}|Bundled npm packages)`),
},
}),
].filter(Boolean),
...extra,
}))
export default [
// Bundle third-party dependencies except core-js polyfills.
{
input: 'src/browser.ts',
external: Object.keys(globals),
plugins,
output: output(`dist/${pkg.name}`, { globals }),
},
// Bundle with all dependencies.
{
input: 'src/browser.ts',
plugins,
// Tree-shaking breaks KaTeX and has almost zero effect on the bundle size.
treeshake: false,
output: output(`dist/${pkg.name}-full`),
}
]

View file

@ -0,0 +1,48 @@
import { createRenderer, NbRendererOpts, Notebook } from '.'
export * from '.'
function unescapeHTML (input: string): string {
return new DOMParser()
.parseFromString(input, 'text/html')
.documentElement
.textContent ?? ''
}
/**
* Renders the given Jupyter *notebook* to HTML. It's a shorthand for
* `createRenderer(document, opts)(notebook)`.
*
* @example
* document.body.appendChild(ipynb2html.render(notebook))
*
* @param notebook Object in Jupyter Notebook Format 4.0+.
* @param opts The renderer options.
* @return An HTMLElement with the rendered *notebook*.
* @see createRenderer
*/
export function render (notebook: Notebook, opts: NbRendererOpts = {}): HTMLElement {
return createRenderer(document, opts)(notebook)
}
/**
* Renders Jupyter Notebook inside each
* `<script type="application/x-ipynb+json">...</script>` found in
* the page's body.
*
* @param opts The renderer options.
*/
export function autoRender (opts: NbRendererOpts = {}): void {
const selector = 'script[type="application/x-ipynb+json"]'
const render = createRenderer(document, opts)
document.querySelectorAll(selector).forEach(script => {
if (script.textContent && script.parentElement) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const notebook = JSON.parse(unescapeHTML(script.textContent))
const nbElement = render(notebook)
script.parentElement.replaceChild(nbElement, script)
}
})
}

6
packages/ipynb2html/src/global.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
// AnsiUp may be provided in the browser environment as an external dependency
// via global variable. It's an alternative to Anser that doesn't provide
// bundle for browsers.
declare class AnsiUp {
ansi_to_html (input: string): string
}

View file

@ -0,0 +1,138 @@
import anser from 'anser'
import hljs from 'highlight.js'
import katex, { KatexOptions } from 'katex'
import marked from 'marked'
import {
createElementCreator,
createHtmlRenderer,
MinimalElement,
NbRenderer,
NbRendererOpts as BaseOptions,
Notebook,
} from 'ipynb2html-core'
import buildMarkdownRenderer, { MarkedOptions } from './markdownRenderer'
export { default as version } from './version'
export { default as readNotebookTitle } from './readNotebookTitle'
export { NbRenderer, Notebook }
export type NbRendererOpts<TElement = HTMLElement> = BaseOptions<TElement> & {
/**
* The prefix to be used for all CSS class names except `lang-*`.
* Default is `nb-`.
*/
classPrefix?: string,
/**
* Options for the KaTeX math renderer. Default is
* `{ displayMode: true, throwOnError: false }`. The provided options will
* be merged with the default.
*/
katexOpts?: KatexOptions,
/**
* Options for the marked Markdown renderer.
*/
markedOpts?: MarkedOptions,
}
/**
* Definition of the smallest possible subset of the Document type required
* for this module's function.
*/
type MinimalDocument<TElement extends MinimalElement> = {
createElement: (tag: string) => TElement,
}
const defaultKatexOpts: KatexOptions = {
displayMode: true,
throwOnError: false,
}
const defaultMarkedOpts: MarkedOptions = {
headerAnchors: true,
headerIdsStripAccents: true,
}
function hljsCodeHighlighter (code: string, lang: string): string {
return hljs.getLanguage(lang)
? hljs.highlight(code, { language: lang }).value
: code
}
/**
* Builds a full-fledged Jupyter Notebook renderer.
*
* It supports rendering of Markdown cells with math (using marked and KaTeX),
* code highlighting (using highlight.js), rendering of ANSI escape sequences
* (using Anser) and SageMath-style math outputs. All of them may be overridden
* via *opts*.
*
* It returns a "callable object" that exposes one renderer function for each
* of the Notebook's AST nodes. You can easily replace any of the functions to
* modify behaviour of the renderer.
*
* @example // Node.js
* import * as fs from 'fs'
* import * as ipynb from 'ipynb2html'
* import { Document } from 'nodom'
*
* const renderNotebook = ipynb.createRenderer(new Document())
* const notebook = JSON.parse(fs.readFileSync('./example.ipynb', 'utf8'))
*
* console.log(renderNotebook(notebook).outerHTML)
*
* @example // Browser
* const render = ipynb2html.createRenderer(document)
* document.body.appendChild(render(notebook))
*
* @param document The `Document` object from the browser's native DOM or any
* fake/virtual DOM library (e.g. nodom). The only required method is
* `createElement`.
* @param opts The renderer options.
* @return A configured instance of the Notebook renderer.
*/
export function createRenderer <TElement extends MinimalElement> (
document: MinimalDocument<TElement>,
opts: NbRendererOpts<TElement> = {},
): NbRenderer<TElement> {
let { ansiCodesRenderer, codeHighlighter, dataRenderers = {}, markdownRenderer } = opts
const katexOpts = { ...defaultKatexOpts, ...opts.katexOpts }
const elementCreator = createElementCreator(
document.createElement.bind(document),
opts.classPrefix,
)
// The following ifs for the imported modules are for the browser bundle
// without dependencies.
if (!ansiCodesRenderer) {
if (anser) {
ansiCodesRenderer = (input) => anser.ansiToHtml(anser.escapeForHtml(input))
} else if (AnsiUp) {
const ansiUp = new AnsiUp()
ansiCodesRenderer = ansiUp.ansi_to_html.bind(ansiUp)
}
}
if (!codeHighlighter && hljs) {
codeHighlighter = hljsCodeHighlighter
}
if (!markdownRenderer && marked) {
const markedOpts = { ...defaultMarkedOpts, ...opts.markedOpts }
markdownRenderer = buildMarkdownRenderer(markedOpts, katexOpts)
}
if (!dataRenderers['text/html'] && katex) {
const mathRenderer = (tex: string) => katex.renderToString(tex, katexOpts)
dataRenderers['text/html'] = createHtmlRenderer({ elementCreator, mathRenderer })
}
return new NbRenderer(elementCreator, {
ansiCodesRenderer,
codeHighlighter,
dataRenderers,
markdownRenderer,
...opts,
})
}

View file

@ -0,0 +1,86 @@
import hljs from 'highlight.js'
import katex, { KatexOptions } from 'katex'
import marked, { Slugger } from 'marked'
import { mathExtractor } from 'ipynb2html-core'
export type MarkdownRenderer = (markdown: string) => string
export interface MarkedOptions extends marked.MarkedOptions {
/** Generate heading anchors (this implies headingIds). */
headerAnchors?: boolean
headerIdsStripAccents?: boolean
}
// Removes accents from the given string.
const stripAccents = (text: string) => text.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
// Removes math markers from the given string.
const stripMath = (text: string) => mathExtractor.restoreMath(text, []).trim()
class Renderer extends marked.Renderer {
heading (text: string, level: 1 | 2 | 3 | 4 | 5 | 6, raw: string, slugger: Slugger): string {
const opts = this.options as MarkedOptions
if (!opts.headerIds && !opts.headerAnchors) {
return super.heading(text, level, raw, slugger)
}
let id = (opts.headerPrefix ?? '') + slugger.slug(stripMath(raw))
if (opts.headerIdsStripAccents) {
id = stripAccents(id)
}
if (opts.headerAnchors) {
text = `<a class="anchor" href="#${id}" aria-hidden="true"></a>${text}`
}
return `<h${level} id="${id}">${text}</h${level}>`
}
link (href: string | null, title: string | null, text: string): string {
return super.link(href && stripMath(href), title && stripMath(title), text)
}
image (href: string | null, title: string | null, text: string): string {
return super.image(href && stripMath(href), title && stripMath(title), stripMath(text))
}
}
function highlight (code: string, lang: string): string {
return hljs.getLanguage(lang)
? hljs.highlight(code, { language: lang, ignoreIllegals: true }).value
: code
}
const renderer = (markedOpts: MarkedOptions) => (markdown: string) => marked.parse(markdown, markedOpts)
const rendererWithMath = (markedOpts: MarkedOptions, katexOpts: KatexOptions) => (markdown: string) => {
const [text, math] = mathExtractor.extractMath(markdown)
const html = marked.parse(text, markedOpts)
const mathHtml = math.map(({ value, displayMode }) => {
return katex.renderToString(value, { ...katexOpts, displayMode })
})
return mathExtractor.restoreMath(html, mathHtml)
}
/**
* Returns a pre-configured marked parser with (optional) math support (using
* KaTeX) and code highlighter (highlight.js).
*
* @param {MarkedOptions} markedOpts Options for the marked Markdown renderer.
* @param {KatexOptions} katexOpts Options for the KaTeX math renderer.
*/
export default (markedOpts: MarkedOptions = {}, katexOpts: KatexOptions = {}): MarkdownRenderer => {
markedOpts = { renderer: new Renderer(markedOpts), ...markedOpts }
if (hljs) { // highlight.js may be an optional dependency (in browser bundle)
markedOpts = { highlight, ...markedOpts }
}
return katex // katex may be an optional dependency (in browser bundle)
? rendererWithMath(markedOpts, katexOpts)
: renderer(markedOpts)
}

View file

@ -0,0 +1,48 @@
import marked from 'marked'
import { Notebook } from 'ipynb2html-core'
class EmptyRenderer extends marked.Renderer {}
// Override all the EmptyRenderer's methods inherited from marked.Renderer to
// always return an empty string.
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
const RendererProto = marked.Renderer.prototype
for (const prop of Object.getOwnPropertyNames(RendererProto)) {
if (prop !== 'constructor' && typeof (RendererProto as any)[prop] === 'function') {
(EmptyRenderer.prototype as any)[prop] = () => ''
}
}
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
class MainTitleRenderer extends EmptyRenderer {
_titleFound = false
heading (_text: string, level: number, raw: string, _slugger: marked.Slugger): string {
if (level === 1 && !this._titleFound) {
this._titleFound = true
return raw
}
return ''
}
}
/**
* Returns title of the given *notebook*, or an empty string if not found.
*
* If the title is not present in the notebook's metadata and the first cell is
* a Markdown cell, it parses it and returns the first level 1 heading.
*/
export default (notebook: Notebook): string => {
if (notebook.metadata.title) {
return notebook.metadata.title
}
if (notebook.cells.length > 0 && notebook.cells[0].cell_type === 'markdown') {
const source = notebook.cells[0].source
const markup = Array.isArray(source) ? source.join('') : source
return marked.parse(markup, { renderer: new MainTitleRenderer() })
}
return ''
}

View file

@ -0,0 +1,6 @@
import { $INLINE_JSON } from 'ts-transformer-inline-file'
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { version } = $INLINE_JSON('../package.json')
export default version as string

View file

@ -0,0 +1,86 @@
/* Reference styles for ipynb2html */
.nb-notebook {
line-height: 1.5;
}
.nb-cell + .nb-cell {
margin-top: 1.4rem;
}
.nb-raw-cell {
font-family: monospace;
white-space: pre-wrap;
}
.nb-code-cell {
position: relative;
}
.nb-source::before,
.nb-output::before {
position: absolute;
font-family: monospace;
color: #999;
left: -7.5em;
width: 7em;
text-align: right;
}
.nb-source::before {
content: "In [" attr(data-execution-count) "]:";
}
.nb-output::before {
content: "Out[" attr(data-execution-count) "]:";
}
.nb-source + .nb-output,
.nb-output + .nb-output {
margin-top: 1.4rem;
}
.nb-source > pre {
background-color: #f7f7f7;
border: 1px solid #cfcfcf;
border-radius: 2px;
padding: 0.5em;
margin: 0;
overflow-x: auto;
}
.nb-output {
min-height: 1em;
width: 100%;
}
.nb-output > pre {
padding: 0.5em;
margin: 0;
overflow-x: auto;
}
.nb-output > img {
max-width: 100%;
}
.nb-stdout,
.nb-stderr {
white-space: pre-wrap;
}
.nb-error,
.nb-stderr {
background-color: #fdd;
border-radius: 2px;
}
@media (max-width: 768px) {
.nb-source::before,
.nb-output::before {
display: block;
position: relative;
left: 0;
padding-bottom: 0.5em;
text-align: left;
}
}

1
packages/ipynb2html/test/global.d.ts vendored Symbolic link
View file

@ -0,0 +1 @@
../src/global.d.ts

View file

@ -0,0 +1,152 @@
import { KatexOptions } from 'katex'
import parseHtml from 'node-html-parser'
import markdownRenderer, { MarkedOptions } from '@/markdownRenderer'
import '~/test/setup' // setupFilesAfterEnv doesn't work here
import { Anything } from '~/test/support/matchers/toMatchElement'
let katexOpts: KatexOptions
let markedOpts: MarkedOptions
beforeEach(() => {
katexOpts = {
displayMode: true,
throwOnError: false,
}
markedOpts = {}
})
describe('headings', () => {
it('renders h tag without anchor', () => {
expect( render('## Some Title') ).toHtmlEqual(
<h2 id="some-title">Some Title</h2>
)
})
it('renders math in text, but strips it in id', () => {
expect( render('## Integers $\\mathbb{Z}$') ).toMatchElement(
<h2 id="integers">
Integers <span class="katex"><Anything /></span>
</h2>
)
})
describe('when headerAnchors is true', () => {
beforeEach(() => {
markedOpts = { headerAnchors: true }
})
it('renders h tag with anchor', () => {
expect( render('## Some Title') ).toHtmlEqual(
<h2 id="some-title">
<a class="anchor" href="#some-title" aria-hidden="true"></a>Some Title
</h2>
)
})
it('renders math in text, but strips it in href and id', () => {
expect( render('## Integers $\\mathbb{Z}$') ).toMatchElement(
<h2 id="integers">
<a class="anchor" href="#integers" aria-hidden="true"></a>
Integers <span class="katex"><Anything /></span>
</h2>
)
})
})
describe('when headerIds is false', () => {
beforeEach(() => {
markedOpts = { headerIds: false }
})
it('renders h tag without id and anchor', () => {
expect( render('## Some Title') ).toHtmlEqual(
<h2>Some Title</h2>
)
})
})
describe('when headerIdsStripAccents is true', () => {
beforeEach(() => {
markedOpts = { headerIdsStripAccents: true }
})
it('strips accents in generated id', () => {
expect( render('## Příliš žluťoučký kůň') ).toHtmlEqual(
<h2 id="prilis-zlutoucky-kun">
Příliš žluťoučký kůň
</h2>
)
})
})
})
describe('link', () => {
it('strips math in title', () => {
expect( render('[link](https://example.org "This is $\\TeX$!")') ).toHtmlEqual(
<p><a href="https://example.org" title="This is !">link</a></p>
)
})
it('strips math in href', () => {
expect( render('[link](https://example.org/$\\TeX$)') ).toHtmlEqual(
<p><a href="https://example.org/">link</a></p>
)
})
it('renders math in text', () => {
expect( render('[This is $\\TeX$!](https://example.org/)') ).toMatchElement(
<p><a href="https://example.org/">This is <span class="katex"><Anything /></span>!</a></p>
)
})
})
describe('image', () => {
it('strips math in title', () => {
expect( render('![x](https://example.org/img.png "This is $\\TeX$!")') ).toHtmlEqual(
<p><img src="https://example.org/img.png" alt="x" title="This is !" /></p>
)
})
it('strips math in href', () => {
expect( render('![image](https://example.org/$\\TeX$.png)') ).toHtmlEqual(
<p><img src="https://example.org/.png" alt="image" /></p>
)
})
it('strips math in alt text', () => {
expect( render('![This is $\\TeX$!](https://example.org/img.png)') ).toHtmlEqual(
<p><img src="https://example.org/img.png" alt="This is !" /></p>
)
})
})
describe('code', () => {
it('highlights fenced code block', () => {
// Note: We use `backticks` in this example to avoid problem that JSX
// implicitly decodes HTML entities, but node-html-parser does not.
expect( render('```js\nconsole.log(`Hello, world!`)\n```') ).toHtmlEqual(
<pre>
<code class="language-js">
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">`Hello, world!`</span>)
</code>
</pre>
)
})
})
function render (text: string) {
const html = markdownRenderer(markedOpts, katexOpts)(text).replace(/\n/, '')
return parseHtml(html, { pre: true }).childNodes[0]
}

View file

@ -0,0 +1,135 @@
import { Notebook, CellType, MarkdownCell } from 'ipynb2html-core'
import readNotebookTitle from '@/readNotebookTitle'
const markdownCell = (source: string | string[]): MarkdownCell => ({
cell_type: CellType.Markdown,
metadata: {},
source,
})
const baseNotebook: Notebook = {
nbformat: 4,
nbformat_minor: 3,
metadata: {},
cells: [
markdownCell('# Markdown Title\n\nLorem ipsum dolor'),
],
}
describe('readNotebookTitle', () => {
describe('when metadata contains title', () => {
const notebook = {
...baseNotebook,
metadata: {
title: 'Metadata Title',
},
}
it('returns title from the metadata', () => {
expect( readNotebookTitle(notebook) ).toEqual('Metadata Title')
})
})
describe('when metadata does not contain title', () => {
const notebook = baseNotebook
it('returns level 1 title from markdown cell', () => {
expect( readNotebookTitle(notebook) ).toEqual('Markdown Title')
})
describe('when first cell is a Markdown cell', () => {
describe('with a single level 1 title', () => {
const notebook = {
...baseNotebook,
cells: [
markdownCell([
'# *Title* Level 1\n',
'\n',
'Lorem ipsum\n',
'\n',
'## Title Level 2\n',
'\n',
'dolor sit amet\n',
]),
],
}
it('returns level 1 title with stripped formatting parsed from the first cell', () => {
expect( readNotebookTitle(notebook) ).toEqual('Title Level 1')
})
})
describe('with a single level 1 title not on the first line', () => {
const notebook = {
...baseNotebook,
cells: [
markdownCell('Lorem ipsum\n\n# Title Level 1\n\ndolor sit amet.\n'),
],
}
it('returns level 1 title parsed from the first cell', () => {
expect( readNotebookTitle(notebook) ).toEqual('Title Level 1')
})
})
describe('with multiple first level titles', () => {
const notebook = {
...baseNotebook,
cells: [
markdownCell(['# First Title\n\nLorem ipsum\n\n# Second Title\n\ndolor sit amet\n']),
],
}
it('returns the first level 1 title parsed from the first cell', () => {
expect( readNotebookTitle(notebook) ).toEqual('First Title')
})
})
describe('without any level 1 title', () => {
const notebook = {
...baseNotebook,
cells: [ markdownCell(['Lorem ipsum\n', 'dolor sit amet\n']) ],
}
it('returns an empty string', () => {
expect( readNotebookTitle(notebook) ).toBe('')
})
})
})
describe('when first cell is not type Markdown', () => {
const notebook: Notebook = {
...baseNotebook,
cells: [
{
cell_type: CellType.Raw,
source: 'Lorem ipsum\n',
metadata: {},
},
],
}
it('returns an empty string', () => {
expect( readNotebookTitle(notebook) ).toBe('')
})
})
describe('when there are no cells', () => {
const notebook = {
...baseNotebook,
cells: [],
}
it('returns an empty string', () => {
expect( readNotebookTitle(notebook) ).toBe('')
})
})
})
})

View file

@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.test.json",
"compilerOptions": {
"baseUrl": ".",
},
"references": [
{ "path": "../" },
],
}

View file

@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"tsBuildInfoFile": "./.tsbuildinfo",
"baseUrl": ".",
},
"include": [
"./src",
],
"references": [
{ "path": "../ipynb2html-core" },
],
}

0
patches/.gitkeep Normal file
View file

View file

@ -0,0 +1,15 @@
TODO: Remove after https://github.com/chrisdarroch/yarn-bump/pull/1 is merged and released.
diff --git a/node_modules/yarn-version-bump/src/workspace.js b/node_modules/yarn-version-bump/src/workspace.js
index 28b1dd1..0cf2ecb 100644
--- a/node_modules/yarn-version-bump/src/workspace.js
+++ b/node_modules/yarn-version-bump/src/workspace.js
@@ -9,7 +9,7 @@ class Workspace {
get workspaceSnapshot() {
return runCommand('yarn',
- ['workspaces', 'info', '--silent'],
+ ['--silent', 'workspaces', 'info'],
{ cwd: this.root }
)
.then(data => JSON.parse(data))

4
scripts/adoc2md Executable file
View file

@ -0,0 +1,4 @@
#!/bin/sh -e
( set -o pipefail 2>/dev/null ) && set -o pipefail
asciidoctor -o - -b docbook "$@" | pandoc -f docbook -t markdown_github --base-header-level 2 -o -

42
scripts/assemble-license Executable file
View file

@ -0,0 +1,42 @@
#!/usr/bin/env node
'use strict'
const fs = require('fs')
const path = require('path')
function extractLicenseTable (filename) {
const source = fs.readFileSync(filename, 'utf8')
const start = source.indexOf('/*!\n * Bundled npm packages')
const end = start + source.slice(start).indexOf('*/')
return source
.slice(start, end)
.split('\n')
.filter(s => s.startsWith(' * |'))
.map(s => s.slice(3))
.join('\n')
}
function readLicense () {
return fs.readFileSync(path.join(__dirname, '../LICENSE'), 'utf8')
}
const argv = process.argv.slice(2)
if (argv.length < 1 || ['-h', '--help'].includes(argv[0])) {
console.warn('Usage: assemble-license <bundle-file>')
process.exit(2)
}
const bundlePath = path.resolve(argv[0])
console.log(`\
${readLicense()}
-------------------------
This product bundles source code of the following projects:
${extractLicenseTable(bundlePath)}\
`)

43
scripts/bump-version Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env node
'use strict'
const fs = require('fs-extra')
const path = require('path')
const { bumpVersion } = require('yarn-version-bump/src/bump-version')
const { processJsonFile } = require('yarn-version-bump/src/util/json')
const Workspace = require('yarn-version-bump/src/workspace')
const rootPkg = require('../package.json')
async function workspacePackages () {
return Object.keys((await new Workspace('.').workspaceSnapshot).packages)
}
async function bumpAllPackages (newVersion) {
processJsonFile('package.json', pkg => {
pkg.version = newVersion
return pkg
})
for (const pkgname of await workspacePackages()) {
console.log(`bumping ${pkgname} to ${newVersion}`)
await bumpVersion(pkgname, newVersion, '.')
}
}
async function updateReadme (newVersion) {
return fs.readFile('README.adoc', 'utf8')
.then(str => str.replace(/^:version: \d.*$/m, `:version: ${newVersion}`))
.then(str => fs.writeFile('README.adoc', str))
}
const newVersion = process.argv[2] || rootPkg.version
process.chdir(path.resolve(__dirname, '..'))
Promise.all([
bumpAllPackages(newVersion),
updateReadme(newVersion),
]).catch(err => {
console.error(err)
process.exit(1)
})

79
scripts/create-archives Executable file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env node
'use strict'
const commonPathPrefix = require('common-path-prefix')
const execa = require('execa')
const fs = require('fs-extra')
const path = require('path')
const tar = require('tar')
const { ZipFile } = require('yazl')
async function writeTarGz (basename, fileList, cwd) {
console.log(`Creating ${basename}.tar.gz...`)
return tar.create({
file: `${basename}.tar.gz`,
prefix: path.basename(basename),
gzip: true,
portable: true,
cwd: cwd || '.',
}, fileList)
}
async function writeZip (basename, fileList, cwd) {
const prefix = path.basename(basename)
const zipfile = new ZipFile()
return new Promise((resolve, reject) => {
console.log(`Creating ${basename}.zip...`)
for (let fname of fileList) {
zipfile.addFile(path.join(cwd || '.', fname), path.join(prefix, fname))
}
zipfile.outputStream
.pipe(fs.createWriteStream(`${basename}.zip`))
.on('close', resolve)
.on('error', reject)
zipfile.end()
})
}
async function gitDescribe (prefix) {
const output = await execa.stdout('git', [
'describe',
'--always',
`--match=${prefix}*`
])
return output.startsWith(prefix) ? output : `v0.0.0-${output}`
}
async function createArchives (destName, srcFiles) {
const version = await gitDescribe('v')
destName += `-${version}`
const srcDir = commonPathPrefix(srcFiles)
const fileList = srcFiles.map(p => p.slice(srcDir.length))
fs.mkdirpSync(path.dirname(destName))
return Promise.all([
writeTarGz(destName, fileList, srcDir),
writeZip(destName, fileList, srcDir),
])
}
const argv = process.argv.slice(2)
if (argv.length < 3 || ['-h', '--help'].includes(argv[0])) {
console.warn('Usage: create-archives <dest-basename> <src-file>...')
process.exit(2)
}
const destName = argv[0]
const srcFiles = argv.slice(1)
createArchives(destName, srcFiles).catch(err => {
console.error(err)
process.exit(1)
})

19
scripts/create-src-tarball Executable file
View file

@ -0,0 +1,19 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/.."
case "${1:-}" in
'' | -h | --help) echo "Usage: $0 <out-file>" >&2; exit 2;;
esac
out_file="$1"
base_dir=$(basename "$(pwd)")
base_name=$(basename "${out_file%.tar.gz}")
mkdir -p dist
tar -czf "$out_file" \
-C .. --exclude-vcs \
--exclude="**/$out_file" \
--xform="s|^$base_dir|$base_name|" \
"$base_dir"

7
test/setup.ts Normal file
View file

@ -0,0 +1,7 @@
import 'jest'
import 'jest-chain'
import * as matchers from './support/matchers'
import './support/jsx'
expect.extend(matchers)

21
test/support/helpers.ts Normal file
View file

@ -0,0 +1,21 @@
type Callable = (...args: any[]) => unknown
export type Mock<F extends Callable> = jest.Mock<ReturnType<F>, Parameters<F>>
export function asMock <F extends Callable> (fn: F): Mock<F> {
if (jest.isMockFunction(fn)) {
return fn
}
throw TypeError('not a mocked function')
}
export function mockResults <F extends Callable> (fn: F): Array<ReturnType<F>> {
return asMock(fn).mock.results
.filter(x => x.type === 'return')
.map(x => x.value as ReturnType<F>)
}
export function mockLastResult <F extends Callable> (fn: F): ReturnType<F> | undefined {
return mockResults(fn).pop()
}

75
test/support/jsx.ts Normal file
View file

@ -0,0 +1,75 @@
import { Document as NDocument, HTMLElement as NHTMLElement, Node as NNode } from 'nodom'
type Assign<T, K> = Pick<T, Exclude<keyof T, keyof K>> & K
type ElementProperties<T extends HTMLElement> = Assign<Partial<T>, {
class?: T['className'],
style?: Partial<T['style']>,
children?: any,
}>
type AnyProperties = {[key: string]: any}
type CreateElement = typeof createElement
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace JSX {
type IntrinsicElements = {
[K in keyof HTMLElementTagNameMap]: ElementProperties<HTMLElementTagNameMap[K]>
}
type ElementChildrenAttribute = {
children: any,
}
const createElement: CreateElement
}
}
const document = new NDocument()
// Mimics React.createElement function.
export function createElement <R extends NHTMLElement> (
factory: (props: AnyProperties) => R, props: AnyProperties, ...children: any[]
): R
export function createElement <K extends keyof JSX.IntrinsicElements> (
type: K, props: JSX.IntrinsicElements[K], ...children: any[]
): NHTMLElement
export function createElement (
type: string | ((props: AnyProperties) => NHTMLElement), props: AnyProperties, ...children: any[]
): NHTMLElement {
if (typeof type === 'function') {
return type({ ...props, children })
}
const el = document.createElement(type)
for (const [key, val] of Object.entries(props || {})) {
if (!val) {
continue
} else if (key === 'class' || key === 'className') {
el.className = String(val)
} else {
el.setAttribute(key, String(val))
}
}
for (const child of children) {
if (!child) {
continue
} else if (child instanceof NNode) {
el.appendChild(child)
} else if (typeof child === 'object' && '__html' in child) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
el.innerHTML = child.__html as string
} else {
el.appendChild(document.createTextNode(String(child)))
}
}
return el
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(global as any).JSX = {
createElement,
}

View file

@ -0,0 +1,2 @@
export { toHtmlEqual } from './toHtmlEqual'
export { toMatchElement } from './toMatchElement'

View file

@ -0,0 +1,48 @@
import { matcherHint, printDiffOrStringify } from 'jest-matcher-utils'
import { HTMLElement } from 'nodom'
type MatcherResult = jest.CustomMatcherResult
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R, T> {
toHtmlEqual: (expected: HTMLElement | string | Array<HTMLElement | string>) => R
}
}
}
function formatArray (items: unknown[]): string {
return '[\n' + items.map(x => ` ${x}`).join(',\n') + '\n]'
}
function stringify (obj: HTMLElement | string): string {
return typeof obj === 'string' ? obj : obj.outerHTML
}
export function toHtmlEqual (
received: HTMLElement | HTMLElement[],
expected: HTMLElement | string | Array<HTMLElement | string>,
): MatcherResult {
const receivedStr = Array.isArray(received)
? formatArray(received.map(stringify))
: stringify(received)
const expectedStr = Array.isArray(expected)
? formatArray(expected.map(stringify))
: stringify(expected)
const pass = receivedStr === expectedStr
const hintOpts = {
isNot: pass,
comment: 'equality of .outerHTML',
}
const message = () =>
matcherHint('toHtmlEqual', undefined, undefined, hintOpts)
+ '\n\n'
+ printDiffOrStringify(expectedStr, receivedStr, 'Expected', 'Received', true)
return { pass, message }
}

View file

@ -0,0 +1,82 @@
import { matcherHint, printDiffOrStringify } from 'jest-matcher-utils'
import { HTMLElement, Node } from 'nodom'
type MatcherResult = jest.CustomMatcherResult
type Options = {
ignoreAttrs?: boolean,
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R, T> {
toMatchElement: (expected: HTMLElement, opts?: Options) => R
}
}
}
export const AnythingNode = new class extends Node {
render () { return '<!--Anything-->' }
toString () { return this.render() }
}()
export const Anything = (): Node => AnythingNode
function isWritable (obj: any, prop: string): boolean {
const desc = Object.getOwnPropertyDescriptor(obj, prop)
// eslint-disable-next-line @typescript-eslint/unbound-method
return !!desc?.writable || !!desc?.set
}
function filterWildcardChildren (rec: Node, exp: Node): void {
if (exp.firstChild === AnythingNode
&& exp.childNodes.length === 1
&& (rec as HTMLElement).innerHTML
) {
if (isWritable(rec, 'innerHTML')) {
(rec as HTMLElement).innerHTML = ''
}
rec.childNodes.splice(0, rec.childNodes.length, AnythingNode)
return
}
for (let i = 0; i < exp.childNodes.length && i < rec.childNodes.length; i++) {
if (exp.childNodes[i] === AnythingNode) {
rec.childNodes[i] = AnythingNode
} else {
filterWildcardChildren(rec.childNodes[i], exp.childNodes[i])
}
}
}
function clearAttributes (node: Node): void {
if (node instanceof HTMLElement) {
node.attributes = {}
node.className = ''
}
node.childNodes.forEach(clearAttributes)
}
export function toMatchElement (received: HTMLElement, expected: HTMLElement, opts?: Options): MatcherResult {
if (received.cloneNode) {
received = received.cloneNode(true) as HTMLElement
}
if (opts?.ignoreAttrs) {
clearAttributes(received)
}
filterWildcardChildren(received, expected)
const receivedStr = received.outerHTML
const expectedStr = expected.outerHTML
const expectedLabel = 'expected' + (opts ? ', ' + JSON.stringify(opts) : '')
const pass = receivedStr === expectedStr
const message = () =>
matcherHint('toMatchElement', undefined, expectedLabel, { isNot: pass })
+ '\n\n'
+ printDiffOrStringify(expectedStr, receivedStr, 'Expected', 'Received', true)
return { pass, message }
}

6
test/tsconfig.json Normal file
View file

@ -0,0 +1,6 @@
{
"extends": "../tsconfig.test.json",
"compilerOptions": {
"baseUrl": ".",
},
}

76
tsconfig.base.json Normal file
View file

@ -0,0 +1,76 @@
{
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es2018", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
"jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
"jsxFactory": "JSX.createElement", /* Specify the JSX factory function to use when targeting react JSX emit, e.g. React.createElement or h. */
"declaration": true, /* Generates corresponding '.d.ts' file. */
"declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
"sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
// "outDir": "./lib", /* Redirect output structure to the directory. */
// "rootDir": ".", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
"composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* Enable strict null checks. */
"strictFunctionTypes": true, /* Enable strict checking of function types. */
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
"paths": { /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
"*": ["../../types/*"], /* This path is relative from baseUrl which is defined in tsconfig.json that includes this file inside each project. */
},
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
"typeRoots": [ /* List of folders to include type definitions from. */
"./types",
"./node_modules/@types"
],
// "types": [], /* Type declaration files to be included in compilation. */
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
"resolveJsonModule": true, /* Include modules imported with .json extension. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
// AST transformers used by ttypescript.
"plugins": [
{ "transform": "ts-transformer-export-default-name" },
{ "transform": "ts-transformer-inline-file/transformer" },
],
}
}

8
tsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"files": [],
"references": [
{ "path": "./packages/ipynb2html-core" },
{ "path": "./packages/ipynb2html" },
{ "path": "./packages/ipynb2html-cli" },
]
}

16
tsconfig.test.json Normal file
View file

@ -0,0 +1,16 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"declaration": false,
"declarationMap": false,
"sourceMap": false,
"composite": false,
"noEmit": true,
"paths": {
// These paths are relative from baseUrl which is defined in
// tsconfig.json that includes this file inside each project.
"@/*": ["../src/*"],
"~/*": ["../../../*"],
},
},
}

6550
yarn.lock Normal file

File diff suppressed because it is too large Load diff