jupyterlab_markdown_syntax_rendering_fix 0.6.9 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,23 +8,38 @@
8
8
  [![Brought To You By KOLOMOLO](https://img.shields.io/badge/Brought%20To%20You%20By-KOLOMOLO-00ffff?style=flat)](https://kolomolo.com)
9
9
  [![Donate PayPal](https://img.shields.io/badge/Donate-PayPal-blue?style=flat)](https://www.paypal.com/donate/?hosted_button_id=B4KPBJDLLXTSA)
10
10
 
11
- Fenced code blocks in rendered Markdown sometimes appear plain and uncoloured - the highlighting silently fails when a CodeMirror language chunk loads late or the language registry is not yet ready, and JupyterLab falls back to plain text. This extension restores the highlighting that was lost to that race.
11
+ JupyterLab 4.x extension that restores syntax-highlight colours in rendered Markdown fenced code blocks when no CodeMirror editor is open.
12
12
 
13
- ## Features
13
+ > [!WARNING]
14
+ > This extension is a temporary fix for a JupyterLab 4.x behaviour where rendered Markdown code highlighting depends on a CodeMirror editor having been opened. Once JupyterLab core mounts the highlight style for the Markdown renderer independently, this extension will be obsolete and should not be installed.
14
15
 
15
- - **Recovers lost highlighting** - re-applies syntax highlighting to fenced code blocks that rendered plain because the async highlighter missed the cache
16
- - **Language agnostic** - works for any fenced language (bash, python, json, ...), independent of mermaid
17
- - **Targets the cold-load race** - handles the case where a language chunk imports late or the registry is wired after an early render
18
- - **Frontend only** - pure TypeScript labextension, no server component
16
+ ## The Problem
19
17
 
20
- ## How it works
18
+ Fenced code blocks in rendered Markdown (the Markdown Preview, README files, `.md` documents) appear in a single flat colour instead of syntax-highlighted, even though the highlighter clearly ran.
21
19
 
22
- JupyterLab highlights fenced code in rendered Markdown with an async pass that fills a cache and a synchronous renderer that reads it. When the async highlight throws - a CodeMirror language-chunk import that rejects, or a registry that is not yet wired when an early render fires - the cache misses and the renderer emits a plain `<pre><code>`. This extension watches the application shell for those plain blocks and re-runs the highlight once the language is available.
20
+ **Symptoms**:
23
21
 
24
- - **Detect** - a `MutationObserver` flags any rendered `pre > code` that has a `language-*` class and text but no token `<span>` children
25
- - **Recover** - re-runs the highlight through `IEditorLanguageRegistry` and swaps in the token spans, only when the highlighted text matches the source exactly so it never truncates content
26
- - **Resilient** - retries a thrown highlight a few times with backoff while the language chunk finishes loading, then gives up cleanly and leaves the original plain text untouched
27
- - **Unobtrusive** - each block is handled at most once, and editor and overlay churn is skipped to keep the observer cheap
22
+ - bash, python, json and other fenced blocks render in one uniform grey, not coloured tokens
23
+ - Opening the same file in the editor, then returning to the preview, makes the colours appear - and they stay for the rest of the session
24
+ - Affects any rendered Markdown when the session has not yet opened a CodeMirror editor (notebook cell, file editor, console)
25
+
26
+ **Root cause - the highlight StyleModule is never mounted**:
27
+
28
+ - JupyterLab's Markdown renderer highlights code through `@jupyterlab/codemirror`, producing token `<span>`s with CodeMirror's generated highlight classes (e.g. `ͼs`, `ͼ11`)
29
+ - Those class names are emitted by a CodeMirror `StyleModule`, and the CSS that gives them colour is mounted only when an `EditorView` is instantiated (via `syntaxHighlighting(jupyterHighlightStyle)`)
30
+ - With only Markdown previews open, no `EditorView` exists, so the StyleModule is never mounted - the spans carry the right classes but no colour rule, and inherit the plain code text colour
31
+ - Opening any editor mounts the StyleModule document-wide, which is why the workaround of opening the file in the editor fixes the preview
32
+
33
+ ## The Fix
34
+
35
+ The extension mounts the highlight StyleModule's CSS once at startup, achieving the same effect as opening an editor - without one.
36
+
37
+ **How it works**:
38
+
39
+ - On activation, reads the rules from `jupyterHighlightStyle.module` (the same StyleModule the Markdown renderer's spans reference) via `getRules()`
40
+ - Injects them into a single `<style>` element in the document head, so every rendered Markdown code block is coloured immediately
41
+ - Colours are expressed as `--jp-mirror-editor-*-color` CSS variables, so they resolve through whatever theme is active and update on theme change
42
+ - Injection is idempotent (fixed element id) and frontend-only - no server component, no per-render DOM observer
28
43
 
29
44
  ## Requirements
30
45
 
@@ -45,3 +60,65 @@ To remove the extension, execute:
45
60
  ```bash
46
61
  pip uninstall jupyterlab_markdown_syntax_rendering_fix
47
62
  ```
63
+
64
+ ## Contributing
65
+
66
+ ### Development install
67
+
68
+ Note: You will need NodeJS to build the extension package.
69
+
70
+ The `jlpm` command is JupyterLab's pinned version of [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use `yarn` or `npm` in lieu of `jlpm` below.
71
+
72
+ ```bash
73
+ # Clone the repo to your local environment
74
+ # Change directory to the jupyterlab_markdown_syntax_rendering_fix directory
75
+
76
+ # Set up a virtual environment and install package in development mode
77
+ python -m venv .venv
78
+ source .venv/bin/activate
79
+ pip install --editable "."
80
+
81
+ # Link your development version of the extension with JupyterLab
82
+ jupyter labextension develop . --overwrite
83
+
84
+ # Rebuild extension TypeScript source after making changes
85
+ # IMPORTANT: Unlike the steps above which are performed only once, do this step
86
+ # every time you make a change.
87
+ jlpm build
88
+ ```
89
+
90
+ 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.
91
+
92
+ ```bash
93
+ # Watch the source directory in one terminal, automatically rebuilding when needed
94
+ jlpm watch
95
+ # Run JupyterLab in another terminal
96
+ jupyter lab
97
+ ```
98
+
99
+ ### Development uninstall
100
+
101
+ ```bash
102
+ pip uninstall jupyterlab_markdown_syntax_rendering_fix
103
+ ```
104
+
105
+ In development mode, you will also need to remove the symlink created by `jupyter labextension develop` command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions` folder is located. Then you can remove the symlink named `jupyterlab_markdown_syntax_rendering_fix` within that folder.
106
+
107
+ ### Testing the extension
108
+
109
+ #### Frontend tests
110
+
111
+ This extension is using [Jest](https://jestjs.io/) for JavaScript code testing.
112
+
113
+ To execute them, execute:
114
+
115
+ ```sh
116
+ jlpm
117
+ jlpm test
118
+ ```
119
+
120
+ #### Integration tests
121
+
122
+ This extension uses [Playwright](https://playwright.dev/docs/intro) for the integration tests (aka user level tests). More precisely, the JupyterLab helper [Galata](https://github.com/jupyterlab/jupyterlab/tree/master/galata) is used to handle testing the extension in JupyterLab.
123
+
124
+ More information are provided within the [ui-tests](./ui-tests/README.md) README.
@@ -1,35 +1,23 @@
1
1
  /**
2
- * Pure DOM helpers for detecting fenced code blocks that rendered without
3
- * syntax highlighting. Kept free of JupyterLab imports so they can be unit
4
- * tested directly.
2
+ * Restore syntax highlighting in rendered Markdown code blocks.
3
+ *
4
+ * Kept free of JupyterLab imports so it can be unit tested directly.
5
5
  */
6
+ /** Id of the injected highlight-style element, used to keep injection idempotent. */
7
+ export declare const HIGHLIGHT_STYLE_ID = "jupyterlab-markdown-syntax-rendering-fix-highlight";
6
8
  /**
7
- * Attribute marking a code block this extension has already handled, so the
8
- * MutationObserver never re-processes the same block.
9
+ * Inject the CodeMirror highlight StyleModule's CSS into `doc` so rendered
10
+ * Markdown token spans are coloured.
11
+ *
12
+ * Rendered code blocks already carry the generated highlight classes (e.g.
13
+ * `ͼs`), but the StyleModule that gives those classes their colours is only
14
+ * mounted when a CodeMirror `EditorView` is instantiated. With only Markdown
15
+ * previews open, no editor exists, so the spans inherit the plain code colour
16
+ * and look unhighlighted. Mounting the rules once - the same effect as opening
17
+ * the editor - colours every preview. Colours resolve through
18
+ * `--jp-mirror-editor-*-color` CSS variables, so they track the active theme.
19
+ *
20
+ * Idempotent: a fixed element id means repeated calls are a no-op, and an empty
21
+ * rule string is ignored.
9
22
  */
10
- export declare const PROCESSED_ATTR = "data-msrf";
11
- /**
12
- * Fenced languages rendered by their own renderer (e.g. mermaid -> SVG). They
13
- * are never plain `pre > code` highlight candidates, so leave them untouched.
14
- */
15
- export declare const SKIP_LANGUAGES: Set<string>;
16
- /**
17
- * Extract the CodeMirror language name from a `language-<name>` class token.
18
- * Returns null when no such class is present.
19
- */
20
- export declare function languageFromClass(className: string): string | null;
21
- /**
22
- * A rendered fenced code block has lost its syntax highlighting when it carries
23
- * a `language-*` class and non-empty text, yet has no child elements - the
24
- * async highlighter threw and the markdown renderer fell back to a plain
25
- * `<pre><code>` (cache miss). A correctly highlighted block holds token
26
- * `<span>` children, so `childElementCount > 0`.
27
- */
28
- export declare function needsHighlight(code: HTMLElement): boolean;
29
- /**
30
- * Collect the plain `pre > code.language-*` blocks at or under `root` that need
31
- * their highlighting recovered. `root` itself is considered, since
32
- * `querySelectorAll` only matches descendants and an observer may hand us the
33
- * `<code>` element directly.
34
- */
35
- export declare function collectPlainBlocks(root: ParentNode): HTMLElement[];
23
+ export declare function injectHighlightRules(rules: string, doc?: Document): void;
package/lib/highlight.js CHANGED
@@ -1,62 +1,32 @@
1
1
  /**
2
- * Pure DOM helpers for detecting fenced code blocks that rendered without
3
- * syntax highlighting. Kept free of JupyterLab imports so they can be unit
4
- * tested directly.
2
+ * Restore syntax highlighting in rendered Markdown code blocks.
3
+ *
4
+ * Kept free of JupyterLab imports so it can be unit tested directly.
5
5
  */
6
+ /** Id of the injected highlight-style element, used to keep injection idempotent. */
7
+ export const HIGHLIGHT_STYLE_ID = 'jupyterlab-markdown-syntax-rendering-fix-highlight';
6
8
  /**
7
- * Attribute marking a code block this extension has already handled, so the
8
- * MutationObserver never re-processes the same block.
9
+ * Inject the CodeMirror highlight StyleModule's CSS into `doc` so rendered
10
+ * Markdown token spans are coloured.
11
+ *
12
+ * Rendered code blocks already carry the generated highlight classes (e.g.
13
+ * `ͼs`), but the StyleModule that gives those classes their colours is only
14
+ * mounted when a CodeMirror `EditorView` is instantiated. With only Markdown
15
+ * previews open, no editor exists, so the spans inherit the plain code colour
16
+ * and look unhighlighted. Mounting the rules once - the same effect as opening
17
+ * the editor - colours every preview. Colours resolve through
18
+ * `--jp-mirror-editor-*-color` CSS variables, so they track the active theme.
19
+ *
20
+ * Idempotent: a fixed element id means repeated calls are a no-op, and an empty
21
+ * rule string is ignored.
9
22
  */
10
- export const PROCESSED_ATTR = 'data-msrf';
11
- /**
12
- * Fenced languages rendered by their own renderer (e.g. mermaid -> SVG). They
13
- * are never plain `pre > code` highlight candidates, so leave them untouched.
14
- */
15
- export const SKIP_LANGUAGES = new Set(['mermaid']);
16
- /**
17
- * Extract the CodeMirror language name from a `language-<name>` class token.
18
- * Returns null when no such class is present.
19
- */
20
- export function languageFromClass(className) {
21
- const match = /(?:^|\s)language-([\w+#-]+)/.exec(className);
22
- return match ? match[1] : null;
23
- }
24
- /**
25
- * A rendered fenced code block has lost its syntax highlighting when it carries
26
- * a `language-*` class and non-empty text, yet has no child elements - the
27
- * async highlighter threw and the markdown renderer fell back to a plain
28
- * `<pre><code>` (cache miss). A correctly highlighted block holds token
29
- * `<span>` children, so `childElementCount > 0`.
30
- */
31
- export function needsHighlight(code) {
23
+ export function injectHighlightRules(rules, doc = document) {
32
24
  var _a;
33
- if (code.hasAttribute(PROCESSED_ATTR) || code.childElementCount > 0) {
34
- return false;
35
- }
36
- const language = languageFromClass(code.className);
37
- if (!language || SKIP_LANGUAGES.has(language)) {
38
- return false;
39
- }
40
- return ((_a = code.textContent) !== null && _a !== void 0 ? _a : '').trim().length > 0;
41
- }
42
- /** CSS selector for a fenced code block carrying a language hint. */
43
- const CODE_SELECTOR = 'pre > code[class*="language-"]';
44
- /**
45
- * Collect the plain `pre > code.language-*` blocks at or under `root` that need
46
- * their highlighting recovered. `root` itself is considered, since
47
- * `querySelectorAll` only matches descendants and an observer may hand us the
48
- * `<code>` element directly.
49
- */
50
- export function collectPlainBlocks(root) {
51
- const blocks = [];
52
- const consider = (code) => {
53
- if (needsHighlight(code)) {
54
- blocks.push(code);
55
- }
56
- };
57
- if (root instanceof HTMLElement && root.matches(CODE_SELECTOR)) {
58
- consider(root);
25
+ if (!rules || doc.getElementById(HIGHLIGHT_STYLE_ID)) {
26
+ return;
59
27
  }
60
- root.querySelectorAll(CODE_SELECTOR).forEach(consider);
61
- return blocks;
28
+ const style = doc.createElement('style');
29
+ style.id = HIGHLIGHT_STYLE_ID;
30
+ style.textContent = rules;
31
+ ((_a = doc.head) !== null && _a !== void 0 ? _a : doc.documentElement).appendChild(style);
62
32
  }
package/lib/index.js CHANGED
@@ -1,146 +1,23 @@
1
- import { IEditorLanguageRegistry } from '@jupyterlab/codemirror';
2
- import { PROCESSED_ATTR, collectPlainBlocks, languageFromClass } from './highlight';
3
- /** How many times to attempt recovery (only a thrown highlight is retried). */
4
- const MAX_ATTEMPTS = 4;
5
- /** Base retry delay; backs off 750/1500/3000ms (~5s total) so a slow chunk import can settle. */
6
- const BASE_RETRY_DELAY_MS = 750;
7
- /** Blocks currently being recovered, to dedupe concurrent observer callbacks. */
8
- const inFlight = new WeakSet();
9
- const delay = (ms) => new Promise(resolve => {
10
- window.setTimeout(resolve, ms);
11
- });
12
- /**
13
- * Recover highlighting on a single plain block, and mark it with a terminal
14
- * `data-msrf` state so it is processed at most once:
15
- *
16
- * - `1` success - token spans written
17
- * - `plain` highlight ran cleanly but produced no tokens (span-less content) -
18
- * not a failure, the original plain text is left untouched
19
- * - `skipped` the language is unsupported (`findBest` miss) - deterministic, so
20
- * no retry and no warning
21
- * - `failed` the highlight threw on every attempt - left as plain text, no
22
- * regression vs without this extension
23
- *
24
- * Only a thrown highlight is retried, since that is the one transient case (a
25
- * cold/flaky CodeMirror language-chunk import). Retrying is effective, not
26
- * theatre: `highlight()` awaits `getLanguage()` internally, which caches the
27
- * loaded support on the spec only on success (`spec.support = await spec.load()`)
28
- * - a rejected load throws and leaves `spec.support` unset, and webpack resets a
29
- * failed chunk load, so each attempt genuinely re-runs the dynamic import.
30
- * Retries back off over ~5s; a chunk that never loads within that window stays
31
- * plain until the block is re-rendered (reload / reopen). Concurrent callbacks
32
- * are deduped via `inFlight`.
33
- */
34
- async function rehighlight(code, languages) {
35
- var _a;
36
- const language = languageFromClass(code.className);
37
- if (!language || inFlight.has(code) || code.hasAttribute(PROCESSED_ATTR)) {
38
- return;
39
- }
40
- const text = (_a = code.textContent) !== null && _a !== void 0 ? _a : '';
41
- inFlight.add(code);
42
- try {
43
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
44
- const spec = languages.findBest(language);
45
- if (!spec) {
46
- // Unsupported language - a synchronous registry miss is deterministic,
47
- // so retrying cannot help. Leave it plain, no warning.
48
- code.setAttribute(PROCESSED_ATTR, 'skipped');
49
- return;
50
- }
51
- const host = document.createElement('div');
52
- try {
53
- await languages.highlight(text, spec, host);
54
- }
55
- catch (_b) {
56
- // The one retryable case: a transient chunk-load flake.
57
- if (attempt < MAX_ATTEMPTS) {
58
- await delay(BASE_RETRY_DELAY_MS * 2 ** (attempt - 1));
59
- // The host may have been re-rendered during the back-off; a fresh node
60
- // is handled independently, so stop working this detached one.
61
- if (!code.isConnected) {
62
- return;
63
- }
64
- continue;
65
- }
66
- code.setAttribute(PROCESSED_ATTR, 'failed');
67
- console.warn(`[jupyterlab_markdown_syntax_rendering_fix] gave up re-highlighting ${language} after ${MAX_ATTEMPTS} attempts`);
68
- return;
69
- }
70
- // The node may have been re-rendered during the await; a fresh node is
71
- // handled independently, so don't write to a detached one.
72
- if (!code.isConnected) {
73
- return;
74
- }
75
- // Highlight ran without throwing. Commit the swap only when the rendered
76
- // text round-trips exactly - never replace the block with a truncated or
77
- // divergent fragment (worse than leaving it plain). An empty result is
78
- // deterministic for the content (a parser that failed to load throws
79
- // rather than resolving empty), so it is terminal, not retried.
80
- if (host.childElementCount > 0 && host.textContent === text) {
81
- // Set the marker in the same synchronous frame as the write so the
82
- // observer's async callback sees data-msrf and skips the spans we add.
83
- code.setAttribute(PROCESSED_ATTR, '1');
84
- code.replaceChildren(...Array.from(host.childNodes));
85
- }
86
- else {
87
- if (host.childElementCount > 0) {
88
- // Spans were produced but the text did not round-trip - surface this
89
- // rare case rather than silently suppressing a possibly-correct render.
90
- console.warn(`[jupyterlab_markdown_syntax_rendering_fix] skipped re-highlight of ${language}: highlighted text did not match source`);
91
- }
92
- code.setAttribute(PROCESSED_ATTR, 'plain');
93
- }
94
- return;
95
- }
96
- }
97
- finally {
98
- inFlight.delete(code);
99
- }
100
- }
1
+ import { jupyterHighlightStyle } from '@jupyterlab/codemirror';
2
+ import { injectHighlightRules } from './highlight';
101
3
  /**
102
4
  * Initialization data for the jupyterlab_markdown_syntax_rendering_fix extension.
103
5
  */
104
6
  const plugin = {
105
7
  id: 'jupyterlab_markdown_syntax_rendering_fix:plugin',
106
- description: 'Jupyterlab extension to fix a common issue with Markdown renderer where some race condition causes for the fenced code block to not have proper syntax highlighting',
8
+ description: 'Restore syntax highlighting in rendered Markdown code blocks by mounting the CodeMirror highlight StyleModule at startup, so token colours apply even when no editor is open.',
107
9
  autoStart: true,
108
- requires: [IEditorLanguageRegistry],
109
- activate: (app, languages) => {
10
+ activate: () => {
11
+ var _a, _b;
110
12
  console.log('JupyterLab extension jupyterlab_markdown_syntax_rendering_fix is activated!');
111
- const scan = (root) => {
112
- for (const code of collectPlainBlocks(root)) {
113
- rehighlight(code, languages).catch(error => {
114
- console.warn('[jupyterlab_markdown_syntax_rendering_fix] unexpected error while re-highlighting', error);
115
- });
116
- }
117
- };
118
- const observer = new MutationObserver(mutations => {
119
- for (const mutation of mutations) {
120
- mutation.addedNodes.forEach(node => {
121
- if (node.nodeType !== Node.ELEMENT_NODE) {
122
- return;
123
- }
124
- const element = node;
125
- // Skip pure observer churn: CodeMirror editors mutate on every
126
- // keystroke and never hold rendered markdown, and the token spans this
127
- // extension itself writes land under an already-processed code block.
128
- if (element.closest('.cm-editor, code[data-msrf]')) {
129
- return;
130
- }
131
- scan(element);
132
- });
133
- }
134
- });
135
- // Observe the application shell rather than document.body: rendered markdown
136
- // lives in the shell (notebook cells, markdown preview). Markdown rendered in
137
- // body-level overlays (completer / hover docstrings) is intentionally not
138
- // covered - a deliberate tradeoff to avoid observing the high-churn overlay
139
- // layer; such code blocks recover on the next in-shell render or a reload.
140
- const root = app.shell.node;
141
- observer.observe(root, { childList: true, subtree: true });
142
- // Recover any markdown already rendered before this extension activated.
143
- scan(root);
13
+ // Rendered Markdown code blocks receive CodeMirror highlight token classes
14
+ // (e.g. ͼs), but the StyleModule that gives those classes their colours is
15
+ // only mounted when a CodeMirror EditorView is instantiated. With only
16
+ // Markdown previews open, no editor exists, so the spans inherit the plain
17
+ // code colour and look unhighlighted. Mounting the rules once - the same
18
+ // effect as opening the editor - colours every preview, and the colours
19
+ // track the active theme through --jp-mirror-editor-*-color variables.
20
+ injectHighlightRules((_b = (_a = jupyterHighlightStyle.module) === null || _a === void 0 ? void 0 : _a.getRules()) !== null && _b !== void 0 ? _b : '');
144
21
  }
145
22
  };
146
23
  export default plugin;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jupyterlab_markdown_syntax_rendering_fix",
3
- "version": "0.6.9",
4
- "description": "Jupyterlab extension to fix a common issue with Markdown renderer where some race condition causes for the fenced code block to not have proper syntax highlighting",
3
+ "version": "1.0.2",
4
+ "description": "JupyterLab extension that restores syntax-highlight colours in rendered Markdown fenced code blocks by mounting the CodeMirror highlight StyleModule, so token colours apply even when no editor is open",
5
5
  "keywords": [
6
6
  "jupyter",
7
7
  "jupyterlab",
@@ -1,100 +1,29 @@
1
- import {
2
- PROCESSED_ATTR,
3
- collectPlainBlocks,
4
- languageFromClass,
5
- needsHighlight
6
- } from '../highlight';
1
+ import { HIGHLIGHT_STYLE_ID, injectHighlightRules } from '../highlight';
7
2
 
8
- /** Build a `pre > code` block with the given class and inner HTML. */
9
- function block(className: string, inner: string): HTMLElement {
10
- const pre = document.createElement('pre');
11
- const code = document.createElement('code');
12
- code.className = className;
13
- code.innerHTML = inner;
14
- pre.appendChild(code);
15
- return code;
16
- }
17
-
18
- describe('languageFromClass', () => {
19
- it('extracts the language from a language-* class', () => {
20
- expect(languageFromClass('language-bash')).toBe('bash');
21
- });
22
-
23
- it('finds the token among other classes', () => {
24
- expect(languageFromClass('hljs language-python foo')).toBe('python');
3
+ describe('injectHighlightRules', () => {
4
+ afterEach(() => {
5
+ document.getElementById(HIGHLIGHT_STYLE_ID)?.remove();
25
6
  });
26
7
 
27
- it('keeps symbol-bearing language names', () => {
28
- expect(languageFromClass('language-c++')).toBe('c++');
29
- expect(languageFromClass('language-c#')).toBe('c#');
8
+ it('appends a style element carrying the rules', () => {
9
+ injectHighlightRules('.ͼs{color:red}');
10
+ const style = document.getElementById(HIGHLIGHT_STYLE_ID);
11
+ expect(style).not.toBeNull();
12
+ expect(style!.tagName).toBe('STYLE');
13
+ expect(style!.textContent).toBe('.ͼs{color:red}');
30
14
  });
31
15
 
32
- it('returns null without a language class', () => {
33
- expect(languageFromClass('hljs')).toBeNull();
34
- expect(languageFromClass('')).toBeNull();
35
- });
36
- });
37
-
38
- describe('needsHighlight', () => {
39
- it('flags a plain block with a language and text', () => {
40
- expect(needsHighlight(block('language-bash', 'pip install x'))).toBe(true);
41
- });
42
-
43
- it('ignores a block that already has token spans', () => {
44
- expect(
45
- needsHighlight(block('language-bash', '<span class="tok">pip</span>'))
46
- ).toBe(false);
47
- });
48
-
49
- it('ignores an already-processed block', () => {
50
- const code = block('language-bash', 'pip install x');
51
- code.setAttribute(PROCESSED_ATTR, '1');
52
- expect(needsHighlight(code)).toBe(false);
53
- });
54
-
55
- it('ignores blocks without a language class', () => {
56
- expect(needsHighlight(block('', 'plain text'))).toBe(false);
57
- });
58
-
59
- it('ignores empty or whitespace-only blocks', () => {
60
- expect(needsHighlight(block('language-bash', ' '))).toBe(false);
61
- });
62
-
63
- it('skips languages owned by other renderers (mermaid)', () => {
64
- expect(needsHighlight(block('language-mermaid', 'graph TD; A-->B'))).toBe(
65
- false
66
- );
67
- });
68
- });
69
-
70
- describe('collectPlainBlocks', () => {
71
- it('returns only the plain language blocks under a root', () => {
72
- const root = document.createElement('div');
73
- root.appendChild(block('language-bash', 'echo hi').parentElement!);
74
- root.appendChild(
75
- block('language-python', '<span class="tok">import os</span>')
76
- .parentElement!
77
- );
78
- root.appendChild(block('language-json', '{"a": 1}').parentElement!);
79
- root.appendChild(
80
- block('language-mermaid', 'graph TD; A-->B').parentElement!
16
+ it('is idempotent - a second call does not add another element', () => {
17
+ injectHighlightRules('.ͼs{color:red}');
18
+ injectHighlightRules('.ͼs{color:blue}');
19
+ expect(document.querySelectorAll(`#${HIGHLIGHT_STYLE_ID}`)).toHaveLength(1);
20
+ expect(document.getElementById(HIGHLIGHT_STYLE_ID)!.textContent).toBe(
21
+ '.ͼs{color:red}'
81
22
  );
82
-
83
- const found = collectPlainBlocks(root);
84
- const langs = found.map(c => languageFromClass(c.className));
85
- expect(langs).toEqual(['bash', 'json']);
86
23
  });
87
24
 
88
- it('matches a bare code element passed as the root', () => {
89
- const code = block('language-bash', 'echo hi');
90
- expect(collectPlainBlocks(code)).toEqual([code]);
91
- });
92
-
93
- it('returns an empty list when everything is already highlighted', () => {
94
- const root = document.createElement('div');
95
- root.appendChild(
96
- block('language-bash', '<span class="tok">echo</span>').parentElement!
97
- );
98
- expect(collectPlainBlocks(root)).toHaveLength(0);
25
+ it('ignores an empty rule string', () => {
26
+ injectHighlightRules('');
27
+ expect(document.getElementById(HIGHLIGHT_STYLE_ID)).toBeNull();
99
28
  });
100
29
  });
package/src/highlight.ts CHANGED
@@ -1,67 +1,37 @@
1
1
  /**
2
- * Pure DOM helpers for detecting fenced code blocks that rendered without
3
- * syntax highlighting. Kept free of JupyterLab imports so they can be unit
4
- * tested directly.
2
+ * Restore syntax highlighting in rendered Markdown code blocks.
3
+ *
4
+ * Kept free of JupyterLab imports so it can be unit tested directly.
5
5
  */
6
6
 
7
- /**
8
- * Attribute marking a code block this extension has already handled, so the
9
- * MutationObserver never re-processes the same block.
10
- */
11
- export const PROCESSED_ATTR = 'data-msrf';
12
-
13
- /**
14
- * Fenced languages rendered by their own renderer (e.g. mermaid -> SVG). They
15
- * are never plain `pre > code` highlight candidates, so leave them untouched.
16
- */
17
- export const SKIP_LANGUAGES = new Set(['mermaid']);
18
-
19
- /**
20
- * Extract the CodeMirror language name from a `language-<name>` class token.
21
- * Returns null when no such class is present.
22
- */
23
- export function languageFromClass(className: string): string | null {
24
- const match = /(?:^|\s)language-([\w+#-]+)/.exec(className);
25
- return match ? match[1] : null;
26
- }
27
-
28
- /**
29
- * A rendered fenced code block has lost its syntax highlighting when it carries
30
- * a `language-*` class and non-empty text, yet has no child elements - the
31
- * async highlighter threw and the markdown renderer fell back to a plain
32
- * `<pre><code>` (cache miss). A correctly highlighted block holds token
33
- * `<span>` children, so `childElementCount > 0`.
34
- */
35
- export function needsHighlight(code: HTMLElement): boolean {
36
- if (code.hasAttribute(PROCESSED_ATTR) || code.childElementCount > 0) {
37
- return false;
38
- }
39
- const language = languageFromClass(code.className);
40
- if (!language || SKIP_LANGUAGES.has(language)) {
41
- return false;
42
- }
43
- return (code.textContent ?? '').trim().length > 0;
44
- }
45
-
46
- /** CSS selector for a fenced code block carrying a language hint. */
47
- const CODE_SELECTOR = 'pre > code[class*="language-"]';
7
+ /** Id of the injected highlight-style element, used to keep injection idempotent. */
8
+ export const HIGHLIGHT_STYLE_ID =
9
+ 'jupyterlab-markdown-syntax-rendering-fix-highlight';
48
10
 
49
11
  /**
50
- * Collect the plain `pre > code.language-*` blocks at or under `root` that need
51
- * their highlighting recovered. `root` itself is considered, since
52
- * `querySelectorAll` only matches descendants and an observer may hand us the
53
- * `<code>` element directly.
12
+ * Inject the CodeMirror highlight StyleModule's CSS into `doc` so rendered
13
+ * Markdown token spans are coloured.
14
+ *
15
+ * Rendered code blocks already carry the generated highlight classes (e.g.
16
+ * `ͼs`), but the StyleModule that gives those classes their colours is only
17
+ * mounted when a CodeMirror `EditorView` is instantiated. With only Markdown
18
+ * previews open, no editor exists, so the spans inherit the plain code colour
19
+ * and look unhighlighted. Mounting the rules once - the same effect as opening
20
+ * the editor - colours every preview. Colours resolve through
21
+ * `--jp-mirror-editor-*-color` CSS variables, so they track the active theme.
22
+ *
23
+ * Idempotent: a fixed element id means repeated calls are a no-op, and an empty
24
+ * rule string is ignored.
54
25
  */
55
- export function collectPlainBlocks(root: ParentNode): HTMLElement[] {
56
- const blocks: HTMLElement[] = [];
57
- const consider = (code: HTMLElement): void => {
58
- if (needsHighlight(code)) {
59
- blocks.push(code);
60
- }
61
- };
62
- if (root instanceof HTMLElement && root.matches(CODE_SELECTOR)) {
63
- consider(root);
26
+ export function injectHighlightRules(
27
+ rules: string,
28
+ doc: Document = document
29
+ ): void {
30
+ if (!rules || doc.getElementById(HIGHLIGHT_STYLE_ID)) {
31
+ return;
64
32
  }
65
- root.querySelectorAll<HTMLElement>(CODE_SELECTOR).forEach(consider);
66
- return blocks;
33
+ const style = doc.createElement('style');
34
+ style.id = HIGHLIGHT_STYLE_ID;
35
+ style.textContent = rules;
36
+ (doc.head ?? doc.documentElement).appendChild(style);
67
37
  }
package/src/index.ts CHANGED
@@ -1,120 +1,6 @@
1
- import {
2
- JupyterFrontEnd,
3
- JupyterFrontEndPlugin
4
- } from '@jupyterlab/application';
5
- import { IEditorLanguageRegistry } from '@jupyterlab/codemirror';
6
- import {
7
- PROCESSED_ATTR,
8
- collectPlainBlocks,
9
- languageFromClass
10
- } from './highlight';
11
-
12
- /** How many times to attempt recovery (only a thrown highlight is retried). */
13
- const MAX_ATTEMPTS = 4;
14
-
15
- /** Base retry delay; backs off 750/1500/3000ms (~5s total) so a slow chunk import can settle. */
16
- const BASE_RETRY_DELAY_MS = 750;
17
-
18
- /** Blocks currently being recovered, to dedupe concurrent observer callbacks. */
19
- const inFlight = new WeakSet<HTMLElement>();
20
-
21
- const delay = (ms: number): Promise<void> =>
22
- new Promise(resolve => {
23
- window.setTimeout(resolve, ms);
24
- });
25
-
26
- /**
27
- * Recover highlighting on a single plain block, and mark it with a terminal
28
- * `data-msrf` state so it is processed at most once:
29
- *
30
- * - `1` success - token spans written
31
- * - `plain` highlight ran cleanly but produced no tokens (span-less content) -
32
- * not a failure, the original plain text is left untouched
33
- * - `skipped` the language is unsupported (`findBest` miss) - deterministic, so
34
- * no retry and no warning
35
- * - `failed` the highlight threw on every attempt - left as plain text, no
36
- * regression vs without this extension
37
- *
38
- * Only a thrown highlight is retried, since that is the one transient case (a
39
- * cold/flaky CodeMirror language-chunk import). Retrying is effective, not
40
- * theatre: `highlight()` awaits `getLanguage()` internally, which caches the
41
- * loaded support on the spec only on success (`spec.support = await spec.load()`)
42
- * - a rejected load throws and leaves `spec.support` unset, and webpack resets a
43
- * failed chunk load, so each attempt genuinely re-runs the dynamic import.
44
- * Retries back off over ~5s; a chunk that never loads within that window stays
45
- * plain until the block is re-rendered (reload / reopen). Concurrent callbacks
46
- * are deduped via `inFlight`.
47
- */
48
- async function rehighlight(
49
- code: HTMLElement,
50
- languages: IEditorLanguageRegistry
51
- ): Promise<void> {
52
- const language = languageFromClass(code.className);
53
- if (!language || inFlight.has(code) || code.hasAttribute(PROCESSED_ATTR)) {
54
- return;
55
- }
56
- const text = code.textContent ?? '';
57
- inFlight.add(code);
58
- try {
59
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
60
- const spec = languages.findBest(language);
61
- if (!spec) {
62
- // Unsupported language - a synchronous registry miss is deterministic,
63
- // so retrying cannot help. Leave it plain, no warning.
64
- code.setAttribute(PROCESSED_ATTR, 'skipped');
65
- return;
66
- }
67
- const host = document.createElement('div');
68
- try {
69
- await languages.highlight(text, spec, host);
70
- } catch {
71
- // The one retryable case: a transient chunk-load flake.
72
- if (attempt < MAX_ATTEMPTS) {
73
- await delay(BASE_RETRY_DELAY_MS * 2 ** (attempt - 1));
74
- // The host may have been re-rendered during the back-off; a fresh node
75
- // is handled independently, so stop working this detached one.
76
- if (!code.isConnected) {
77
- return;
78
- }
79
- continue;
80
- }
81
- code.setAttribute(PROCESSED_ATTR, 'failed');
82
- console.warn(
83
- `[jupyterlab_markdown_syntax_rendering_fix] gave up re-highlighting ${language} after ${MAX_ATTEMPTS} attempts`
84
- );
85
- return;
86
- }
87
- // The node may have been re-rendered during the await; a fresh node is
88
- // handled independently, so don't write to a detached one.
89
- if (!code.isConnected) {
90
- return;
91
- }
92
- // Highlight ran without throwing. Commit the swap only when the rendered
93
- // text round-trips exactly - never replace the block with a truncated or
94
- // divergent fragment (worse than leaving it plain). An empty result is
95
- // deterministic for the content (a parser that failed to load throws
96
- // rather than resolving empty), so it is terminal, not retried.
97
- if (host.childElementCount > 0 && host.textContent === text) {
98
- // Set the marker in the same synchronous frame as the write so the
99
- // observer's async callback sees data-msrf and skips the spans we add.
100
- code.setAttribute(PROCESSED_ATTR, '1');
101
- code.replaceChildren(...Array.from(host.childNodes));
102
- } else {
103
- if (host.childElementCount > 0) {
104
- // Spans were produced but the text did not round-trip - surface this
105
- // rare case rather than silently suppressing a possibly-correct render.
106
- console.warn(
107
- `[jupyterlab_markdown_syntax_rendering_fix] skipped re-highlight of ${language}: highlighted text did not match source`
108
- );
109
- }
110
- code.setAttribute(PROCESSED_ATTR, 'plain');
111
- }
112
- return;
113
- }
114
- } finally {
115
- inFlight.delete(code);
116
- }
117
- }
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ import { jupyterHighlightStyle } from '@jupyterlab/codemirror';
3
+ import { injectHighlightRules } from './highlight';
118
4
 
119
5
  /**
120
6
  * Initialization data for the jupyterlab_markdown_syntax_rendering_fix extension.
@@ -122,53 +8,21 @@ async function rehighlight(
122
8
  const plugin: JupyterFrontEndPlugin<void> = {
123
9
  id: 'jupyterlab_markdown_syntax_rendering_fix:plugin',
124
10
  description:
125
- 'Jupyterlab extension to fix a common issue with Markdown renderer where some race condition causes for the fenced code block to not have proper syntax highlighting',
11
+ 'Restore syntax highlighting in rendered Markdown code blocks by mounting the CodeMirror highlight StyleModule at startup, so token colours apply even when no editor is open.',
126
12
  autoStart: true,
127
- requires: [IEditorLanguageRegistry],
128
- activate: (app: JupyterFrontEnd, languages: IEditorLanguageRegistry) => {
13
+ activate: (): void => {
129
14
  console.log(
130
15
  'JupyterLab extension jupyterlab_markdown_syntax_rendering_fix is activated!'
131
16
  );
132
17
 
133
- const scan = (root: ParentNode): void => {
134
- for (const code of collectPlainBlocks(root)) {
135
- rehighlight(code, languages).catch(error => {
136
- console.warn(
137
- '[jupyterlab_markdown_syntax_rendering_fix] unexpected error while re-highlighting',
138
- error
139
- );
140
- });
141
- }
142
- };
143
-
144
- const observer = new MutationObserver(mutations => {
145
- for (const mutation of mutations) {
146
- mutation.addedNodes.forEach(node => {
147
- if (node.nodeType !== Node.ELEMENT_NODE) {
148
- return;
149
- }
150
- const element = node as HTMLElement;
151
- // Skip pure observer churn: CodeMirror editors mutate on every
152
- // keystroke and never hold rendered markdown, and the token spans this
153
- // extension itself writes land under an already-processed code block.
154
- if (element.closest('.cm-editor, code[data-msrf]')) {
155
- return;
156
- }
157
- scan(element);
158
- });
159
- }
160
- });
161
-
162
- // Observe the application shell rather than document.body: rendered markdown
163
- // lives in the shell (notebook cells, markdown preview). Markdown rendered in
164
- // body-level overlays (completer / hover docstrings) is intentionally not
165
- // covered - a deliberate tradeoff to avoid observing the high-churn overlay
166
- // layer; such code blocks recover on the next in-shell render or a reload.
167
- const root = app.shell.node;
168
- observer.observe(root, { childList: true, subtree: true });
169
-
170
- // Recover any markdown already rendered before this extension activated.
171
- scan(root);
18
+ // Rendered Markdown code blocks receive CodeMirror highlight token classes
19
+ // (e.g. ͼs), but the StyleModule that gives those classes their colours is
20
+ // only mounted when a CodeMirror EditorView is instantiated. With only
21
+ // Markdown previews open, no editor exists, so the spans inherit the plain
22
+ // code colour and look unhighlighted. Mounting the rules once - the same
23
+ // effect as opening the editor - colours every preview, and the colours
24
+ // track the active theme through --jp-mirror-editor-*-color variables.
25
+ injectHighlightRules(jupyterHighlightStyle.module?.getRules() ?? '');
172
26
  }
173
27
  };
174
28