Forge
csharp4405de34
1#nullable enable
2
3using CascadeIDE.Services;
4
5namespace CascadeIDE.Services.MarkdownPreview;
6
7/// <summary>Контекст рендера preview: источник, workspace, навигация, якоря.</summary>
8public sealed class MarkdownPreviewRenderContext
9{
10 public MarkdownPreviewRenderContext(
11 string? sourceFilePath,
12 string? workspaceRoot,
13 Action<string>? openLink = null,
14 MarkdownPreviewAnchorRegistry? anchors = null)
15 {
16 SourceFilePath = string.IsNullOrWhiteSpace(sourceFilePath) ? null : sourceFilePath.Trim();
17 WorkspaceRoot = string.IsNullOrWhiteSpace(workspaceRoot) ? null : workspaceRoot.Trim();
18 OpenLink = openLink;
19 Anchors = anchors ?? new MarkdownPreviewAnchorRegistry();
20 }
21
22 public string? SourceFilePath { get; }
23
24 public string? WorkspaceRoot { get; }
25
26 /// <summary>Единый обработчик ссылок: http(s), .md, #fragment, cascade-code-anchor.</summary>
27 public Action<string>? OpenLink { get; }
28
29 public MarkdownPreviewAnchorRegistry Anchors { get; }
30
31 /// <summary>Legacy alias для открытия документов без fragment.</summary>
32 public Action<string>? OpenDocument => OpenLink is null
33 ? null
34 : url =>
35 {
36 var (path, _) = SplitUrl(url);
37 if (!string.IsNullOrWhiteSpace(path))
38 OpenLink(path);
39 };
40
41 /// <summary>Путь для <see cref="WorkspaceMarkdownPreviewOpener"/> (repo-relative или absolute).</summary>
42 public string? ResolveNavigateTarget(string? url)
43 {
44 var (path, _) = SplitUrl(url);
45 if (string.IsNullOrWhiteSpace(path))
46 return null;
47
48 var trimmed = path.Trim();
49 if (trimmed.StartsWith(MarkdownCodeAnchorPreviewExpander.UriScheme, StringComparison.OrdinalIgnoreCase))
50 return null;
51
52 if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
53 || trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
54 {
55 return trimmed;
56 }
57
58 string? absolute;
59 if (Path.IsPathRooted(trimmed))
60 {
61 absolute = trimmed;
62 }
63 else if (SourceFilePath is not null)
64 {
65 var dir = Path.GetDirectoryName(SourceFilePath);
66 if (string.IsNullOrWhiteSpace(dir))
67 absolute = null;
68 else
69 absolute = Path.GetFullPath(Path.Combine(dir, trimmed.Replace('/', Path.DirectorySeparatorChar)));
70 }
71 else
72 {
73 absolute = null;
74 }
75
76 if (absolute is not null && File.Exists(absolute) && WorkspaceRoot is not null)
77 {
78 var rel = WorkspaceAdrMapResolver.TryComputeRepoRelativePath(WorkspaceRoot, absolute);
79 if (rel is not null)
80 return rel;
81 }
82
83 if (WorkspaceRoot is not null
84 && WorkspaceAdrMapResolver.TryResolveAbsoluteDocPath(WorkspaceRoot, trimmed) is not null)
85 {
86 return trimmed;
87 }
88
89 return absolute is not null && File.Exists(absolute) ? absolute : null;
90 }
91
92 public static (string? Path, string? Fragment) SplitUrl(string? url)
93 {
94 if (string.IsNullOrWhiteSpace(url))
95 return (null, null);
96
97 var trimmed = url.Trim();
98 if (trimmed.StartsWith('#'))
99 return (null, trimmed[1..]);
100
101 var hash = trimmed.IndexOf('#');
102 if (hash < 0)
103 return (trimmed, null);
104
105 var path = hash == 0 ? null : trimmed[..hash];
106 var fragment = trimmed[(hash + 1)..];
107 return (string.IsNullOrWhiteSpace(path) ? null : path, string.IsNullOrWhiteSpace(fragment) ? null : fragment);
108 }
109}
110
View only · write via MCP/CIDE