jupyterlab_markdown_syntax_rendering_fix 0.6.9

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/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Stellars Henson
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.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # jupyterlab_markdown_syntax_rendering_fix
2
+
3
+ [![GitHub Actions](https://github.com/stellarshenson/jupyterlab_markdown_syntax_rendering_fix/actions/workflows/build.yml/badge.svg)](https://github.com/stellarshenson/jupyterlab_markdown_syntax_rendering_fix/actions/workflows/build.yml)
4
+ [![npm version](https://img.shields.io/npm/v/jupyterlab_markdown_syntax_rendering_fix.svg)](https://www.npmjs.com/package/jupyterlab_markdown_syntax_rendering_fix)
5
+ [![PyPI version](https://img.shields.io/pypi/v/jupyterlab-markdown-syntax-rendering-fix.svg)](https://pypi.org/project/jupyterlab-markdown-syntax-rendering-fix/)
6
+ [![Total PyPI downloads](https://static.pepy.tech/badge/jupyterlab-markdown-syntax-rendering-fix)](https://pepy.tech/project/jupyterlab-markdown-syntax-rendering-fix)
7
+ [![JupyterLab 4](https://img.shields.io/badge/JupyterLab-4-orange.svg)](https://jupyterlab.readthedocs.io/en/stable/)
8
+ [![Brought To You By KOLOMOLO](https://img.shields.io/badge/Brought%20To%20You%20By-KOLOMOLO-00ffff?style=flat)](https://kolomolo.com)
9
+ [![Donate PayPal](https://img.shields.io/badge/Donate-PayPal-blue?style=flat)](https://www.paypal.com/donate/?hosted_button_id=B4KPBJDLLXTSA)
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.
12
+
13
+ ## Features
14
+
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
19
+
20
+ ## How it works
21
+
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.
23
+
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
28
+
29
+ ## Requirements
30
+
31
+ - JupyterLab >= 4.0.0
32
+
33
+ ## Install
34
+
35
+ To install the extension, execute:
36
+
37
+ ```bash
38
+ pip install jupyterlab_markdown_syntax_rendering_fix
39
+ ```
40
+
41
+ ## Uninstall
42
+
43
+ To remove the extension, execute:
44
+
45
+ ```bash
46
+ pip uninstall jupyterlab_markdown_syntax_rendering_fix
47
+ ```
@@ -0,0 +1,35 @@
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.
5
+ */
6
+ /**
7
+ * Attribute marking a code block this extension has already handled, so the
8
+ * MutationObserver never re-processes the same block.
9
+ */
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[];
@@ -0,0 +1,62 @@
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.
5
+ */
6
+ /**
7
+ * Attribute marking a code block this extension has already handled, so the
8
+ * MutationObserver never re-processes the same block.
9
+ */
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) {
32
+ 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);
59
+ }
60
+ root.querySelectorAll(CODE_SELECTOR).forEach(consider);
61
+ return blocks;
62
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ /**
3
+ * Initialization data for the jupyterlab_markdown_syntax_rendering_fix extension.
4
+ */
5
+ declare const plugin: JupyterFrontEndPlugin<void>;
6
+ export default plugin;
package/lib/index.js ADDED
@@ -0,0 +1,146 @@
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
+ }
101
+ /**
102
+ * Initialization data for the jupyterlab_markdown_syntax_rendering_fix extension.
103
+ */
104
+ const plugin = {
105
+ 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',
107
+ autoStart: true,
108
+ requires: [IEditorLanguageRegistry],
109
+ activate: (app, languages) => {
110
+ 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);
144
+ }
145
+ };
146
+ export default plugin;
package/package.json ADDED
@@ -0,0 +1,140 @@
1
+ {
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",
5
+ "keywords": [
6
+ "jupyter",
7
+ "jupyterlab",
8
+ "jupyterlab-extension"
9
+ ],
10
+ "homepage": "https://github.com/stellarshenson/jupyterlab_markdown_syntax_rendering_fix",
11
+ "bugs": {
12
+ "url": "https://github.com/stellarshenson/jupyterlab_markdown_syntax_rendering_fix/issues"
13
+ },
14
+ "license": "BSD-3-Clause",
15
+ "author": {
16
+ "name": "Stellars Henson",
17
+ "email": "konrad.jelen+github@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
+ "src/**/*.{ts,tsx}"
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/stellarshenson/jupyterlab_markdown_syntax_rendering_fix.git"
30
+ },
31
+ "scripts": {
32
+ "build": "jlpm build:lib && jlpm build:labextension:dev",
33
+ "build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension",
34
+ "build:labextension": "jupyter-builder build .",
35
+ "build:labextension:dev": "jupyter-builder build --development True .",
36
+ "build:lib": "tsc --sourceMap",
37
+ "build:lib:prod": "tsc",
38
+ "clean": "jlpm clean:lib",
39
+ "clean:lib": "rimraf lib tsconfig.tsbuildinfo",
40
+ "clean:lintcache": "rimraf .eslintcache .stylelintcache",
41
+ "clean:labextension": "rimraf jupyterlab_markdown_syntax_rendering_fix/labextension jupyterlab_markdown_syntax_rendering_fix/_version.py",
42
+ "clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache",
43
+ "eslint": "jlpm eslint:check --fix",
44
+ "eslint:check": "eslint . --cache",
45
+ "install:extension": "jlpm build",
46
+ "lint": "jlpm stylelint && jlpm prettier && jlpm eslint",
47
+ "lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check",
48
+ "prettier": "jlpm prettier:base --write --list-different",
49
+ "prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"",
50
+ "prettier:check": "jlpm prettier:base --check",
51
+ "stylelint": "jlpm stylelint:check --fix",
52
+ "stylelint:check": "stylelint --cache \"style/**/*.css\"",
53
+ "test": "jest --coverage",
54
+ "watch": "run-p watch:src watch:labextension",
55
+ "watch:src": "tsc -w --sourceMap",
56
+ "watch:labextension": "jupyter-builder watch ."
57
+ },
58
+ "dependencies": {
59
+ "@jupyterlab/application": "^4.0.0",
60
+ "@jupyterlab/codemirror": "^4.0.0"
61
+ },
62
+ "devDependencies": {
63
+ "@eslint/js": "^9.0.0",
64
+ "@jupyter/builder": "^1.0.0",
65
+ "@jupyter/eslint-plugin": "^0.0.5",
66
+ "@jupyterlab/testutils": "^4.0.0",
67
+ "@types/jest": "^29.2.0",
68
+ "@types/json-schema": "^7.0.11",
69
+ "@types/react": "^18.0.26",
70
+ "@types/react-addons-linked-state-mixin": "^0.14.22",
71
+ "eslint": "^9.0.0",
72
+ "eslint-config-prettier": "^9.0.0",
73
+ "eslint-plugin-prettier": "^5.0.0",
74
+ "globals": "^15.0.0",
75
+ "jest": "^29.2.0",
76
+ "npm-run-all2": "^7.0.1",
77
+ "prettier": "^3.0.0",
78
+ "rimraf": "^5.0.1",
79
+ "stylelint": "^15.10.1",
80
+ "stylelint-config-recommended": "^13.0.0",
81
+ "stylelint-config-standard": "^34.0.0",
82
+ "stylelint-csstree-validator": "^3.0.0",
83
+ "stylelint-prettier": "^4.0.0",
84
+ "typescript": "~5.8.0",
85
+ "typescript-eslint": "^8.0.0",
86
+ "yjs": "^13.5.0"
87
+ },
88
+ "resolutions": {
89
+ "lib0": "0.2.111",
90
+ "webpack": "5.106.0",
91
+ "chalk": "4.1.2"
92
+ },
93
+ "overrides": {
94
+ "webpack": "5.106.0",
95
+ "chalk": "4.1.2"
96
+ },
97
+ "sideEffects": [
98
+ "style/*.css",
99
+ "style/index.js"
100
+ ],
101
+ "styleModule": "style/index.js",
102
+ "publishConfig": {
103
+ "access": "public"
104
+ },
105
+ "jupyterlab": {
106
+ "extension": true,
107
+ "outputDir": "jupyterlab_markdown_syntax_rendering_fix/labextension"
108
+ },
109
+ "prettier": {
110
+ "singleQuote": true,
111
+ "trailingComma": "none",
112
+ "arrowParens": "avoid",
113
+ "endOfLine": "auto",
114
+ "overrides": [
115
+ {
116
+ "files": "package.json",
117
+ "options": {
118
+ "tabWidth": 4
119
+ }
120
+ }
121
+ ]
122
+ },
123
+ "stylelint": {
124
+ "extends": [
125
+ "stylelint-config-recommended",
126
+ "stylelint-config-standard",
127
+ "stylelint-prettier/recommended"
128
+ ],
129
+ "plugins": [
130
+ "stylelint-csstree-validator"
131
+ ],
132
+ "rules": {
133
+ "csstree/validator": true,
134
+ "property-no-vendor-prefix": null,
135
+ "selector-class-pattern": "^([a-z][A-z\\d]*)(-[A-z\\d]+)*$",
136
+ "selector-no-vendor-prefix": null,
137
+ "value-no-vendor-prefix": null
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,100 @@
1
+ import {
2
+ PROCESSED_ATTR,
3
+ collectPlainBlocks,
4
+ languageFromClass,
5
+ needsHighlight
6
+ } from '../highlight';
7
+
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');
25
+ });
26
+
27
+ it('keeps symbol-bearing language names', () => {
28
+ expect(languageFromClass('language-c++')).toBe('c++');
29
+ expect(languageFromClass('language-c#')).toBe('c#');
30
+ });
31
+
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!
81
+ );
82
+
83
+ const found = collectPlainBlocks(root);
84
+ const langs = found.map(c => languageFromClass(c.className));
85
+ expect(langs).toEqual(['bash', 'json']);
86
+ });
87
+
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);
99
+ });
100
+ });
@@ -0,0 +1,67 @@
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.
5
+ */
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-"]';
48
+
49
+ /**
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.
54
+ */
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);
64
+ }
65
+ root.querySelectorAll<HTMLElement>(CODE_SELECTOR).forEach(consider);
66
+ return blocks;
67
+ }
package/src/index.ts ADDED
@@ -0,0 +1,175 @@
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
+ }
118
+
119
+ /**
120
+ * Initialization data for the jupyterlab_markdown_syntax_rendering_fix extension.
121
+ */
122
+ const plugin: JupyterFrontEndPlugin<void> = {
123
+ id: 'jupyterlab_markdown_syntax_rendering_fix:plugin',
124
+ 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',
126
+ autoStart: true,
127
+ requires: [IEditorLanguageRegistry],
128
+ activate: (app: JupyterFrontEnd, languages: IEditorLanguageRegistry) => {
129
+ console.log(
130
+ 'JupyterLab extension jupyterlab_markdown_syntax_rendering_fix is activated!'
131
+ );
132
+
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);
172
+ }
173
+ };
174
+
175
+ export default plugin;
package/style/base.css ADDED
@@ -0,0 +1,5 @@
1
+ /*
2
+ See the JupyterLab Developer Guide for useful CSS Patterns:
3
+
4
+ https://jupyterlab.readthedocs.io/en/stable/developer/css.html
5
+ */
@@ -0,0 +1 @@
1
+ @import url('base.css');
package/style/index.js ADDED
@@ -0,0 +1 @@
1
+ import './base.css';