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 +29 -0
- package/README.md +47 -0
- package/lib/highlight.d.ts +35 -0
- package/lib/highlight.js +62 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +146 -0
- package/package.json +140 -0
- package/src/__tests__/jupyterlab_markdown_syntax_rendering_fix.spec.ts +100 -0
- package/src/highlight.ts +67 -0
- package/src/index.ts +175 -0
- package/style/base.css +5 -0
- package/style/index.css +1 -0
- package/style/index.js +1 -0
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
|
+
[](https://github.com/stellarshenson/jupyterlab_markdown_syntax_rendering_fix/actions/workflows/build.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/jupyterlab_markdown_syntax_rendering_fix)
|
|
5
|
+
[](https://pypi.org/project/jupyterlab-markdown-syntax-rendering-fix/)
|
|
6
|
+
[](https://pepy.tech/project/jupyterlab-markdown-syntax-rendering-fix)
|
|
7
|
+
[](https://jupyterlab.readthedocs.io/en/stable/)
|
|
8
|
+
[](https://kolomolo.com)
|
|
9
|
+
[](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[];
|
package/lib/highlight.js
ADDED
|
@@ -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
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
|
+
});
|
package/src/highlight.ts
ADDED
|
@@ -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
package/style/index.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import url('base.css');
|
package/style/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './base.css';
|