|
1 | 1 | import type { Root, RootContent } from "mdast"; |
2 | | -import { unified } from "unified"; |
| 2 | +import { unified, type Processor } from "unified"; |
3 | 3 | import remarkParse from "remark-parse"; |
4 | 4 | import remarkGfm from "remark-gfm"; |
| 5 | +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
5 | 6 |
|
6 | 7 | const parser = unified().use(remarkParse).use(remarkGfm); |
7 | 8 | export type MarkdownNode = Root | RootContent; |
@@ -382,13 +383,62 @@ export function markdownWithTableBreaks(markdown: string): string { |
382 | 383 | return transformNativeMarkdown(markdown, undefined, true); |
383 | 384 | } |
384 | 385 |
|
| 386 | +// `>` covers a destination continued on a blockquote line. |
| 387 | +const WINDOWS_DESTINATION = /\](?:\(|:)[\s>]*<?(?:[A-Za-z]:[\\/]|\\\\)/; |
| 388 | + |
| 389 | +interface DestinationToken { |
| 390 | + readonly start: { readonly offset: number }; |
| 391 | + readonly end: { readonly offset: number }; |
| 392 | +} |
| 393 | + |
| 394 | +/** |
| 395 | + * Markdown reads `C:\work\.t3` as `C:\work.t3`. Web keeps authored Windows destinations |
| 396 | + * literally (see `remarkKeepWindowsPaths` in shared), so escape their backslashes to give |
| 397 | + * both parsers here the same path. `\&` stays an escape, as `markdownWithHtml` emits it. |
| 398 | + * Expects authored Markdown: already escaped output would be escaped twice. |
| 399 | + */ |
| 400 | +function markdownWithWindowsPathDestinations(markdown: string): string { |
| 401 | + // Skips the extra parse for the common message that has no Windows destination. |
| 402 | + if (!WINDOWS_DESTINATION.test(markdown)) return markdown; |
| 403 | + const edits: MarkdownEdit[] = []; |
| 404 | + function record( |
| 405 | + this: { resume(): string; sliceSerialize(token: DestinationToken): string }, |
| 406 | + token: DestinationToken, |
| 407 | + ) { |
| 408 | + this.resume(); |
| 409 | + const authored = this.sliceSerialize(token); |
| 410 | + if (!isWindowsAbsolutePath(authored) || !authored.includes("\\") || /[<>]/.test(authored)) |
| 411 | + return; |
| 412 | + const start = token.start.offset; |
| 413 | + const end = token.end.offset; |
| 414 | + const escaped = authored.replace(/\\(?!&)/g, "\\\\"); |
| 415 | + // A bare destination ends at an unescaped `)`, so wrap it to keep `\)` inside. |
| 416 | + const angled = markdown[start - 1] === "<"; |
| 417 | + edits.push({ start, end, content: angled ? escaped : `<${escaped}>` }); |
| 418 | + } |
| 419 | + unified() |
| 420 | + .use(remarkParse) |
| 421 | + .use(remarkGfm) |
| 422 | + .use(function (this: Processor) { |
| 423 | + (this.data().fromMarkdownExtensions ??= []).push({ |
| 424 | + exit: { resourceDestinationString: record, definitionDestinationString: record }, |
| 425 | + }); |
| 426 | + }) |
| 427 | + .parse(markdown); |
| 428 | + return applyMarkdownEdits(markdown, edits); |
| 429 | +} |
| 430 | + |
| 431 | +// HTML wrappers can expose Markdown that wasn't in the first AST, so normalize them first. |
| 432 | +export function markdownForNativeParsing(markdown: string): string { |
| 433 | + return markdownWithWindowsPathDestinations(markdownWithHtml(markdown)); |
| 434 | +} |
| 435 | + |
385 | 436 | export function parseMarkdownDocument(markdown: string): Root { |
386 | | - return parser.parse(markdownWithHtml(markdown)); |
| 437 | + return parser.parse(markdownForNativeParsing(markdown)); |
387 | 438 | } |
388 | 439 |
|
389 | 440 | export function markdownForNativeRendering(markdown: string, fileLink: RegExp): string { |
390 | | - // HTML wrappers can expose Markdown that wasn't in the first AST, so normalize them first. |
391 | | - return transformNativeMarkdown(markdownWithHtml(markdown), fileLink, true); |
| 441 | + return transformNativeMarkdown(markdownForNativeParsing(markdown), fileLink, true); |
392 | 442 | } |
393 | 443 |
|
394 | 444 | /** Native media slots only cover standalone images; nested file embeds must become links. */ |
|
0 commit comments