@diplodoc/transform 4.76.9 → 4.77.0

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.
@@ -3,6 +3,8 @@
3
3
  import type {MarkdownItPluginCb} from './typings';
4
4
  import type {IDGenerator} from './utils';
5
5
 
6
+ import {escapeHtml} from 'markdown-it/lib/common/utils';
7
+
6
8
  import {generateID as globalGenerateID} from './utils';
7
9
 
8
10
  const wrapInFloatingContainer = (
@@ -134,7 +136,57 @@ function balanceSpansPerLine(lines: string[]): string[] {
134
136
  });
135
137
  }
136
138
 
137
- function addLineNumbers(code: string, {lineWrapping}: {lineWrapping: boolean}): string {
139
+ /**
140
+ * Strips the prompt prefix from the raw fence content BEFORE it reaches the highlighter.
141
+ *
142
+ * @param content - Raw fence content.
143
+ * @param prompt - Raw prompt string (e.g. `$`, `#`, `>>>`).
144
+ * @returns Stripped content (same number of lines) and a per-line prompt flag array.
145
+ */
146
+ function stripPrompt(content: string, prompt: string): {content: string; flags: boolean[]} {
147
+ const hasTrailingNewline = content.endsWith('\n');
148
+ const lines = content.split('\n');
149
+ const linesToProcess = hasTrailingNewline ? lines.slice(0, -1) : lines;
150
+
151
+ const flags: boolean[] = [];
152
+ const stripped = linesToProcess.map((line) => {
153
+ const trimmed = line.trimStart();
154
+ const leadingWs = line.slice(0, line.length - trimmed.length);
155
+
156
+ if (trimmed.startsWith(prompt + ' ')) {
157
+ flags.push(true);
158
+ return leadingWs + trimmed.slice(prompt.length + 1);
159
+ }
160
+
161
+ if (trimmed === prompt) {
162
+ flags.push(true);
163
+ return leadingWs;
164
+ }
165
+
166
+ flags.push(false);
167
+ return line;
168
+ });
169
+
170
+ return {
171
+ content: stripped.join('\n') + (hasTrailingNewline ? '\n' : ''),
172
+ flags,
173
+ };
174
+ }
175
+
176
+ function postProcessCode(
177
+ code: string,
178
+ {
179
+ showLineNumbers,
180
+ lineWrapping,
181
+ promptFlags,
182
+ promptEscaped,
183
+ }: {
184
+ showLineNumbers: boolean;
185
+ lineWrapping: boolean;
186
+ promptFlags?: boolean[];
187
+ promptEscaped?: string;
188
+ },
189
+ ): string {
138
190
  const hasTrailingNewline = code.endsWith('\n');
139
191
  const lines = code.split('\n');
140
192
  const linesToProcess = hasTrailingNewline ? lines.slice(0, -1) : lines;
@@ -144,15 +196,53 @@ function addLineNumbers(code: string, {lineWrapping}: {lineWrapping: boolean}):
144
196
  return (
145
197
  normalized
146
198
  .map((line, index) => {
147
- const lineNumber = String(index + 1).padStart(maxDigits, ' ');
148
- return lineWrapping
149
- ? `<span class="yfm-line-number">${lineNumber}</span><span class="yfm-line">${line}</span>`
150
- : `<span class="yfm-line-number">${lineNumber}</span>${line}`;
199
+ // The prompt was already stripped from the raw content before
200
+ // highlighting, so `line` here is the highlighted command only.
201
+ // Re-insert the prompt as a dedicated, non-selectable span after
202
+ // any leading whitespace. `promptFlags` marks which lines carried
203
+ // a prompt in the original source.
204
+ if (promptFlags?.[index] && promptEscaped !== undefined) {
205
+ const trimmed = line.trimStart();
206
+ const leadingWs = line.slice(0, line.length - trimmed.length);
207
+ line =
208
+ `${leadingWs}` +
209
+ `<span class="yfm-code-prompt" aria-hidden="true">${promptEscaped} </span>` +
210
+ `${trimmed}`;
211
+ }
212
+
213
+ if (showLineNumbers) {
214
+ const lineNumber = String(index + 1).padStart(maxDigits, ' ');
215
+ line = lineWrapping
216
+ ? `<span class="yfm-line-number">${lineNumber}</span><span class="yfm-line">${line}</span>`
217
+ : `<span class="yfm-line-number">${lineNumber}</span>${line}`;
218
+ }
219
+
220
+ return line;
151
221
  })
152
222
  .join('\n') + (hasTrailingNewline ? '\n' : '')
153
223
  );
154
224
  }
155
225
 
226
+ /**
227
+ * @param info - Raw fence info string (e.g. `js showLineNumbers prompt="$"`).
228
+ * @returns Parsed flags and the optional prompt value.
229
+ */
230
+ function parseFenceInfo(info: string): {
231
+ showLineNumbers: boolean;
232
+ wrapLines: boolean;
233
+ prompt?: string;
234
+ } {
235
+ const promptMatch = /(?:^|\s)prompt=(?:"([^"]*)"|'([^']*)')/.exec(info);
236
+ const prompt = promptMatch?.[1] ?? promptMatch?.[2];
237
+ const rest = promptMatch ? info.replace(promptMatch[0], ' ') : info;
238
+
239
+ return {
240
+ showLineNumbers: /\bshowLineNumbers\b/.test(rest),
241
+ wrapLines: /\bwrap\b/.test(rest),
242
+ prompt,
243
+ };
244
+ }
245
+
156
246
  type CodeOptions = {
157
247
  /**
158
248
  * Show button to toggle line wrapping of code.
@@ -164,36 +254,69 @@ type CodeOptions = {
164
254
 
165
255
  const code: MarkdownItPluginCb<CodeOptions> = (md, opts) => {
166
256
  const lineWrapping = opts?.codeLineWrapping || false;
167
- const generateID = opts.generateID ?? globalGenerateID;
257
+ const generateID = opts?.generateID ?? globalGenerateID;
168
258
 
169
259
  const superCodeRenderer = md.renderer.rules.fence;
170
260
  md.renderer.rules.fence = function (tokens, idx, options, env, self) {
171
261
  const token = tokens[idx];
172
- const showLineNumbers = token.info.includes('showLineNumbers');
173
- const wrapLines = token.info.includes('wrap');
174
-
262
+ const {showLineNumbers, prompt, wrapLines} = parseFenceInfo(token.info);
175
263
  const shouldWrap = wrapLines;
176
264
 
265
+ // Strip the prompt prefix from the raw content BEFORE handing it
266
+ // to the highlighter, so language grammars never misinterpret prompt
267
+ // symbols (e.g. `#` as a shell comment, `$` as a variable). The prompt is
268
+ // re-inserted as a span in postProcessCode, driven by `promptFlags`.
269
+ let promptFlags: boolean[] | undefined;
270
+ let promptEscaped: string | undefined;
271
+ const originalContent = token.content;
272
+ if (prompt) {
273
+ const stripped = stripPrompt(token.content, prompt);
274
+ token.content = stripped.content;
275
+ promptFlags = stripped.flags;
276
+ promptEscaped = escapeHtml(prompt);
277
+ }
278
+
177
279
  let superCode = superCodeRenderer?.(tokens, idx, options, env, self);
178
280
 
179
- if (superCode && showLineNumbers) {
281
+ // Restore the original content so the mutation does not leak to other
282
+ // consumers of the token.
283
+ token.content = originalContent;
284
+
285
+ if (superCode && (showLineNumbers || prompt)) {
180
286
  // Extract the code content from the pre/code tags
181
287
  const codeMatch = superCode.match(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
182
288
  if (codeMatch) {
183
289
  const codeContent = codeMatch[1];
184
- const codeWithLineNumbers = addLineNumbers(codeContent, {lineWrapping});
290
+ const processedCode = postProcessCode(codeContent, {
291
+ showLineNumbers,
292
+ lineWrapping,
293
+ promptFlags,
294
+ promptEscaped,
295
+ });
185
296
  // Escape $ in replacement string: $$ becomes a literal $ in String.replace()
186
- const escapedReplacement = codeWithLineNumbers.replace(/\$/g, '$$$$');
297
+ const escapedReplacement = processedCode.replace(/\$/g, '$$$$');
187
298
  superCode = superCode.replace(codeContent, escapedReplacement);
188
299
  }
189
300
  }
190
301
 
191
- if (superCode && shouldWrap) {
192
- superCode = superCode.replace(/<code([^>]*)>/, (_match, attrs) => {
193
- if (attrs.includes('class=')) {
194
- return `<code${attrs.replace(/class="([^"]*)"/, 'class="$1 wrap"')}>`;
302
+ // Attach the raw prompt value as a data attribute on <code> so that the
303
+ // copy widget can strip it from the plain-text content without relying on
304
+ // any class names or DOM structure introduced by the plugin.
305
+ if (superCode && (prompt || shouldWrap)) {
306
+ superCode = superCode.replace(/(<pre[^>]*><code)([^>]*)>/, (_match, open, attrs) => {
307
+ // Add the wrap class
308
+ if (shouldWrap) {
309
+ attrs = /class="[^"]*"/.test(attrs)
310
+ ? attrs.replace(/class="([^"]*)"/, 'class="$1 wrap"')
311
+ : ` class="wrap"${attrs}`;
312
+ }
313
+
314
+ // Add the data-prompt attribute
315
+ if (prompt) {
316
+ attrs = ` data-prompt="${escapeHtml(prompt)}"${attrs}`;
195
317
  }
196
- return `<code class="wrap"${attrs}>`;
318
+
319
+ return `${open}${attrs}>`;
197
320
  });
198
321
  }
199
322
 
@@ -1,6 +1,6 @@
1
1
  import MarkdownIt from 'markdown-it';
2
2
  import {sep} from 'path';
3
- import url from 'url';
3
+ import {parseHref} from '@diplodoc/utils';
4
4
 
5
5
  import {PAGE_LINK_REGEXP, getHrefTokenAttr, isLocalUrl} from '../../utils';
6
6
  import {getSinglePageAnchorId, resolveRelativePath} from '../../utilsFS';
@@ -62,7 +62,7 @@ const collect = (input: string, options: Options) => {
62
62
  continue;
63
63
  }
64
64
 
65
- const {pathname, hash} = url.parse(href);
65
+ const {pathname, hash} = parseHref(href);
66
66
  if (pathname) {
67
67
  const isPageFile = PAGE_LINK_REGEXP.test(pathname);
68
68
  if (isPageFile) {
@@ -4,7 +4,7 @@ import type {CacheContext, StateCore} from '../../typings';
4
4
  import type {MarkdownItPluginCb, MarkdownItPluginOpts} from '../typings';
5
5
  import type {MarkdownItIncluded} from '../includes/types';
6
6
 
7
- import url from 'url';
7
+ import {formatHref, parseHref} from '@diplodoc/utils';
8
8
  import {bold} from 'chalk';
9
9
  import path, {isAbsolute, parse, relative, resolve} from 'path';
10
10
 
@@ -153,7 +153,7 @@ function processLink(
153
153
  return;
154
154
  }
155
155
 
156
- const {pathname, hash} = url.parse(originalHref);
156
+ const {pathname, hash} = parseHref(originalHref);
157
157
  let file;
158
158
  let fileExists;
159
159
  let isPageFile;
@@ -219,8 +219,8 @@ function processLink(
219
219
 
220
220
  const patchedHref =
221
221
  !isAbsolute(originalHref) && !originalHref.includes('//')
222
- ? url.format({
223
- ...url.parse(originalHref),
222
+ ? formatHref({
223
+ ...parseHref(originalHref),
224
224
  pathname: getPublicPath(opts, file),
225
225
  })
226
226
  : originalHref;
@@ -1,9 +1,12 @@
1
1
  import type Token from 'markdown-it/lib/token';
2
2
 
3
- import url from 'url';
3
+ import {formatHref, parseHref} from '@diplodoc/utils';
4
4
 
5
5
  export function isLocalUrl(url: string) {
6
- return !/^(?:[a-z]+:)?\/\//i.test(url);
6
+ // A URL is local unless it has an external scheme: either `scheme://...`
7
+ // / `//host...` (protocol-relative), or an opaque scheme URI without
8
+ // a slash-slash part (e.g. `mailto:`, `tel:`, `custom:`).
9
+ return !isExternalHref(url);
7
10
  }
8
11
 
9
12
  export function findBlockTokens(tokens: Token[], id: string) {
@@ -105,8 +108,8 @@ export function getSrcTokenAttr(token: Token) {
105
108
  export const PAGE_LINK_REGEXP = /\.(md|ya?ml)$/i;
106
109
 
107
110
  export function defaultTransformLink(href: string) {
108
- const parsed = url.parse(href);
109
- href = url.format({
111
+ const parsed = parseHref(href);
112
+ href = formatHref({
110
113
  ...parsed,
111
114
  pathname: parsed.pathname?.replace(PAGE_LINK_REGEXP, '.html'),
112
115
  });