Finally… Simple Custom Styled Markdown Cells in JupyterLab

So, I have a recipe for custom styling Markdown cells in JupyterLab / RetroLab, sort of. It actually co-opts jupyterlab-myst, which can parse MyST special content / admonition blocks contained inside a markdown cell and add class attributes associated with the special content block type to the corresponding cell DOM elements:

We can then tune in to that <aside/> block using a CSS selecter of the form .admonition.note:

.admonition.note {
    outline: 0.0625rem solid #f08080;
    background-color: lightblue;
    border-left-color: green !important;

To get the CSS in to the appropriate place, you have to download the internet and a developer toolchain and build a JupyterLab extension. Me neither. But the jupyterlab/extension-cookiecutter-js seems to provide an “easy” way of doing this (I chose the javascript package in the hope it was a bit more compact ands easier to build than the .ts/TypeScript one (though I’m not sure it is):

  • install the cookiecutter package: pip install cookiecutter
  • run it against the JupyterLab extension cookiecutter: cookiecutter
  • you’ll be prompted for various things: give it your name, and then use the same project name (for convenience) for the Python package and the extension name;
  • edit the style/base.css file and save it; <- this is the customisation bit;
  • build the package in editable mode: python -m pip install -e . if you are in the top level directory of the extension directory you created, or python -m pip install -e . ./MY_EXTENSION_NAME if you’re in the parent directory; install your development version of the extension with JupyterLab jupyter labextension develop . --overwrite; and if you make changes, run jlpm run build and restart the JupyerLab server; and that’s it, I think (at least, after a while, as the build process downloads the internet and maybe rebuilds JupyterLab and does who knows what else?!); if you aren’t developing, you should be able to just pip install .; be wary though, I did a simple pip install . and no matter how I tried, I couldn’t seem to upgrade the package version that JupyterLab ran away from the first version I managed to build (it’s languishing there still for all I know…). You can check this extension is installed and available from the command-line using the command: jupyter labextension list; if the extension isn’t enabled, try enabling it jupyter labextension enable MY_EXTENSION_NAME; if it still doesn’t work, or you can’t see it in the listing, try jupyter labextension install MY_EXTENSION_NAME and then perhaps the enable command. As this is JupyterLab, shouting at it a bit as well might help you feel better.
  • I don’t really understand how the build works, because after install the package, if you update it you need to run jlpm run build ?

Things were so much easier when you could just pop a CSS file into a classic notebook config directory…

Usual caveats apply to the below: this is not meant to cause offence, isn’t meant to be disrespectful, isn’t intended as an ad hominem attack; it does involve a certain amount of caricature and parody, etc etc, and may well be wrong, misguided or even complete and utter nonsense.

This is still not very useful if you want to custom style code cells. Various suggestions for being able to add class attributes based on (code cell) tag metadata have been in the issues queue for ages (I sincerely hope, if any of the PRs ever get merged, that support for propagating markdown cell tags over to class attributes is also provided, and not just copying over tags for code cells); several of the issues and PRs I’ve been aware of over the years include the following, but there may be more:

Over in the RetroLab repo — where I suspect that all sorts of stuff you can’t do in the new UI that you could do, or relatively straightforwardly hack into, the classic notebook UI, will soon start raising its head — there’s an open issue on Should custom.css equivalent be supported? .

What I’ve felt right from the start is that UI / notebook presentation level “innovation outside” is really difficult in JupyterLab even at the user experience level, particularly around the boundary of notebook structure and notebook content. The notebook cell structure provides some really useful levels of structured content separation (markdown, code and code output) as well as structural metadata (cell tags). If you can exploit the structural elements in the way you present the content, then there is a lot you can do to customise the presentation of the content in a way that is sympathetic to the content and is sensitive to the metadata (cell type, cell tags or other metadata, etc.).

I think we’re still at the early stages of finding out how to make most effective use of notebooks in education, and this means finding playful ways of creating really simple extensions that help explore that edge space where notebook structure can be mapped onto, which is to say, used to transform, presentational elements, for example, that space where tags and other metadata elements can be used to control style.

But the architecture really gets in the way of that.

Currently, the content author has control over the content of a cell, and, to a limited extent, by virtue of the cell type, the presentation of the cell. But if they had additional control over the presentation of the content, for example, tag-sensitive styling, they could author even richer documents, particularly if the styling was also user customisable.

Whilst things like jupyter-myst make additional styling features available to the author, it does so in a way that forces an element of structured authoring inside the content field. To create an admonition block, I don’t select a markdown block with an admonition style (as I might do with tag based styling), but instead I select a markdown block and then put structural information inside it (the labelled, triple backticked code fence markers). (Cf. being able to put HTML content into markdown cells: this is really messy and can clutter thngs badly. Markdown is much cleaner and uses “natural” text to indicate structure; but even better if you can put block level metadata/structure at the level of the block, but not inside the block.)

Presumably because of the way the jupyterlab-myst plugin works (a simplification that perhaps allows the contents of a markdown cell to be treated as “code” that is then parsed and rendered subject to the markdown-as-code parsing extension without having to mess with core JupyterLab code), the contents of the markdown cell, and its parser, have no sight of the structural metadata associated with the cell. So we can’t just tag the code cell and expect it to be rendered as a special content block because that would required hacking JupyterLab core.

Right from the start, it seems as if a decision was taken in the JupyterLab development that the users could do want they want inside code scope and insde a notebook cell source element, and that code execution could return things into the output element, but that the cell metadata was essentially out of bounds unless you were coding at the JupyterLab core level (“it’s our editor and it’s for looking at notebooks how we want look at ’em.”; there’s also a second, later take, which is that the geeky dev user should be able to choose their own theme. In education, it’s often useful for the publisher to control the styling, because the styling is the classroom and style can be an important signposter, reinforcer, framer and contextualiser. Which is not to say that users shouldnlt also be able to select eg light, or dark, or accessible-yellow tinted themes which the publisher should provide and support). This is a real blocker. If the above mentioned, languishing PRs had been (capable of being?) published as simple extensions, they’d have been useable by end-users for months and years already; as extensions, with less code to make sense of, it’s possibly more likely that other people would have been able to develop them further and/or use them as cribs for other extensions; (although there is still the question of dev tools and what to type where to even get your dev environment up and running…) Extensions also mean there are no side effects on the core code-base if the extension code is substandard; if bad things happen from installing the extension, uninstall it. As it is, if you want the functionality that apparently resides inside the PRs, then you have to install jupyterlab from a personal fork / PR branch of the JupyterLab repo, and build it yourself from source in order to even try it out. (Providing good automation in the repo can help here because it means that people can rely on the automation process to mange the development environment and build the distribution, rather than the user necessarily having to figure out a developmnt environment of their own and what commands to run when in order to manage the build process.)

There may well be good reasons why you don’t want “portable” documents such as notebooks to have too much influence over the UI (for example, malicious Python code that spoofs UI elements to steal credentials); but with tools such as ipywidgets, you do allow Python code to have sight of various elements of the DOM, and control over them. Related to this, I note things like davidbrochart/ipyurl, a “Jupyter Widget Library for accessing the server’s URL” that uses the ipywidgets machinery as a hack to get hold of a Jupyter server’s URL because it’s not directly accessible as jupyter scoped state in a code execution environment. I also note jtpio/ipylab, the aim of which is to “provide access to most of the JupyterLab environment from Python notebooks”. But whilst this means that code in notebooks can tinker with the UI, this is not really relevant if I want to expose users to subject matter content in a modified UI. Unless, perhaps, I can create a JupyterLab workspace that is capable of auto-running (in the background, on launch) a configuration notebook that uses ipylab to set up the environment/workspace when I open a workspace?

The jupyterlab/jupyterlab-plugin-playground is another extension that seems to provide a shortcut way of testing plugin code without the need for a complex build environment, but I’m not really sure how it helps anyone other than folk who already know what they’re doing when it comes to developing plugins, if not setting up build and release environments for them. Looking at the code for something like jupyterlab-contrib/jupyterlab-cell-flash (could that be run from the jupyterlab-plugin-playground ? If so, what role does all the other stuff in the cell-flash repo play?!) I note a handy looking NotebookActions.executed method for handling an event, or some other sort of signal. But what other events / signals are available, and how might they be used? (The onModelDBMetadataChange method looks like it might also be handy, but how do I use it to monitor all cells?) And where do I find a convenient list of hooks for things that a handler might influence in turn, other than perhaps poring through jupyterlab/extension-examples looking for cribs?

Enough… What I meant to do when I started this post was publish an example of some simple CSS to style a simple example by co-opting an attention admonition class. And I’ve still not done that!

Author: Tony Hirst

I'm a Senior Lecturer at The Open University, with an interest in #opendata policy and practice, as well as general web tinkering...

%d bloggers like this: