├── .yarnrc.yml ├── style ├── index.js ├── index.css ├── tailwind.css ├── jupyterlab-typography.css ├── mystlogo.svg ├── base.css └── preflight.css ├── requirements-test.txt ├── setup.py ├── jupyterlab_myst ├── tests │ └── __init__.py ├── __init__.py └── notary.py ├── babel.config.js ├── .stylelintignore ├── src ├── components │ ├── index.tsx │ ├── listItem.tsx │ └── inlineExpression.tsx ├── transforms │ ├── index.ts │ ├── citations.ts │ ├── images.ts │ └── links.tsx ├── providers │ ├── index.tsx │ ├── sanitizer.tsx │ ├── taskItem.tsx │ └── userExpressions.tsx ├── globals.d.ts ├── icon.ts ├── __tests__ │ └── jupyterlab_myst.spec.ts ├── types.ts ├── MySTContentFactory.ts ├── renderers.tsx ├── mime.tsx ├── userExpressions.ts ├── index.ts ├── actions.ts ├── widget.tsx ├── MySTMarkdownCell.tsx └── myst.ts ├── images ├── cookies.gif ├── stock-price.gif ├── walkthrough.gif └── tasklists-in-jupyterlab.gif ├── docs ├── images │ ├── favicon.ico │ ├── logo.svg │ └── logo-dark.svg ├── thumbnails │ └── figures.png ├── myst.yml ├── figures.md └── index.md ├── .prettierignore ├── tsconfig.test.json ├── jupyter-config ├── nb-config │ └── jupyterlab_myst.json ├── server-config │ └── jupyterlab_myst.json └── jupyter_server_config.d │ └── jupyterlab-myst.json ├── install.json ├── conftest.py ├── ui-tests ├── tests │ ├── files-run.spec.ts-snapshots │ │ ├── File-Run-View-Markdown-file-and-render-result-1-linux.png │ │ ├── File-Run-files-directives-md-View-Markdown-file-and-render-result-1-linux.png │ │ └── File-Run-files-typography-md-View-Markdown-file-and-render-result-1-linux.png │ ├── notebook-run.spec.ts-snapshots │ │ ├── Notebook-Run-Run-Notebook-and-capture-cell-outputs-1-linux.png │ │ ├── Notebook-Run-notebooks-directives-ipynb-Run-Notebook-and-capture-cell-outputs-1-linux.png │ │ └── Notebook-Run-notebooks-typography-ipynb-Run-Notebook-and-capture-cell-outputs-1-linux.png │ ├── jupyterlab_myst.spec.ts │ ├── files │ │ ├── typography.md │ │ └── directives.md │ ├── notebook-run.spec.ts │ ├── files-run.spec.ts │ └── notebooks │ │ ├── directives.ipynb │ │ └── typography.ipynb ├── jupyter_server_test_config.py ├── playwright.config.js ├── package.json └── README.md ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── enhancement.md │ └── bug_report.md └── workflows │ ├── binder-on-pr.yml │ ├── enforce-label.yml │ ├── check-release.yml │ ├── docs.yml │ ├── update-integration-tests.yml │ ├── prep-release.yml │ ├── publish-release.yml │ └── build.yml ├── Makefile ├── .copier-answers.yml ├── binder ├── environment.yml └── postBuild ├── jest.config.js ├── tsconfig.json ├── tests ├── test_trust.py └── notebooks │ ├── simple-markdown.ipynb │ ├── inline-markdown.ipynb │ ├── simple-code.ipynb │ └── rich-code.ipynb ├── LICENSE ├── flake.lock ├── .gitignore ├── flake.nix ├── pyproject.toml ├── examples ├── myst_tests.md ├── display-markdown.ipynb └── myst_tests.ipynb ├── RELEASE.md ├── tailwind.config.js ├── package.jsonnet ├── README.md └── package.json /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /style/index.js: -------------------------------------------------------------------------------- 1 | import './base.css'; 2 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | nbformat 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | __import__("setuptools").setup() 2 | -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | @import url('base.css'); 2 | -------------------------------------------------------------------------------- /jupyterlab_myst/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Python unit tests for jupyterlab_myst.""" 2 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@jupyterlab/testutils/lib/babel.config'); 2 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | style/jupyterlab-typography.css 2 | style/app.css 3 | style/preflight.css 4 | -------------------------------------------------------------------------------- /src/components/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './inlineExpression'; 2 | export * from './listItem'; 3 | -------------------------------------------------------------------------------- /images/cookies.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-book/jupyterlab-myst/HEAD/images/cookies.gif -------------------------------------------------------------------------------- /images/stock-price.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-book/jupyterlab-myst/HEAD/images/stock-price.gif -------------------------------------------------------------------------------- /images/walkthrough.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-book/jupyterlab-myst/HEAD/images/walkthrough.gif -------------------------------------------------------------------------------- /docs/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-book/jupyterlab-myst/HEAD/docs/images/favicon.ico -------------------------------------------------------------------------------- /docs/thumbnails/figures.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-book/jupyterlab-myst/HEAD/docs/thumbnails/figures.png -------------------------------------------------------------------------------- /src/transforms/index.ts: -------------------------------------------------------------------------------- 1 | export * from './citations'; 2 | export * from './images'; 3 | export * from './links'; 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/node_modules 3 | **/lib 4 | **/package.json 5 | !/package.json 6 | jupyterlab_myst 7 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "types": ["jest"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/providers/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './sanitizer'; 2 | export * from './taskItem'; 3 | export * from './userExpressions'; 4 | -------------------------------------------------------------------------------- /images/tasklists-in-jupyterlab.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-book/jupyterlab-myst/HEAD/images/tasklists-in-jupyterlab.gif -------------------------------------------------------------------------------- /jupyter-config/nb-config/jupyterlab_myst.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotebookApp": { 3 | "nbserver_extensions": { 4 | "jupyterlab_myst": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyter-config/server-config/jupyterlab_myst.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerApp": { 3 | "jpserver_extensions": { 4 | "jupyterlab_myst": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyter-config/jupyter_server_config.d/jupyterlab-myst.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerApp": { 3 | "jpserver_extensions": { 4 | "jupyterlab_myst": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'markdown-it-deflist'; 2 | declare module 'markdown-it-footnote'; 3 | declare module 'markdown-it-task-lists'; 4 | declare module '*.svg'; 5 | -------------------------------------------------------------------------------- /install.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "python", 3 | "packageName": "jupyterlab_myst", 4 | "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package jupyterlab_myst" 5 | } 6 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytest_plugins = ("pytest_jupyter.jupyter_server", ) 4 | 5 | 6 | @pytest.fixture 7 | def jp_server_config(jp_server_config): 8 | return {"ServerApp": {"jpserver_extensions": {"jupyterlab_myst": True}}} 9 | -------------------------------------------------------------------------------- /src/icon.ts: -------------------------------------------------------------------------------- 1 | import { LabIcon } from '@jupyterlab/ui-components'; 2 | 3 | import mystIconSvg from '../style/mystlogo.svg'; 4 | 5 | export const mystIcon = new LabIcon({ 6 | name: 'jupyterlab-myst:mystIcon', 7 | svgstr: mystIconSvg 8 | }); 9 | -------------------------------------------------------------------------------- /ui-tests/tests/files-run.spec.ts-snapshots/File-Run-View-Markdown-file-and-render-result-1-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-book/jupyterlab-myst/HEAD/ui-tests/tests/files-run.spec.ts-snapshots/File-Run-View-Markdown-file-and-render-result-1-linux.png -------------------------------------------------------------------------------- /src/__tests__/jupyterlab_myst.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example of [Jest](https://jestjs.io/docs/getting-started) unit tests 3 | */ 4 | 5 | describe('jupyterlab-myst', () => { 6 | it('should be tested', () => { 7 | expect(1 + 1).toEqual(2); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /ui-tests/tests/notebook-run.spec.ts-snapshots/Notebook-Run-Run-Notebook-and-capture-cell-outputs-1-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-book/jupyterlab-myst/HEAD/ui-tests/tests/notebook-run.spec.ts-snapshots/Notebook-Run-Run-Notebook-and-capture-cell-outputs-1-linux.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Questions or general discussion with the community 3 | url: https://github.com/executablebooks/meta/discussions 4 | about: Use our Community Forum for general conversations that aren't meant for actionable issues. 5 | -------------------------------------------------------------------------------- /ui-tests/tests/files-run.spec.ts-snapshots/File-Run-files-directives-md-View-Markdown-file-and-render-result-1-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-book/jupyterlab-myst/HEAD/ui-tests/tests/files-run.spec.ts-snapshots/File-Run-files-directives-md-View-Markdown-file-and-render-result-1-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/files-run.spec.ts-snapshots/File-Run-files-typography-md-View-Markdown-file-and-render-result-1-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-book/jupyterlab-myst/HEAD/ui-tests/tests/files-run.spec.ts-snapshots/File-Run-files-typography-md-View-Markdown-file-and-render-result-1-linux.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Request an enhancement 💡 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | ## Proposal 12 | 13 | ## Additional notes 14 | -------------------------------------------------------------------------------- /ui-tests/tests/notebook-run.spec.ts-snapshots/Notebook-Run-notebooks-directives-ipynb-Run-Notebook-and-capture-cell-outputs-1-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-book/jupyterlab-myst/HEAD/ui-tests/tests/notebook-run.spec.ts-snapshots/Notebook-Run-notebooks-directives-ipynb-Run-Notebook-and-capture-cell-outputs-1-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/notebook-run.spec.ts-snapshots/Notebook-Run-notebooks-typography-ipynb-Run-Notebook-and-capture-cell-outputs-1-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-book/jupyterlab-myst/HEAD/ui-tests/tests/notebook-run.spec.ts-snapshots/Notebook-Run-notebooks-typography-ipynb-Run-Notebook-and-capture-cell-outputs-1-linux.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Report a problem 🐛 3 | about: Something behaves incorrectly, or differently from how you'd expect? Let us know! 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | ## Description 12 | 13 | ## Proposed solution 14 | 15 | ## Additional notes 16 | -------------------------------------------------------------------------------- /.github/workflows/binder-on-pr.yml: -------------------------------------------------------------------------------- 1 | name: Binder Badge 2 | on: 3 | pull_request_target: 4 | types: [opened] 5 | 6 | jobs: 7 | binder: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - uses: jupyterlab/maintainer-tools/.github/actions/binder-link@v1 13 | with: 14 | github_token: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.github/workflows/enforce-label.yml: -------------------------------------------------------------------------------- 1 | name: Enforce PR label 2 | 3 | on: 4 | pull_request: 5 | types: [labeled, unlabeled, opened, edited, synchronize] 6 | jobs: 7 | enforce-label: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - name: enforce-triage-label 13 | uses: jupyterlab/maintainer-tools/.github/actions/enforce-label@v1 14 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownCell } from '@jupyterlab/cells'; 2 | import { IMySTModel } from './widget'; 3 | import { IRenderMime } from '@jupyterlab/rendermime-interfaces'; 4 | 5 | export type IMySTMarkdownCell = MarkdownCell & { 6 | readonly fragmentMDAST: any | undefined; 7 | readonly attachmentsResolver: IRenderMime.IResolver; 8 | mystModel: IMySTModel; 9 | updateFragmentMDAST(): Promise; 10 | }; 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: docs clean 2 | 3 | # docs: 4 | # sphinx-build -M html "docs" "build" 5 | 6 | clean: 7 | rm -rf build 8 | rm -rf dist 9 | yarn clean:all 10 | 11 | build: clean 12 | python -m build 13 | 14 | deploy-check: 15 | python -m twine check dist/* 16 | 17 | deploy-test: 18 | python -m twine upload --repository-url=https://test.pypi.org/legacy/ dist/* 19 | 20 | deploy: deploy-check 21 | python -m twine upload dist/* 22 | -------------------------------------------------------------------------------- /ui-tests/jupyter_server_test_config.py: -------------------------------------------------------------------------------- 1 | """Server configuration for integration tests. 2 | 3 | !! Never use this configuration in production because it 4 | opens the server to the world and provide access to JupyterLab 5 | JavaScript objects through the global window variable. 6 | """ 7 | from jupyterlab.galata import configure_jupyter_server 8 | 9 | configure_jupyter_server(c) 10 | 11 | # Uncomment to set server log level to debug level 12 | # c.ServerApp.log_level = "DEBUG" 13 | 14 | c.ServerApp.port = 9999 15 | -------------------------------------------------------------------------------- /src/MySTContentFactory.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownCell } from '@jupyterlab/cells'; 2 | import { NotebookPanel } from '@jupyterlab/notebook'; 3 | import { MySTMarkdownCell } from './MySTMarkdownCell'; 4 | 5 | export class MySTContentFactory extends NotebookPanel.ContentFactory { 6 | createMarkdownCell(options: MarkdownCell.IOptions): MySTMarkdownCell { 7 | if (!options.contentFactory) { 8 | options.contentFactory = this; 9 | } 10 | return new MySTMarkdownCell(options).initializeState(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 2 | _commit: v4.2.5 3 | _src_path: https://github.com/jupyterlab/extension-template 4 | author_email: executablebooks@gmail.com 5 | author_name: Executable Book Project 6 | has_binder: true 7 | has_settings: false 8 | kind: server 9 | labextension_name: jupyterlab-myst 10 | project_short_description: Use MyST in JupyterLab 11 | python_name: jupyterlab_myst 12 | repository: https://github.com/executablebooks/jupyterlab-myst.git 13 | test: true 14 | 15 | -------------------------------------------------------------------------------- /ui-tests/playwright.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration for Playwright using default from @jupyterlab/galata 3 | */ 4 | const baseConfig = require('@jupyterlab/galata/lib/playwright-config'); 5 | 6 | module.exports = { 7 | ...baseConfig, 8 | use: { 9 | ...baseConfig.use, 10 | baseURL: process.env.TARGET_URL ?? 'http://127.0.0.1:9999' 11 | }, 12 | webServer: { 13 | command: 'npm start', 14 | url: 'http://localhost:9999/lab', 15 | timeout: 120 * 1000, 16 | reuseExistingServer: !process.env.CI 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /ui-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyterlab-myst-ui-tests", 3 | "version": "1.0.0", 4 | "description": "JupyterLab jupyterlab-myst Integration Tests", 5 | "private": true, 6 | "scripts": { 7 | "start": "jupyter lab --config jupyter_server_test_config.py", 8 | "test": "playwright test", 9 | "test:update": "playwright test --update-snapshots" 10 | }, 11 | "devDependencies": { 12 | "@jupyterlab/galata": "^5.0.5", 13 | "@playwright/test": "^1.37.0", 14 | "glob": "^10.3.12" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /binder/environment.yml: -------------------------------------------------------------------------------- 1 | # a mybinder.org-ready environment for demoing jupyterlab_myst 2 | # this environment may also be used locally on Linux/MacOS/Windows, e.g. 3 | # 4 | # conda env update --file binder/environment.yml 5 | # conda activate jupyterlab-myst-demo 6 | # 7 | name: jupyterlab-myst-demo 8 | 9 | channels: 10 | - conda-forge 11 | 12 | dependencies: 13 | # runtime dependencies 14 | - python >=3.10,<3.11.0a0 15 | - jupyterlab >=4.0.0,<5 16 | # labextension build dependencies 17 | - nodejs >=18,<19 18 | - pip 19 | - wheel 20 | # additional packages for demos 21 | # - ipywidgets 22 | -------------------------------------------------------------------------------- /ui-tests/tests/jupyterlab_myst.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jupyterlab/galata'; 2 | 3 | /** 4 | * Don't load JupyterLab webpage before running the tests. 5 | * This is required to ensure we capture all log messages. 6 | */ 7 | test.use({ autoGoto: false }); 8 | 9 | test('should emit an activation console message', async ({ page }) => { 10 | const logs: string[] = []; 11 | 12 | page.on('console', message => { 13 | logs.push(message.text()); 14 | }); 15 | 16 | await page.goto(); 17 | 18 | expect( 19 | logs.filter(s => s === 'JupyterLab extension jupyterlab-myst is activated!') 20 | ).toHaveLength(1); 21 | }); 22 | -------------------------------------------------------------------------------- /style/tailwind.css: -------------------------------------------------------------------------------- 1 | @import url('./preflight.css'); 2 | @import url('./jupyterlab-typography.css'); 3 | @import url('@myst-theme/styles/details.css'); 4 | @import url('@myst-theme/styles/citations.css'); 5 | @import url('@myst-theme/styles/figures.css'); 6 | @import url('@myst-theme/styles/text-spacers.css'); 7 | @import url('@myst-theme/styles/code-highlight.css'); 8 | @import url('@myst-theme/styles/math.css'); 9 | @import url('@myst-theme/styles/cross-references.css'); 10 | @import url('@myst-theme/styles/block-styles.css'); 11 | @import url('@myst-theme/styles/tasklists.css'); 12 | @import url('@myst-theme/styles/hover.css'); 13 | @import url('@myst-theme/styles/proof.css'); 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const jestJupyterLab = require('@jupyterlab/testutils/lib/jest-config'); 2 | 3 | const esModules = [ 4 | '@codemirror', 5 | '@jupyter/ydoc', 6 | '@jupyterlab/', 7 | 'lib0', 8 | 'nanoid', 9 | 'vscode-ws-jsonrpc', 10 | 'y-protocols', 11 | 'y-websocket', 12 | 'yjs' 13 | ].join('|'); 14 | 15 | const baseConfig = jestJupyterLab(__dirname); 16 | 17 | module.exports = { 18 | ...baseConfig, 19 | automock: false, 20 | collectCoverageFrom: [ 21 | 'src/**/*.{ts,tsx}', 22 | '!src/**/*.d.ts', 23 | '!src/**/.ipynb_checkpoints/*' 24 | ], 25 | coverageReporters: ['lcov', 'text'], 26 | testRegex: 'src/.*/.*.spec.ts[x]?$', 27 | transformIgnorePatterns: [`/node_modules/(?!${esModules}).+`] 28 | }; 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "composite": true, 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "incremental": true, 9 | "jsx": "react-jsx", 10 | "lib": ["DOM", "ES2018", "ES2020.Intl"], 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "noEmitOnError": true, 14 | "noImplicitAny": true, 15 | "noUnusedLocals": true, 16 | "preserveWatchOutput": true, 17 | "resolveJsonModule": true, 18 | "outDir": "lib", 19 | "rootDir": "src", 20 | "strict": true, 21 | "strictNullChecks": true, 22 | "target": "ES2018" 23 | }, 24 | "include": ["src/**/*"] 25 | } 26 | -------------------------------------------------------------------------------- /ui-tests/tests/files/typography.md: -------------------------------------------------------------------------------- 1 | # Typography 2 | 3 | ## Subtitle! 4 | 5 | - Bullet 6 | - List 7 | 1. Containing 8 | 2. Some 9 | - Numbers 10 | 11 | A link https://google.com and an autolink and a custom link [to anywhere!](https://wikipedia.org) 12 | 13 | Term 1 14 | : Definition 15 | 16 | Term 2 17 | : Definition 18 | 19 | {kbd}`Ctrl` + {kbd}`Space` 20 | 21 | Fleas \ 22 | Adam \ 23 | Had 'em. 24 | 25 | By Strickland Gillilan 26 | 27 | H{sub}`2`O, and 4{sup}`th` of July 28 | 29 | Well {abbr}`MyST (Markedly Structured Text)` is cool! 30 | 31 | Foo [^a] and Bar [^b] 32 | 33 | [^a]: A footnote 34 | [^b]: Another footnote 35 | 36 | Foo [^c] and Bar [^d] 37 | 38 | [^c]: A footnote 39 | [^d]: Another footnote 40 | -------------------------------------------------------------------------------- /src/transforms/citations.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'unified'; 2 | import type { Root } from 'myst-spec'; 3 | import type { GenericNode } from 'myst-common'; 4 | import { selectAll } from 'unist-util-select'; 5 | 6 | /** 7 | * Add fake children to the citations 8 | */ 9 | export async function addCiteChildrenTransform(tree: Root): Promise { 10 | const links = selectAll('cite', tree) as GenericNode[]; 11 | links.forEach(async cite => { 12 | if (cite.children && cite.children.length > 0) { 13 | return; 14 | } 15 | cite.error = true; 16 | cite.children = [{ type: 'text', value: cite.label }]; 17 | }); 18 | } 19 | 20 | export const addCiteChildrenPlugin: Plugin<[], Root, Root> = () => tree => { 21 | addCiteChildrenTransform(tree); 22 | }; 23 | -------------------------------------------------------------------------------- /src/providers/sanitizer.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react'; 2 | import { ISanitizer } from '@jupyterlab/apputils'; 3 | 4 | type SanitizerState = { 5 | sanitizer?: ISanitizer; 6 | }; 7 | 8 | const SanitizerContext = createContext(undefined); 9 | 10 | // Create a provider for components to consume and subscribe to changes 11 | export function SanitizerProvider({ 12 | sanitizer, 13 | children 14 | }: { 15 | sanitizer?: ISanitizer; 16 | children: React.ReactNode; 17 | }): JSX.Element { 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | } 24 | 25 | export function useSanitizer(): SanitizerState { 26 | return useContext(SanitizerContext) ?? {}; 27 | } 28 | -------------------------------------------------------------------------------- /src/transforms/images.ts: -------------------------------------------------------------------------------- 1 | import type { Image, Root } from 'myst-spec'; 2 | import { selectAll } from 'unist-util-select'; 3 | import { IRenderMime } from '@jupyterlab/rendermime'; 4 | 5 | type Options = { 6 | resolver: IRenderMime.IResolver | undefined; 7 | }; 8 | 9 | export async function imageUrlSourceTransform( 10 | tree: Root, 11 | opts: Options 12 | ): Promise { 13 | const resolver = opts.resolver; 14 | if (!resolver) { 15 | return; 16 | } 17 | const images = selectAll('image', tree) as Image[]; 18 | await Promise.all( 19 | images.map(async image => { 20 | if (!image || !image.url) { 21 | return; 22 | } 23 | const path = await resolver.resolveUrl(image.url); 24 | if (!path) { 25 | return; 26 | } 27 | const url = await resolver.getDownloadUrl(path); 28 | if (!url) { 29 | return; 30 | } 31 | image.url = url; 32 | }) 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /jupyterlab_myst/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import __version__ 2 | from .notary import MySTNotebookNotary 3 | 4 | 5 | def _jupyter_labextension_paths(): 6 | return [{ 7 | "src": "labextension", 8 | "dest": "jupyterlab-myst" 9 | }] 10 | 11 | 12 | def _load_jupyter_server_extension(app): 13 | contents = app.contents_manager 14 | 15 | # Overwrite the default Notary with our own that understands Markdown trustedness 16 | # This notary is less permissive (Markdown cells with untrusted expressions *also* invalidate the notebook trust) 17 | if not isinstance(contents.notary, MySTNotebookNotary): 18 | contents.notary = MySTNotebookNotary(parent=contents) 19 | 20 | 21 | def _jupyter_server_extension_points(): 22 | """ 23 | Returns a list of dictionaries with metadata describing 24 | where to find the `_load_jupyter_server_extension` function. 25 | """ 26 | return [{"module": "jupyterlab_myst"}] 27 | 28 | -------------------------------------------------------------------------------- /.github/workflows/check-release.yml: -------------------------------------------------------------------------------- 1 | name: Check Release 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | branches: ["*"] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | check_release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v5 18 | - name: Base Setup 19 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 20 | - name: Install dependencies 21 | run: python -m pip install -U "jupyterlab>=4.0.0,<5" 22 | - name: Check Release 23 | uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 24 | with: 25 | token: ${{ secrets.GITHUB_TOKEN }} 26 | - name: Upload Distributions 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: jupyterlab_myst-releaser-dist-${{ github.run_number }} 30 | path: .jupyter_releaser_checkout/dist 31 | -------------------------------------------------------------------------------- /src/providers/taskItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react'; 2 | 3 | export interface ITaskItemChange { 4 | line: number; 5 | checked: boolean; 6 | } 7 | 8 | export interface ITaskItemController { 9 | (change: ITaskItemChange): void; 10 | } 11 | 12 | type TaskItemControllerState = { 13 | controller?: ITaskItemController; 14 | }; 15 | 16 | const TaskItemControllerContext = createContext< 17 | TaskItemControllerState | undefined 18 | >(undefined); 19 | 20 | // Create a provider for components to consume and subscribe to changes 21 | export function TaskItemControllerProvider({ 22 | controller, 23 | children 24 | }: { 25 | controller?: ITaskItemController; 26 | children: React.ReactNode; 27 | }): JSX.Element { 28 | return ( 29 | 30 | {children} 31 | 32 | ); 33 | } 34 | 35 | export function useTaskItemController(): TaskItemControllerState { 36 | return useContext(TaskItemControllerContext) ?? {}; 37 | } 38 | -------------------------------------------------------------------------------- /style/jupyterlab-typography.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | .prose table td { 7 | @apply p-1 sm:p-2 align-top; 8 | } 9 | 10 | .prose table p, 11 | .prose table li, 12 | .prose figure table { 13 | @apply mt-0 mb-0; 14 | } 15 | 16 | .prose table ul > li, 17 | .prose table ol > li { 18 | @apply pl-0; 19 | } 20 | 21 | .prose table tr:hover td { 22 | @apply bg-slate-50 dark:bg-stone-800; 23 | } 24 | 25 | .prose dt > strong { 26 | @apply font-bold text-blue-900 dark:text-blue-100; 27 | } 28 | 29 | .prose dd { 30 | @apply ml-8; 31 | } 32 | 33 | .myst { 34 | @apply prose prose-stone dark:prose-invert break-words max-w-none w-full overflow-auto; 35 | 36 | /* Comes from .jp-RenderedHTMLCommon */ 37 | padding-right: 20px; 38 | } 39 | 40 | .myst > *:last-child { 41 | margin-bottom: 0.5em; 42 | } 43 | 44 | .myst hr { 45 | border-top-width: 2px; 46 | } 47 | 48 | .article { 49 | /* This is a required class currently in the hover-card */ 50 | @apply myst; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /ui-tests/tests/files/directives.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Directives 3 | date: 2024-04-26 4 | authors: 5 | - name: Angus Hollands 6 | affiliations: 7 | - 2i2c 8 | --- 9 | 10 | ```{note} 11 | :class: dropdown 12 | 13 | This is MyST in a notebook rendered by `jupyterlab-myst`!! 14 | ``` 15 | 16 | :::{pull-quote} 17 | We know what we are, but know not what we may be. 18 | ::: 19 | 20 | They say the owl was a baker’s daughter. Lord, we know what we are, but know not what we may be. God be at your table. 21 | 22 | :::{prf:proof} 23 | :label: full-proof 24 | Let $z$ be any other point in $S$ and use the fact that $S$ is a linear subspace to deduce 25 | 26 | ```{math} 27 | \| y - z \|^2 28 | = \| (y - \hat y) + (\hat y - z) \|^2 29 | = \| y - \hat y \|^2 + \| \hat y - z \|^2 30 | ``` 31 | 32 | Hence $\| y - z \| \geq \| y - \hat y \|$, which completes the proof. 33 | ::: 34 | 35 | ```{mermaid} 36 | flowchart LR 37 | A[Jupyter Notebook] --> C 38 | B[MyST Markdown] --> C 39 | C(mystmd) --> D{AST} 40 | D <--> E[LaTeX] 41 | E --> F[PDF] 42 | D --> G[Word] 43 | D --> H[React] 44 | D --> I[HTML] 45 | D <--> J[JATS] 46 | ``` 47 | -------------------------------------------------------------------------------- /docs/myst.yml: -------------------------------------------------------------------------------- 1 | # See docs at: https://mystmd.org/docs/mystjs/frontmatter 2 | version: 1 3 | project: 4 | title: jupyterlab-myst 5 | description: Write MyST in JupyterLab Notebooks. This extension replaces the default markdown rendering enabling the rich features of MyST to be used in Markdown cells. 6 | keywords: [] 7 | authors: [] 8 | github: https://github.com/executablebooks/jupyterlab-myst 9 | references: 10 | guide: https://mystmd.org/guide/ 11 | spec: https://mystmd.org/spec/ 12 | abbreviations: 13 | AST: Abstract Syntax Tree 14 | DOI: Digital Object Identifier 15 | MyST: Markedly Structured Text 16 | CLI: Command Line Interface 17 | HTML: Hypertext Markup Language 18 | PDF: Portable Document Format 19 | YAML: Yet Another Markup Language 20 | REES: Reproducible Execution Environment Specification 21 | RST: reStructuredText 22 | site: 23 | template: book-theme 24 | nav: [] 25 | options: 26 | logo: images/logo.svg 27 | logo_dark: images/logo-dark.svg 28 | favicon: images/favicon.ico 29 | actions: 30 | - title: Learn More 31 | url: https://mystmd.org/guide/quickstart-jupyter-lab-myst 32 | -------------------------------------------------------------------------------- /src/renderers.tsx: -------------------------------------------------------------------------------- 1 | import { DEFAULT_RENDERERS } from 'myst-to-react'; 2 | import { MermaidNodeRenderer } from '@myst-theme/diagrams'; 3 | import { NodeRenderer } from '@myst-theme/providers'; 4 | import { InlineExpression, ListItem } from './components'; 5 | import { useSanitizer } from './providers'; 6 | 7 | export const renderers: Record = { 8 | ...DEFAULT_RENDERERS, 9 | mermaid: MermaidNodeRenderer, 10 | inlineExpression: ({ node }) => { 11 | return ; 12 | }, 13 | listItem: ({ node }) => { 14 | return ( 15 | 20 | ); 21 | }, 22 | html: ({ node }, children) => { 23 | const { sanitizer } = useSanitizer(); 24 | if (sanitizer !== undefined) { 25 | return ( 26 | 30 | ); 31 | } else { 32 | return
{node.value}
; 33 | } 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/providers/userExpressions.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react'; 2 | import { IUserExpressionMetadata } from '../userExpressions'; 3 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 4 | 5 | type UserExpressionsState = { 6 | expressions?: IUserExpressionMetadata[]; 7 | rendermime?: IRenderMimeRegistry; 8 | trusted?: boolean; 9 | }; 10 | 11 | const UserExpressionsContext = createContext( 12 | undefined 13 | ); 14 | 15 | // Create a provider for components to consume and subscribe to changes 16 | export function UserExpressionsProvider({ 17 | expressions, 18 | rendermime, 19 | trusted, 20 | children 21 | }: { 22 | expressions?: IUserExpressionMetadata[]; 23 | rendermime?: IRenderMimeRegistry; 24 | trusted?: boolean; 25 | children: React.ReactNode; 26 | }): JSX.Element { 27 | return ( 28 | 31 | {children} 32 | 33 | ); 34 | } 35 | 36 | export function useUserExpressions(): UserExpressionsState { 37 | return useContext(UserExpressionsContext) ?? {}; 38 | } 39 | -------------------------------------------------------------------------------- /tests/test_trust.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import nbformat 3 | import jupyterlab_myst.notary 4 | import pathlib 5 | import tempfile 6 | 7 | 8 | @pytest.fixture 9 | def notary(): 10 | with tempfile.TemporaryDirectory() as d: 11 | yield jupyterlab_myst.notary.MySTNotebookNotary(data_dir=d) 12 | 13 | 14 | @pytest.fixture 15 | def notebooks_path(): 16 | return pathlib.Path(__file__).parent / "notebooks" 17 | 18 | 19 | def test_inline_markdown(notebooks_path, notary): 20 | nb = nbformat.read(notebooks_path / "inline-markdown.ipynb", as_version=nbformat.NO_CONVERT) 21 | assert not notary.check_cells(nb) 22 | 23 | 24 | def test_simple_code(notebooks_path, notary): 25 | nb = nbformat.read(notebooks_path / "simple-code.ipynb", as_version=nbformat.NO_CONVERT) 26 | assert notary.check_cells(nb) 27 | 28 | 29 | def test_simple_markdown(notebooks_path, notary): 30 | nb = nbformat.read(notebooks_path / "simple-markdown.ipynb", as_version=nbformat.NO_CONVERT) 31 | assert notary.check_cells(nb) 32 | 33 | 34 | def test_rich_code(notebooks_path, notary): 35 | nb = nbformat.read(notebooks_path / "rich-code.ipynb", as_version=nbformat.NO_CONVERT) 36 | assert not notary.check_cells(nb) 37 | -------------------------------------------------------------------------------- /ui-tests/tests/notebook-run.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { expect, galata, test } from '@jupyterlab/galata'; 5 | import * as path from 'path'; 6 | import { globSync } from 'glob'; 7 | 8 | // test.use({ tmpPath: 'notebook-run-test' }); 9 | 10 | const files = globSync('notebooks/*.ipynb', { cwd: __dirname }); 11 | 12 | for (const file of files) { 13 | test.describe.serial(`Notebook Run: ${file}`, () => { 14 | const fileName = path.basename(file); 15 | test.beforeEach(async ({ request, page, tmpPath }) => { 16 | const contents = galata.newContentsHelper(request, page); 17 | await contents.uploadFile( 18 | path.resolve(__dirname, file), 19 | `${tmpPath}/${fileName}` 20 | ); 21 | }); 22 | 23 | test('Run Notebook and capture cell outputs', async ({ page, tmpPath }) => { 24 | await page.notebook.openByPath(`${tmpPath}/${fileName}`); 25 | await page.notebook.activate(fileName); 26 | 27 | await page.notebook.run(); 28 | 29 | const nbPanel = await page.notebook.getNotebookInPanel(); 30 | 31 | expect(await nbPanel!.screenshot()).toMatchSnapshot(); 32 | }); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /tests/notebooks/simple-markdown.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "56cb8a6d-7793-4de0-8e65-cbe98cdf193a", 6 | "metadata": { 7 | "tags": [], 8 | "user_expressions": [] 9 | }, 10 | "source": [ 11 | "`x = 1`" 12 | ] 13 | }, 14 | { 15 | "cell_type": "markdown", 16 | "id": "975e82f7-c1b5-4803-a7b5-22c84886c555", 17 | "metadata": { 18 | "tags": [], 19 | "user_expressions": [] 20 | }, 21 | "source": [ 22 | "``print(\"`x` has a value of \", x)``" 23 | ] 24 | } 25 | ], 26 | "metadata": { 27 | "kernelspec": { 28 | "display_name": "Python 3 (ipykernel)", 29 | "language": "python", 30 | "name": "python3" 31 | }, 32 | "language_info": { 33 | "codemirror_mode": { 34 | "name": "ipython", 35 | "version": 3 36 | }, 37 | "file_extension": ".py", 38 | "mimetype": "text/x-python", 39 | "name": "python", 40 | "nbconvert_exporter": "python", 41 | "pygments_lexer": "ipython3", 42 | "version": "3.10.6" 43 | }, 44 | "widgets": { 45 | "application/vnd.jupyter.widget-state+json": { 46 | "state": {}, 47 | "version_major": 2, 48 | "version_minor": 0 49 | } 50 | } 51 | }, 52 | "nbformat": 4, 53 | "nbformat_minor": 5 54 | } 55 | -------------------------------------------------------------------------------- /ui-tests/tests/files-run.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { expect, galata, test } from '@jupyterlab/galata'; 5 | import * as path from 'path'; 6 | import { globSync } from 'glob'; 7 | 8 | const FACTORY = 'Markdown Preview'; 9 | // test.use({ tmpPath: 'notebook-run-test' }); 10 | 11 | const files = globSync('files/*.md', { cwd: __dirname }); 12 | 13 | for (const file of files) { 14 | const fileName = path.basename(file); 15 | test.describe.serial(`File Run: ${file}`, () => { 16 | test.beforeEach(async ({ request, page, tmpPath }) => { 17 | const contents = galata.newContentsHelper(request, page); 18 | await contents.uploadFile( 19 | path.resolve(__dirname, file), 20 | `${tmpPath}/${fileName}` 21 | ); 22 | }); 23 | 24 | test('View Markdown file and render result', async ({ page, tmpPath }) => { 25 | const filePath = `${tmpPath}/${fileName}`; 26 | await page.filebrowser.open(filePath, FACTORY); 27 | 28 | const name = path.basename(filePath); 29 | await page.activity.getTab(name); 30 | 31 | const panel = await page.activity.getPanel(name); 32 | 33 | expect(await panel!.screenshot()).toMatchSnapshot(); 34 | }); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /style/mystlogo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/notebooks/inline-markdown.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 2, 6 | "id": "3fac41c9-450f-49b3-a9f8-e2f09754861a", 7 | "metadata": { 8 | "tags": [] 9 | }, 10 | "outputs": [], 11 | "source": [ 12 | "x = 1" 13 | ] 14 | }, 15 | { 16 | "cell_type": "markdown", 17 | "id": "99906164-db40-49e5-b346-35af477b5559", 18 | "metadata": { 19 | "user_expressions": [ 20 | { 21 | "expression": "x", 22 | "result": { 23 | "data": { 24 | "text/plain": "1" 25 | }, 26 | "metadata": {}, 27 | "status": "ok" 28 | } 29 | } 30 | ] 31 | }, 32 | "source": [ 33 | "`x` has a value of {eval}`x`" 34 | ] 35 | } 36 | ], 37 | "metadata": { 38 | "kernelspec": { 39 | "display_name": "Python 3 (ipykernel)", 40 | "language": "python", 41 | "name": "python3" 42 | }, 43 | "language_info": { 44 | "codemirror_mode": { 45 | "name": "ipython", 46 | "version": 3 47 | }, 48 | "file_extension": ".py", 49 | "mimetype": "text/x-python", 50 | "name": "python", 51 | "nbconvert_exporter": "python", 52 | "pygments_lexer": "ipython3", 53 | "version": "3.10.6" 54 | }, 55 | "widgets": { 56 | "application/vnd.jupyter.widget-state+json": { 57 | "state": {}, 58 | "version_major": 2, 59 | "version_minor": 0 60 | } 61 | } 62 | }, 63 | "nbformat": 4, 64 | "nbformat_minor": 5 65 | } 66 | -------------------------------------------------------------------------------- /tests/notebooks/simple-code.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "3fac41c9-450f-49b3-a9f8-e2f09754861a", 7 | "metadata": { 8 | "tags": [] 9 | }, 10 | "outputs": [], 11 | "source": [ 12 | "x = 1" 13 | ] 14 | }, 15 | { 16 | "cell_type": "code", 17 | "execution_count": 2, 18 | "id": "fd22d37c-7c41-48ff-acaf-e54ee1635479", 19 | "metadata": { 20 | "tags": [], 21 | "user_expressions": [] 22 | }, 23 | "outputs": [ 24 | { 25 | "name": "stdout", 26 | "output_type": "stream", 27 | "text": [ 28 | "`x` has a value of 1\n" 29 | ] 30 | } 31 | ], 32 | "source": [ 33 | "print(\"`x` has a value of \", x)" 34 | ] 35 | } 36 | ], 37 | "metadata": { 38 | "kernelspec": { 39 | "display_name": "Python 3 (ipykernel)", 40 | "language": "python", 41 | "name": "python3" 42 | }, 43 | "language_info": { 44 | "codemirror_mode": { 45 | "name": "ipython", 46 | "version": 3 47 | }, 48 | "file_extension": ".py", 49 | "mimetype": "text/x-python", 50 | "name": "python", 51 | "nbconvert_exporter": "python", 52 | "pygments_lexer": "ipython3", 53 | "version": "3.10.6" 54 | }, 55 | "widgets": { 56 | "application/vnd.jupyter.widget-state+json": { 57 | "state": {}, 58 | "version_major": 2, 59 | "version_minor": 0 60 | } 61 | } 62 | }, 63 | "nbformat": 4, 64 | "nbformat_minor": 5 65 | } 66 | -------------------------------------------------------------------------------- /style/base.css: -------------------------------------------------------------------------------- 1 | /* 2 | See the JupyterLab Developer Guide for useful CSS Patterns: 3 | 4 | https://jupyterlab.readthedocs.io/en/stable/developer/css.html 5 | */ 6 | @import url('app.css'); 7 | @import url('~katex/dist/katex.css'); 8 | 9 | /* Allow overflow on input areas for myst */ 10 | .jp-MySTMarkdownCell .jp-InputArea, 11 | .jp-MySTMarkdownCell .jp-InputArea > .p-Widget { 12 | overflow: visible; 13 | } 14 | 15 | .myst p .lm-Widget img { 16 | height: 1.5em; 17 | transform: translateY(25%); 18 | } 19 | 20 | .myst .lm-Widget { 21 | overflow: inherit; 22 | } 23 | 24 | .myst .jp-RenderedHTMLCommon > *:last-child { 25 | margin-bottom: 0; 26 | } 27 | 28 | .myst .jp-RenderedHTMLCommon { 29 | padding-right: 0.1em; 30 | } 31 | 32 | .myst .jp-RenderedText { 33 | text-align: left; 34 | padding-left: 0; 35 | line-height: inherit; 36 | } 37 | 38 | /* Markdown viewer */ 39 | .jp-Document .jp-MarkdownViewer .jp-RenderedMySTMarkdown { 40 | padding: var(--jp-private-markdownviewer-padding); 41 | overflow: auto; 42 | } 43 | 44 | /* Taken from generated styles, bump precedence */ 45 | .jp-ThemedContainer 46 | .myst 47 | :where(a):not(:where([class~='not-prose'], [class~='not-prose'] *)) { 48 | color: var(--jp-content-link-color, #1976d2); 49 | text-decoration: none; 50 | font-weight: 400; 51 | } 52 | 53 | .jp-ThemedContainer 54 | .myst 55 | :where(a):not(:where([class~='not-prose'], [class~='not-prose'] *)):hover { 56 | text-decoration: underline; 57 | } 58 | -------------------------------------------------------------------------------- /style/preflight.css: -------------------------------------------------------------------------------- 1 | /* 2 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 3 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 4 | */ 5 | 6 | /* This is a highly simplified version of https://tailwindcss.com/docs/preflight to work with JupyterLab */ 7 | 8 | .myst 9 | :where(*):not( 10 | :where([class~='jp-RenderedHTMLCommon'], [class~='jp-RenderedHTMLCommon'] *) 11 | ) { 12 | box-sizing: border-box; /* 1 */ 13 | border-width: 0; /* 2 */ 14 | border-style: solid; /* 2 */ 15 | border-color: theme('borderColor.DEFAULT', currentcolor); /* 2 */ 16 | } 17 | 18 | .myst pre { 19 | margin: 0; 20 | } 21 | 22 | /* 23 | 1. Correct the inability to style clickable types in iOS and Safari. 24 | 2. Remove default button styles. 25 | */ 26 | 27 | .myst 28 | :where(button):not( 29 | :where([class~='jp-RenderedHTMLCommon'], [class~='jp-RenderedHTMLCommon'] *) 30 | ) { 31 | -webkit-appearance: button; /* 1 */ 32 | background-color: transparent; /* 2 */ 33 | background-image: none; /* 2 */ 34 | } 35 | 36 | .myst 37 | :where(img):not( 38 | :where([class~='jp-RenderedHTMLCommon'], [class~='jp-RenderedHTMLCommon'] *) 39 | ) { 40 | max-width: 100%; 41 | } 42 | 43 | .myst 44 | :where(figure img):not( 45 | :where([class~='jp-RenderedHTMLCommon'], [class~='jp-RenderedHTMLCommon'] *) 46 | ) { 47 | display: block; 48 | max-width: 100%; 49 | } 50 | 51 | .myst :where(button, input, optgroup, select, textarea) { 52 | color: inherit; /* 1 */ 53 | } 54 | -------------------------------------------------------------------------------- /src/components/listItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTaskItemController } from '../providers'; 3 | import { MyST } from 'myst-to-react'; 4 | 5 | function TaskItem({ 6 | checked, 7 | line, 8 | children 9 | }: { 10 | checked?: boolean; 11 | children?: React.ReactNode; 12 | line?: number; 13 | }) { 14 | // The rendering waiting on promises from Jupyter is slow 15 | // By keeping state here we can render fast & optimistically 16 | const [local, setLocal] = React.useState(checked ?? false); 17 | const { controller } = useTaskItemController(); 18 | return ( 19 |
  • 20 | { 26 | // Bail if no line number was found 27 | if (!controller || line === undefined) { 28 | return; 29 | } 30 | controller({ line: line - 1, checked: !local }); 31 | setLocal(!local); 32 | }} 33 | /> 34 | {children} 35 |
  • 36 | ); 37 | } 38 | 39 | export function ListItem({ 40 | checked, 41 | line, 42 | children 43 | }: { 44 | checked?: boolean; 45 | line?: number; 46 | children?: any[]; 47 | }): JSX.Element { 48 | if (typeof checked === 'boolean') { 49 | return ( 50 | 51 | 52 | 53 | ); 54 | } 55 | return ( 56 |
  • 57 | 58 |
  • 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | # This file was created automatically with `myst init --gh-pages` 🪄 💚 2 | 3 | name: MyST GitHub Pages Deploy 4 | on: 5 | push: 6 | # Runs on pushes targeting the default branch 7 | branches: [main] 8 | env: 9 | # `BASE_URL` determines the website is served from, including CSS & JS assets 10 | # You may need to change this to `BASE_URL: ''` 11 | BASE_URL: /${{ github.event.repository.name }} 12 | 13 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 14 | permissions: 15 | contents: read 16 | pages: write 17 | id-token: write 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: 'pages' 22 | cancel-in-progress: false 23 | jobs: 24 | deploy: 25 | environment: 26 | name: github-pages 27 | url: ${{ steps.deployment.outputs.page_url }} 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v5 31 | - uses: actions/setup-node@v5 32 | with: 33 | node-version: 22.x 34 | - name: Install MyST Markdown 35 | run: npm install -g mystmd 36 | - name: Build HTML Assets 37 | run: myst build --html 38 | working-directory: ./docs 39 | - name: Upload artifact 40 | uses: actions/upload-pages-artifact@v4 41 | with: 42 | path: './docs/_build/html' 43 | - name: Deploy to GitHub Pages 44 | id: deployment 45 | uses: actions/deploy-pages@v4 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Executable Book Project 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ perform a development install of jupyterlab_myst 3 | 4 | On Binder, this will run _after_ the environment has been fully created from 5 | the environment.yml in this directory. 6 | 7 | This script should also run locally on Linux/MacOS/Windows: 8 | 9 | python3 binder/postBuild 10 | """ 11 | import subprocess 12 | import sys 13 | from pathlib import Path 14 | 15 | 16 | ROOT = Path.cwd() 17 | 18 | def _(*args, **kwargs): 19 | """ Run a command, echoing the args 20 | 21 | fails hard if something goes wrong 22 | """ 23 | print("\n\t", " ".join(args), "\n") 24 | return_code = subprocess.call(args, **kwargs) 25 | if return_code != 0: 26 | print("\nERROR", return_code, " ".join(args)) 27 | sys.exit(return_code) 28 | 29 | # verify the environment is self-consistent before even starting 30 | _(sys.executable, "-m", "pip", "check") 31 | 32 | # install the labextension 33 | _(sys.executable, "-m", "pip", "install", "-e", ".") 34 | _(sys.executable, "-m", "jupyter", "labextension", "develop", "--overwrite", ".") 35 | _( 36 | sys.executable, 37 | "-m", 38 | "jupyter", 39 | "server", 40 | "extension", 41 | "enable", 42 | "jupyterlab_myst", 43 | ) 44 | 45 | # verify the environment the extension didn't break anything 46 | _(sys.executable, "-m", "pip", "check") 47 | 48 | # list the extensions 49 | _("jupyter", "server", "extension", "list") 50 | 51 | # initially list installed extensions to determine if there are any surprises 52 | _("jupyter", "labextension", "list") 53 | 54 | 55 | print("JupyterLab with jupyterlab_myst is ready to run with:\n") 56 | print("\tjupyter lab\n") 57 | -------------------------------------------------------------------------------- /tests/notebooks/rich-code.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 4, 6 | "id": "22dbb7f0-8374-49c2-b4d1-197d17180134", 7 | "metadata": { 8 | "tags": [] 9 | }, 10 | "outputs": [], 11 | "source": [ 12 | "from IPython.display import HTML" 13 | ] 14 | }, 15 | { 16 | "cell_type": "code", 17 | "execution_count": 5, 18 | "id": "fd22d37c-7c41-48ff-acaf-e54ee1635479", 19 | "metadata": { 20 | "tags": [], 21 | "user_expressions": [] 22 | }, 23 | "outputs": [ 24 | { 25 | "data": { 26 | "text/html": [ 27 | "\n", 28 | "Elgoog\n" 29 | ], 30 | "text/plain": [ 31 | "" 32 | ] 33 | }, 34 | "execution_count": 5, 35 | "metadata": {}, 36 | "output_type": "execute_result" 37 | } 38 | ], 39 | "source": [ 40 | "HTML(\"\"\"\n", 41 | "Elgoog\n", 42 | "\"\"\")" 43 | ] 44 | } 45 | ], 46 | "metadata": { 47 | "kernelspec": { 48 | "display_name": "Python 3 (ipykernel)", 49 | "language": "python", 50 | "name": "python3" 51 | }, 52 | "language_info": { 53 | "codemirror_mode": { 54 | "name": "ipython", 55 | "version": 3 56 | }, 57 | "file_extension": ".py", 58 | "mimetype": "text/x-python", 59 | "name": "python", 60 | "nbconvert_exporter": "python", 61 | "pygments_lexer": "ipython3", 62 | "version": "3.10.6" 63 | }, 64 | "widgets": { 65 | "application/vnd.jupyter.widget-state+json": { 66 | "state": {}, 67 | "version_major": 2, 68 | "version_minor": 0 69 | } 70 | } 71 | }, 72 | "nbformat": 4, 73 | "nbformat_minor": 5 74 | } 75 | -------------------------------------------------------------------------------- /.github/workflows/update-integration-tests.yml: -------------------------------------------------------------------------------- 1 | name: Update Playwright Snapshots 2 | 3 | on: 4 | issue_comment: 5 | types: [created, edited] 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | update-snapshots: 13 | if: ${{ github.event.issue.pull_request && contains(github.event.comment.body, 'please update snapshots') }} 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: React to the triggering comment 18 | run: | 19 | gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions --raw-field 'content=+1' 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | 23 | - name: Checkout 24 | uses: actions/checkout@v5 25 | with: 26 | token: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Checkout the branch from the PR that triggered the job 29 | run: gh pr checkout ${{ github.event.issue.number }} 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Base Setup 34 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 35 | 36 | - name: Install dependencies 37 | run: python -m pip install -U "jupyterlab>=4.0.0,<5" 38 | 39 | - name: Install extension 40 | run: | 41 | set -eux 42 | npm install 43 | python -m pip install . 44 | 45 | - uses: jupyterlab/maintainer-tools/.github/actions/update-snapshots@v1 46 | with: 47 | github_token: ${{ secrets.GITHUB_TOKEN }} 48 | # Playwright knows how to start JupyterLab server 49 | start_server_script: 'null' 50 | test_folder: ui-tests 51 | npm_client: npm 52 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1759733170, 24 | "narHash": "sha256-TXnlsVb5Z8HXZ6mZoeOAIwxmvGHp1g4Dw89eLvIwKVI=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "8913c168d1c56dc49a7718685968f38752171c3b", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /.github/workflows/prep-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 1: Prep Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version_spec: 6 | description: "New Version Specifier" 7 | default: "next" 8 | required: false 9 | branch: 10 | description: "The branch to target" 11 | required: false 12 | post_version_spec: 13 | description: "Post Version Specifier" 14 | required: false 15 | # silent: 16 | # description: "Set a placeholder in the changelog and don't publish the release." 17 | # required: false 18 | # type: boolean 19 | since: 20 | description: "Use PRs with activity since this date or git reference" 21 | required: false 22 | since_last_stable: 23 | description: "Use PRs with activity since the last stable git tag" 24 | required: false 25 | type: boolean 26 | jobs: 27 | prep_release: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 31 | 32 | - name: Prep Release 33 | id: prep-release 34 | uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 35 | with: 36 | token: ${{ secrets.ADMIN_GITHUB_TOKEN }} 37 | version_spec: ${{ github.event.inputs.version_spec }} 38 | post_version_spec: ${{ github.event.inputs.post_version_spec }} 39 | branch: ${{ github.event.inputs.branch }} 40 | # silent: ${{ github.event.inputs.silent }} 41 | since: ${{ github.event.inputs.since }} 42 | since_last_stable: ${{ github.event.inputs.since_last_stable }} 43 | 44 | - name: "** Next Step **" 45 | run: | 46 | echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" 47 | -------------------------------------------------------------------------------- /src/mime.tsx: -------------------------------------------------------------------------------- 1 | import { IRenderMime } from '@jupyterlab/rendermime-interfaces'; 2 | import { markdownParse, processArticleMDAST } from './myst'; 3 | import { MySTModel, MySTWidget } from './widget'; 4 | 5 | /** 6 | * The MIME type for Markdown. 7 | */ 8 | export const MIME_TYPE = 'text/markdown'; 9 | 10 | export class RenderedMySTMarkdown 11 | extends MySTWidget 12 | implements IRenderMime.IRenderer 13 | { 14 | readonly resolver?: IRenderMime.IResolver; 15 | readonly linkHandler?: IRenderMime.ILinkHandler; 16 | 17 | constructor(options: IRenderMime.IRendererOptions) { 18 | super({ 19 | model: undefined, 20 | resolver: options.resolver ?? undefined, 21 | linkHandler: options.linkHandler ?? undefined, 22 | sanitizer: options.sanitizer ?? undefined 23 | }); 24 | this.resolver = options.resolver ?? undefined; 25 | this.node.dataset['mimeType'] = MIME_TYPE; 26 | this.addClass('jp-RenderedMySTMarkdown'); 27 | } 28 | 29 | async renderModel(model: IRenderMime.IMimeModel): Promise { 30 | if ((window as any).trigger) { 31 | throw Error('triggered!'); 32 | } 33 | const mdast = markdownParse(model.data[MIME_TYPE] as string); 34 | const { 35 | references, 36 | mdast: mdastNext, 37 | frontmatter 38 | } = await processArticleMDAST(mdast, this.resolver); 39 | const mystModel = new MySTModel(); 40 | mystModel.references = references; 41 | mystModel.mdast = mdastNext; 42 | mystModel.frontmatter = frontmatter as MySTModel['frontmatter']; 43 | if (this.model) { 44 | // Re-use expressions even if AST changes 45 | mystModel.expressions = this.model.expressions; 46 | } 47 | this.model = mystModel; 48 | 49 | console.debug('State changed', this); 50 | return this.renderPromise || Promise.resolve(); 51 | } 52 | } 53 | 54 | /** 55 | * A mime renderer factory for Markdown. 56 | */ 57 | export const mystMarkdownRendererFactory: IRenderMime.IRendererFactory = { 58 | safe: true, 59 | mimeTypes: ['text/markdown'], 60 | defaultRank: 50, 61 | createRenderer: options => new RenderedMySTMarkdown(options) 62 | }; 63 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 2: Publish Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | branch: 6 | description: "The target branch" 7 | required: false 8 | release_url: 9 | description: "The URL of the draft GitHub release" 10 | required: false 11 | steps_to_skip: 12 | description: "Comma separated list of steps to skip" 13 | required: false 14 | 15 | jobs: 16 | publish_release: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | # This is useful if you want to use PyPI trusted publisher 20 | # and NPM provenance 21 | id-token: write 22 | steps: 23 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 24 | 25 | - name: Populate Release 26 | id: populate-release 27 | uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 28 | with: 29 | token: ${{ secrets.ADMIN_GITHUB_TOKEN }} 30 | branch: ${{ github.event.inputs.branch }} 31 | release_url: ${{ github.event.inputs.release_url }} 32 | steps_to_skip: ${{ github.event.inputs.steps_to_skip }} 33 | 34 | - name: Finalize Release 35 | id: finalize-release 36 | # env: 37 | # The following are needed if you use legacy PyPI set up 38 | # PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 39 | # PYPI_TOKEN_MAP: ${{ secrets.PYPI_TOKEN_MAP }} 40 | # TWINE_USERNAME: __token__ 41 | # NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 43 | with: 44 | token: ${{ secrets.ADMIN_GITHUB_TOKEN }} 45 | release_url: ${{ steps.populate-release.outputs.release_url }} 46 | 47 | - name: "** Next Step **" 48 | if: ${{ success() }} 49 | run: | 50 | echo "Verify the final release" 51 | echo ${{ steps.finalize-release.outputs.release_url }} 52 | 53 | - name: "** Failure Message **" 54 | if: ${{ failure() }} 55 | run: | 56 | echo "Failed to Publish the Draft Release Url:" 57 | echo ${{ steps.populate-release.outputs.release_url }} 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.log 5 | .eslintcache 6 | .stylelintcache 7 | *.egg-info/ 8 | .ipynb_checkpoints 9 | *.tsbuildinfo 10 | jupyterlab_myst/labextension 11 | # Version file is handled by hatchling 12 | jupyterlab_myst/_version.py 13 | 14 | # Integration tests 15 | ui-tests/test-results/ 16 | ui-tests/playwright-report/ 17 | 18 | # Created by https://www.gitignore.io/api/python 19 | # Edit at https://www.gitignore.io/?templates=python 20 | 21 | ### Python ### 22 | # Byte-compiled / optimized / DLL files 23 | __pycache__/ 24 | *.py[cod] 25 | *$py.class 26 | 27 | # C extensions 28 | *.so 29 | 30 | # Distribution / packaging 31 | .Python 32 | build/ 33 | develop-eggs/ 34 | dist/ 35 | downloads/ 36 | eggs/ 37 | .eggs/ 38 | lib/ 39 | lib64/ 40 | parts/ 41 | sdist/ 42 | var/ 43 | wheels/ 44 | pip-wheel-metadata/ 45 | share/python-wheels/ 46 | .installed.cfg 47 | *.egg 48 | MANIFEST 49 | 50 | # PyInstaller 51 | # Usually these files are written by a python script from a template 52 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 53 | *.manifest 54 | *.spec 55 | 56 | # Installer logs 57 | pip-log.txt 58 | pip-delete-this-directory.txt 59 | 60 | # Unit test / coverage reports 61 | htmlcov/ 62 | .tox/ 63 | .nox/ 64 | .coverage 65 | .coverage.* 66 | .cache 67 | nosetests.xml 68 | coverage/ 69 | coverage.xml 70 | *.cover 71 | .hypothesis/ 72 | .pytest_cache/ 73 | 74 | # Translations 75 | *.mo 76 | *.pot 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | target/ 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # celery beat schedule file 91 | celerybeat-schedule 92 | 93 | # SageMath parsed files 94 | *.sage.py 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # Mr Developer 104 | .mr.developer.cfg 105 | .project 106 | .pydevproject 107 | 108 | # mkdocs documentation 109 | /site 110 | 111 | # mypy 112 | .mypy_cache/ 113 | .dmypy.json 114 | dmypy.json 115 | 116 | # Pyre type checker 117 | .pyre/ 118 | 119 | # End of https://www.gitignore.io/api/python 120 | 121 | # OSX files 122 | .DS_Store 123 | 124 | # Yarn cache 125 | .yarn/ 126 | 127 | # Generated css files by tailwind 128 | style/app.css 129 | -------------------------------------------------------------------------------- /docs/figures.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Images and figures 3 | description: MyST Markdown allows you to create images and figures in your documents, including cross-referencing content throughout your pages. 4 | thumbnail: ./thumbnails/todo.png 5 | --- 6 | 7 | MyST Markdown can be used to include images and figures in your notebooks as well as referencing those images easily in other cells of the notebook. 8 | 9 | ```{tip} 10 | For a general description of the MyST figures and image directives see the [main MyST Markdown docs](https://mystmd.org/guide/figures) at , read on for notebook specific considerations. 11 | ``` 12 | 13 | ## Simple Images 14 | 15 | Adding an image to your notebook using the standard Markdown syntax will work as usual with the standard markdown syntax: 16 | 17 | ```markdown 18 | ![alt](link 'title') 19 | ``` 20 | 21 | ## Figure Directives 22 | 23 | By switching to MyST Directives or figures (and images) can be inserted with some additional control. `figure`s can also be cross-referenced in other places in the notebook. For example, the following `figure`: 24 | 25 | ```{myst} 26 | :::{figure} https://picsum.photos/seed/myst-101/400/200 27 | :name: myFigure 28 | :alt: Image of a peer by the ocean 29 | :align: center 30 | 31 | Ocean image from Lorem Picsum 🏖 32 | ::: 33 | 34 | Check out [](#myFigure)!! 35 | ``` 36 | 37 | Has a `name` attribute allows you to cross-reference the figure from any other markdown cell in the notebook using the familiar markdown link syntax (see the documentation on [Cross References](https://mystmd.org/guide/cross-references)). 38 | The `figure` directive is similar to `image` but allows for a caption and sequential figure numbering. 39 | 40 | ## Cell Attachments 41 | 42 | Notebooks allow images to be added as cell attachments. This is typically achieved via drag and drop in the notebook interface and results in the image being added to the notebook itself as a base64 encoded string. 43 | 44 | Cell attachments are inserted into the notebook using standard markdown syntax such as: 45 | 46 | ```markdown 47 | ![image.png](attachment:7c0e625d-6238-464f-8100-8d008f30848b.png) 48 | ``` 49 | 50 | These links are inserted automatically by jupyter when an attachment is added. Once the link syntax is known these can be changed to `image` or `figure` directives where captions can be added attributes can be used to, for example control the image size. Attachments are cell specific, so this will only work in the same cell that the attachment was added. 51 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | # code-owner: @agoose77 2 | # This flake sets up an dev-shell that installs all the required 3 | # packages for running deployer, and then installs the tool in the virtual environment 4 | # It is not best-practice for the nix-way of distributing this code, 5 | # but its purpose is to get an environment up and running. 6 | { 7 | inputs = { 8 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 9 | flake-utils.url = "github:numtide/flake-utils"; 10 | }; 11 | outputs = { 12 | self, 13 | nixpkgs, 14 | flake-utils, 15 | }: 16 | flake-utils.lib.eachDefaultSystem (system: let 17 | pkgs = import nixpkgs { 18 | inherit system; 19 | config.allowUnfree = true; 20 | }; 21 | inherit (pkgs) lib; 22 | 23 | python = pkgs.python313; 24 | packages = 25 | [ 26 | python 27 | ] 28 | ++ (with pkgs; [ 29 | cmake 30 | ninja 31 | gcc 32 | pre-commit 33 | # Infra packages 34 | nodejs_22 35 | playwright-driver.browsers 36 | go-jsonnet 37 | ]); 38 | shellHook = '' 39 | # Unset leaky PYTHONPATH 40 | unset PYTHONPATH 41 | 42 | __hash=$(echo ${python.interpreter} | sha256sum) 43 | 44 | # Setup if not defined #### 45 | if [[ ! -f ".venv/$__hash" ]]; then 46 | __setup_env() { 47 | # Remove existing venv 48 | if [[ -d .venv ]]; then 49 | rm -r .venv 50 | fi 51 | 52 | # Stand up new venv 53 | ${python.interpreter} -m venv .venv 54 | 55 | ".venv/bin/python" -m pip install -e ".[dev]" 56 | 57 | # Add a marker that marks this venv as "ready" 58 | touch ".venv/$__hash" 59 | } 60 | 61 | __setup_env 62 | fi 63 | ########################### 64 | # Activate venv 65 | source .venv/bin/activate 66 | 67 | export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} 68 | export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true 69 | ''; 70 | env = lib.optionalAttrs pkgs.stdenv.isLinux { 71 | # Python uses dynamic loading for certain libraries. 72 | # We'll set the linker path instead of patching RPATH 73 | LD_LIBRARY_PATH = lib.makeLibraryPath pkgs.pythonManylinuxPackages.manylinux2014; 74 | }; 75 | in { 76 | devShell = pkgs.mkShell { 77 | inherit env packages shellHook; 78 | }; 79 | }); 80 | } 81 | -------------------------------------------------------------------------------- /src/userExpressions.ts: -------------------------------------------------------------------------------- 1 | import { PartialJSONObject } from '@lumino/coreutils'; 2 | import { Cell } from '@jupyterlab/cells'; 3 | import type { IMySTMarkdownCell } from './types'; 4 | 5 | export const metadataSection = 'user_expressions'; 6 | 7 | /** 8 | * Interfaces for `IExecuteReplyMsg.user_expressisons` 9 | */ 10 | 11 | export interface IBaseExpressionResult extends PartialJSONObject { 12 | status: string; 13 | } 14 | 15 | export interface IExpressionOutput extends IBaseExpressionResult { 16 | status: 'ok'; 17 | data: PartialJSONObject; 18 | metadata: PartialJSONObject; 19 | } 20 | 21 | export interface IExpressionError extends IBaseExpressionResult { 22 | status: 'error'; 23 | traceback: string[]; 24 | ename: string; 25 | evalue: string; 26 | } 27 | 28 | export type IExpressionResult = IExpressionError | IExpressionOutput; 29 | 30 | export function isOutput( 31 | output: IExpressionResult 32 | ): output is IExpressionOutput { 33 | return output.status === 'ok'; 34 | } 35 | 36 | export function isError(output: IExpressionResult): output is IExpressionError { 37 | return output.status === 'error'; 38 | } 39 | 40 | export interface IUserExpressionMetadata extends PartialJSONObject { 41 | expression: string; 42 | result: IExpressionResult; 43 | } 44 | 45 | export interface IUserExpressionsMetadata extends PartialJSONObject { 46 | [metadataSection]: IUserExpressionMetadata[]; 47 | } 48 | 49 | export function getUserExpressions( 50 | cell: IMySTMarkdownCell | Cell 51 | ): IUserExpressionMetadata[] | undefined { 52 | if (!cell.model.getMetadata) { 53 | // this is JupyterLab 3.6 54 | return (cell.model.metadata as any)?.get(metadataSection) as 55 | | IUserExpressionMetadata[] 56 | | undefined; 57 | } 58 | return cell.model.getMetadata(metadataSection) as 59 | | IUserExpressionMetadata[] 60 | | undefined; 61 | } 62 | 63 | export function setUserExpressions( 64 | cell: IMySTMarkdownCell | Cell, 65 | expressions: IUserExpressionMetadata[] 66 | ) { 67 | if (!cell) { 68 | return; 69 | } 70 | if (!cell.model.setMetadata) { 71 | // this is JupyterLab 3.6 72 | (cell.model.metadata as any).set(metadataSection, expressions); 73 | } else { 74 | cell.model.setMetadata(metadataSection, expressions); 75 | } 76 | } 77 | 78 | export function deleteUserExpressions(cell: IMySTMarkdownCell | Cell) { 79 | if (!cell) { 80 | return; 81 | } 82 | if (!cell.model.setMetadata) { 83 | // this is JupyterLab 3.6 84 | (cell.model.metadata as any).delete(metadataSection); 85 | } else { 86 | cell.model.deleteMetadata(metadataSection); 87 | } 88 | } 89 | 90 | export interface IUserExpressionMetadata extends PartialJSONObject { 91 | expression: string; 92 | result: IExpressionResult; 93 | } 94 | 95 | export interface IUserExpressionsMetadata extends PartialJSONObject { 96 | [metadataSection]: IUserExpressionMetadata[]; 97 | } 98 | -------------------------------------------------------------------------------- /ui-tests/tests/notebooks/directives.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "ca08bb8d-dc60-494e-a63e-5a9a7d4be7bd", 6 | "metadata": {}, 7 | "source": [ 8 | "---\n", 9 | "title: Directives\n", 10 | "date: 2024-04-26\n", 11 | "authors:\n", 12 | " - name: Angus Hollands\n", 13 | " affiliations:\n", 14 | " - 2i2c\n", 15 | "---" 16 | ] 17 | }, 18 | { 19 | "cell_type": "markdown", 20 | "id": "d79b49b1-d263-4759-bfce-699f88f8c778", 21 | "metadata": { 22 | "tags": [] 23 | }, 24 | "source": [ 25 | "```{note}\n", 26 | ":class: dropdown\n", 27 | "\n", 28 | "This is MyST in a notebook rendered by `jupyterlab-myst`!!\n", 29 | "```" 30 | ] 31 | }, 32 | { 33 | "cell_type": "markdown", 34 | "id": "aaaab279-c281-4f20-8852-dcaa08c0d07c", 35 | "metadata": {}, 36 | "source": [ 37 | ":::{pull-quote}\n", 38 | "We know what we are, but know not what we may be.\n", 39 | ":::\n", 40 | "\n", 41 | "They say the owl was a baker’s daughter. Lord, we know what we are, but know not what we may be. God be at your table." 42 | ] 43 | }, 44 | { 45 | "cell_type": "markdown", 46 | "id": "c2113213-77a9-4ffc-aec8-fe686f85c4a8", 47 | "metadata": {}, 48 | "source": [ 49 | ":::{prf:proof}\n", 50 | ":label: full-proof\n", 51 | "Let $z$ be any other point in $S$ and use the fact that $S$ is a linear subspace to deduce\n", 52 | "\n", 53 | "```{math}\n", 54 | "\\| y - z \\|^2\n", 55 | "= \\| (y - \\hat y) + (\\hat y - z) \\|^2\n", 56 | "= \\| y - \\hat y \\|^2 + \\| \\hat y - z \\|^2\n", 57 | "```\n", 58 | "\n", 59 | "Hence $\\| y - z \\| \\geq \\| y - \\hat y \\|$, which completes the proof.\n", 60 | ":::" 61 | ] 62 | }, 63 | { 64 | "cell_type": "markdown", 65 | "id": "950ad6fc-7ae3-4eaf-a497-03aa5817cf90", 66 | "metadata": {}, 67 | "source": [ 68 | "```{mermaid} \n", 69 | "flowchart LR\n", 70 | " A[Jupyter Notebook] --> C\n", 71 | " B[MyST Markdown] --> C\n", 72 | " C(mystmd) --> D{AST}\n", 73 | " D <--> E[LaTeX]\n", 74 | " E --> F[PDF]\n", 75 | " D --> G[Word]\n", 76 | " D --> H[React]\n", 77 | " D --> I[HTML]\n", 78 | " D <--> J[JATS]\n", 79 | "```" 80 | ] 81 | } 82 | ], 83 | "metadata": { 84 | "kernelspec": { 85 | "display_name": "Python 3 (ipykernel)", 86 | "language": "python", 87 | "name": "python3" 88 | }, 89 | "language_info": { 90 | "codemirror_mode": { 91 | "name": "ipython", 92 | "version": 3 93 | }, 94 | "file_extension": ".py", 95 | "mimetype": "text/x-python", 96 | "name": "python", 97 | "nbconvert_exporter": "python", 98 | "pygments_lexer": "ipython3", 99 | "version": "3.10.13" 100 | } 101 | }, 102 | "nbformat": 4, 103 | "nbformat_minor": 5 104 | } 105 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.5.0", "jupyterlab>=4.0.0,<5", "hatch-nodejs-version>=0.3.2"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "jupyterlab_myst" 7 | readme = "README.md" 8 | license = { file = "LICENSE" } 9 | requires-python = ">=3.8" 10 | classifiers = [ 11 | "Framework :: Jupyter", 12 | "Framework :: Jupyter :: JupyterLab", 13 | "Framework :: Jupyter :: JupyterLab :: 4", 14 | "Framework :: Jupyter :: JupyterLab :: Extensions", 15 | "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt", 16 | "License :: OSI Approved :: BSD License", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | ] 25 | dependencies = [ 26 | "jupyter_server>=2.0.1,<3;platform_system!='Emscripten'" 27 | ] 28 | dynamic = ["version", "description", "authors", "urls", "keywords"] 29 | 30 | [project.optional-dependencies] 31 | test = [ 32 | "coverage", 33 | "pytest", 34 | "pytest-asyncio", 35 | "pytest-cov", 36 | "pytest-jupyter[server]>=0.6.0" 37 | ] 38 | 39 | [tool.hatch.version] 40 | source = "nodejs" 41 | 42 | [tool.hatch.metadata.hooks.nodejs] 43 | fields = ["description", "authors", "urls"] 44 | 45 | [tool.hatch.build.targets.sdist] 46 | artifacts = ["jupyterlab_myst/labextension"] 47 | exclude = [".github", "binder"] 48 | 49 | [tool.hatch.build.targets.wheel.shared-data] 50 | "jupyterlab_myst/labextension" = "share/jupyter/labextensions/jupyterlab-myst" 51 | "install.json" = "share/jupyter/labextensions/jupyterlab-myst/install.json" 52 | "jupyter-config/server-config" = "etc/jupyter/jupyter_server_config.d" 53 | 54 | [tool.hatch.build.hooks.version] 55 | path = "jupyterlab_myst/_version.py" 56 | 57 | [tool.hatch.build.hooks.jupyter-builder] 58 | dependencies = ["hatch-jupyter-builder>=0.5"] 59 | build-function = "hatch_jupyter_builder.npm_builder" 60 | ensured-targets = [ 61 | "jupyterlab_myst/labextension/static/style.js", 62 | "jupyterlab_myst/labextension/package.json", 63 | ] 64 | skip-if-exists = ["jupyterlab_myst/labextension/static/style.js"] 65 | 66 | [tool.hatch.build.hooks.jupyter-builder.build-kwargs] 67 | build_cmd = "build:prod" 68 | npm = ["npm"] 69 | 70 | [tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs] 71 | build_cmd = "install:extension" 72 | npm = ["npm"] 73 | source_dir = "src" 74 | build_dir = "jupyterlab_myst/labextension" 75 | 76 | [tool.jupyter-releaser.options] 77 | version_cmd = "hatch version" 78 | 79 | [tool.jupyter-releaser.hooks] 80 | before-build-npm = [ 81 | # Used by Jupyter Releaser 82 | "python -m pip install 'jupyterlab>=4.0.0,<5'", 83 | "npm install", 84 | "npm run build:prod" 85 | ] 86 | before-build-python = ["npm run clean:all"] 87 | 88 | [tool.check-wheel-contents] 89 | ignore = ["W002"] 90 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JupyterFrontEnd, 3 | JupyterFrontEndPlugin 4 | } from '@jupyterlab/application'; 5 | import { IMarkdownViewerTracker } from '@jupyterlab/markdownviewer'; 6 | 7 | import { IEditorServices } from '@jupyterlab/codeeditor'; 8 | 9 | import { 10 | INotebookTracker, 11 | Notebook, 12 | NotebookActions, 13 | NotebookPanel 14 | } from '@jupyterlab/notebook'; 15 | import { Cell } from '@jupyterlab/cells'; 16 | import { MySTContentFactory } from './MySTContentFactory'; 17 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 18 | import { ISettingRegistry } from '@jupyterlab/settingregistry'; 19 | import { notebookCellExecuted } from './actions'; 20 | import { mystMarkdownRendererFactory } from './mime'; 21 | 22 | /** 23 | * The notebook content factory provider. 24 | */ 25 | const plugin: JupyterFrontEndPlugin = { 26 | id: 'jupyterlab-myst:content-factory', 27 | provides: NotebookPanel.IContentFactory, 28 | requires: [IEditorServices], 29 | autoStart: true, 30 | activate: (app: JupyterFrontEnd, editorServices: IEditorServices) => { 31 | console.log('JupyterLab extension jupyterlab-myst is activated!'); 32 | const editorFactory = editorServices.factoryService.newInlineEditor; 33 | return new MySTContentFactory({ editorFactory }); 34 | } 35 | }; 36 | 37 | /** 38 | * The notebook cell executor. 39 | */ 40 | const executorPlugin: JupyterFrontEndPlugin = { 41 | id: 'jupyterlab-myst:executor', 42 | requires: [INotebookTracker], 43 | autoStart: true, 44 | activate: (app: JupyterFrontEnd, tracker: INotebookTracker) => { 45 | console.log('Using jupyterlab-myst:executor'); 46 | 47 | NotebookActions.executed.connect( 48 | async (sender: any, value: { notebook: Notebook; cell: Cell }) => { 49 | const { notebook, cell } = value; 50 | await notebookCellExecuted(notebook, cell, tracker); 51 | } 52 | ); 53 | 54 | return; 55 | } 56 | }; 57 | 58 | const mimeRendererPlugin: JupyterFrontEndPlugin = { 59 | id: 'jupyterlab-myst:mime-renderer', 60 | requires: [IRenderMimeRegistry], 61 | autoStart: true, 62 | optional: [IMarkdownViewerTracker, ISettingRegistry], 63 | activate: ( 64 | app: JupyterFrontEnd, 65 | registry: IRenderMimeRegistry, 66 | // We don't need this tracker directly, but it ensures that the built-in 67 | // Markdown renderer is registered, so that we can then safely add our own. 68 | tracker?: IMarkdownViewerTracker, 69 | settingRegistry?: ISettingRegistry 70 | ) => { 71 | console.log('Using jupyterlab-myst:mime-renderer'); 72 | 73 | // Enable frontmatter by default 74 | if (settingRegistry) { 75 | settingRegistry 76 | .load('@jupyterlab/markdownviewer-extension:plugin') 77 | .then((settings: ISettingRegistry.ISettings) => { 78 | settings.set('hideFrontMatter', false); 79 | }); 80 | } 81 | 82 | // Add the MyST markdown renderer factory. 83 | registry.addFactory(mystMarkdownRendererFactory); 84 | } 85 | }; 86 | 87 | export default [plugin, executorPlugin, mimeRendererPlugin]; 88 | -------------------------------------------------------------------------------- /jupyterlab_myst/notary.py: -------------------------------------------------------------------------------- 1 | from nbformat.sign import NotebookNotary, yield_code_cells 2 | from nbformat import NotebookNode 3 | 4 | 5 | 6 | def yield_markdown_cells(nb: NotebookNode): 7 | """Iterator that yields all cells in a notebook 8 | nbformat version independent 9 | """ 10 | if nb.nbformat >= 4: # noqa 11 | for cell in nb["cells"]: 12 | if cell["cell_type"] == "markdown": 13 | yield cell 14 | elif nb.nbformat == 3: # noqa 15 | for ws in nb["worksheets"]: 16 | for cell in ws["cells"]: 17 | if cell["cell_type"] == "markdown": 18 | yield cell 19 | 20 | 21 | class MySTNotebookNotary(NotebookNotary): 22 | def _check_markdown_cell(self, cell, nbformat_version) -> bool: 23 | """Do we trust an individual Markdown cell? 24 | Return True if: 25 | - cell is explicitly trusted 26 | - cell has no inline expressions 27 | """ 28 | # explicitly trusted 29 | if cell["metadata"].pop("trusted", False): 30 | return True 31 | 32 | # Any expression with a non-error output is considered untrusted 33 | expressions = cell["metadata"].get("user_expressions", []) 34 | return all([e.get("result", {}).get("status") == "error" for e in expressions]) 35 | 36 | def check_cells(self, nb: NotebookNode) -> bool: 37 | """Return whether all code/markdown cells are trusted. 38 | A cell is trusted if the 'trusted' field in its metadata is truthy, or 39 | if it has no potentially unsafe outputs. 40 | If there are no code or markdown cells, return True. 41 | This function is the inverse of mark_cells. 42 | """ 43 | self.log.debug("Checking if cells are trusted") 44 | 45 | if nb.nbformat < 3: # noqa 46 | return False 47 | trusted = True 48 | for cell in yield_code_cells(nb): 49 | # only distrust a cell if it actually has some output to distrust 50 | if not self._check_cell(cell, nb.nbformat): 51 | trusted = False 52 | for cell in yield_markdown_cells(nb): 53 | # only distrust a cell if it actually has some output to distrust 54 | if not self._check_markdown_cell(cell, nb.nbformat): 55 | trusted = False 56 | return trusted 57 | 58 | def mark_cells(self, nb: NotebookNode, trusted: bool): 59 | """Mark cells as trusted if the notebook's signature can be verified 60 | Sets ``cell.metadata.trusted = True | False`` on all code/markdown cells, 61 | depending on the *trusted* parameter. This will typically be the return 62 | value from ``self.check_signature(nb)``. 63 | This function is the inverse of check_cells 64 | """ 65 | if nb.nbformat < 3: # noqa 66 | return 67 | 68 | self.log.debug("Marking cells as trusted") 69 | 70 | for cell in yield_code_cells(nb): 71 | cell["metadata"]["trusted"] = trusted 72 | 73 | for cell in yield_markdown_cells(nb): 74 | cell["metadata"]["trusted"] = trusted 75 | -------------------------------------------------------------------------------- /docs/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 17 | 20 | jupyterlab 30 | { 40 | MyST 50 | } 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /docs/images/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 17 | 20 | jupyterlab 30 | { 40 | MyST 50 | } 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /ui-tests/tests/notebooks/typography.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "e3ccc667-c8e6-4548-bbcb-8ece3891d6b6", 6 | "metadata": { 7 | "tags": [] 8 | }, 9 | "source": [ 10 | "# Typography\n", 11 | "\n", 12 | "## Subtitle!" 13 | ] 14 | }, 15 | { 16 | "cell_type": "markdown", 17 | "id": "70841c8a-f3a4-4979-b9bd-c663e0ed6021", 18 | "metadata": {}, 19 | "source": [ 20 | "- Bullet\n", 21 | " - List\n", 22 | " 1. Containing\n", 23 | " 2. Some\n", 24 | " - Numbers" 25 | ] 26 | }, 27 | { 28 | "cell_type": "markdown", 29 | "id": "d07b65c2-56a6-4eef-8315-a83a29bc8d89", 30 | "metadata": {}, 31 | "source": [ 32 | "A link https://google.com and an autolink and a custom link [to anywhere!](https://wikipedia.org)" 33 | ] 34 | }, 35 | { 36 | "cell_type": "markdown", 37 | "id": "e30ff2f4-b6c2-4728-bb30-1c47cc52fca4", 38 | "metadata": {}, 39 | "source": [ 40 | "Term 1\n", 41 | ": Definition\n", 42 | "\n", 43 | "Term 2\n", 44 | ": Definition" 45 | ] 46 | }, 47 | { 48 | "cell_type": "markdown", 49 | "id": "1cec76fd-466e-4583-95e5-c72a72dae0fa", 50 | "metadata": {}, 51 | "source": [ 52 | "{kbd}`Ctrl` + {kbd}`Space`" 53 | ] 54 | }, 55 | { 56 | "cell_type": "markdown", 57 | "id": "1d47e1fc-d3db-4b7f-a791-fbbf489eb66b", 58 | "metadata": {}, 59 | "source": [ 60 | "Fleas \\\n", 61 | "Adam \\\n", 62 | "Had 'em.\n", 63 | "\n", 64 | "By Strickland Gillilan" 65 | ] 66 | }, 67 | { 68 | "cell_type": "markdown", 69 | "id": "773b0abc-0466-4134-b513-605e3f31392f", 70 | "metadata": {}, 71 | "source": [ 72 | "H{sub}`2`O, and 4{sup}`th` of July" 73 | ] 74 | }, 75 | { 76 | "cell_type": "markdown", 77 | "id": "c4661b3f-918f-4a14-b1d9-4e1f8fcdfab9", 78 | "metadata": {}, 79 | "source": [ 80 | "Well {abbr}`MyST (Markedly Structured Text)` is cool!" 81 | ] 82 | }, 83 | { 84 | "cell_type": "markdown", 85 | "id": "af41d124-6565-4915-9ff4-b97746738a0b", 86 | "metadata": {}, 87 | "source": [ 88 | "Foo [^a] and Bar [^b]\n", 89 | "\n", 90 | "[^a]: A footnote\n", 91 | "[^b]: Another footnote" 92 | ] 93 | }, 94 | { 95 | "cell_type": "markdown", 96 | "id": "35c3a924-acaa-4163-9edc-c1e92151cde9", 97 | "metadata": {}, 98 | "source": [ 99 | "Foo [^c] and Bar [^d]\n", 100 | "\n", 101 | "[^c]: A footnote\n", 102 | "[^d]: Another footnote" 103 | ] 104 | } 105 | ], 106 | "metadata": { 107 | "kernelspec": { 108 | "display_name": "Python 3 (ipykernel)", 109 | "language": "python", 110 | "name": "python3" 111 | }, 112 | "language_info": { 113 | "codemirror_mode": { 114 | "name": "ipython", 115 | "version": 3 116 | }, 117 | "file_extension": ".py", 118 | "mimetype": "text/x-python", 119 | "name": "python", 120 | "nbconvert_exporter": "python", 121 | "pygments_lexer": "ipython3", 122 | "version": "3.13.7" 123 | }, 124 | "vscode": { 125 | "interpreter": { 126 | "hash": "a665b5d41d17b532ea9890333293a1b812fa0b73c9c25c950b3cedf1bebd0438" 127 | } 128 | }, 129 | "widgets": { 130 | "application/vnd.jupyter.widget-state+json": { 131 | "state": {}, 132 | "version_major": 2, 133 | "version_minor": 0 134 | } 135 | } 136 | }, 137 | "nbformat": 4, 138 | "nbformat_minor": 5 139 | } 140 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: MyST extension for JupyterLab 3 | description: The MyST Markdown JupyterLab extension switches the default markdown rendering in JupyterLab to MyST. Allowing notebook authors to create richer content using MyST roles and directives alongside plain markdown to create notebook based content from technical tutorials though to publication-quality documents with bibliography support. 4 | --- 5 | 6 | Render markdown cells using [MyST Markdown](https://mystmd.org/), including support for rich frontmatter, interactive references, admonitions, figure numbering, tabs, cards, and grids! 7 | 8 | ![](../images/walkthrough.gif) 9 | 10 | ## Requirements 11 | 12 | - JupyterLab >= 4.0 13 | 14 | ## Install 15 | 16 | To install the extension, execute: 17 | 18 | ```bash 19 | pip install jupyterlab_myst 20 | ``` 21 | 22 | ## Features 23 | 24 | `jupyterlab-myst` is a fully featured markdown renderer for technical documents, [get started with MyST Markdown](xref:guide/quickstart). It supports the MyST `{eval}` inline role, which facilitates the interweaving of code outputs and prose. For example, we can use inline expressions to explore the properties of a NumPy array. 25 | 26 | In the code cell: 27 | 28 | ```python 29 | import numpy as np 30 | array = np.arange(4) 31 | ``` 32 | 33 | In the markdown cell: 34 | 35 | ```markdown 36 | Let's consider the following array: {eval}`array`. 37 | 38 | We can compute the total: {eval}`array.sum()` and the maximum value is {eval}`array.max()`. 39 | ``` 40 | 41 | This will evaluate inline, and show: 42 | 43 | ```text 44 | Let's consider the following array: array([0, 1, 2, 3]). 45 | 46 | We can compute the total: 6 and the maximum value is 3. 47 | ``` 48 | 49 | You can also use this with `ipywidgets`, and have inline interactive text: 50 | 51 | ![](../images/cookies.gif) 52 | 53 | Or with `matplotlib` to show inline spark-lines: 54 | 55 | ![](../images/stock-price.gif) 56 | 57 | You can also edit task-lists directly in the rendered markdown. 58 | 59 | ![](../images/tasklists-in-jupyterlab.gif) 60 | 61 | ## Usage 62 | 63 | [MyST](xref:guide/quickstart) is a flavour of Markdown, which combines the experience of writing Markdown with the programmable extensibility of reStructuredText. This extension for JupyterLab makes it easier to develop rich, computational narratives, technical documentation, and open scientific communication. 64 | 65 | ### Execution 🚀 66 | 67 | To facilitate inline expressions, `jupyterlab-myst` defines a `jupyterlab-myst:executor` plugin. This plugin sends expression code fragments to the active kernel when the user "executes" a Markdown cell. To disable this functionality, disable the `jupyterlab-myst:executor` plugin with: 68 | 69 | ```bash 70 | jupyter labextension disable jupyterlab-myst:executor 71 | ``` 72 | 73 | ### Trust 🕵️‍♀️ 74 | 75 | Jupyter Notebooks implement a [trust-based security model](https://jupyter-server.readthedocs.io/en/stable/operators/security.html). With the addition of inline expressions, Markdown cells are now considered when determining whether a given notebook is "trusted". Any Markdown cell with inline-expression metadata (with display data) is considered "untrusted". Like outputs, expression results are rendered using safe renderers if the cell is not considered trusted. 76 | Executing the notebook will cause each cell to be considered trusted. 77 | 78 | To facilitate this extension of the trust model, the `jupyterlab_myst` server extension replaces the `NotebookNotary` from `nbformat` with `MySTNotebookNotary`. This can be disabled with 79 | 80 | ```bash 81 | jupyter server extension disable jupyterlab-myst 82 | ``` 83 | 84 | The `MySTNotebookNotary` adds additional code that makes it possible to mark Markdown cells as trusted. 85 | 86 | ## Uninstall 87 | 88 | To remove the extension, execute: 89 | 90 | ```bash 91 | pip uninstall jupyterlab_myst 92 | ``` 93 | -------------------------------------------------------------------------------- /examples/myst_tests.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Working with MyST Markdown 3 | subtitle: In JupyterLab 4 | doi: 10.14288/1.0362383 5 | license: CC-BY-4.0 6 | github: https://github.com/executablebooks/myst 7 | subject: Tutorial 8 | venue: Jupyter Journal 9 | biblio: 10 | volume: '1' 11 | issue: '1' 12 | authors: 13 | - name: Rowan Cockett 14 | email: rowan@curvenote.com 15 | corresponding: true 16 | orcid: 0000-0002-7859-8394 17 | affiliations: 18 | - Curvenote 19 | - ExecutableBooks 20 | - name: Steve Purves 21 | affiliations: 22 | - Curvenote 23 | - ExecutableBooks 24 | math: 25 | '\dobs': '\mathbf{d}_\text{obs}' 26 | '\dpred': '\mathbf{d}_\text{pred}\left( #1 \right)' 27 | '\mref': '\mathbf{m}_\text{ref}' 28 | --- 29 | 30 | :::{note} 31 | :class: dropdown 32 | This is MyST in a notebook rendered by `jupyterlab-myst`!! 33 | ::: 34 | 35 | ```{figure} https://picsum.photos/800/200 36 | :name: hello 37 | :width: 40% 38 | A random 800x200 image 39 | ``` 40 | 41 | ```{figure} https://picsum.photos/seed/myst-003/800/200 42 | :name: fig4 43 | :width: 40% 44 | Image of a wolf 🐺 45 | ``` 46 | 47 | This chart shows an example of `using` an interval **selection**[^1] to filter the contents of an attached histogram, allowing the user to see the proportion of items in each category within the selection. See more in the [Altair Documentation](https://altair-viz.github.io/gallery/selection_histogram.html) 48 | 49 | [^1]: Footnote text 50 | 51 | ```{figure} https://picsum.photos/seed/myst-013/800/300 52 | :name: fig3 53 | :width: 80% 54 | A rocky coastline 55 | ``` 56 | 57 | ```{math} 58 | :label: ok 59 | Ax=b 60 | ``` 61 | 62 | [hello](https://en.wikipedia.org/wiki/OK) or [code](https://github.com/executablebooks/mystjs/blob/main/packages/myst-directives/src/admonition.ts#L5) 63 | 64 | [](#ok) [](#hello) [](#cross) 65 | 66 | ```{warning} 67 | :class: dropdown 68 | ok 69 | ``` 70 | 71 | The residual is the predicted data for the model, $\dpred{m}$, minus the observed data, $\dobs$. You can also calculate the predicted data for the reference model $\dpred{\mref}$. 72 | 73 | For example, this [](#cross): 74 | 75 | ```{math} 76 | :label: equation 77 | \sqrt{\frac{5}{2}} 78 | ``` 79 | 80 | For example, this equation: 81 | 82 | ```{math} 83 | :label: cross 84 | \mathbf{u} \times \mathbf{v}=\left|\begin{array}{ll}u_{2} & u_{3} \\ v_{2} & v_{3}\end{array}\right| \mathbf{i}+\left|\begin{array}{ll}u_{3} & u_{1} \\ v_{3} & v_{1}\end{array}\right| \mathbf{j}+\left|\begin{array}{ll}u_{1} & u_{2} \\ v_{1} & v_{2}\end{array}\right| \mathbf{k} 85 | ``` 86 | 87 |
    A random 800x300 image
    88 | 89 | 90 | 91 | ## Tabs 92 | 93 | ````{tab-set} 94 | ```{tab-item} Tab 1 95 | :sync: tab1 96 | Tab one can sync (see below)! 97 | ``` 98 | ```{tab-item} Tab 2 99 | :sync: tab2 100 | Tab two 101 | ``` 102 | ```` 103 | 104 | These tabs are set to sync: 105 | 106 | ````{tab-set} 107 | ```{tab-item} Tab 1 - Sync! 108 | :sync: tab1 109 | Tab one can sync! 110 | ``` 111 | ```{tab-item} Tab 2 112 | :sync: tab2 113 | Tab two 114 | ``` 115 | ```` 116 | 117 | ## Grids 118 | 119 | ::::{grid} 1 1 2 3 120 | 121 | :::{grid-item-card} 122 | Text content ✏️ 123 | ^^^ 124 | Structure books with text files and Jupyter Notebooks with minimal configuration. 125 | ::: 126 | 127 | :::{grid-item-card} 128 | MyST Markdown ✨ 129 | ^^^ 130 | Write MyST Markdown to create enriched documents with publication-quality features. 131 | ::: 132 | 133 | :::{grid-item-card} 134 | Executable content 🔁 135 | ^^^ 136 | Execute notebook cells, store results, and insert outputs across pages. 137 | ::: 138 | :::: 139 | 140 | ## Cards 141 | 142 | :::{card} 143 | MyST Markdown 🚀 144 | ^^^ 145 | Write content in JupyterLab! 146 | ::: 147 | -------------------------------------------------------------------------------- /src/transforms/links.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Root, Link } from 'myst-spec'; 3 | import { selectAll } from 'unist-util-select'; 4 | import { URLExt } from '@jupyterlab/coreutils'; 5 | import type { LinkProps } from '@myst-theme/providers'; 6 | import { IRenderMime } from '@jupyterlab/rendermime'; 7 | import { updateLinkTextIfEmpty } from 'myst-transforms'; 8 | 9 | /** 10 | * Handle an anchor node. 11 | * Originally from @jupyterlab/rendermime renderers.ts 12 | */ 13 | async function handleAnchor( 14 | anchor: HTMLAnchorElement, 15 | resolver: IRenderMime.IResolver, 16 | linkHandler: IRenderMime.ILinkHandler | undefined 17 | ): Promise { 18 | // Get the link path without the location prepended. 19 | // (e.g. "./foo.md#Header 1" vs "http://localhost:8888/foo.md#Header 1") 20 | let href = anchor.getAttribute('href') || ''; 21 | const isLocal = resolver.isLocal 22 | ? resolver.isLocal(href) 23 | : URLExt.isLocal(href); 24 | // Bail if it is not a file-like url. 25 | if (!href || !isLocal) { 26 | return; 27 | } 28 | // Remove the hash until we can handle it. 29 | const hash = anchor.hash; 30 | if (hash) { 31 | // Handle internal link in the file. 32 | if (hash === href) { 33 | anchor.target = '_self'; 34 | return; 35 | } 36 | // For external links, remove the hash until we have hash handling. 37 | href = href.replace(hash, ''); 38 | } 39 | try { 40 | // Get the appropriate file path. 41 | const urlPath = await resolver.resolveUrl(href); 42 | // decode encoded url from url to api path 43 | const path = decodeURIComponent(urlPath); 44 | // Handle the click override. 45 | if (linkHandler) { 46 | linkHandler.handleLink(anchor, path, hash); 47 | } 48 | // Get the appropriate file download path. 49 | const url = await resolver.getDownloadUrl(urlPath); 50 | // Set the visible anchor. 51 | anchor.href = url + hash; 52 | } catch (error) { 53 | // If there was an error getting the url, 54 | // just make it an empty link. 55 | anchor.href = ''; 56 | } 57 | } 58 | 59 | export const linkFactory = 60 | ( 61 | resolver: IRenderMime.IResolver | undefined, 62 | linkHandler: IRenderMime.ILinkHandler | undefined 63 | ) => 64 | (props: LinkProps): JSX.Element => { 65 | const ref = React.useRef(null); 66 | const { to: url } = props; 67 | React.useEffect(() => { 68 | if (!ref || !ref.current || !resolver) { 69 | return; 70 | } 71 | handleAnchor(ref.current, resolver, linkHandler); 72 | }, [ref, url]); 73 | return ( 74 | 75 | {props.children} 76 | 77 | ); 78 | }; 79 | 80 | type Options = { 81 | resolver: IRenderMime.IResolver | undefined; 82 | }; 83 | 84 | /** 85 | * Use the resolver to mark links as internal so they can be handled differently in the UI 86 | */ 87 | export async function internalLinksTransform( 88 | tree: Root, 89 | opts: Options 90 | ): Promise { 91 | const links = selectAll('link,linkBlock', tree) as Link[]; 92 | await Promise.all( 93 | links.map(async link => { 94 | if (!link || !link.url) { 95 | return; 96 | } 97 | const resolver = opts.resolver; 98 | const href = link.url; 99 | updateLinkTextIfEmpty(link, href); 100 | const isLocal = resolver?.isLocal 101 | ? resolver.isLocal(href) 102 | : URLExt.isLocal(href); 103 | if (!isLocal) { 104 | return; 105 | } 106 | if (!resolver) { 107 | return; 108 | } 109 | if ((link as any).static) { 110 | // TODO: remove hash 111 | const urlPath = await resolver.resolveUrl(href); 112 | const url = await resolver.getDownloadUrl(urlPath); 113 | (link as any).urlSource = href; 114 | link.url = url; 115 | } else { 116 | (link as any).internal = true; 117 | } 118 | }) 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /examples/display-markdown.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "1445c778-f92f-46c4-a7f9-b9aece94450f", 6 | "metadata": { 7 | "user_expressions": [] 8 | }, 9 | "source": [ 10 | "---\n", 11 | "title: Markdown Rendering in Jupyter using MyST\n", 12 | "subtitle: Using inline widgets in JupyterLab markdown cells\n", 13 | "author:\n", 14 | " - name: Rowan Cockett\n", 15 | " affiliations: Executable Books; Curvenote\n", 16 | " orcid: 0000-0002-7859-8394\n", 17 | " email: rowan@curvenote.com\n", 18 | " - name: Angus Hollands\n", 19 | " affiliations: Executable Books\n", 20 | " - name: Steve Purves\n", 21 | " affiliations: Executable Books; Curvenote\n", 22 | "date: 2023/02/20\n", 23 | "---\n", 24 | "\n", 25 | "The [JupyterLab MyST extension](https://github.com/executablebooks/jupyterlab-myst) allows you to invoke the MyST renderer from code cell outputs." 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": 12, 31 | "id": "53c89be3-63ef-4a5c-b7f3-28ed138fa92e", 32 | "metadata": { 33 | "tags": [] 34 | }, 35 | "outputs": [], 36 | "source": [ 37 | "from IPython.display import Markdown" 38 | ] 39 | }, 40 | { 41 | "cell_type": "code", 42 | "execution_count": 13, 43 | "id": "32fad109-f60a-4162-b784-4e3d9ed45921", 44 | "metadata": { 45 | "tags": [] 46 | }, 47 | "outputs": [ 48 | { 49 | "data": { 50 | "text/markdown": [ 51 | "\n", 52 | "\n", 53 | "\n", 54 | "The [JupyterLab MyST extension](https://github.com/executablebooks/jupyterlab-myst) allows you to have MyST renderer in your markdown cells that includes interactivity and inline-evaluation.\n", 55 | "\n", 56 | ":::{warning} Syntax is Subject to Change\n", 57 | ":class: dropdown\n", 58 | "The current syntax is based on a myst role, which may change in the future -- for example to be closer to an inline expression `${}`. This will go through a MyST enhancement proposal, and the `{eval}` role will likely still be supported.\n", 59 | ":::\n", 60 | "\n", 61 | "\n", 62 | "```python\n", 63 | "import numpy as np\n", 64 | "array = np.arange(4)\n", 65 | "```\n", 66 | "\n" 67 | ], 68 | "text/plain": [ 69 | "" 70 | ] 71 | }, 72 | "execution_count": 13, 73 | "metadata": {}, 74 | "output_type": "execute_result" 75 | } 76 | ], 77 | "source": [ 78 | "Markdown('''\n", 79 | "\n", 80 | "\n", 81 | "The [JupyterLab MyST extension](https://github.com/executablebooks/jupyterlab-myst) allows you to have MyST renderer in your markdown cells that includes interactivity and inline-evaluation.\n", 82 | "\n", 83 | ":::{warning} Syntax is Subject to Change\n", 84 | ":class: dropdown\n", 85 | "The current syntax is based on a myst role, which may change in the future -- for example to be closer to an inline expression `${}`. This will go through a MyST enhancement proposal, and the `{eval}` role will likely still be supported.\n", 86 | ":::\n", 87 | "\n", 88 | "\n", 89 | "```python\n", 90 | "import numpy as np\n", 91 | "array = np.arange(4)\n", 92 | "```\n", 93 | "\n", 94 | "''')" 95 | ] 96 | } 97 | ], 98 | "metadata": { 99 | "kernelspec": { 100 | "display_name": "Python 3 (ipykernel)", 101 | "language": "python", 102 | "name": "python3" 103 | }, 104 | "language_info": { 105 | "codemirror_mode": { 106 | "name": "ipython", 107 | "version": 3 108 | }, 109 | "file_extension": ".py", 110 | "mimetype": "text/x-python", 111 | "name": "python", 112 | "nbconvert_exporter": "python", 113 | "pygments_lexer": "ipython3", 114 | "version": "3.13.7" 115 | }, 116 | "widgets": { 117 | "application/vnd.jupyter.widget-state+json": { 118 | "state": {}, 119 | "version_major": 2, 120 | "version_minor": 0 121 | } 122 | } 123 | }, 124 | "nbformat": 4, 125 | "nbformat_minor": 5 126 | } 127 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a new release of jupyterlab_myst 2 | 3 | The extension can be published to `PyPI` and `npm` manually or using the [Jupyter Releaser](https://github.com/jupyter-server/jupyter_releaser). 4 | 5 | ## Manual release 6 | 7 | ### Python package 8 | 9 | This extension can be distributed as Python packages. All of the Python 10 | packaging instructions are in the `pyproject.toml` file to wrap your extension in a 11 | Python package. Before generating a package, you first need to install some tools: 12 | 13 | ```bash 14 | pip install build twine hatch 15 | ``` 16 | 17 | Bump the version using `hatch`. By default this will create a tag. 18 | See the docs on [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version#semver) for details. 19 | 20 | ```bash 21 | hatch version 22 | ``` 23 | 24 | Make sure to clean up all the development files before building the package: 25 | 26 | ```bash 27 | npm run clean:all 28 | ``` 29 | 30 | You could also clean up the local git repository: 31 | 32 | ```bash 33 | git clean -dfX 34 | ``` 35 | 36 | To create a Python source package (`.tar.gz`) and the binary package (`.whl`) in the `dist/` directory, do: 37 | 38 | ```bash 39 | python -m build 40 | ``` 41 | 42 | > `python setup.py sdist bdist_wheel` is deprecated and will not work for this package. 43 | 44 | Then to upload the package to PyPI, do: 45 | 46 | ```bash 47 | twine upload dist/* 48 | ``` 49 | 50 | ### NPM package 51 | 52 | To publish the frontend part of the extension as a NPM package, do: 53 | 54 | ```bash 55 | npm login 56 | npm publish --access public 57 | ``` 58 | 59 | ## Automated releases with the Jupyter Releaser 60 | 61 | The extension repository should already be compatible with the Jupyter Releaser. 62 | 63 | Check out the [workflow documentation](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html) for more information. 64 | 65 | Here is a summary of the steps to cut a new release: 66 | 67 | - Add tokens to the [Github Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) in the repository: 68 | - `ADMIN_GITHUB_TOKEN` (with "public_repo" and "repo:status" permissions); see the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) 69 | - `NPM_TOKEN` (with "automation" permission); see the [documentation](https://docs.npmjs.com/creating-and-viewing-access-tokens) 70 | - Set up PyPI 71 | 72 |
    Using PyPI trusted publisher (modern way) 73 | 74 | - Set up your PyPI project by [adding a trusted publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) 75 | - The _workflow name_ is `publish-release.yml` and the _environment_ should be left blank. 76 | - Ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.pypi.org/trusted-publishers/using-a-publisher/)) 77 | 78 |
    79 | 80 |
    Using PyPI token (legacy way) 81 | 82 | - If the repo generates PyPI release(s), create a scoped PyPI [token](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github). We recommend using a scoped token for security reasons. 83 | 84 | - You can store the token as `PYPI_TOKEN` in your fork's `Secrets`. 85 | 86 | - Advanced usage: if you are releasing multiple repos, you can create a secret named `PYPI_TOKEN_MAP` instead of `PYPI_TOKEN` that is formatted as follows: 87 | 88 | ```text 89 | owner1/repo1,token1 90 | owner2/repo2,token2 91 | ``` 92 | 93 | If you have multiple Python packages in the same repository, you can point to them as follows: 94 | 95 | ```text 96 | owner1/repo1/path/to/package1,token1 97 | owner1/repo1/path/to/package2,token2 98 | ``` 99 | 100 |
    101 | 102 | - Go to the Actions panel 103 | - Run the "Step 1: Prep Release" workflow 104 | - Check the draft changelog 105 | - Run the "Step 2: Publish Release" workflow 106 | 107 | ## Publishing to `conda-forge` 108 | 109 | If the package is not on conda forge yet, check the documentation to learn how to add it: https://conda-forge.org/docs/maintainer/adding_pkgs.html 110 | 111 | Otherwise a bot should pick up the new version publish to PyPI, and open a new PR on the feedstock repository automatically. 112 | -------------------------------------------------------------------------------- /ui-tests/README.md: -------------------------------------------------------------------------------- 1 | # Integration Testing 2 | 3 | This folder contains the integration tests of the extension. 4 | 5 | They are defined using [Playwright](https://playwright.dev/docs/intro) test runner 6 | and [Galata](https://github.com/jupyterlab/jupyterlab/tree/main/galata) helper. 7 | 8 | The Playwright configuration is defined in [playwright.config.js](./playwright.config.js). 9 | 10 | The JupyterLab server configuration to use for the integration test is defined 11 | in [jupyter_server_test_config.py](./jupyter_server_test_config.py). 12 | 13 | The default configuration will produce video for failing tests and an HTML report. 14 | 15 | > There is a new experimental UI mode that you may fall in love with; see [that video](https://www.youtube.com/watch?v=jF0yA-JLQW0). 16 | 17 | ## Run the tests 18 | 19 | > All commands are assumed to be executed from the root directory 20 | 21 | To run the tests, you need to: 22 | 23 | 1. Compile the extension: 24 | 25 | ```sh 26 | npm install 27 | npm run build:prod 28 | ``` 29 | 30 | > Check the extension is installed in JupyterLab. 31 | 32 | 2. Install test dependencies (needed only once): 33 | 34 | ```sh 35 | cd ./ui-tests 36 | npm install 37 | npx playwright install 38 | cd .. 39 | ``` 40 | 41 | 3. Execute the [Playwright](https://playwright.dev/docs/intro) tests: 42 | 43 | ```sh 44 | cd ./ui-tests 45 | npx playwright test 46 | ``` 47 | 48 | Test results will be shown in the terminal. In case of any test failures, the test report 49 | will be opened in your browser at the end of the tests execution; see 50 | [Playwright documentation](https://playwright.dev/docs/test-reporters#html-reporter) 51 | for configuring that behavior. 52 | 53 | ## Update the tests snapshots 54 | 55 | > All commands are assumed to be executed from the root directory 56 | 57 | If you are comparing snapshots to validate your tests, you may need to update 58 | the reference snapshots stored in the repository. To do that, you need to: 59 | 60 | 1. Compile the extension: 61 | 62 | ```sh 63 | npm install 64 | npm run build:prod 65 | ``` 66 | 67 | > Check the extension is installed in JupyterLab. 68 | 69 | 2. Install test dependencies (needed only once): 70 | 71 | ```sh 72 | cd ./ui-tests 73 | npm install 74 | npx playwright install 75 | cd .. 76 | ``` 77 | 78 | 3. Execute the [Playwright](https://playwright.dev/docs/intro) command: 79 | 80 | ```sh 81 | cd ./ui-tests 82 | npx playwright test -u 83 | ``` 84 | 85 | > Some discrepancy may occurs between the snapshots generated on your computer and 86 | > the one generated on the CI. To ease updating the snapshots on a PR, you can 87 | > type `please update playwright snapshots` to trigger the update by a bot on the CI. 88 | > Once the bot has computed new snapshots, it will commit them to the PR branch. 89 | 90 | ## Create tests 91 | 92 | > All commands are assumed to be executed from the root directory 93 | 94 | To create tests, the easiest way is to use the code generator tool of playwright: 95 | 96 | 1. Compile the extension: 97 | 98 | ```sh 99 | npm install 100 | npm run build:prod 101 | ``` 102 | 103 | > Check the extension is installed in JupyterLab. 104 | 105 | 2. Install test dependencies (needed only once): 106 | 107 | ```sh 108 | cd ./ui-tests 109 | npm install 110 | npx playwright install 111 | cd .. 112 | ``` 113 | 114 | 3. Start the server: 115 | 116 | ```sh 117 | cd ./ui-tests 118 | npm start 119 | ``` 120 | 121 | 4. Execute the [Playwright code generator](https://playwright.dev/docs/codegen) in **another terminal**: 122 | 123 | ```sh 124 | cd ./ui-tests 125 | npx playwright codegen localhost:8888 126 | ``` 127 | 128 | ## Debug tests 129 | 130 | > All commands are assumed to be executed from the root directory 131 | 132 | To debug tests, a good way is to use the inspector tool of playwright: 133 | 134 | 1. Compile the extension: 135 | 136 | ```sh 137 | npm install 138 | npm run build:prod 139 | ``` 140 | 141 | > Check the extension is installed in JupyterLab. 142 | 143 | 2. Install test dependencies (needed only once): 144 | 145 | ```sh 146 | cd ./ui-tests 147 | npm install 148 | npx playwright install 149 | cd .. 150 | ``` 151 | 152 | 3. Execute the Playwright tests in [debug mode](https://playwright.dev/docs/debug): 153 | 154 | ```sh 155 | cd ./ui-tests 156 | npx playwright test --debug 157 | ``` 158 | 159 | ## Upgrade Playwright and the browsers 160 | 161 | To update the web browser versions, you must update the package `@playwright/test`: 162 | 163 | ```sh 164 | cd ./ui-tests 165 | npm up "@playwright/test" 166 | npx playwright install 167 | ``` 168 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | import { ISessionContext } from '@jupyterlab/apputils'; 2 | import { Cell } from '@jupyterlab/cells'; 3 | import { KernelMessage } from '@jupyterlab/services'; 4 | import { JSONObject } from '@lumino/coreutils'; 5 | import { IExpressionResult } from './userExpressions'; 6 | import { 7 | IUserExpressionMetadata, 8 | getUserExpressions, 9 | setUserExpressions, 10 | deleteUserExpressions 11 | } from './userExpressions'; 12 | import { 13 | INotebookTracker, 14 | Notebook, 15 | NotebookPanel 16 | } from '@jupyterlab/notebook'; 17 | import { IMySTMarkdownCell } from './types'; 18 | import { selectAll } from 'unist-util-select'; 19 | 20 | function isMySTMarkdownCell(cell: Cell): cell is IMySTMarkdownCell { 21 | return cell.model.type === 'markdown'; 22 | } 23 | 24 | /** 25 | * Load user expressions for given XMarkdown cell from kernel. 26 | * Store results in cell attachments. 27 | */ 28 | export async function executeUserExpressions( 29 | cell: IMySTMarkdownCell, 30 | sessionContext: ISessionContext 31 | ): Promise { 32 | // Check we have a kernel 33 | const kernel = sessionContext.session?.kernel; 34 | if (!kernel) { 35 | throw new Error('Session has no kernel.'); 36 | } 37 | 38 | const mdast = cell.mystModel?.mdast ?? {}; 39 | const expressions = selectAll('inlineExpression', mdast).map( 40 | node => (node as any).value 41 | ); 42 | // Build ordered map from string index to node 43 | const namedExpressions = new Map( 44 | expressions.map((expr, index) => [`${index}`, expr]) 45 | ); 46 | console.debug('Executing named expressions', namedExpressions); 47 | // No expressions! 48 | if (namedExpressions.size === 0) { 49 | return Promise.resolve([]); 50 | } 51 | 52 | // Extract expression values 53 | const userExpressions: JSONObject = {}; 54 | namedExpressions.forEach((expr, key) => { 55 | userExpressions[key] = expr; 56 | }); 57 | 58 | // Populate request data 59 | const content: KernelMessage.IExecuteRequestMsg['content'] = { 60 | code: '', 61 | user_expressions: userExpressions 62 | }; 63 | 64 | return new Promise((resolve, reject) => { 65 | // Perform request 66 | console.debug('Performing kernel request', content); 67 | const future = kernel.requestExecute(content, false); 68 | 69 | // Set response handler 70 | future.onReply = (msg: KernelMessage.IExecuteReplyMsg) => { 71 | console.debug('Handling kernel response', msg); 72 | // Only work with `ok` results 73 | const content = msg.content; 74 | if (content.status !== 'ok') { 75 | return reject('Kernel response was not OK'); 76 | } 77 | 78 | // Store results as metadata 79 | const expressions: IUserExpressionMetadata[] = []; 80 | for (const key in content.user_expressions) { 81 | const expr = namedExpressions.get(key); 82 | 83 | if (expr === undefined) { 84 | return reject( 85 | "namedExpressions doesn't have key. This should never happen" 86 | ); 87 | } 88 | const result = content.user_expressions[key] as IExpressionResult; 89 | 90 | const expressionMetadata: IUserExpressionMetadata = { 91 | expression: expr, 92 | result: result 93 | }; 94 | expressions.push(expressionMetadata); 95 | } 96 | 97 | return resolve(expressions); 98 | }; 99 | }); 100 | } 101 | 102 | export async function notebookCellExecuted( 103 | notebook: Notebook, 104 | cell: Cell, 105 | tracker: INotebookTracker 106 | ): Promise { 107 | console.debug('Executing cell, expressions', getUserExpressions(cell)); 108 | // Find the Notebook panel 109 | const panel = tracker.find((w: NotebookPanel) => { 110 | return w.content === notebook; 111 | }); 112 | // Retrieve the kernel context 113 | const ctx = panel?.sessionContext; 114 | if (ctx === undefined) { 115 | return; 116 | } 117 | // Load the user expressions for the given cell. 118 | if (!isMySTMarkdownCell(cell)) { 119 | return; 120 | } 121 | console.debug(`Markdown cell ${cell.model.id} was executed`); 122 | 123 | await cell.updateFragmentMDAST(); 124 | 125 | // Trust cell! 126 | const expressions = await executeUserExpressions(cell, ctx); 127 | console.debug('Handling evaluated user expressions', expressions); 128 | if (expressions.length) { 129 | console.debug( 130 | 'Setting metadata, before:', 131 | getUserExpressions(cell), 132 | 'after:', 133 | expressions 134 | ); 135 | setUserExpressions(cell, expressions); 136 | } else { 137 | deleteUserExpressions(cell); 138 | } 139 | cell.model.trusted = true; 140 | } 141 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const colors = require('tailwindcss/colors'); 2 | 3 | module.exports = { 4 | darkMode: ['class', '[data-jp-theme-light="false"]'], 5 | content: [ 6 | './src/**/*.{js,ts,jsx,tsx}', 7 | 'node_modules/myst-to-react/dist/**/*.{js,ts,jsx,tsx}', 8 | 'node_modules/@myst-theme/frontmatter/dist/**/*.{js,ts,jsx,tsx}', 9 | // Occasionally look to these folders as well in development only 10 | '.yalc/myst-to-react/dist/**/*.{js,ts,jsx,tsx}', 11 | '.yalc/@myst-theme/frontmatter/dist/**/*.{js,ts,jsx,tsx}' 12 | ], 13 | theme: { 14 | extend: { 15 | colors: { 16 | primary: colors.blue, 17 | success: colors.green[500] 18 | }, 19 | // See https://github.com/tailwindlabs/tailwindcss-typography/blob/master/src/styles.js 20 | typography: theme => ({ 21 | DEFAULT: { 22 | css: { 23 | fontSize: 'var(--jp-content-font-size1)', 24 | color: 'var(--jp-content-font-color1)', 25 | fontFamily: 'var(--jp-content-font-family)', 26 | lineHeight: 'var(--jp-content-line-height)', 27 | p: { 28 | marginTop: 0, 29 | marginBottom: '1em' 30 | }, 31 | 'h1,h2,h3,h4,h5,h6': { 32 | lineHeight: 'var(--jp-content-heading-line-height, 1)', 33 | fontWeight: 'var(--jp-content-heading-font-weight, 500)', 34 | fontStyle: 'normal', 35 | marginTop: 'var(--jp-content-heading-margin-top, 1.2em)', 36 | marginBottom: 'var(--jp-content-heading-margin-bottom, 0.8em)' 37 | }, 38 | 'h1:first-child,h2:first-child,h3:first-child,h4:first-child,h5:first-child,h6:first-child': 39 | { 40 | marginTop: 'calc(0.5 * var(--jp-content-heading-margin-top))' 41 | }, 42 | 'h1:last-child,h2:last-child,h3:last-child,h4:last-child,h5:last-child,h6:last-child': 43 | { 44 | marginBottom: 45 | 'calc(0.5 * var(--jp-content-heading-margin-bottom))' 46 | }, 47 | h1: { 48 | fontSize: 'var(--jp-content-font-size5)' 49 | }, 50 | h2: { 51 | fontSize: 'var(--jp-content-font-size4)' 52 | }, 53 | h3: { 54 | fontSize: 'var(--jp-content-font-size3)' 55 | }, 56 | h4: { 57 | fontSize: 'var(--jp-content-font-size2)' 58 | }, 59 | h5: { 60 | fontSize: 'var(--jp-content-font-size1)' 61 | }, 62 | h6: { 63 | fontSize: 'var(--jp-content-font-size0)' 64 | }, 65 | code: { 66 | fontWeight: 'inherit', 67 | color: 'var(--jp-content-font-color1)', 68 | fontFamily: 'var(--jp-code-font-family)', 69 | fontSize: 'inherit', 70 | lineHeight: 'var(--jp-code-line-height)', 71 | padding: 0, 72 | whiteSpace: 'pre-wrap', 73 | backgroundColor: 'var(--jp-layout-color2)', 74 | padding: '1px 5px' 75 | }, 76 | 'code::before': { 77 | content: '' 78 | }, 79 | 'code::after': { 80 | content: '' 81 | }, 82 | 'blockquote p:first-of-type::before': { content: 'none' }, 83 | 'blockquote p:first-of-type::after': { content: 'none' }, 84 | li: { 85 | marginTop: '0.25rem', 86 | marginBottom: '0.25rem' 87 | }, 88 | a: { 89 | textDecoration: 'none', 90 | color: 'var(--jp-content-link-color, #1976d2)', // --md-blue-700 91 | fontWeight: 400, 92 | '&:hover': { 93 | color: 'var(--jp-content-link-color, #1976d2)', // --md-blue-700 94 | textDecoration: 'underline', 95 | fontWeight: 400 96 | } 97 | }, 98 | 'li > p, dd > p, header > p, footer > p': { 99 | marginTop: '0.25rem', 100 | marginBottom: '0.25rem' 101 | } 102 | } 103 | }, 104 | stone: { 105 | css: { 106 | '--tw-prose-bullets': 'var(--jp-content-font-color1)' 107 | } 108 | } 109 | }), 110 | keyframes: { 111 | load: { 112 | '0%': { width: '0%' }, 113 | '100%': { width: '50%' } 114 | }, 115 | fadeIn: { 116 | '0%': { opacity: 0.0 }, 117 | '25%': { opacity: 0.25 }, 118 | '50%': { opacity: 0.5 }, 119 | '75%': { opacity: 0.75 }, 120 | '100%': { opacity: 1 } 121 | } 122 | }, 123 | animation: { 124 | load: 'load 2.5s ease-out', 125 | 'fadein-fast': 'fadeIn 1s ease-out' 126 | } 127 | } 128 | }, 129 | corePlugins: { 130 | preflight: false 131 | }, 132 | plugins: [require('@tailwindcss/typography')] 133 | }; 134 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v5 22 | 23 | - name: Base Setup 24 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 25 | 26 | - name: Install dependencies 27 | run: python -m pip install -U "jupyterlab>=4.0.0,<5" 28 | 29 | - name: Lint the extension 30 | run: | 31 | set -eux 32 | npm install 33 | npm run lint:check 34 | 35 | - name: Test the extension 36 | run: | 37 | set -eux 38 | npm test 39 | 40 | - name: Build the extension 41 | run: | 42 | set -eux 43 | python -m pip install .[test] 44 | 45 | pytest -vv -r ap --cov jupyterlab_myst 46 | jupyter server extension list 47 | jupyter server extension list 2>&1 | grep -ie "jupyterlab_myst.*OK" 48 | 49 | jupyter labextension list 50 | jupyter labextension list 2>&1 | grep -ie "jupyterlab-myst.*OK" 51 | python -m jupyterlab.browser_check 52 | 53 | - name: Package the extension 54 | run: | 55 | set -eux 56 | 57 | pip install build 58 | python -m build 59 | pip uninstall -y "jupyterlab_myst" jupyterlab 60 | 61 | - name: Upload extension packages 62 | uses: actions/upload-artifact@v4 63 | with: 64 | name: extension-artifacts 65 | path: dist/jupyterlab_myst* 66 | if-no-files-found: error 67 | 68 | test_isolated: 69 | needs: build 70 | runs-on: ubuntu-latest 71 | 72 | steps: 73 | - name: Install Python 74 | uses: actions/setup-python@v6 75 | with: 76 | python-version: '3.12' 77 | architecture: 'x64' 78 | - uses: actions/download-artifact@v4 79 | with: 80 | name: extension-artifacts 81 | - name: Install and Test 82 | run: | 83 | set -eux 84 | # Remove NodeJS, twice to take care of system and locally installed node versions. 85 | sudo rm -rf $(which node) 86 | sudo rm -rf $(which node) 87 | 88 | pip install "jupyterlab>=4.0.0,<5" jupyterlab_myst*.whl 89 | 90 | 91 | jupyter server extension list 92 | jupyter server extension list 2>&1 | grep -ie "jupyterlab_myst.*OK" 93 | 94 | jupyter labextension list 95 | jupyter labextension list 2>&1 | grep -ie "jupyterlab-myst.*OK" 96 | python -m jupyterlab.browser_check --no-browser-test 97 | 98 | integration-tests: 99 | name: Integration tests 100 | needs: build 101 | runs-on: ubuntu-latest 102 | 103 | env: 104 | PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/pw-browsers 105 | 106 | steps: 107 | - name: Checkout 108 | uses: actions/checkout@v5 109 | 110 | - name: Base Setup 111 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 112 | 113 | - name: Download extension package 114 | uses: actions/download-artifact@v4 115 | with: 116 | name: extension-artifacts 117 | 118 | - name: Install the extension 119 | run: | 120 | set -eux 121 | python -m pip install "jupyterlab>=4.0.0,<5" jupyterlab_myst*.whl 122 | 123 | - name: Install dependencies 124 | working-directory: ui-tests 125 | env: 126 | YARN_ENABLE_IMMUTABLE_INSTALLS: 0 127 | PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 128 | run: npm install 129 | 130 | - name: Set up browser cache 131 | uses: actions/cache@v4 132 | with: 133 | path: | 134 | ${{ github.workspace }}/pw-browsers 135 | key: ${{ runner.os }}-${{ hashFiles('ui-tests/yarn.lock') }} 136 | 137 | - name: Install browser 138 | run: npx playwright install chromium 139 | working-directory: ui-tests 140 | 141 | - name: Execute integration tests 142 | working-directory: ui-tests 143 | run: | 144 | npx playwright test 145 | 146 | - name: Upload Playwright Test report 147 | if: always() 148 | uses: actions/upload-artifact@v4 149 | with: 150 | name: jupyterlab_myst-playwright-tests 151 | path: | 152 | ui-tests/test-results 153 | ui-tests/playwright-report 154 | 155 | check_links: 156 | name: Check Links 157 | runs-on: ubuntu-latest 158 | timeout-minutes: 15 159 | steps: 160 | - uses: actions/checkout@v5 161 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 162 | - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 163 | with: 164 | ignore_links: "https?://mybinder\\.org.*" 165 | -------------------------------------------------------------------------------- /src/widget.tsx: -------------------------------------------------------------------------------- 1 | import { IRenderMime } from '@jupyterlab/rendermime-interfaces'; 2 | import { ISignal, Signal } from '@lumino/signaling'; 3 | import { FrontmatterBlock } from '@myst-theme/frontmatter'; 4 | import { ISanitizer, VDomModel, VDomRenderer } from '@jupyterlab/apputils'; 5 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 6 | import { References } from 'myst-common'; 7 | import type { FrontmatterParts } from 'myst-common'; 8 | import type { SiteAction, SiteExport } from 'myst-config'; 9 | import { PageFrontmatter } from 'myst-frontmatter'; 10 | import { SourceFileKind } from 'myst-spec-ext'; 11 | import { 12 | ArticleProvider, 13 | TabStateProvider, 14 | Theme, 15 | ThemeProvider 16 | } from '@myst-theme/providers'; 17 | import { MyST } from 'myst-to-react'; 18 | import React from 'react'; 19 | import { 20 | UserExpressionsProvider, 21 | ITaskItemChange, 22 | ITaskItemController, 23 | TaskItemControllerProvider, 24 | SanitizerProvider 25 | } from './providers'; 26 | import { renderers } from './renderers'; 27 | import { IUserExpressionMetadata } from './userExpressions'; 28 | import { linkFactory } from './transforms'; 29 | 30 | /** 31 | * The MIME type for Markdown. 32 | */ 33 | export const MIME_TYPE = 'text/markdown'; 34 | 35 | function getJupyterTheme(): Theme { 36 | if (typeof document === 'undefined') { 37 | return Theme.light; 38 | } 39 | return document.body.dataset.jpThemeLight === 'false' 40 | ? Theme.dark 41 | : Theme.light; 42 | } 43 | 44 | type Frontmatter = Omit & { 45 | parts?: FrontmatterParts; 46 | downloads?: SiteAction[]; 47 | exports?: SiteExport[]; 48 | }; 49 | 50 | // export interface IMySTFragmentContext extends ITaskItemController { 51 | // requestUpdate(renderer: RenderedMySTMarkdown): Promise; 52 | // setTaskItem(line: number, checked: boolean): void; 53 | // } 54 | 55 | export interface IMySTModel extends VDomRenderer.IModel { 56 | references?: References; 57 | mdast?: any; 58 | expressions?: IUserExpressionMetadata[]; 59 | frontmatter?: Frontmatter; 60 | readonly stateChanged: ISignal; 61 | } 62 | 63 | export class MySTModel extends VDomModel implements IMySTModel { 64 | private _references?: References; 65 | private _mdast?: any; 66 | private _expressions?: IUserExpressionMetadata[]; 67 | private _frontmatter?: Frontmatter; 68 | 69 | get references(): References | undefined { 70 | return this._references; 71 | } 72 | 73 | set references(value: References | undefined) { 74 | this._references = value; 75 | this.stateChanged.emit(); 76 | } 77 | 78 | get mdast(): any | undefined { 79 | return this._mdast; 80 | } 81 | 82 | set mdast(value: any | undefined) { 83 | this._mdast = value; 84 | this.stateChanged.emit(); 85 | } 86 | 87 | get expressions(): IUserExpressionMetadata[] | undefined { 88 | return this._expressions; 89 | } 90 | 91 | set expressions(value: IUserExpressionMetadata[] | undefined) { 92 | this._expressions = value; 93 | this.stateChanged.emit(); 94 | } 95 | 96 | get frontmatter(): Frontmatter | undefined { 97 | return this._frontmatter; 98 | } 99 | 100 | set frontmatter(value: Frontmatter | undefined) { 101 | this._frontmatter = value; 102 | this.stateChanged.emit(); 103 | } 104 | } 105 | 106 | export interface IMySTOptions { 107 | model?: IMySTModel; 108 | resolver?: IRenderMime.IResolver; 109 | linkHandler?: IRenderMime.ILinkHandler; 110 | rendermime?: IRenderMimeRegistry; 111 | trusted?: boolean; 112 | sanitizer?: ISanitizer; 113 | } 114 | 115 | function setTheme() {} 116 | 117 | /** 118 | * A mime renderer for displaying Markdown with embedded latex. 119 | */ 120 | export class MySTWidget extends VDomRenderer { 121 | /** 122 | * Construct a new MyST markdown widget. 123 | * 124 | * @param options - The options for initializing the widget. 125 | */ 126 | constructor(options: IMySTOptions) { 127 | const { model, resolver, linkHandler, rendermime, trusted, sanitizer } = 128 | options; 129 | super(model); 130 | 131 | this._resolver = resolver; 132 | this._linkHandler = linkHandler; 133 | this._rendermime = rendermime; 134 | this._trusted = trusted; 135 | this._sanitizer = sanitizer; 136 | this._taskItemController = change => this._taskItemChanged.emit(change); 137 | this.addClass('myst'); 138 | } 139 | 140 | private _trusted?: boolean = false; 141 | private readonly _resolver?: IRenderMime.IResolver; 142 | private readonly _linkHandler?: IRenderMime.ILinkHandler; 143 | private readonly _sanitizer?: IRenderMime.ISanitizer; 144 | private readonly _rendermime?: IRenderMimeRegistry; 145 | private readonly _taskItemChanged = new Signal(this); 146 | private readonly _taskItemController: ITaskItemController; 147 | 148 | get taskItemChanged(): ISignal { 149 | return this._taskItemChanged; 150 | } 151 | 152 | get trusted(): boolean | undefined { 153 | return this._trusted; 154 | } 155 | 156 | set trusted(value: boolean | undefined) { 157 | this._trusted = value; 158 | this.update(); 159 | } 160 | 161 | protected render(): React.JSX.Element { 162 | console.debug( 163 | 'Re-rendering VDOM for MySTWidget', 164 | this.model, 165 | this._trusted 166 | ); 167 | if (!this.model) { 168 | return MyST Renderer!; 169 | } 170 | const { references, frontmatter, mdast, expressions } = this.model; 171 | 172 | return ( 173 | 174 | 180 | 181 | 186 | 187 | 192 | {frontmatter && ( 193 | 194 | )} 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | ); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /examples/myst_tests.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "7fd00f2c-690c-4a9d-af67-c9fde0bfc86d", 6 | "metadata": { 7 | "tags": [] 8 | }, 9 | "source": [ 10 | "---\n", 11 | "title: Working with MyST Markdown\n", 12 | "subtitle: In JupyterLab\n", 13 | "doi: 10.14288/1.0362383\n", 14 | "license: CC-BY-4.0\n", 15 | "github: https://github.com/executablebooks/myst\n", 16 | "subject: Tutorial\n", 17 | "venue: Jupyter Journal\n", 18 | "biblio:\n", 19 | " volume: '1'\n", 20 | " issue: '1'\n", 21 | "authors:\n", 22 | " - name: Rowan Cockett\n", 23 | " email: rowan@curvenote.com\n", 24 | " corresponding: true\n", 25 | " orcid: 0000-0002-7859-8394\n", 26 | " affiliations:\n", 27 | " - Curvenote\n", 28 | " - ExecutableBooks\n", 29 | " - name: Steve Purves\n", 30 | " affiliations:\n", 31 | " - Curvenote\n", 32 | " - ExecutableBooks\n", 33 | "math:\n", 34 | " '\\dobs': '\\mathbf{d}_\\text{obs}'\n", 35 | " '\\dpred': '\\mathbf{d}_\\text{pred}\\left( #1 \\right)'\n", 36 | " '\\mref': '\\mathbf{m}_\\text{ref}'\n", 37 | "---" 38 | ] 39 | }, 40 | { 41 | "cell_type": "markdown", 42 | "id": "124203ea-d9d8-4c2b-91b7-eca7cd68ee3f", 43 | "metadata": {}, 44 | "source": [ 45 | ":::{note}\n", 46 | ":class: dropdown\n", 47 | "This is MyST in a notebook rendered by `jupyterlab-myst`!!\n", 48 | ":::" 49 | ] 50 | }, 51 | { 52 | "cell_type": "markdown", 53 | "id": "aed570c6", 54 | "metadata": {}, 55 | "source": [ 56 | "```{figure} https://picsum.photos/id/15/800/200\n", 57 | ":name: hello\n", 58 | ":width: 40%\n", 59 | "A nice waterfall 🌅!!\n", 60 | "```" 61 | ] 62 | }, 63 | { 64 | "cell_type": "markdown", 65 | "id": "d754aa61", 66 | "metadata": {}, 67 | "source": [ 68 | "```{figure} https://picsum.photos/id/28/600/300\n", 69 | ":name: fig4\n", 70 | ":width: 40%\n", 71 | "Relaxing in a jungle? \n", 72 | "```" 73 | ] 74 | }, 75 | { 76 | "cell_type": "markdown", 77 | "id": "b7e16780", 78 | "metadata": {}, 79 | "source": [ 80 | "This chart shows an example of `using` an interval **selection**[^1] to filter the contents of an attached histogram, allowing the user to see the proportion of items in each category within the selection. See more in the [Altair Documentation](https://altair-viz.github.io/gallery/selection_histogram.html)\n", 81 | "\n", 82 | "[^1]: Footnote text\n", 83 | "\n", 84 | "```{figure} https://picsum.photos/id/28/600/300\n", 85 | ":name: fig3\n", 86 | ":width: 80%\n", 87 | "Relaxing in a jungle?\n", 88 | "```\n", 89 | "\n", 90 | "```{math}\n", 91 | ":label: ok\n", 92 | "Ax=b\n", 93 | "```\n", 94 | "\n", 95 | "[hello](https://en.wikipedia.org/wiki/OK) or [code](https://github.com/executablebooks/mystjs/blob/main/packages/myst-directives/src/admonition.ts#L5)\n", 96 | "\n", 97 | "[](#ok) [](#hello) [](#cross)\n", 98 | "\n", 99 | "```{warning}\n", 100 | ":class: dropdown\n", 101 | "ok\n", 102 | "```" 103 | ] 104 | }, 105 | { 106 | "cell_type": "markdown", 107 | "id": "6b039ec2", 108 | "metadata": {}, 109 | "source": [ 110 | "The residual is the predicted data for the model, $\\dpred{m}$, minus the observed data, $\\dobs$. You can also calculate the predicted data for the reference model $\\dpred{\\mref}$." 111 | ] 112 | }, 113 | { 114 | "cell_type": "markdown", 115 | "id": "744002eb", 116 | "metadata": {}, 117 | "source": [ 118 | "For example, this [](#cross):\n", 119 | "\n", 120 | "```{math}\n", 121 | ":label: equation\n", 122 | "\\sqrt{\\frac{5}{2}}\n", 123 | "```" 124 | ] 125 | }, 126 | { 127 | "cell_type": "markdown", 128 | "id": "fc4bf610", 129 | "metadata": {}, 130 | "source": [ 131 | "For example, this equation:\n", 132 | "\n", 133 | "```{math}\n", 134 | ":label: cross\n", 135 | "\\mathbf{u} \\times \\mathbf{v}=\\left|\\begin{array}{ll}u_{2} & u_{3} \\\\ v_{2} & v_{3}\\end{array}\\right| \\mathbf{i}+\\left|\\begin{array}{ll}u_{3} & u_{1} \\\\ v_{3} & v_{1}\\end{array}\\right| \\mathbf{j}+\\left|\\begin{array}{ll}u_{1} & u_{2} \\\\ v_{1} & v_{2}\\end{array}\\right| \\mathbf{k}\n", 136 | "```" 137 | ] 138 | }, 139 | { 140 | "cell_type": "markdown", 141 | "id": "4c980678-c49c-41a5-a05d-0e38414ca27c", 142 | "metadata": {}, 143 | "source": [ 144 | "
    \n", 145 | "\n", 146 | "" 147 | ] 148 | }, 149 | { 150 | "cell_type": "markdown", 151 | "id": "656a66e1", 152 | "metadata": {}, 153 | "source": [ 154 | "## Tabs\n", 155 | "\n", 156 | "````{tab-set}\n", 157 | "```{tab-item} Tab 1\n", 158 | ":sync: tab1\n", 159 | "Tab one can sync (see below)!\n", 160 | "```\n", 161 | "```{tab-item} Tab 2\n", 162 | ":sync: tab2\n", 163 | "Tab two\n", 164 | "```\n", 165 | "````\n", 166 | "\n", 167 | "These tabs are set to sync:\n", 168 | "\n", 169 | "````{tab-set}\n", 170 | "```{tab-item} Tab 1 - Sync!\n", 171 | ":sync: tab1\n", 172 | "Tab one can sync!\n", 173 | "```\n", 174 | "```{tab-item} Tab 2\n", 175 | ":sync: tab2\n", 176 | "Tab two\n", 177 | "```\n", 178 | "````\n", 179 | "\n", 180 | "## Grids\n", 181 | "\n", 182 | "::::{grid} 1 1 2 3\n", 183 | "\n", 184 | ":::{grid-item-card}\n", 185 | "Text content ✏️\n", 186 | "^^^\n", 187 | "Structure books with text files and Jupyter Notebooks with minimal configuration.\n", 188 | ":::\n", 189 | "\n", 190 | ":::{grid-item-card}\n", 191 | "MyST Markdown ✨\n", 192 | "^^^\n", 193 | "Write MyST Markdown to create enriched documents with publication-quality features.\n", 194 | ":::\n", 195 | "\n", 196 | ":::{grid-item-card}\n", 197 | "Executable content 🔁\n", 198 | "^^^\n", 199 | "Execute notebook cells, store results, and insert outputs across pages.\n", 200 | ":::\n", 201 | "::::\n", 202 | "\n", 203 | "\n", 204 | "## Cards\n", 205 | "\n", 206 | "\n", 207 | ":::{card}\n", 208 | "MyST Markdown 🚀\n", 209 | "^^^\n", 210 | "Write content in JupyterLab!\n", 211 | ":::\n" 212 | ] 213 | } 214 | ], 215 | "metadata": { 216 | "kernelspec": { 217 | "display_name": "Python 3 (ipykernel)", 218 | "language": "python", 219 | "name": "python3" 220 | }, 221 | "language_info": { 222 | "codemirror_mode": { 223 | "name": "ipython", 224 | "version": 3 225 | }, 226 | "file_extension": ".py", 227 | "mimetype": "text/x-python", 228 | "name": "python", 229 | "nbconvert_exporter": "python", 230 | "pygments_lexer": "ipython3", 231 | "version": "3.13.7" 232 | }, 233 | "vscode": { 234 | "interpreter": { 235 | "hash": "a665b5d41d17b532ea9890333293a1b812fa0b73c9c25c950b3cedf1bebd0438" 236 | } 237 | }, 238 | "widgets": { 239 | "application/vnd.jupyter.widget-state+json": { 240 | "state": {}, 241 | "version_major": 2, 242 | "version_minor": 0 243 | } 244 | } 245 | }, 246 | "nbformat": 4, 247 | "nbformat_minor": 5 248 | } 249 | -------------------------------------------------------------------------------- /src/MySTMarkdownCell.tsx: -------------------------------------------------------------------------------- 1 | import { CellModel, MarkdownCell, MarkdownCellModel } from '@jupyterlab/cells'; 2 | import { StaticNotebook } from '@jupyterlab/notebook'; 3 | import { ActivityMonitor } from '@jupyterlab/coreutils'; 4 | import { AttachmentsResolver } from '@jupyterlab/attachments'; 5 | import { IMapChange } from '@jupyter/ydoc'; 6 | import { IMySTMarkdownCell } from './types'; 7 | import { getUserExpressions, metadataSection } from './userExpressions'; 8 | import { IMySTModel, MySTModel, MySTWidget } from './widget'; 9 | import { markdownParse, processCellMDAST, renderNotebook } from './myst'; 10 | import { IRenderMime } from '@jupyterlab/rendermime-interfaces'; 11 | import { ITaskItemChange } from './providers'; 12 | 13 | class IMySTWidget {} 14 | 15 | export class MySTMarkdownCell 16 | extends MarkdownCell 17 | implements IMySTMarkdownCell 18 | { 19 | private readonly _notebookRendermime; 20 | private readonly _attachmentsResolver: IRenderMime.IResolver; 21 | private readonly _mystWidget: MySTWidget; 22 | private _metadataJustChanged = false; 23 | private _fragmentMDAST: any | undefined; 24 | private _mystModel: IMySTModel; 25 | 26 | constructor(options: MarkdownCell.IOptions) { 27 | super(options); 28 | 29 | // We need the notebook's rendermime registry for widgets 30 | this._notebookRendermime = options.rendermime; 31 | // But, we also want a per-cell rendermime for resolving attachments 32 | this._attachmentsResolver = new AttachmentsResolver({ 33 | parent: options.rendermime.resolver ?? undefined, 34 | model: this.model.attachments 35 | }); 36 | 37 | // Create MyST renderer 38 | this._mystModel = new MySTModel(); 39 | this._mystWidget = new MySTWidget({ 40 | model: this._mystModel, 41 | rendermime: this._notebookRendermime, 42 | linkHandler: this._notebookRendermime.linkHandler ?? undefined, 43 | resolver: this._attachmentsResolver, 44 | trusted: this.model.trusted, 45 | sanitizer: this._notebookRendermime.sanitizer ?? undefined 46 | }); 47 | this._mystWidget.taskItemChanged.connect((caller, change) => 48 | this.setTaskItem(caller, change) 49 | ); 50 | 51 | // HACK: we don't use the rendermime system for output rendering. 52 | // Let's instead create an unused text/plain renderer 53 | this['_renderer'].dispose(); 54 | this['_renderer'] = this._notebookRendermime.createRenderer('text/plain'); 55 | 56 | // HACK: catch changes to the cell model trust 57 | (this.model as MarkdownCellModel).onTrustedChanged = () => 58 | this.onModelTrustedChanged(); 59 | 60 | // HACK: re-order signal handlers by recreating activity monitor, 61 | // and introduce veto for metadataonly changes 62 | (this as any)._monitor.dispose(); 63 | this['_monitor'] = this.createActivityMonitor(); 64 | 65 | // HACK: patch the callback for changes to `rendered` 66 | this['_handleRendered'] = this.onRenderedChanged; 67 | 68 | // We need to write the initial metadata values from the cell 69 | this.restoreExpressionsFromMetadata(); 70 | } 71 | 72 | private setTaskItem(_: IMySTWidget, change: ITaskItemChange): void { 73 | const text = this.model.sharedModel.getSource(); 74 | // This is a pretty cautious replacement for the identified line 75 | const lines = text.split('\n'); 76 | lines[change.line] = lines[change.line].replace( 77 | /^(\s*(?:-|\*)\s*)(\[[\s|x]\])/, 78 | change.checked ? '$1[x]' : '$1[ ]' 79 | ); 80 | // Update the Jupyter cell markdown value 81 | this.model.sharedModel.setSource(lines.join('\n')); 82 | } 83 | 84 | /** 85 | * Handle the rendered state. 86 | */ 87 | private async onRenderedChanged(): Promise { 88 | if (!this.rendered) { 89 | this.showEditor(); 90 | } else { 91 | if (this.placeholder) { 92 | await this.updateFragmentMDAST(); 93 | return; 94 | } 95 | 96 | if (this.rendered) { 97 | // The rendered flag may be updated in the mean time 98 | await this.render(); 99 | } 100 | } 101 | } 102 | 103 | private createActivityMonitor() { 104 | // HACK: activity monitor also triggers for metadata changes 105 | // So, let's re-order the signal registration so that metadata changes can 106 | // veto the delayed render 107 | const activityMonitor = new ActivityMonitor({ 108 | signal: this.model.contentChanged, 109 | // timeout: (this as any)._monitor.timeout 110 | // HACK: This generally makes the jupyter content update happen first 111 | // Which prevents a noticeable double render of the react component 112 | // This is really signalling the content change before the shift-enter 113 | timeout: 100 114 | }); 115 | // Throttle the rendering rate of the widget. 116 | this.ready 117 | .then(() => { 118 | if (this.isDisposed) { 119 | // Bail early 120 | return; 121 | } 122 | console.debug('ready and connected activityStopped signal'); 123 | activityMonitor.activityStopped.connect(() => { 124 | console.debug('Activity monitor expired'); 125 | if (this.rendered && !this._metadataJustChanged) { 126 | console.debug('Updating cell!'); 127 | this.update(); 128 | } 129 | this._metadataJustChanged = false; 130 | }, this); 131 | }) 132 | .catch(reason => { 133 | console.error('Failed to be ready', reason); 134 | }); 135 | return activityMonitor; 136 | } 137 | 138 | private restoreExpressionsFromMetadata() { 139 | const expressions = getUserExpressions(this); 140 | if (expressions !== undefined) { 141 | console.debug('Restoring expressions from metadata', expressions); 142 | this._mystWidget.model.expressions = expressions; 143 | } 144 | } 145 | 146 | private onModelTrustedChanged() { 147 | console.debug('trust changed', this.model.trusted); 148 | this._mystWidget.trusted = this.model.trusted; 149 | this.restoreExpressionsFromMetadata(); 150 | } 151 | 152 | /** 153 | * Handle changes in the metadata. 154 | */ 155 | protected onMetadataChanged(model: CellModel, args: IMapChange): void { 156 | console.debug('metadata changed', args); 157 | this._metadataJustChanged = true; 158 | switch (args.key) { 159 | case metadataSection: 160 | console.debug('metadata changed', args); 161 | this.restoreExpressionsFromMetadata(); 162 | break; 163 | default: 164 | super.onMetadataChanged(model, args); 165 | } 166 | } 167 | 168 | get attachmentsResolver(): IRenderMime.IResolver { 169 | return this._attachmentsResolver; 170 | } 171 | 172 | get mystModel(): IMySTModel { 173 | return this._mystModel; 174 | } 175 | 176 | set mystModel(model: IMySTModel) { 177 | if (model !== this._mystModel) { 178 | this._mystModel.dispose(); 179 | } 180 | this._mystModel = model; 181 | this._mystWidget.model = model; 182 | } 183 | 184 | get fragmentMDAST(): any { 185 | return this._fragmentMDAST; 186 | } 187 | 188 | /** 189 | * Update fragment MDAST from raw source of cell model. 190 | * This ensures that notebook-wide updates take the latest version of a cell. 191 | * Even if it is not yet rendered. 192 | */ 193 | async updateFragmentMDAST() { 194 | // Resolve per-cell MDAST 195 | let fragmentMDAST: any = markdownParse(this.model.sharedModel.getSource()); 196 | if (this._attachmentsResolver) { 197 | fragmentMDAST = await processCellMDAST( 198 | this._attachmentsResolver, 199 | fragmentMDAST 200 | ); 201 | } 202 | fragmentMDAST.type = 'block'; 203 | this._fragmentMDAST = fragmentMDAST; 204 | } 205 | 206 | async render() { 207 | await this.updateFragmentMDAST(); 208 | 209 | if (!this._mystWidget.node || !this.isAttached) { 210 | return; 211 | } 212 | 213 | // The notebook update is asynchronous 214 | await renderNotebook(this.parent as StaticNotebook); 215 | 216 | // Let's wait for this cell to be rendered 217 | await this._mystWidget.renderPromise; 218 | 219 | this.inputArea!.renderInput(this._mystWidget); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/myst.ts: -------------------------------------------------------------------------------- 1 | import { mystParse } from 'myst-parser'; 2 | import { copyNode, References } from 'myst-common'; 3 | import { 4 | abbreviationPlugin, 5 | basicTransformationsPlugin, 6 | DOITransformer, 7 | enumerateTargetsPlugin, 8 | footnotesPlugin, 9 | getFrontmatter, 10 | GithubTransformer, 11 | glossaryPlugin, 12 | keysPlugin, 13 | linksPlugin, 14 | mathPlugin, 15 | reconstructHtmlTransform, 16 | ReferenceState, 17 | resolveReferencesPlugin, 18 | RRIDTransformer, 19 | WikiTransformer 20 | } from 'myst-transforms'; 21 | import type { Root } from 'mdast'; 22 | import { unified } from 'unified'; 23 | import { VFile } from 'vfile'; 24 | import { PageFrontmatter, validatePageFrontmatter } from 'myst-frontmatter'; 25 | import { cardDirective } from 'myst-ext-card'; 26 | import { gridDirectives } from 'myst-ext-grid'; 27 | import { tabDirectives } from 'myst-ext-tabs'; 28 | import { proofDirective } from 'myst-ext-proof'; 29 | import { exerciseDirectives } from 'myst-ext-exercise'; 30 | import { StaticNotebook } from '@jupyterlab/notebook'; 31 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 32 | import { IRenderMime } from '@jupyterlab/rendermime-interfaces'; 33 | import { 34 | imageUrlSourceTransform, 35 | internalLinksTransform, 36 | addCiteChildrenPlugin 37 | } from './transforms'; 38 | import { IUserExpressionMetadata } from './userExpressions'; 39 | import { IMySTMarkdownCell } from './types'; 40 | import { Cell, ICellModel } from '@jupyterlab/cells'; 41 | import { MySTModel } from './widget'; 42 | 43 | export interface IMySTDocumentState { 44 | references: References; 45 | frontmatter: PageFrontmatter; 46 | mdast: any; 47 | } 48 | export interface IMySTExpressionsState { 49 | expressions: IUserExpressionMetadata[]; 50 | rendermime: IRenderMimeRegistry; 51 | trusted: boolean; 52 | } 53 | 54 | export function markdownParse(text: string): Root { 55 | const parseMyst = (content: string) => { 56 | return mystParse(content, { 57 | markdownit: { linkify: true }, 58 | directives: [ 59 | cardDirective, 60 | ...gridDirectives, 61 | proofDirective, 62 | ...exerciseDirectives, 63 | ...tabDirectives 64 | ], 65 | roles: [] 66 | }); 67 | }; 68 | 69 | const mdast = parseMyst(text); 70 | // Parsing individually here requires that link and footnote references are contained to the cell 71 | // This is consistent with the current Jupyter markdown renderer 72 | unified() 73 | .use(basicTransformationsPlugin, { 74 | parser: parseMyst 75 | }) 76 | .runSync(mdast as any); 77 | return mdast as Root; 78 | } 79 | 80 | /** 81 | * Called when processing a full markdown article. 82 | */ 83 | export async function processArticleMDAST( 84 | mdast: any, 85 | resolver: IRenderMime.IResolver | undefined 86 | ): Promise { 87 | mdast = copyNode(mdast); 88 | const linkTransforms = [ 89 | new WikiTransformer(), 90 | new GithubTransformer(), 91 | new DOITransformer(), 92 | new RRIDTransformer() 93 | ]; 94 | const file = new VFile(); 95 | const references = { 96 | cite: { order: [], data: {} }, 97 | article: mdast as any 98 | }; 99 | 100 | const { frontmatter: frontmatterRaw } = getFrontmatter(file, mdast); 101 | // unnestKernelSpec(rawPageFrontmatter); 102 | const frontmatter = validatePageFrontmatter(frontmatterRaw, { 103 | property: 'frontmatter', 104 | messages: {} 105 | }); 106 | 107 | const state = new ReferenceState('', { 108 | frontmatter, 109 | vfile: file 110 | }); 111 | unified() 112 | .use(mathPlugin, { macros: frontmatter?.math ?? {} }) // This must happen before enumeration, as it can add labels 113 | .use(glossaryPlugin) // This should be before the enumerate plugins 114 | .use(abbreviationPlugin, { abbreviations: frontmatter.abbreviations }) 115 | .use(enumerateTargetsPlugin, { state }) 116 | .use(linksPlugin, { transformers: linkTransforms }) 117 | .use(footnotesPlugin) 118 | .use(resolveReferencesPlugin, { state }) 119 | .use(addCiteChildrenPlugin) 120 | .use(keysPlugin) 121 | .runSync(mdast as any, file); 122 | 123 | // Go through all links and replace the source if they are local 124 | await internalLinksTransform(mdast, { resolver }); 125 | await imageUrlSourceTransform(mdast, { resolver }); 126 | 127 | // Fix inline html 128 | reconstructHtmlTransform(mdast); 129 | 130 | return { 131 | references, 132 | frontmatter, 133 | mdast 134 | }; 135 | } 136 | 137 | function isMySTMarkdownCell(cell: Cell): cell is IMySTMarkdownCell { 138 | return cell.model.type === 'markdown'; 139 | } 140 | 141 | export function buildNotebookMDAST(mystCells: IMySTMarkdownCell[]): any { 142 | const blocks = mystCells.map(cell => copyNode(cell.fragmentMDAST)); 143 | return { type: 'root', children: blocks }; 144 | } 145 | 146 | export async function processNotebookMDAST( 147 | mdast: any, 148 | resolver: IRenderMime.IResolver | undefined 149 | ): Promise { 150 | const linkTransforms = [ 151 | new WikiTransformer(), 152 | new GithubTransformer(), 153 | new DOITransformer(), 154 | new RRIDTransformer() 155 | ]; 156 | const file = new VFile(); 157 | const references = { 158 | cite: { order: [], data: {} }, 159 | article: mdast as any 160 | }; 161 | const { frontmatter: frontmatterRaw } = getFrontmatter( 162 | file, 163 | // This is the first cell, which might have a YAML block or header. 164 | mdast.children[0] as any 165 | ); 166 | 167 | const frontmatter = validatePageFrontmatter(frontmatterRaw, { 168 | property: 'frontmatter', 169 | messages: {} 170 | }); 171 | 172 | const state = new ReferenceState('', { 173 | frontmatter, 174 | vfile: file 175 | }); 176 | 177 | unified() 178 | .use(mathPlugin, { macros: frontmatter?.math ?? {} }) // This must happen before enumeration, as it can add labels 179 | .use(glossaryPlugin) // This should be before the enumerate plugins 180 | .use(abbreviationPlugin, { abbreviations: frontmatter.abbreviations }) 181 | .use(enumerateTargetsPlugin, { state }) 182 | .use(linksPlugin, { transformers: linkTransforms }) 183 | .use(footnotesPlugin) 184 | .use(resolveReferencesPlugin, { state }) 185 | .use(addCiteChildrenPlugin) 186 | .use(keysPlugin) 187 | .runSync(mdast as any, file); 188 | 189 | await internalLinksTransform(mdast, { resolver }); 190 | 191 | // Fix inline html 192 | reconstructHtmlTransform(mdast); 193 | 194 | if (file.messages.length > 0) { 195 | // TODO: better error messages in the future 196 | console.error(file.messages.map(m => m.message).join('\n')); 197 | } 198 | 199 | return { references, frontmatter, mdast }; 200 | } 201 | 202 | export async function processCellMDAST( 203 | resolver: IRenderMime.IResolver, 204 | mdast: any 205 | ) { 206 | mdast = copyNode(mdast); 207 | try { 208 | // Go through all links and replace the source if they are local 209 | await imageUrlSourceTransform(mdast as any, { 210 | resolver: resolver 211 | }); 212 | } catch (error) { 213 | // pass 214 | } 215 | 216 | return mdast; 217 | } 218 | 219 | export async function renderNotebook(notebook: StaticNotebook) { 220 | const mystCells = notebook.widgets.filter(isMySTMarkdownCell).filter( 221 | // In the future, we may want to process the code cells as well, but not now 222 | cell => cell.fragmentMDAST !== undefined 223 | ); 224 | const mdast = buildNotebookMDAST(mystCells); 225 | const { 226 | references, 227 | frontmatter, 228 | mdast: processedMDAST 229 | } = await processNotebookMDAST( 230 | mdast, 231 | notebook.rendermime.resolver ?? undefined 232 | ); 233 | 234 | mystCells.forEach((cell, index) => { 235 | if (cell.rendered) { 236 | const nextModel = new MySTModel(); 237 | nextModel.references = references; 238 | nextModel.frontmatter = 239 | // FIXME: is it possible that forcing the types to agree is a hack? /s 240 | index === 0 ? (frontmatter as MySTModel['frontmatter']) : undefined; 241 | nextModel.mdast = processedMDAST.children[index]; 242 | nextModel.expressions = cell.mystModel.expressions; 243 | cell.mystModel = nextModel; 244 | } 245 | }); 246 | } 247 | -------------------------------------------------------------------------------- /package.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | name: 'jupyterlab-myst', 3 | version: '2.4.2', 4 | description: 'Use MyST in JupyterLab', 5 | keywords: [ 6 | 'jupyter', 7 | 'jupyterlab', 8 | 'jupyterlab-extension', 9 | ], 10 | homepage: 'https://github.com/jupyter-book/jupyterlab-myst', 11 | bugs: { 12 | url: 'https://github.com/jupyter-book/jupyterlab-myst/issues', 13 | }, 14 | license: 'MIT', 15 | author: { 16 | name: 'Executable Book Project', 17 | email: 'executablebooks@gmail.com', 18 | }, 19 | files: [ 20 | 'lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}', 21 | 'style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}', 22 | 'style/index.js', 23 | ], 24 | main: 'lib/index.js', 25 | types: 'lib/index.d.ts', 26 | style: 'style/index.css', 27 | repository: { 28 | type: 'git', 29 | url: 'https://github.com/jupyter-book/jupyterlab-myst.git', 30 | }, 31 | scripts: { 32 | build: 'npm run build:css && npm run build:lib && npm run build:labextension:dev', 33 | 'build:css': 'tailwindcss -m -i ./style/tailwind.css -o style/app.css', 34 | 'build:labextension': 'jupyter labextension build .', 35 | 'build:labextension:dev': 'jupyter labextension build --development True .', 36 | 'build:lib': 'tsc --sourceMap', 37 | 'build:lib:prod': 'tsc', 38 | 'build:prod': 'npm run clean && npm run build:css && npm run build:lib:prod && npm run build:labextension', 39 | clean: 'npm run clean:lib', 40 | 'clean:all': 'npm run clean:lib && npm run clean:labextension && npm run clean:lintcache', 41 | 'clean:labextension': 'rimraf jupyterlab_myst/labextension jupyterlab_myst/_version.py', 42 | 'clean:lib': 'rimraf lib tsconfig.tsbuildinfo', 43 | 'clean:lintcache': 'rimraf .eslintcache .stylelintcache', 44 | eslint: 'npm run eslint:check --fix', 45 | 'eslint:check': 'eslint . --cache --ext .ts,.tsx', 46 | 'install:extension': 'npm run build', 47 | lint: 'npm run stylelint && npm run prettier && npm run eslint', 48 | 'lint:check': 'npm run stylelint:check && npm run prettier:check && npm run eslint:check', 49 | prettier: 'npm run prettier:base --write --list-different', 50 | 'prettier:base': 'prettier "**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}"', 51 | 'prettier:check': 'npm run prettier:base --check', 52 | stylelint: 'npm run stylelint:check --fix', 53 | 'stylelint:check': 'stylelint --cache "style/**/*.css"', 54 | test: 'jest --coverage', 55 | watch: 'run-p watch:css watch:src watch:labextension', 56 | 'watch:css': 'tailwindcss -w -i ./style/tailwind.css -o style/app.css', 57 | 'watch:labextension': 'jupyter labextension watch .', 58 | 'watch:src': 'tsc -w --sourceMap', 59 | }, 60 | overrides: { 61 | 'vscode-jsonrpc': '^6.0.0', 62 | }, 63 | // Define grouped dependencies 64 | local groups = [ 65 | { 66 | version: '^4.0.0', 67 | dependencies: [ 68 | '@jupyterlab/application', 69 | '@jupyterlab/apputils', 70 | '@jupyterlab/codeeditor', 71 | '@jupyterlab/markdownviewer', 72 | '@jupyterlab/notebook', 73 | '@jupyterlab/rendermime', 74 | '@jupyterlab/translation', 75 | ], 76 | }, 77 | { 78 | version: '1.0.0', 79 | dependencies: [ 80 | '@myst-theme/diagrams', 81 | '@myst-theme/frontmatter', 82 | '@myst-theme/providers', 83 | // These types are needed for now 84 | '@myst-theme/search', 85 | 'myst-to-react', 86 | 87 | ], 88 | }, 89 | { 90 | version: '1.9.2', 91 | dependencies: [ 92 | 93 | 'myst-common', 94 | 'myst-config', 95 | 'myst-frontmatter', 96 | 'myst-spec-ext' 97 | ], 98 | }, 99 | { 100 | version: '1.6.3', 101 | dependencies: [ 102 | 'myst-parser', 103 | 'myst-to-html', 104 | ], 105 | }, 106 | ], 107 | dependencies: { 108 | [n]: g.version 109 | for g in groups 110 | for n in g.dependencies 111 | } + { 112 | katex: '^0.16.22', 113 | // Individually versioned extensions 114 | 'myst-ext-button': '0.0.1', 115 | 'myst-ext-card': '1.0.9', 116 | 'myst-ext-exercise': '1.0.9', 117 | 'myst-ext-grid': '1.0.9', 118 | 'myst-ext-icon': '0.0.2', 119 | 'myst-ext-proof': '1.0.12', 120 | 'myst-ext-tabs': '1.0.9', 121 | // Floating transforms 122 | 'myst-transforms': '1.3.44', 123 | }, 124 | devDependencies: { 125 | '@babel/core': '^7.0.0', 126 | '@babel/preset-env': '^7.0.0', 127 | '@jupyterlab/builder': '^4.0.0', 128 | '@jupyterlab/testutils': '^4.0.0', 129 | '@myst-theme/styles': '>=0.9.0 <1.0.0', 130 | '@tailwindcss/typography': '^0.5.8', 131 | '@types/jest': '^29.2.0', 132 | '@types/json-schema': '^7.0.11', 133 | '@types/react': '^18.0.26', 134 | '@types/react-addons-linked-state-mixin': '^0.14.22', 135 | '@types/react-dom': '^18.0.9', 136 | '@typescript-eslint/eslint-plugin': '^6.1.0', 137 | '@typescript-eslint/parser': '^6.1.0', 138 | 'css-loader': '^6.7.1', 139 | eslint: '^8.36.0', 140 | 'eslint-config-prettier': '^8.8.0', 141 | 'eslint-plugin-prettier': '^5.0.0', 142 | jest: '^29.2.0', 143 | mkdirp: '^1.0.3', 144 | 'npm-run-all': '^4.1.5', 145 | prettier: '^3.0.0', 146 | rimraf: '^5.0.1', 147 | 'source-map-loader': '^1.0.2', 148 | 'style-loader': '^3.3.1', 149 | stylelint: '^15.10.1', 150 | 'stylelint-config-recommended': '^13.0.0', 151 | 'stylelint-config-standard': '^34.0.0', 152 | 'stylelint-csstree-validator': '^3.0.0', 153 | 'stylelint-prettier': '^4.0.0', 154 | tailwindcss: '^3.2.4', 155 | 'ts-jest': '^29.1.0', 156 | typescript: '~5.8.0', 157 | yjs: '^13.5.40', 158 | }, 159 | sideEffects: [ 160 | 'style/*.css', 161 | 'style/index.js', 162 | ], 163 | styleModule: 'style/index.js', 164 | publishConfig: { 165 | access: 'public', 166 | }, 167 | jupyterlab: { 168 | extension: true, 169 | outputDir: 'jupyterlab_myst/labextension', 170 | }, 171 | eslintConfig: { 172 | extends: [ 173 | 'eslint:recommended', 174 | 'plugin:@typescript-eslint/eslint-recommended', 175 | 'plugin:@typescript-eslint/recommended', 176 | 'plugin:prettier/recommended', 177 | ], 178 | parser: '@typescript-eslint/parser', 179 | parserOptions: { 180 | project: 'tsconfig.json', 181 | sourceType: 'module', 182 | }, 183 | plugins: [ 184 | '@typescript-eslint', 185 | ], 186 | rules: { 187 | '@typescript-eslint/naming-convention': [ 188 | 'error', 189 | { 190 | selector: 'interface', 191 | format: [ 192 | 'PascalCase', 193 | ], 194 | custom: { 195 | regex: '^I[A-Z]', 196 | match: true, 197 | }, 198 | }, 199 | ], 200 | '@typescript-eslint/no-unused-vars': [ 201 | 'warn', 202 | { 203 | args: 'none', 204 | }, 205 | ], 206 | '@typescript-eslint/no-explicit-any': 'off', 207 | '@typescript-eslint/no-namespace': 'off', 208 | '@typescript-eslint/no-use-before-define': 'off', 209 | '@typescript-eslint/quotes': [ 210 | 'error', 211 | 'single', 212 | { 213 | avoidEscape: true, 214 | allowTemplateLiterals: false, 215 | }, 216 | ], 217 | curly: [ 218 | 'error', 219 | 'all', 220 | ], 221 | eqeqeq: 'error', 222 | 'prefer-arrow-callback': 'error', 223 | }, 224 | }, 225 | eslintIgnore: [ 226 | 'node_modules', 227 | 'dist', 228 | 'coverage', 229 | '**/*.d.ts', 230 | 'tests', 231 | '**/__tests__', 232 | 'ui-tests', 233 | ], 234 | prettier: { 235 | singleQuote: true, 236 | trailingComma: 'none', 237 | arrowParens: 'avoid', 238 | endOfLine: 'auto', 239 | overrides: [ 240 | { 241 | files: 'package.json', 242 | options: { 243 | tabWidth: 4, 244 | }, 245 | }, 246 | ], 247 | }, 248 | stylelint: { 249 | extends: [ 250 | 'stylelint-config-recommended', 251 | 'stylelint-config-standard', 252 | 'stylelint-prettier/recommended', 253 | ], 254 | plugins: [ 255 | 'stylelint-csstree-validator', 256 | ], 257 | rules: { 258 | 'csstree/validator': true, 259 | 'property-no-vendor-prefix': null, 260 | 'selector-class-pattern': '^([a-z][A-z\\d]*)(-[A-z\\d]+)*$', 261 | 'selector-no-vendor-prefix': null, 262 | 'value-no-vendor-prefix': null, 263 | }, 264 | }, 265 | } 266 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JupyterLab MyST Extension 2 | 3 | [![Made with MyST][myst-badge]][myst-link] 4 | [![GitHub Actions Status][actions-badge]][actions-link] 5 | [![Launch on Binder][binder-badge]][binder-link] 6 | [![PyPI][pypi-badge]][pypi-link] 7 | 8 | Render markdown cells using [MyST Markdown](https://mystmd.org/), including support for rich frontmatter, interactive references, admonitions, figure numbering, tabs, proofs, exercises, glossaries, cards, and grids! 9 | 10 | ![](./images/walkthrough.gif) 11 | 12 | > **Note**: If you are looking for the version of this repository based on jupyterlab-markup, 13 | > see the [`v0 branch`](https://github.com/executablebooks/jupyterlab-myst/tree/v0). 14 | 15 | > **Info** 16 | > This extension is composed of a Python package named `jupyterlab_myst` 17 | > for the server extension and a NPM package named `jupyterlab-myst` 18 | > for the frontend extension. 19 | 20 | ## Requirements 21 | 22 | - JupyterLab >= 4.0.0 23 | 24 | ## Install 25 | 26 | To install the extension, execute: 27 | 28 | ```bash 29 | pip install jupyterlab_myst 30 | ``` 31 | 32 | ## Features 33 | 34 | `jupyterlab-myst` is a fully featured markdown renderer for technical documents, [get started with MyST Markdown](https://mystmd.org/guide/quickstart-myst-markdown). It supports the MyST `{eval}` inline role, which facilitates the interweaving of code outputs and prose. For example, we can use inline expressions to explore the properties of a NumPy array. 35 | 36 | In the code cell: 37 | 38 | ```python 39 | import numpy as np 40 | array = np.arange(4) 41 | ``` 42 | 43 | In the markdown cell: 44 | 45 | ```markdown 46 | Let's consider the following array: {eval}`array`. 47 | 48 | We can compute the total: {eval}`array.sum()` and the maximum value is {eval}`array.max()`. 49 | ``` 50 | 51 | This will evaluate inline, and show: 52 | 53 | ```text 54 | Let's consider the following array: array([0, 1, 2, 3]). 55 | 56 | We can compute the total: 6 and the maximum value is 3. 57 | ``` 58 | 59 | You can also use this with `ipywidgets`, and have inline interactive text: 60 | 61 | ![](./images/cookies.gif) 62 | 63 | Or with `matplotlib` to show inline spark-lines: 64 | 65 | ![](./images/stock-price.gif) 66 | 67 | You can also edit task lists directly in the rendered markdown. 68 | 69 | ![](./images/tasklists-in-jupyterlab.gif) 70 | 71 | ## Usage 72 | 73 | [MyST][myst-quickstart] is a flavour of Markdown, which combines the fluid experience of writing Markdown with the programmable extensibility of reStructuredText. This extension for JupyterLab makes it easier to develop rich, computational narratives, technical documentation, and open scientific communication. 74 | 75 | ### Execution 🚀 76 | 77 | To facilitate inline expressions, `jupyterlab-myst` defines a `jupyterlab-myst:executor` plugin. This plugin sends expression code fragments to the active kernel when the user "executes" a Markdown cell. To disable this functionality, disable the `jupyterlab-myst:executor` plugin with: 78 | 79 | ```bash 80 | jupyter labextension disable jupyterlab-myst:executor 81 | ``` 82 | 83 | ### Trust 🔎 84 | 85 | Jupyter Notebooks implement a [trust-based security model](https://jupyter-server.readthedocs.io/en/stable/operators/security.html). With the addition of inline expressions, Markdown cells are now considered when determining whether a given notebook is "trusted". Any Markdown cell with inline-expression metadata (with display data) is considered "untrusted". Like outputs, expression results are rendered using safe renderers if the cell is not considered trusted. 86 | Executing the notebook will cause each cell to be considered trusted. 87 | 88 | To facilitate this extension of the trust model, the `jupyterlab_myst` server extension replaces the `NotebookNotary` from `nbformat` with `MySTNotebookNotary`. This can be disabled with 89 | 90 | ```bash 91 | jupyter server extension disable jupyterlab-myst 92 | ``` 93 | 94 | By disabling this extension, it will not be possible to render unsafe expression results from inline expressions; the `MySTNotebookNotary` adds additional code that makes it possible to mark Markdown cells as trusted. 95 | 96 | ## Uninstall 97 | 98 | To remove the extension, execute: 99 | 100 | ```bash 101 | pip uninstall jupyterlab_myst 102 | ``` 103 | 104 | ## Troubleshoot 105 | 106 | If you are seeing the frontend extension, but it is not working, check 107 | that the server extension is enabled: 108 | 109 | ```bash 110 | jupyter server extension list 111 | ``` 112 | 113 | If the server extension is installed and enabled, but you are not seeing 114 | the frontend extension, check the frontend extension is installed: 115 | 116 | ```bash 117 | jupyter labextension list 118 | ``` 119 | 120 | ## Contributing 121 | 122 | > [!IMPORTANT] 123 | > jupyterlab-myst uses `jsonnet` to render `package.jsonnet` into `package.json` 124 | 125 | ### Development install 126 | 127 | Note: You will need NodeJS to build the extension package. 128 | 129 | ```bash 130 | # Clone the repo to your local environment 131 | # Change directory to the jupyterlab_myst directory 132 | # Install package in development mode 133 | pip install -e ".[test]" 134 | # Link your development version of the extension with JupyterLab 135 | jupyter labextension develop . --overwrite 136 | # Server extension must be manually installed in develop mode 137 | jupyter server extension enable jupyterlab_myst 138 | # Rebuild extension Typescript source after making changes 139 | npm run build 140 | ``` 141 | 142 | You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension. 143 | 144 | ```bash 145 | # Watch the source directory in one terminal, automatically rebuilding when needed 146 | npm run watch 147 | # Run JupyterLab in another terminal 148 | jupyter lab 149 | ``` 150 | 151 | With the watch command running, every saved change will immediately be built locally and available in your running JupyterLab. Refresh JupyterLab to load the change in your browser (you may need to wait several seconds for the extension to be rebuilt). 152 | 153 | By default, the `npm run build` command generates the source maps for this extension to make it easier to debug using the browser dev tools. To also generate source maps for the JupyterLab core extensions, you can run the following command: 154 | 155 | ```bash 156 | jupyter lab build --minimize=False 157 | ``` 158 | 159 | ### Development uninstall 160 | 161 | ```bash 162 | # Server extension must be manually disabled in develop mode 163 | jupyter server extension disable jupyterlab_myst 164 | pip uninstall jupyterlab_myst 165 | ``` 166 | 167 | In development mode, you will also need to remove the symlink created by `jupyter labextension develop` 168 | command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions` 169 | folder is located. Then you can remove the symlink named `jupyterlab-myst` within that folder. 170 | 171 | ### Testing the extension 172 | 173 | #### Server tests 174 | 175 | This extension is using [Pytest](https://docs.pytest.org/) for Python code testing. 176 | 177 | Install test dependencies (needed only once): 178 | 179 | ```sh 180 | pip install -e ".[test]" 181 | # Each time you install the Python package, you need to restore the front-end extension link 182 | jupyter labextension develop . --overwrite 183 | ``` 184 | 185 | To execute them, run: 186 | 187 | ```sh 188 | pytest -vv -r ap --cov jupyterlab_myst 189 | ``` 190 | 191 | #### Frontend tests 192 | 193 | This extension is using [Jest](https://jestjs.io/) for JavaScript code testing. 194 | 195 | To execute them, execute: 196 | 197 | ```sh 198 | npm install 199 | npm test 200 | ``` 201 | 202 | #### Integration tests 203 | 204 | This extension uses [Playwright](https://playwright.dev/docs/intro) for the integration tests (aka user level tests). 205 | More precisely, the JupyterLab helper [Galata](https://github.com/jupyterlab/jupyterlab/tree/master/galata) is used to handle testing the extension in JupyterLab. 206 | 207 | More information are provided within the [ui-tests](./ui-tests/README.md) README. 208 | 209 | ### Packaging the extension 210 | 211 | See [RELEASE](RELEASE.md) 212 | 213 | [myst-badge]: https://img.shields.io/badge/made%20with-myst-orange 214 | [myst-link]: https://mystmd.org 215 | [myst-quickstart]: https://mystmd.org/guide/quickstart-myst-markdown 216 | [actions-badge]: https://github.com/executablebooks/jupyterlab-myst/workflows/Build/badge.svg 217 | [actions-link]: https://github.com/executablebooks/jupyterlab-myst/actions/workflows/build.yml 218 | [binder-badge]: https://mybinder.org/badge_logo.svg 219 | [binder-link]: https://mybinder.org/v2/gh/executablebooks/jupyterlab-myst/main?urlpath=lab 220 | [pypi-badge]: https://img.shields.io/pypi/v/jupyterlab-myst.svg 221 | [pypi-link]: https://pypi.org/project/jupyterlab-myst 222 | -------------------------------------------------------------------------------- /src/components/inlineExpression.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { useUserExpressions } from '../providers'; 3 | import { SingletonLayout, Widget } from '@lumino/widgets'; 4 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 5 | import { 6 | IUserExpressionMetadata, 7 | IExpressionError, 8 | IExpressionResult, 9 | isError, 10 | isOutput 11 | } from '../userExpressions'; 12 | 13 | export interface IRenderedExpressionOptions { 14 | expression: string; 15 | trusted: boolean; 16 | rendermime: IRenderMimeRegistry; 17 | safe?: 'ensure' | 'prefer' | 'any'; 18 | } 19 | 20 | export class RenderedExpressionError extends Widget { 21 | constructor() { 22 | super(); 23 | this.addClass('myst-RenderedExpressionError'); 24 | } 25 | } 26 | 27 | export class RenderedExpression extends Widget { 28 | readonly expression: string; 29 | readonly trusted: boolean; 30 | readonly rendermime: IRenderMimeRegistry; 31 | readonly safe?: 'ensure' | 'prefer' | 'any'; 32 | 33 | constructor(options: IRenderedExpressionOptions) { 34 | super(); 35 | 36 | this.trusted = options.trusted; 37 | this.expression = options.expression; 38 | this.rendermime = options.rendermime; 39 | this.safe = options.safe; 40 | 41 | this.addClass('myst-RenderedExpression'); 42 | 43 | // We can only hold one renderer at a time 44 | const layout = (this.layout = new SingletonLayout()); 45 | layout.widget = new RenderedExpressionError(); 46 | } 47 | 48 | renderExpression(payload: IExpressionResult): Promise { 49 | const layout = this.layout as SingletonLayout; 50 | 51 | if (!layout) { 52 | console.error('Our layout is already disposed!!'); 53 | return Promise.resolve(); 54 | } 55 | 56 | if (this.isDisposed) { 57 | console.error('Our layout is already disposed!!'); 58 | return Promise.resolve(); 59 | } 60 | 61 | let options: any; 62 | if (isOutput(payload)) { 63 | // Output results are simple to reinterpret 64 | options = { 65 | trusted: this.trusted, 66 | data: payload.data, 67 | metadata: payload.metadata 68 | }; 69 | } else { 70 | // Errors need to be formatted as stderr objects 71 | // Note, this may no longer be necessary as errors are explicitly rendered 72 | options = { 73 | data: { 74 | 'application/vnd.jupyter.stderr': 75 | payload.traceback.join('\n') || 76 | `${payload.ename}: ${payload.evalue}` 77 | } 78 | }; 79 | } 80 | 81 | // Invoke MIME renderer 82 | const model = this.rendermime.createModel(options); 83 | 84 | // Select preferred mimetype for bundle 85 | const mimeType = this.rendermime.preferredMimeType(model.data, this.safe); 86 | if (mimeType === undefined) { 87 | console.error("Couldn't find mimetype for ", model); 88 | 89 | // Create error 90 | layout.widget = new RenderedExpressionError(); 91 | return Promise.resolve(); 92 | } 93 | 94 | // Create renderer 95 | const renderer = this.rendermime.createRenderer(mimeType); 96 | layout.widget = renderer; 97 | console.assert(renderer.isAttached, 'renderer was not attached!', renderer); 98 | // Render model 99 | return renderer.renderModel(model); 100 | } 101 | } 102 | 103 | function PlainTextRenderer({ content }: { content: string }) { 104 | content = content.replace(/^(["'])(.*)\1$/, '$2'); 105 | return {content}; 106 | } 107 | 108 | /** 109 | * The `ErrorRenderer` does a slightly better job of showing errors inline than Jupyter's widget view. 110 | */ 111 | function ErrorRenderer({ error }: { error: IExpressionError }) { 112 | return ( 113 | 122 | {error.ename}: {error.evalue} 123 | 124 | ); 125 | } 126 | 127 | function MIMEBundleRenderer({ 128 | rendermime, 129 | trusted, 130 | expressionMetadata 131 | }: { 132 | rendermime: IRenderMimeRegistry; 133 | trusted: boolean; 134 | expressionMetadata: IUserExpressionMetadata; 135 | }) { 136 | const ref = useRef(null); 137 | const [renderer, setRenderer] = useState( 138 | undefined 139 | ); 140 | 141 | // Create renderer 142 | useEffect(() => { 143 | console.debug( 144 | `Creating inline renderer for \`${expressionMetadata.expression}\`` 145 | ); 146 | const thisRenderer = new RenderedExpression({ 147 | expression: expressionMetadata.expression, 148 | trusted, 149 | rendermime, 150 | safe: 'any' 151 | }); 152 | setRenderer(thisRenderer); 153 | 154 | return () => { 155 | if (thisRenderer.isAttached && !thisRenderer.node.isConnected) { 156 | console.error( 157 | `Could not dispose of renderer for \`${expressionMetadata.expression}\`: node is not connected` 158 | ); 159 | } else { 160 | thisRenderer.dispose(); 161 | } 162 | }; 163 | }, [rendermime, expressionMetadata]); 164 | 165 | // Attach when ref changes 166 | useEffect(() => { 167 | const thisRenderer = renderer; 168 | if (!ref.current || !thisRenderer) { 169 | console.debug( 170 | `Cannot attach expression renderer for \`${expressionMetadata.expression}\`` 171 | ); 172 | return; 173 | } 174 | if (thisRenderer.isAttached) { 175 | console.error( 176 | `Expression renderer for \`${expressionMetadata.expression}\` is already attached to another node` 177 | ); 178 | } 179 | Widget.attach(thisRenderer, ref.current); 180 | console.debug( 181 | `Attached expression renderer for \`${expressionMetadata.expression}\` to parent widget` 182 | ); 183 | 184 | return () => { 185 | // Widget may also be detached through disposal above 186 | if (thisRenderer.isAttached && !thisRenderer.node.isConnected) { 187 | console.error( 188 | `Unable to detach expression renderer for \`${expressionMetadata.expression}\`: node is not connected` 189 | ); 190 | } else if (thisRenderer.isAttached) { 191 | console.debug( 192 | `Detaching expression renderer for \`${expressionMetadata.expression}\`` 193 | ); 194 | Widget.detach(thisRenderer); 195 | } 196 | }; 197 | }, [ref, renderer]); 198 | 199 | // Attach and render the widget when the expression result changes 200 | useEffect(() => { 201 | if (!renderer || !expressionMetadata) { 202 | console.debug( 203 | `Cannot render expression \`${expressionMetadata.expression}\`` 204 | ); 205 | return; 206 | } 207 | renderer.renderExpression(expressionMetadata.result); 208 | }, [renderer, expressionMetadata]); 209 | 210 | console.debug( 211 | `Rendering MIME bundle for expression: '${expressionMetadata.expression}'` 212 | ); 213 | return
    ; 214 | } 215 | 216 | export function InlineExpression({ value }: { value?: string }): JSX.Element { 217 | const { expressions, rendermime, trusted } = useUserExpressions(); 218 | 219 | if (!expressions || !rendermime) { 220 | return {value}; 221 | } 222 | 223 | // Find the expressionResult that is for this node 224 | const expressionMetadata = expressions?.find(p => p.expression === value); 225 | const mimeBundle = expressionMetadata?.result.data as 226 | | Record 227 | | undefined; 228 | 229 | console.debug( 230 | `Rendering \`${value}\` inline ${trusted ? 'with' : 'without'} trust` 231 | ); 232 | if (!expressionMetadata) { 233 | console.debug('No metadata for', value); 234 | return {value}; 235 | } 236 | 237 | console.debug(`Using MIME bundle for \`${value}\``, mimeBundle); 238 | 239 | // Explicitly render text/plain 240 | const preferred = rendermime.preferredMimeType( 241 | mimeBundle ?? {}, 242 | trusted ? 'any' : 'ensure' 243 | ); 244 | if (preferred === 'text/plain') { 245 | return ; 246 | } 247 | // Explicitly render errors 248 | if (isError(expressionMetadata.result)) { 249 | console.debug('Error for', value, expressionMetadata.result); 250 | return ; 251 | } 252 | 253 | return ( 254 | 259 | ); 260 | } 261 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "email": "executablebooks@gmail.com", 4 | "name": "Executable Book Project" 5 | }, 6 | "bugs": { 7 | "url": "https://github.com/jupyter-book/jupyterlab-myst/issues" 8 | }, 9 | "dependencies": { 10 | "@jupyterlab/application": "^4.0.0", 11 | "@jupyterlab/apputils": "^4.0.0", 12 | "@jupyterlab/codeeditor": "^4.0.0", 13 | "@jupyterlab/markdownviewer": "^4.0.0", 14 | "@jupyterlab/notebook": "^4.0.0", 15 | "@jupyterlab/rendermime": "^4.0.0", 16 | "@jupyterlab/translation": "^4.0.0", 17 | "@myst-theme/diagrams": "1.0.0", 18 | "@myst-theme/frontmatter": "1.0.0", 19 | "@myst-theme/providers": "1.0.0", 20 | "@myst-theme/search": "1.0.0", 21 | "katex": "^0.16.22", 22 | "myst-common": "1.9.2", 23 | "myst-config": "1.9.2", 24 | "myst-ext-button": "0.0.1", 25 | "myst-ext-card": "1.0.9", 26 | "myst-ext-exercise": "1.0.9", 27 | "myst-ext-grid": "1.0.9", 28 | "myst-ext-icon": "0.0.2", 29 | "myst-ext-proof": "1.0.12", 30 | "myst-ext-tabs": "1.0.9", 31 | "myst-frontmatter": "1.9.2", 32 | "myst-parser": "1.6.3", 33 | "myst-to-html": "1.6.3", 34 | "myst-to-react": "1.0.0", 35 | "myst-transforms": "1.3.44" 36 | }, 37 | "description": "Use MyST in JupyterLab", 38 | "devDependencies": { 39 | "@babel/core": "^7.0.0", 40 | "@babel/preset-env": "^7.0.0", 41 | "@jupyterlab/builder": "^4.0.0", 42 | "@jupyterlab/testutils": "^4.0.0", 43 | "@myst-theme/styles": ">=0.9.0 <1.0.0", 44 | "@tailwindcss/typography": "^0.5.8", 45 | "@types/jest": "^29.2.0", 46 | "@types/json-schema": "^7.0.11", 47 | "@types/react": "^18.0.26", 48 | "@types/react-addons-linked-state-mixin": "^0.14.22", 49 | "@types/react-dom": "^18.0.9", 50 | "@typescript-eslint/eslint-plugin": "^6.1.0", 51 | "@typescript-eslint/parser": "^6.1.0", 52 | "css-loader": "^6.7.1", 53 | "eslint": "^8.36.0", 54 | "eslint-config-prettier": "^8.8.0", 55 | "eslint-plugin-prettier": "^5.0.0", 56 | "jest": "^29.2.0", 57 | "mkdirp": "^1.0.3", 58 | "npm-run-all": "^4.1.5", 59 | "prettier": "^3.0.0", 60 | "rimraf": "^5.0.1", 61 | "source-map-loader": "^1.0.2", 62 | "style-loader": "^3.3.1", 63 | "stylelint": "^15.10.1", 64 | "stylelint-config-recommended": "^13.0.0", 65 | "stylelint-config-standard": "^34.0.0", 66 | "stylelint-csstree-validator": "^3.0.0", 67 | "stylelint-prettier": "^4.0.0", 68 | "tailwindcss": "^3.2.4", 69 | "ts-jest": "^29.1.0", 70 | "typescript": "~5.5.4", 71 | "yjs": "^13.5.0" 72 | }, 73 | "eslintConfig": { 74 | "extends": [ 75 | "eslint:recommended", 76 | "plugin:@typescript-eslint/eslint-recommended", 77 | "plugin:@typescript-eslint/recommended", 78 | "plugin:prettier/recommended" 79 | ], 80 | "parser": "@typescript-eslint/parser", 81 | "parserOptions": { 82 | "project": "tsconfig.json", 83 | "sourceType": "module" 84 | }, 85 | "plugins": [ 86 | "@typescript-eslint" 87 | ], 88 | "rules": { 89 | "@typescript-eslint/naming-convention": [ 90 | "error", 91 | { 92 | "custom": { 93 | "match": true, 94 | "regex": "^I[A-Z]" 95 | }, 96 | "format": [ 97 | "PascalCase" 98 | ], 99 | "selector": "interface" 100 | } 101 | ], 102 | "@typescript-eslint/no-explicit-any": "off", 103 | "@typescript-eslint/no-namespace": "off", 104 | "@typescript-eslint/no-unused-vars": [ 105 | "warn", 106 | { 107 | "args": "none" 108 | } 109 | ], 110 | "@typescript-eslint/no-use-before-define": "off", 111 | "@typescript-eslint/quotes": [ 112 | "error", 113 | "single", 114 | { 115 | "allowTemplateLiterals": false, 116 | "avoidEscape": true 117 | } 118 | ], 119 | "curly": [ 120 | "error", 121 | "all" 122 | ], 123 | "eqeqeq": "error", 124 | "prefer-arrow-callback": "error" 125 | } 126 | }, 127 | "eslintIgnore": [ 128 | "node_modules", 129 | "dist", 130 | "coverage", 131 | "**/*.d.ts", 132 | "tests", 133 | "**/__tests__", 134 | "ui-tests" 135 | ], 136 | "files": [ 137 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", 138 | "style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}", 139 | "style/index.js" 140 | ], 141 | "homepage": "https://github.com/jupyter-book/jupyterlab-myst", 142 | "jupyterlab": { 143 | "extension": true, 144 | "outputDir": "jupyterlab_myst/labextension" 145 | }, 146 | "keywords": [ 147 | "jupyter", 148 | "jupyterlab", 149 | "jupyterlab-extension" 150 | ], 151 | "license": "MIT", 152 | "main": "lib/index.js", 153 | "name": "jupyterlab-myst", 154 | "overrides": { 155 | "lib0": "0.2.111" 156 | }, 157 | "prettier": { 158 | "arrowParens": "avoid", 159 | "endOfLine": "auto", 160 | "overrides": [ 161 | { 162 | "files": "package.json", 163 | "options": { 164 | "tabWidth": 4 165 | } 166 | } 167 | ], 168 | "singleQuote": true, 169 | "trailingComma": "none" 170 | }, 171 | "publishConfig": { 172 | "access": "public" 173 | }, 174 | "repository": { 175 | "type": "git", 176 | "url": "https://github.com/jupyter-book/jupyterlab-myst.git" 177 | }, 178 | "scripts": { 179 | "build": "npm run build:css && npm run build:lib && npm run build:labextension:dev", 180 | "build:css": "tailwindcss -m -i ./style/tailwind.css -o style/app.css", 181 | "build:labextension": "jupyter labextension build .", 182 | "build:labextension:dev": "jupyter labextension build --development True .", 183 | "build:lib": "tsc --sourceMap", 184 | "build:lib:prod": "tsc", 185 | "build:prod": "npm run clean && npm run build:css && npm run build:lib:prod && npm run build:labextension", 186 | "clean": "npm run clean:lib", 187 | "clean:all": "npm run clean:lib && npm run clean:labextension && npm run clean:lintcache", 188 | "clean:labextension": "rimraf jupyterlab_myst/labextension jupyterlab_myst/_version.py", 189 | "clean:lib": "rimraf lib tsconfig.tsbuildinfo", 190 | "clean:lintcache": "rimraf .eslintcache .stylelintcache", 191 | "eslint": "npm run eslint:check --fix", 192 | "eslint:check": "eslint . --cache --ext .ts,.tsx", 193 | "install:extension": "npm run build", 194 | "lint": "npm run stylelint && npm run prettier && npm run eslint", 195 | "lint:check": "npm run stylelint:check && npm run prettier:check && npm run eslint:check", 196 | "prettier": "npm run prettier:base --write --list-different", 197 | "prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"", 198 | "prettier:check": "npm run prettier:base --check", 199 | "stylelint": "npm run stylelint:check --fix", 200 | "stylelint:check": "stylelint --cache \"style/**/*.css\"", 201 | "test": "jest --coverage", 202 | "watch": "run-p watch:css watch:src watch:labextension", 203 | "watch:css": "tailwindcss -w -i ./style/tailwind.css -o style/app.css", 204 | "watch:labextension": "jupyter labextension watch .", 205 | "watch:src": "tsc -w --sourceMap" 206 | }, 207 | "sideEffects": [ 208 | "style/*.css", 209 | "style/index.js" 210 | ], 211 | "style": "style/index.css", 212 | "styleModule": "style/index.js", 213 | "stylelint": { 214 | "extends": [ 215 | "stylelint-config-recommended", 216 | "stylelint-config-standard", 217 | "stylelint-prettier/recommended" 218 | ], 219 | "plugins": [ 220 | "stylelint-csstree-validator" 221 | ], 222 | "rules": { 223 | "csstree/validator": true, 224 | "property-no-vendor-prefix": null, 225 | "selector-class-pattern": "^([a-z][A-z\\d]*)(-[A-z\\d]+)*$", 226 | "selector-no-vendor-prefix": null, 227 | "value-no-vendor-prefix": null 228 | } 229 | }, 230 | "types": "lib/index.d.ts", 231 | "version": "2.6.0" 232 | } 233 | --------------------------------------------------------------------------------