jupyterlab_edit_markdown_at_content_extension 0.4.3

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,33 @@
1
+ # jupyterlab_edit_markdown_at_content_extension
2
+
3
+ [![GitHub Actions](https://github.com/stellarshenson/jupyterlab_edit_markdown_at_content_extension/actions/workflows/build.yml/badge.svg)](https://github.com/stellarshenson/jupyterlab_edit_markdown_at_content_extension/actions/workflows/build.yml)
4
+ [![npm version](https://img.shields.io/npm/v/jupyterlab_edit_markdown_at_content_extension.svg)](https://www.npmjs.com/package/jupyterlab_edit_markdown_at_content_extension)
5
+ [![PyPI version](https://img.shields.io/pypi/v/jupyterlab-edit-markdown-at-content-extension.svg)](https://pypi.org/project/jupyterlab-edit-markdown-at-content-extension/)
6
+ [![Total PyPI downloads](https://static.pepy.tech/badge/jupyterlab-edit-markdown-at-content-extension)](https://pepy.tech/project/jupyterlab-edit-markdown-at-content-extension)
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
+ Jump straight from a rendered markdown file into the editor at the exact line you were reading. No more opening the editor and scrolling to find the content again - this extension opens the editor positioned right where the content is.
12
+
13
+ ## Features
14
+
15
+ - **Edit at content location** - open the editor scrolled to the line matching the rendered content you are viewing
16
+ - **No-scroll workflow** - skips the manual hunt for the right line after switching from preview to editor
17
+ - **Server-side support** - a Python `jupyter_server` extension backs the frontend with the routes it needs
18
+
19
+ ## Requirements
20
+
21
+ - JupyterLab >= 4.0.0
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install jupyterlab_edit_markdown_at_content_extension
27
+ ```
28
+
29
+ ## Uninstall
30
+
31
+ ```bash
32
+ pip uninstall jupyterlab_edit_markdown_at_content_extension
33
+ ```
package/lib/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ /**
3
+ * Initialization data for the jupyterlab_edit_markdown_at_content_extension extension.
4
+ */
5
+ declare const plugin: JupyterFrontEndPlugin<void>;
6
+ export default plugin;
package/lib/index.js ADDED
@@ -0,0 +1,178 @@
1
+ import { IDocumentManager } from '@jupyterlab/docmanager';
2
+ import { IEditorTracker } from '@jupyterlab/fileeditor';
3
+ import { IMarkdownViewerTracker } from '@jupyterlab/markdownviewer';
4
+ import { buildBlockMap, blockToLine, lineToBlock, headingSlug } from './mapping';
5
+ const PLUGIN_ID = 'jupyterlab_edit_markdown_at_content_extension:plugin';
6
+ const CMD_EDIT_AT = 'editmarkdownatcontent:edit-at-location';
7
+ const CMD_REVEAL_IN = 'editmarkdownatcontent:reveal-in-preview';
8
+ const PREVIEW_SELECTOR = '.jp-MarkdownViewer .jp-RenderedMarkdown';
9
+ const EDITOR_SELECTOR = '.jp-FileEditor';
10
+ const EDITOR_FACTORY = 'Editor';
11
+ const PREVIEW_FACTORY = 'Markdown Preview';
12
+ const LOG = '[edit-markdown-at-content]';
13
+ /**
14
+ * Initialization data for the jupyterlab_edit_markdown_at_content_extension extension.
15
+ */
16
+ const plugin = {
17
+ id: PLUGIN_ID,
18
+ description: 'Jupyterlab extension to save you the scrolling time from when you are at markdown file location and open editor and need to scroll to the exact place in the file where the content is. This extension opens the editor at the place where the content is',
19
+ autoStart: true,
20
+ requires: [IDocumentManager, IEditorTracker, IMarkdownViewerTracker],
21
+ activate: (app, docManager, editorTracker, markdownTracker) => {
22
+ console.log('JupyterLab extension jupyterlab_edit_markdown_at_content_extension is activated!');
23
+ // Lumino commands receive no DOM target, so a single capture-phase
24
+ // listener stashes the right-clicked node for both directions.
25
+ let lastPreviewTarget = null;
26
+ let lastEditorTarget = null;
27
+ document.addEventListener('contextmenu', (event) => {
28
+ var _a, _b;
29
+ const target = event.target;
30
+ lastPreviewTarget = ((_a = target === null || target === void 0 ? void 0 : target.closest) === null || _a === void 0 ? void 0 : _a.call(target, PREVIEW_SELECTOR)) ? target : null;
31
+ lastEditorTarget = ((_b = target === null || target === void 0 ? void 0 : target.closest) === null || _b === void 0 ? void 0 : _b.call(target, EDITOR_SELECTOR)) ? target : null;
32
+ }, true);
33
+ /** The MarkdownDocument widget whose rendered host contains `target`. */
34
+ const findPreviewWidget = (target) => {
35
+ let found = null;
36
+ markdownTracker.forEach(widget => {
37
+ if (!found && widget.node.contains(target)) {
38
+ found = widget;
39
+ }
40
+ });
41
+ return found;
42
+ };
43
+ /** The FileEditor document widget whose node contains `target`. */
44
+ const findEditorWidget = (target) => {
45
+ let found = null;
46
+ editorTracker.forEach(widget => {
47
+ if (!found && widget.node.contains(target)) {
48
+ found = widget;
49
+ }
50
+ });
51
+ return found !== null && found !== void 0 ? found : editorTracker.currentWidget;
52
+ };
53
+ /** The `.jp-RenderedMarkdown` host element inside a preview/editor widget. */
54
+ const renderedHost = (widget) => { var _a, _b; return (_b = (_a = widget === null || widget === void 0 ? void 0 : widget.node) === null || _a === void 0 ? void 0 : _a.querySelector('.jp-RenderedMarkdown')) !== null && _b !== void 0 ? _b : null; };
55
+ // ---- Preview -> Editor -------------------------------------------------
56
+ app.commands.addCommand(CMD_EDIT_AT, {
57
+ label: 'Edit at this location',
58
+ execute: async () => {
59
+ const target = lastPreviewTarget;
60
+ if (!target) {
61
+ console.warn(`${LOG} no markdown preview target under the cursor`);
62
+ return;
63
+ }
64
+ const widget = findPreviewWidget(target);
65
+ if (!widget) {
66
+ console.warn(`${LOG} could not resolve the owning Markdown Preview`);
67
+ return;
68
+ }
69
+ const host = renderedHost(widget);
70
+ if (!host) {
71
+ console.warn(`${LOG} rendered host not found`);
72
+ return;
73
+ }
74
+ // Walk up to the top-level block (direct child of the host).
75
+ let block = target;
76
+ while (block && block.parentElement !== host) {
77
+ block = block.parentElement;
78
+ }
79
+ if (!block) {
80
+ console.warn(`${LOG} clicked content is not a rendered block`);
81
+ return;
82
+ }
83
+ const ordinal = Array.from(host.children).indexOf(block);
84
+ const source = widget.context.model.toString();
85
+ const line = blockToLine(source, ordinal);
86
+ if (line < 0) {
87
+ console.warn(`${LOG} block ordinal ${ordinal} is not mappable`);
88
+ return;
89
+ }
90
+ const editorWidget = docManager.openOrReveal(widget.context.path, EDITOR_FACTORY);
91
+ if (!editorWidget) {
92
+ return;
93
+ }
94
+ await editorWidget.context.ready;
95
+ await editorWidget.revealed;
96
+ const editor = editorWidget.content.editor;
97
+ const clamped = Math.min(line, editor.lineCount - 1);
98
+ editor.setCursorPosition({ line: clamped, column: 0 });
99
+ // Focus so the cursor is live (you asked to edit here) and the active
100
+ // line is rendered, then scroll it into view.
101
+ editor.focus();
102
+ editor.revealPosition({ line: clamped, column: 0 });
103
+ }
104
+ });
105
+ app.contextMenu.addItem({
106
+ command: CMD_EDIT_AT,
107
+ selector: PREVIEW_SELECTOR,
108
+ rank: 0
109
+ });
110
+ // ---- Editor -> Preview -------------------------------------------------
111
+ app.commands.addCommand(CMD_REVEAL_IN, {
112
+ label: 'Reveal in Markdown Preview',
113
+ execute: async () => {
114
+ var _a;
115
+ const target = lastEditorTarget;
116
+ if (!target) {
117
+ console.warn(`${LOG} no editor target under the cursor`);
118
+ return;
119
+ }
120
+ const widget = findEditorWidget(target);
121
+ if (!widget) {
122
+ console.warn(`${LOG} could not resolve the owning file editor`);
123
+ return;
124
+ }
125
+ const editor = widget.content.editor;
126
+ const line = editor.getCursorPosition().line;
127
+ const source = widget.context.model.toString();
128
+ const { ordinal, headingSlug: slug, headingNth } = lineToBlock(source, line);
129
+ const previewWidget = docManager.openOrReveal(widget.context.path, PREVIEW_FACTORY);
130
+ if (!previewWidget) {
131
+ return;
132
+ }
133
+ await previewWidget.context.ready;
134
+ await previewWidget.revealed;
135
+ const host = renderedHost(previewWidget);
136
+ if (!host) {
137
+ console.warn(`${LOG} rendered preview host not found`);
138
+ return;
139
+ }
140
+ const children = Array.from(host.children);
141
+ const expected = buildBlockMap(source).blocks.length;
142
+ if (children.length === expected && ordinal >= 0) {
143
+ children[ordinal].scrollIntoView({ block: 'start' });
144
+ return;
145
+ }
146
+ // Fallback (AC #8): ordinal alignment is unreliable (math, sanitizer,
147
+ // injected nodes). Re-derive heading ids from the live headings and
148
+ // scroll to the headingNth-th match.
149
+ if (slug) {
150
+ const headings = Array.from(host.querySelectorAll('h1, h2, h3, h4, h5, h6'));
151
+ let seen = 0;
152
+ for (const h of headings) {
153
+ // rendermime stores createHeaderId in `id` (trusted) or
154
+ // `data-jupyter-id` (untrusted); the live textContent also contains
155
+ // the appended '¶' anchor, so prefer the stored attribute.
156
+ const id = h.id ||
157
+ h.getAttribute('data-jupyter-id') ||
158
+ headingSlug((_a = h.textContent) !== null && _a !== void 0 ? _a : '');
159
+ if (id === slug) {
160
+ seen += 1;
161
+ if (seen === (headingNth !== null && headingNth !== void 0 ? headingNth : 1)) {
162
+ h.scrollIntoView({ block: 'start' });
163
+ return;
164
+ }
165
+ }
166
+ }
167
+ }
168
+ console.warn(`${LOG} could not align preview to line ${line}`);
169
+ }
170
+ });
171
+ app.contextMenu.addItem({
172
+ command: CMD_REVEAL_IN,
173
+ selector: EDITOR_SELECTOR,
174
+ rank: 0
175
+ });
176
+ }
177
+ };
178
+ export default plugin;
@@ -0,0 +1,53 @@
1
+ /** One rendered top-level block, ordinal-aligned to the rendered host's children. */
2
+ export interface IBlockDescriptor {
3
+ /** Index into the DOM-correlated block list == rendered host child index. */
4
+ ordinal: number;
5
+ /** 0-based first source line of the block. */
6
+ startLine: number;
7
+ /** 0-based last source line of the block (inclusive of trailing blanks). */
8
+ endLine: number;
9
+ /** marked token type (heading, paragraph, code, list, table, ...). */
10
+ type: string;
11
+ /** Normalized block text, for the empty-block guard. */
12
+ text: string;
13
+ /** Present only for heading tokens: createHeaderId form of the heading text. */
14
+ headingSlug?: string;
15
+ }
16
+ export interface IBlockMap {
17
+ /** Rendered (non-space/def/comment) tokens, in DOM order. */
18
+ blocks: IBlockDescriptor[];
19
+ /** Heading blocks, for the nearest-preceding-heading fallback. */
20
+ headings: {
21
+ line: number;
22
+ slug: string;
23
+ ordinal: number;
24
+ }[];
25
+ }
26
+ /** Reproduce rendermime `createHeaderId`: spaces -> hyphens on the plain text. */
27
+ export declare function headingSlug(headingText: string): string;
28
+ /**
29
+ * Re-lex `source` and build the ordinal<->line map.
30
+ *
31
+ * Uses `marked.lexer(source, { gfm: true })` to match core's parser config.
32
+ * The line walk includes EVERY top-level token (space/def advance the
33
+ * counter); the DOM-aligned block list excludes space, def and comment-only
34
+ * html tokens.
35
+ */
36
+ export declare function buildBlockMap(source: string): IBlockMap;
37
+ /**
38
+ * Preview -> Editor. Returns the 0-based start line for the block at DOM
39
+ * ordinal `ordinal`, or -1 when the ordinal is out of range or the block has
40
+ * no textual content (caller no-ops + warns, AC #5).
41
+ */
42
+ export declare function blockToLine(source: string, ordinal: number): number;
43
+ /**
44
+ * Editor -> Preview. Returns the DOM ordinal of the block containing `line`
45
+ * (the last block whose startLine <= line), plus the nearest-preceding
46
+ * heading slug and its 1-based occurrence index for the fallback.
47
+ * `ordinal` is -1 when no block precedes the line.
48
+ */
49
+ export declare function lineToBlock(source: string, line: number): {
50
+ ordinal: number;
51
+ headingSlug?: string;
52
+ headingNth?: number;
53
+ };
package/lib/mapping.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Pure, DOM-free source<->rendered-block mapping for Markdown.
3
+ *
4
+ * The Markdown Preview renders with `marked` and emits no source-line
5
+ * attributes, so we re-lex the source ourselves and correlate rendered
6
+ * top-level blocks to source lines by ORDINAL INDEX: the nth rendered
7
+ * top-level child corresponds to the nth non-space/def/comment block token.
8
+ *
9
+ * The line accumulator replicates JupyterLab core's `getHeadingTokens`
10
+ * walk (`@jupyterlab/markedparser-extension`): `currentLine` is a cumulative
11
+ * newline count advanced by EVERY top-level token, recorded BEFORE advancing.
12
+ *
13
+ * This module imports only `marked` - no `@jupyterlab` / `@lumino` / DOM - so
14
+ * it is unit-testable offline with Jest.
15
+ */
16
+ import { marked } from 'marked';
17
+ /** marked token types that are NOT rendered as a top-level DOM child. */
18
+ const NON_DOM_TYPES = new Set(['space', 'def']);
19
+ /** True for an html token that renders nothing (HTML comment only). */
20
+ function isCommentOnlyHtml(token) {
21
+ var _a;
22
+ if (token.type !== 'html') {
23
+ return false;
24
+ }
25
+ const raw = ((_a = token.raw) !== null && _a !== void 0 ? _a : '').trim();
26
+ return /^<!--[\s\S]*-->$/.test(raw);
27
+ }
28
+ /** Reproduce rendermime `createHeaderId`: spaces -> hyphens on the plain text. */
29
+ export function headingSlug(headingText) {
30
+ return plainText(headingText).replace(/ /g, '-');
31
+ }
32
+ /** Best-effort strip of inline markdown so a heading token's text matches the
33
+ * rendered element's textContent (used only for the heading fallback). */
34
+ function plainText(text) {
35
+ return text
36
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> alt
37
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> label
38
+ .replace(/[*_~`]/g, '') // emphasis / code markers
39
+ .trim();
40
+ }
41
+ /** Normalized text used for the empty-block guard. */
42
+ function blockText(token) {
43
+ var _a, _b;
44
+ if (token.type === 'hr') {
45
+ return '';
46
+ }
47
+ return ((_b = (_a = token.text) !== null && _a !== void 0 ? _a : token.raw) !== null && _b !== void 0 ? _b : '').trim();
48
+ }
49
+ /**
50
+ * Re-lex `source` and build the ordinal<->line map.
51
+ *
52
+ * Uses `marked.lexer(source, { gfm: true })` to match core's parser config.
53
+ * The line walk includes EVERY top-level token (space/def advance the
54
+ * counter); the DOM-aligned block list excludes space, def and comment-only
55
+ * html tokens.
56
+ */
57
+ export function buildBlockMap(source) {
58
+ var _a;
59
+ const tokens = marked.lexer(source, { gfm: true });
60
+ const blocks = [];
61
+ const headings = [];
62
+ let currentLine = 0;
63
+ for (const token of tokens) {
64
+ const startLine = currentLine;
65
+ const spanned = token.raw.split('\n').length - 1;
66
+ currentLine += spanned;
67
+ if (NON_DOM_TYPES.has(token.type) || isCommentOnlyHtml(token)) {
68
+ continue;
69
+ }
70
+ const ordinal = blocks.length;
71
+ const descriptor = {
72
+ ordinal,
73
+ startLine,
74
+ endLine: startLine + Math.max(spanned - 1, 0),
75
+ type: token.type,
76
+ text: blockText(token)
77
+ };
78
+ if (token.type === 'heading') {
79
+ descriptor.headingSlug = headingSlug((_a = token.text) !== null && _a !== void 0 ? _a : '');
80
+ headings.push({
81
+ line: startLine,
82
+ slug: descriptor.headingSlug,
83
+ ordinal
84
+ });
85
+ }
86
+ blocks.push(descriptor);
87
+ }
88
+ return { blocks, headings };
89
+ }
90
+ /**
91
+ * Preview -> Editor. Returns the 0-based start line for the block at DOM
92
+ * ordinal `ordinal`, or -1 when the ordinal is out of range or the block has
93
+ * no textual content (caller no-ops + warns, AC #5).
94
+ */
95
+ export function blockToLine(source, ordinal) {
96
+ const { blocks } = buildBlockMap(source);
97
+ if (ordinal < 0 || ordinal >= blocks.length) {
98
+ return -1;
99
+ }
100
+ const block = blocks[ordinal];
101
+ if (block.text.length === 0) {
102
+ return -1;
103
+ }
104
+ return block.startLine;
105
+ }
106
+ /**
107
+ * Editor -> Preview. Returns the DOM ordinal of the block containing `line`
108
+ * (the last block whose startLine <= line), plus the nearest-preceding
109
+ * heading slug and its 1-based occurrence index for the fallback.
110
+ * `ordinal` is -1 when no block precedes the line.
111
+ */
112
+ export function lineToBlock(source, line) {
113
+ var _a;
114
+ const { blocks } = buildBlockMap(source);
115
+ let ordinal = -1;
116
+ for (const block of blocks) {
117
+ if (block.startLine <= line) {
118
+ ordinal = block.ordinal;
119
+ }
120
+ else {
121
+ break;
122
+ }
123
+ }
124
+ // Nearest preceding heading + its occurrence index among same-slug headings.
125
+ let headingSlugValue;
126
+ let headingNth;
127
+ const slugCounts = new Map();
128
+ for (const block of blocks) {
129
+ if (block.type !== 'heading' || block.headingSlug === undefined) {
130
+ continue;
131
+ }
132
+ const nth = ((_a = slugCounts.get(block.headingSlug)) !== null && _a !== void 0 ? _a : 0) + 1;
133
+ slugCounts.set(block.headingSlug, nth);
134
+ if (block.startLine <= line) {
135
+ headingSlugValue = block.headingSlug;
136
+ headingNth = nth;
137
+ }
138
+ else {
139
+ break;
140
+ }
141
+ }
142
+ return { ordinal, headingSlug: headingSlugValue, headingNth };
143
+ }
package/package.json ADDED
@@ -0,0 +1,210 @@
1
+ {
2
+ "name": "jupyterlab_edit_markdown_at_content_extension",
3
+ "version": "0.4.3",
4
+ "description": "Jupyterlab extension to save you the scrolling time from when you are at markdown file location and open editor and need to scroll to the exact place in the file where the content is. This extension opens the editor at the place where the content is",
5
+ "keywords": [
6
+ "jupyter",
7
+ "jupyterlab",
8
+ "jupyterlab-extension"
9
+ ],
10
+ "homepage": "https://github.com/stellarshenson/jupyterlab_edit_markdown_at_content_extension",
11
+ "bugs": {
12
+ "url": "https://github.com/stellarshenson/jupyterlab_edit_markdown_at_content_extension/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_edit_markdown_at_content_extension.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 labextension build .",
35
+ "build:labextension:dev": "jupyter labextension 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_edit_markdown_at_content_extension/labextension jupyterlab_edit_markdown_at_content_extension/_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 --ext .ts,.tsx",
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 labextension watch ."
57
+ },
58
+ "dependencies": {
59
+ "@jupyterlab/application": "^4.5.0",
60
+ "@jupyterlab/codeeditor": "^4.5.0",
61
+ "@jupyterlab/docmanager": "^4.5.0",
62
+ "@jupyterlab/docregistry": "^4.5.0",
63
+ "@jupyterlab/fileeditor": "^4.5.0",
64
+ "@jupyterlab/markdownviewer": "^4.5.0",
65
+ "marked": "^17.0.0"
66
+ },
67
+ "devDependencies": {
68
+ "@jupyterlab/builder": "^4.0.0",
69
+ "@jupyterlab/testutils": "^4.0.0",
70
+ "@types/jest": "^29.2.0",
71
+ "@types/json-schema": "^7.0.11",
72
+ "@types/react": "^18.0.26",
73
+ "@types/react-addons-linked-state-mixin": "^0.14.22",
74
+ "@typescript-eslint/eslint-plugin": "^6.1.0",
75
+ "@typescript-eslint/parser": "^6.1.0",
76
+ "css-loader": "^6.7.1",
77
+ "eslint": "^8.36.0",
78
+ "eslint-config-prettier": "^8.8.0",
79
+ "eslint-plugin-prettier": "^5.0.0",
80
+ "jest": "^29.2.0",
81
+ "mkdirp": "^1.0.3",
82
+ "npm-run-all2": "^7.0.1",
83
+ "prettier": "^3.0.0",
84
+ "rimraf": "^5.0.1",
85
+ "source-map-loader": "^1.0.2",
86
+ "style-loader": "^3.3.1",
87
+ "stylelint": "^15.10.1",
88
+ "stylelint-config-recommended": "^13.0.0",
89
+ "stylelint-config-standard": "^34.0.0",
90
+ "stylelint-csstree-validator": "^3.0.0",
91
+ "stylelint-prettier": "^4.0.0",
92
+ "typescript": "~5.8.0",
93
+ "yjs": "^13.5.0"
94
+ },
95
+ "resolutions": {
96
+ "lib0": "0.2.111",
97
+ "webpack": "5.106.0",
98
+ "chalk": "4.1.2"
99
+ },
100
+ "overrides": {
101
+ "webpack": "5.106.0",
102
+ "chalk": "4.1.2"
103
+ },
104
+ "sideEffects": [
105
+ "style/*.css",
106
+ "style/index.js"
107
+ ],
108
+ "styleModule": "style/index.js",
109
+ "publishConfig": {
110
+ "access": "public"
111
+ },
112
+ "jupyterlab": {
113
+ "extension": true,
114
+ "outputDir": "jupyterlab_edit_markdown_at_content_extension/labextension"
115
+ },
116
+ "eslintIgnore": [
117
+ "node_modules",
118
+ "dist",
119
+ "coverage",
120
+ "**/*.d.ts",
121
+ "tests",
122
+ "**/__tests__",
123
+ "ui-tests"
124
+ ],
125
+ "eslintConfig": {
126
+ "extends": [
127
+ "eslint:recommended",
128
+ "plugin:@typescript-eslint/eslint-recommended",
129
+ "plugin:@typescript-eslint/recommended",
130
+ "plugin:prettier/recommended"
131
+ ],
132
+ "parser": "@typescript-eslint/parser",
133
+ "parserOptions": {
134
+ "project": "tsconfig.json",
135
+ "sourceType": "module"
136
+ },
137
+ "plugins": [
138
+ "@typescript-eslint"
139
+ ],
140
+ "rules": {
141
+ "@typescript-eslint/naming-convention": [
142
+ "error",
143
+ {
144
+ "selector": "interface",
145
+ "format": [
146
+ "PascalCase"
147
+ ],
148
+ "custom": {
149
+ "regex": "^I[A-Z]",
150
+ "match": true
151
+ }
152
+ }
153
+ ],
154
+ "@typescript-eslint/no-unused-vars": [
155
+ "warn",
156
+ {
157
+ "args": "none"
158
+ }
159
+ ],
160
+ "@typescript-eslint/no-explicit-any": "off",
161
+ "@typescript-eslint/no-namespace": "off",
162
+ "@typescript-eslint/no-use-before-define": "off",
163
+ "@typescript-eslint/quotes": [
164
+ "error",
165
+ "single",
166
+ {
167
+ "avoidEscape": true,
168
+ "allowTemplateLiterals": false
169
+ }
170
+ ],
171
+ "curly": [
172
+ "error",
173
+ "all"
174
+ ],
175
+ "eqeqeq": "error",
176
+ "prefer-arrow-callback": "error"
177
+ }
178
+ },
179
+ "prettier": {
180
+ "singleQuote": true,
181
+ "trailingComma": "none",
182
+ "arrowParens": "avoid",
183
+ "endOfLine": "auto",
184
+ "overrides": [
185
+ {
186
+ "files": "package.json",
187
+ "options": {
188
+ "tabWidth": 4
189
+ }
190
+ }
191
+ ]
192
+ },
193
+ "stylelint": {
194
+ "extends": [
195
+ "stylelint-config-recommended",
196
+ "stylelint-config-standard",
197
+ "stylelint-prettier/recommended"
198
+ ],
199
+ "plugins": [
200
+ "stylelint-csstree-validator"
201
+ ],
202
+ "rules": {
203
+ "csstree/validator": true,
204
+ "property-no-vendor-prefix": null,
205
+ "selector-class-pattern": "^([a-z][A-z\\d]*)(-[A-z\\d]+)*$",
206
+ "selector-no-vendor-prefix": null,
207
+ "value-no-vendor-prefix": null
208
+ }
209
+ }
210
+ }
@@ -0,0 +1,32 @@
1
+ declare const require: (module: string) => any;
2
+ declare const __dirname: string;
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ /** index.ts source with comments stripped, for static regression checks. */
8
+ function indexSourceNoComments(): string {
9
+ const source = fs.readFileSync(
10
+ path.resolve(__dirname, '..', 'index.ts'),
11
+ 'utf-8'
12
+ );
13
+ return source.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
14
+ }
15
+
16
+ describe('index - registry hygiene (AC #10)', () => {
17
+ it('does not call docRegistry.addFileType (must not override icons)', () => {
18
+ expect(indexSourceNoComments()).not.toMatch(/addFileType\s*\(/);
19
+ });
20
+
21
+ it('does not depend on IDocumentRegistry', () => {
22
+ expect(indexSourceNoComments()).not.toMatch(/IDocumentRegistry/);
23
+ });
24
+ });
25
+
26
+ describe('index - activation message (AC #9)', () => {
27
+ it('logs the exact activation message the UI test expects', () => {
28
+ expect(indexSourceNoComments()).toContain(
29
+ 'JupyterLab extension jupyterlab_edit_markdown_at_content_extension is activated!'
30
+ );
31
+ });
32
+ });
@@ -0,0 +1,122 @@
1
+ import {
2
+ buildBlockMap,
3
+ blockToLine,
4
+ lineToBlock,
5
+ headingSlug
6
+ } from '../mapping';
7
+
8
+ /** Return the 0-based source line, for content-based assertions. */
9
+ function lineAt(src: string, n: number): string {
10
+ return src.split('\n')[n];
11
+ }
12
+
13
+ describe('mapping - buildBlockMap', () => {
14
+ it('orders blocks and starts the first block at line 0', () => {
15
+ const src = '# Title\n\nFirst paragraph.\n';
16
+ const { blocks } = buildBlockMap(src);
17
+ expect(blocks[0].ordinal).toBe(0);
18
+ expect(blocks[0].startLine).toBe(0);
19
+ expect(blocks.map(b => b.ordinal)).toEqual([0, 1]);
20
+ });
21
+ });
22
+
23
+ describe('mapping - cumulative line arithmetic across a fenced code block', () => {
24
+ const src = [
25
+ '# Title', // 0
26
+ '', // 1
27
+ 'First paragraph.', // 2
28
+ '', // 3
29
+ '## Section', // 4
30
+ '', // 5
31
+ '```js', // 6
32
+ 'const a = 1;', // 7
33
+ 'const b = 2;', // 8
34
+ '```', // 9
35
+ '', // 10
36
+ 'After code paragraph.' // 11
37
+ ].join('\n');
38
+
39
+ it('lands each block on its true source line (verifies the accumulator)', () => {
40
+ const { blocks } = buildBlockMap(src);
41
+ const byType = (t: string) => blocks.filter(b => b.type === t);
42
+
43
+ expect(blocks[0].startLine).toBe(0); // heading
44
+ expect(lineAt(src, blockToLine(src, 1))).toBe('First paragraph.');
45
+ // The paragraph AFTER the 4-line code fence must resolve to line 11,
46
+ // not be off-by-N from naive +1-per-token counting.
47
+ const lastPara = byType('paragraph').slice(-1)[0];
48
+ expect(blockToLine(src, lastPara.ordinal)).toBe(11);
49
+ expect(lineAt(src, 11)).toBe('After code paragraph.');
50
+ });
51
+
52
+ it('round-trips line -> block -> line for the trailing paragraph', () => {
53
+ const { blocks } = buildBlockMap(src);
54
+ const lastPara = blocks.filter(b => b.type === 'paragraph').slice(-1)[0];
55
+ const { ordinal } = lineToBlock(src, 11);
56
+ expect(ordinal).toBe(lastPara.ordinal);
57
+ });
58
+
59
+ it('maps a line inside the code fence to the code block + preceding heading', () => {
60
+ const { blocks } = buildBlockMap(src);
61
+ const code = blocks.find(b => b.type === 'code')!;
62
+ const res = lineToBlock(src, 8); // 'const b = 2;'
63
+ expect(res.ordinal).toBe(code.ordinal);
64
+ expect(res.headingSlug).toBe('Section');
65
+ expect(res.headingNth).toBe(1);
66
+ });
67
+ });
68
+
69
+ describe('mapping - duplicate adjacent paragraphs (position, not content)', () => {
70
+ const src = 'Repeat me.\n\nRepeat me.\n';
71
+ it('resolves identical paragraphs to distinct ordinals and lines', () => {
72
+ const { blocks } = buildBlockMap(src);
73
+ expect(blocks).toHaveLength(2);
74
+ expect(blockToLine(src, 0)).toBe(0);
75
+ expect(blockToLine(src, 1)).toBe(2);
76
+ expect(lineAt(src, 2)).toBe('Repeat me.');
77
+ });
78
+ });
79
+
80
+ describe('mapping - empty/marker-only blocks guard (AC #5)', () => {
81
+ it('returns -1 for a horizontal rule block', () => {
82
+ const src = 'Text\n\n---\n\nMore\n';
83
+ const { blocks } = buildBlockMap(src);
84
+ const hr = blocks.find(b => b.type === 'hr')!;
85
+ expect(hr).toBeDefined();
86
+ expect(blockToLine(src, hr.ordinal)).toBe(-1);
87
+ });
88
+
89
+ it('returns -1 for an out-of-range ordinal', () => {
90
+ expect(blockToLine('# Only\n', 999)).toBe(-1);
91
+ expect(blockToLine('# Only\n', -1)).toBe(-1);
92
+ });
93
+ });
94
+
95
+ describe('mapping - nested list is a single top-level block', () => {
96
+ const src = '- a\n - b\n- c\n';
97
+ it('treats the whole list as one ordinal', () => {
98
+ const { blocks } = buildBlockMap(src);
99
+ expect(blocks).toHaveLength(1);
100
+ expect(blocks[0].type).toBe('list');
101
+ expect(lineToBlock(src, 1).ordinal).toBe(0);
102
+ expect(blockToLine(src, 0)).toBe(0);
103
+ });
104
+ });
105
+
106
+ describe('mapping - GFM table is a single top-level block', () => {
107
+ const src = '| H | I |\n|---|---|\n| 1 | 2 |\n';
108
+ it('treats the table as one ordinal at line 0', () => {
109
+ const { blocks } = buildBlockMap(src);
110
+ expect(blocks[0].type).toBe('table');
111
+ expect(blockToLine(src, 0)).toBe(0);
112
+ });
113
+ });
114
+
115
+ describe('mapping - headingSlug', () => {
116
+ it('replaces spaces with hyphens on plain text', () => {
117
+ expect(headingSlug('Hello World')).toBe('Hello-World');
118
+ });
119
+ it('strips inline markdown before slugging', () => {
120
+ expect(headingSlug('A **bold** title')).toBe('A-bold-title');
121
+ });
122
+ });
package/src/index.ts ADDED
@@ -0,0 +1,232 @@
1
+ import {
2
+ JupyterFrontEnd,
3
+ JupyterFrontEndPlugin
4
+ } from '@jupyterlab/application';
5
+
6
+ import { IDocumentManager } from '@jupyterlab/docmanager';
7
+
8
+ import { IEditorTracker } from '@jupyterlab/fileeditor';
9
+
10
+ import { IMarkdownViewerTracker } from '@jupyterlab/markdownviewer';
11
+
12
+ import {
13
+ buildBlockMap,
14
+ blockToLine,
15
+ lineToBlock,
16
+ headingSlug
17
+ } from './mapping';
18
+
19
+ const PLUGIN_ID = 'jupyterlab_edit_markdown_at_content_extension:plugin';
20
+ const CMD_EDIT_AT = 'editmarkdownatcontent:edit-at-location';
21
+ const CMD_REVEAL_IN = 'editmarkdownatcontent:reveal-in-preview';
22
+
23
+ const PREVIEW_SELECTOR = '.jp-MarkdownViewer .jp-RenderedMarkdown';
24
+ const EDITOR_SELECTOR = '.jp-FileEditor';
25
+ const EDITOR_FACTORY = 'Editor';
26
+ const PREVIEW_FACTORY = 'Markdown Preview';
27
+
28
+ const LOG = '[edit-markdown-at-content]';
29
+
30
+ /**
31
+ * Initialization data for the jupyterlab_edit_markdown_at_content_extension extension.
32
+ */
33
+ const plugin: JupyterFrontEndPlugin<void> = {
34
+ id: PLUGIN_ID,
35
+ description:
36
+ 'Jupyterlab extension to save you the scrolling time from when you are at markdown file location and open editor and need to scroll to the exact place in the file where the content is. This extension opens the editor at the place where the content is',
37
+ autoStart: true,
38
+ requires: [IDocumentManager, IEditorTracker, IMarkdownViewerTracker],
39
+ activate: (
40
+ app: JupyterFrontEnd,
41
+ docManager: IDocumentManager,
42
+ editorTracker: IEditorTracker,
43
+ markdownTracker: IMarkdownViewerTracker
44
+ ) => {
45
+ console.log(
46
+ 'JupyterLab extension jupyterlab_edit_markdown_at_content_extension is activated!'
47
+ );
48
+
49
+ // Lumino commands receive no DOM target, so a single capture-phase
50
+ // listener stashes the right-clicked node for both directions.
51
+ let lastPreviewTarget: Element | null = null;
52
+ let lastEditorTarget: Element | null = null;
53
+
54
+ document.addEventListener(
55
+ 'contextmenu',
56
+ (event: MouseEvent) => {
57
+ const target = event.target as Element | null;
58
+ lastPreviewTarget = target?.closest?.(PREVIEW_SELECTOR) ? target : null;
59
+ lastEditorTarget = target?.closest?.(EDITOR_SELECTOR) ? target : null;
60
+ },
61
+ true
62
+ );
63
+
64
+ /** The MarkdownDocument widget whose rendered host contains `target`. */
65
+ const findPreviewWidget = (target: Element): any | null => {
66
+ let found: any | null = null;
67
+ markdownTracker.forEach(widget => {
68
+ if (!found && widget.node.contains(target)) {
69
+ found = widget;
70
+ }
71
+ });
72
+ return found;
73
+ };
74
+
75
+ /** The FileEditor document widget whose node contains `target`. */
76
+ const findEditorWidget = (target: Element): any | null => {
77
+ let found: any | null = null;
78
+ editorTracker.forEach(widget => {
79
+ if (!found && widget.node.contains(target)) {
80
+ found = widget;
81
+ }
82
+ });
83
+ return found ?? editorTracker.currentWidget;
84
+ };
85
+
86
+ /** The `.jp-RenderedMarkdown` host element inside a preview/editor widget. */
87
+ const renderedHost = (widget: any): HTMLElement | null =>
88
+ widget?.node?.querySelector('.jp-RenderedMarkdown') ?? null;
89
+
90
+ // ---- Preview -> Editor -------------------------------------------------
91
+ app.commands.addCommand(CMD_EDIT_AT, {
92
+ label: 'Edit at this location',
93
+ execute: async () => {
94
+ const target = lastPreviewTarget;
95
+ if (!target) {
96
+ console.warn(`${LOG} no markdown preview target under the cursor`);
97
+ return;
98
+ }
99
+ const widget = findPreviewWidget(target);
100
+ if (!widget) {
101
+ console.warn(`${LOG} could not resolve the owning Markdown Preview`);
102
+ return;
103
+ }
104
+ const host = renderedHost(widget);
105
+ if (!host) {
106
+ console.warn(`${LOG} rendered host not found`);
107
+ return;
108
+ }
109
+
110
+ // Walk up to the top-level block (direct child of the host).
111
+ let block: Element | null = target;
112
+ while (block && block.parentElement !== host) {
113
+ block = block.parentElement;
114
+ }
115
+ if (!block) {
116
+ console.warn(`${LOG} clicked content is not a rendered block`);
117
+ return;
118
+ }
119
+ const ordinal = Array.from(host.children).indexOf(block);
120
+ const source: string = widget.context.model.toString();
121
+ const line = blockToLine(source, ordinal);
122
+ if (line < 0) {
123
+ console.warn(`${LOG} block ordinal ${ordinal} is not mappable`);
124
+ return;
125
+ }
126
+
127
+ const editorWidget: any = docManager.openOrReveal(
128
+ widget.context.path,
129
+ EDITOR_FACTORY
130
+ );
131
+ if (!editorWidget) {
132
+ return;
133
+ }
134
+ await editorWidget.context.ready;
135
+ await editorWidget.revealed;
136
+ const editor = editorWidget.content.editor;
137
+ const clamped = Math.min(line, editor.lineCount - 1);
138
+ editor.setCursorPosition({ line: clamped, column: 0 });
139
+ // Focus so the cursor is live (you asked to edit here) and the active
140
+ // line is rendered, then scroll it into view.
141
+ editor.focus();
142
+ editor.revealPosition({ line: clamped, column: 0 });
143
+ }
144
+ });
145
+ app.contextMenu.addItem({
146
+ command: CMD_EDIT_AT,
147
+ selector: PREVIEW_SELECTOR,
148
+ rank: 0
149
+ });
150
+
151
+ // ---- Editor -> Preview -------------------------------------------------
152
+ app.commands.addCommand(CMD_REVEAL_IN, {
153
+ label: 'Reveal in Markdown Preview',
154
+ execute: async () => {
155
+ const target = lastEditorTarget;
156
+ if (!target) {
157
+ console.warn(`${LOG} no editor target under the cursor`);
158
+ return;
159
+ }
160
+ const widget = findEditorWidget(target);
161
+ if (!widget) {
162
+ console.warn(`${LOG} could not resolve the owning file editor`);
163
+ return;
164
+ }
165
+ const editor = widget.content.editor;
166
+ const line = editor.getCursorPosition().line;
167
+ const source: string = widget.context.model.toString();
168
+ const {
169
+ ordinal,
170
+ headingSlug: slug,
171
+ headingNth
172
+ } = lineToBlock(source, line);
173
+
174
+ const previewWidget: any = docManager.openOrReveal(
175
+ widget.context.path,
176
+ PREVIEW_FACTORY
177
+ );
178
+ if (!previewWidget) {
179
+ return;
180
+ }
181
+ await previewWidget.context.ready;
182
+ await previewWidget.revealed;
183
+ const host = renderedHost(previewWidget);
184
+ if (!host) {
185
+ console.warn(`${LOG} rendered preview host not found`);
186
+ return;
187
+ }
188
+
189
+ const children = Array.from(host.children);
190
+ const expected = buildBlockMap(source).blocks.length;
191
+ if (children.length === expected && ordinal >= 0) {
192
+ children[ordinal].scrollIntoView({ block: 'start' });
193
+ return;
194
+ }
195
+
196
+ // Fallback (AC #8): ordinal alignment is unreliable (math, sanitizer,
197
+ // injected nodes). Re-derive heading ids from the live headings and
198
+ // scroll to the headingNth-th match.
199
+ if (slug) {
200
+ const headings = Array.from(
201
+ host.querySelectorAll('h1, h2, h3, h4, h5, h6')
202
+ );
203
+ let seen = 0;
204
+ for (const h of headings) {
205
+ // rendermime stores createHeaderId in `id` (trusted) or
206
+ // `data-jupyter-id` (untrusted); the live textContent also contains
207
+ // the appended '¶' anchor, so prefer the stored attribute.
208
+ const id =
209
+ (h as HTMLElement).id ||
210
+ h.getAttribute('data-jupyter-id') ||
211
+ headingSlug(h.textContent ?? '');
212
+ if (id === slug) {
213
+ seen += 1;
214
+ if (seen === (headingNth ?? 1)) {
215
+ h.scrollIntoView({ block: 'start' });
216
+ return;
217
+ }
218
+ }
219
+ }
220
+ }
221
+ console.warn(`${LOG} could not align preview to line ${line}`);
222
+ }
223
+ });
224
+ app.contextMenu.addItem({
225
+ command: CMD_REVEAL_IN,
226
+ selector: EDITOR_SELECTOR,
227
+ rank: 0
228
+ });
229
+ }
230
+ };
231
+
232
+ export default plugin;
package/src/mapping.ts ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Pure, DOM-free source<->rendered-block mapping for Markdown.
3
+ *
4
+ * The Markdown Preview renders with `marked` and emits no source-line
5
+ * attributes, so we re-lex the source ourselves and correlate rendered
6
+ * top-level blocks to source lines by ORDINAL INDEX: the nth rendered
7
+ * top-level child corresponds to the nth non-space/def/comment block token.
8
+ *
9
+ * The line accumulator replicates JupyterLab core's `getHeadingTokens`
10
+ * walk (`@jupyterlab/markedparser-extension`): `currentLine` is a cumulative
11
+ * newline count advanced by EVERY top-level token, recorded BEFORE advancing.
12
+ *
13
+ * This module imports only `marked` - no `@jupyterlab` / `@lumino` / DOM - so
14
+ * it is unit-testable offline with Jest.
15
+ */
16
+ import { marked } from 'marked';
17
+
18
+ /** One rendered top-level block, ordinal-aligned to the rendered host's children. */
19
+ export interface IBlockDescriptor {
20
+ /** Index into the DOM-correlated block list == rendered host child index. */
21
+ ordinal: number;
22
+ /** 0-based first source line of the block. */
23
+ startLine: number;
24
+ /** 0-based last source line of the block (inclusive of trailing blanks). */
25
+ endLine: number;
26
+ /** marked token type (heading, paragraph, code, list, table, ...). */
27
+ type: string;
28
+ /** Normalized block text, for the empty-block guard. */
29
+ text: string;
30
+ /** Present only for heading tokens: createHeaderId form of the heading text. */
31
+ headingSlug?: string;
32
+ }
33
+
34
+ export interface IBlockMap {
35
+ /** Rendered (non-space/def/comment) tokens, in DOM order. */
36
+ blocks: IBlockDescriptor[];
37
+ /** Heading blocks, for the nearest-preceding-heading fallback. */
38
+ headings: { line: number; slug: string; ordinal: number }[];
39
+ }
40
+
41
+ /** marked token types that are NOT rendered as a top-level DOM child. */
42
+ const NON_DOM_TYPES = new Set(['space', 'def']);
43
+
44
+ /** True for an html token that renders nothing (HTML comment only). */
45
+ function isCommentOnlyHtml(token: { type: string; raw?: string }): boolean {
46
+ if (token.type !== 'html') {
47
+ return false;
48
+ }
49
+ const raw = (token.raw ?? '').trim();
50
+ return /^<!--[\s\S]*-->$/.test(raw);
51
+ }
52
+
53
+ /** Reproduce rendermime `createHeaderId`: spaces -> hyphens on the plain text. */
54
+ export function headingSlug(headingText: string): string {
55
+ return plainText(headingText).replace(/ /g, '-');
56
+ }
57
+
58
+ /** Best-effort strip of inline markdown so a heading token's text matches the
59
+ * rendered element's textContent (used only for the heading fallback). */
60
+ function plainText(text: string): string {
61
+ return text
62
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> alt
63
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> label
64
+ .replace(/[*_~`]/g, '') // emphasis / code markers
65
+ .trim();
66
+ }
67
+
68
+ /** Normalized text used for the empty-block guard. */
69
+ function blockText(token: {
70
+ type: string;
71
+ text?: string;
72
+ raw?: string;
73
+ }): string {
74
+ if (token.type === 'hr') {
75
+ return '';
76
+ }
77
+ return (token.text ?? token.raw ?? '').trim();
78
+ }
79
+
80
+ /**
81
+ * Re-lex `source` and build the ordinal<->line map.
82
+ *
83
+ * Uses `marked.lexer(source, { gfm: true })` to match core's parser config.
84
+ * The line walk includes EVERY top-level token (space/def advance the
85
+ * counter); the DOM-aligned block list excludes space, def and comment-only
86
+ * html tokens.
87
+ */
88
+ export function buildBlockMap(source: string): IBlockMap {
89
+ const tokens = marked.lexer(source, { gfm: true }) as Array<{
90
+ type: string;
91
+ raw: string;
92
+ text?: string;
93
+ depth?: number;
94
+ }>;
95
+
96
+ const blocks: IBlockDescriptor[] = [];
97
+ const headings: { line: number; slug: string; ordinal: number }[] = [];
98
+
99
+ let currentLine = 0;
100
+ for (const token of tokens) {
101
+ const startLine = currentLine;
102
+ const spanned = token.raw.split('\n').length - 1;
103
+ currentLine += spanned;
104
+
105
+ if (NON_DOM_TYPES.has(token.type) || isCommentOnlyHtml(token)) {
106
+ continue;
107
+ }
108
+
109
+ const ordinal = blocks.length;
110
+ const descriptor: IBlockDescriptor = {
111
+ ordinal,
112
+ startLine,
113
+ endLine: startLine + Math.max(spanned - 1, 0),
114
+ type: token.type,
115
+ text: blockText(token)
116
+ };
117
+
118
+ if (token.type === 'heading') {
119
+ descriptor.headingSlug = headingSlug(token.text ?? '');
120
+ headings.push({
121
+ line: startLine,
122
+ slug: descriptor.headingSlug,
123
+ ordinal
124
+ });
125
+ }
126
+
127
+ blocks.push(descriptor);
128
+ }
129
+
130
+ return { blocks, headings };
131
+ }
132
+
133
+ /**
134
+ * Preview -> Editor. Returns the 0-based start line for the block at DOM
135
+ * ordinal `ordinal`, or -1 when the ordinal is out of range or the block has
136
+ * no textual content (caller no-ops + warns, AC #5).
137
+ */
138
+ export function blockToLine(source: string, ordinal: number): number {
139
+ const { blocks } = buildBlockMap(source);
140
+ if (ordinal < 0 || ordinal >= blocks.length) {
141
+ return -1;
142
+ }
143
+ const block = blocks[ordinal];
144
+ if (block.text.length === 0) {
145
+ return -1;
146
+ }
147
+ return block.startLine;
148
+ }
149
+
150
+ /**
151
+ * Editor -> Preview. Returns the DOM ordinal of the block containing `line`
152
+ * (the last block whose startLine <= line), plus the nearest-preceding
153
+ * heading slug and its 1-based occurrence index for the fallback.
154
+ * `ordinal` is -1 when no block precedes the line.
155
+ */
156
+ export function lineToBlock(
157
+ source: string,
158
+ line: number
159
+ ): { ordinal: number; headingSlug?: string; headingNth?: number } {
160
+ const { blocks } = buildBlockMap(source);
161
+
162
+ let ordinal = -1;
163
+ for (const block of blocks) {
164
+ if (block.startLine <= line) {
165
+ ordinal = block.ordinal;
166
+ } else {
167
+ break;
168
+ }
169
+ }
170
+
171
+ // Nearest preceding heading + its occurrence index among same-slug headings.
172
+ let headingSlugValue: string | undefined;
173
+ let headingNth: number | undefined;
174
+ const slugCounts = new Map<string, number>();
175
+ for (const block of blocks) {
176
+ if (block.type !== 'heading' || block.headingSlug === undefined) {
177
+ continue;
178
+ }
179
+ const nth = (slugCounts.get(block.headingSlug) ?? 0) + 1;
180
+ slugCounts.set(block.headingSlug, nth);
181
+ if (block.startLine <= line) {
182
+ headingSlugValue = block.headingSlug;
183
+ headingNth = nth;
184
+ } else {
185
+ break;
186
+ }
187
+ }
188
+
189
+ return { ordinal, headingSlug: headingSlugValue, headingNth };
190
+ }
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';