-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSlotExtractor.cs
More file actions
230 lines (195 loc) · 8.61 KB
/
Copy pathSlotExtractor.cs
File metadata and controls
230 lines (195 loc) · 8.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
using System.Text;
using System.Text.RegularExpressions;
namespace ShellDocs.Markdown;
internal class SlotExtractor
{
private readonly TypeRegistry _registry;
private static readonly Regex FenceBlock = new(
@"^(?<indent>[ \t]*)```(?<lang>[^\n\r]*)\r?\n(?<body>[\s\S]*?)\r?\n\1```(?=\r?\n|$)",
RegexOptions.Multiline | RegexOptions.Compiled);
private static readonly Regex OpeningTag = new(
@"<(?<name>[A-Z][A-Za-z0-9]*)(?<attrs>\s[^>]*?)?\s*(?<self>/)?>",
RegexOptions.Compiled);
private static readonly Regex ClosingTag = new(
@"</(?<name>[A-Z][A-Za-z0-9]*)\s*>",
RegexOptions.Compiled);
private static readonly Regex Attribute = new(
@"(?<name>[A-Za-z][A-Za-z0-9]*)\s*=\s*""(?<value>[^""]*)""",
RegexOptions.Compiled);
public SlotExtractor(TypeRegistry registry)
{
_registry = registry;
}
public (string Processed, IReadOnlyList<Slot> Slots, IReadOnlyList<string> Warnings) Process(string markdown)
{
var slots = new List<Slot>();
var warnings = new List<string>();
// Mask code fences so component-tag scanning skips them.
// razor:preview fences get replaced with a placeholder slot marker in-line;
// other fences get a mask token that's restored verbatim at the end.
var maskedFences = new Dictionary<string, string>();
var processed = FenceBlock.Replace(markdown, m =>
{
var indent = m.Groups["indent"].Value;
var lang = m.Groups["lang"].Value.Trim();
var body = m.Groups["body"].Value;
if (lang == "razor:preview" || lang.StartsWith("razor:preview "))
{
var preview = TryBuildPreviewSlot(body, warnings);
if (preview is not null)
{
slots.Add(preview);
return indent + PlaceholderHtml("preview", preview.Id);
}
}
var maskId = NewMaskId();
maskedFences[maskId] = m.Value;
return maskId;
});
processed = ReplaceComponentTags(processed, slots, warnings);
foreach (var (id, original) in maskedFences)
{
processed = processed.Replace(id, original);
}
// Sort slots by the position of their placeholder in the processed text
// so the returned list reflects document order.
var ordered = slots.OrderBy(s => processed.IndexOf(s.Id, StringComparison.Ordinal)).ToList();
return (processed, ordered, warnings);
}
private string ReplaceComponentTags(string text, List<Slot> slots, List<string> warnings)
{
var result = new StringBuilder(text.Length);
var cursor = 0;
while (cursor < text.Length)
{
var open = OpeningTag.Match(text, cursor);
if (!open.Success)
{
result.Append(text, cursor, text.Length - cursor);
break;
}
var name = open.Groups["name"].Value;
var isSelfClosing = open.Groups["self"].Success;
var registered = _registry.Resolve(name);
if (registered is null)
{
warnings.Add($"Unknown component <{name}> — passed through as raw markup.");
result.Append(text, cursor, open.Index + open.Length - cursor);
cursor = open.Index + open.Length;
continue;
}
result.Append(text, cursor, open.Index - cursor);
var attrs = ParseAttributes(open.Groups["attrs"].Value);
string? childRaw = null;
int endIndex;
if (isSelfClosing)
{
endIndex = open.Index + open.Length;
}
else
{
var (closeStart, closeEnd) = FindMatchingClose(text, name, open.Index + open.Length);
if (closeStart < 0)
{
warnings.Add($"Unclosed <{name}> — passed through as raw markup.");
result.Append(text, open.Index, open.Length);
cursor = open.Index + open.Length;
continue;
}
/* Preserve original indentation — SlotRenderer.Dedent normalizes
the common leading whitespace before feeding to Markdig, and
Trim()-ing here would strip the first line's indent and defeat
that (Markdig would then treat the remaining 4-space-indented
lines as an indented code block). */
childRaw = text.Substring(open.Index + open.Length, closeStart - (open.Index + open.Length));
endIndex = closeEnd;
}
var slot = new ComponentSlot(NewSlotId(), registered, attrs, childRaw);
slots.Add(slot);
result.Append(PlaceholderHtml("component", slot.Id));
cursor = endIndex;
}
return result.ToString();
}
private PreviewSlot? TryBuildPreviewSlot(string code, List<string> warnings)
{
var open = OpeningTag.Match(code);
if (!open.Success)
{
warnings.Add("razor:preview fence must start with a component tag.");
return null;
}
var name = open.Groups["name"].Value;
var type = _registry.Resolve(name);
var attrs = ParseAttributes(open.Groups["attrs"].Value);
/* Extract inner ChildContent for non-self-closing tags even in the
error case — Copy button in the error state should still hand back
the exact source the author authored. */
string? childContentRaw = null;
if (!open.Groups["self"].Success)
{
var range = FindMatchingClose(code, name, open.Index + open.Length);
if (range.Start >= 0)
{
childContentRaw = code.Substring(open.Index + open.Length, range.Start - (open.Index + open.Length));
}
}
if (type is null)
{
/* Unknown component. Emit an error PreviewSlot so PreviewFrame can
render a visible "Unknown component <X>" panel in the browser.
Returning null here (the pre-fix behavior) caused the whole
fence to render as a plain code block — silent failure that sent
authors hunting for a nonexistent component bug. Warning still
emitted for build logs. */
var msg = $"Unknown component <{name}>. Register it via `o.RegisterComponent<{name}>()` or `o.RegisterComponentsFromAssembly<TMarker>()`.";
warnings.Add($"razor:preview references unknown component <{name}>.");
return new PreviewSlot(NewSlotId(), null, attrs, code, "razor", childContentRaw, Error: msg);
}
return new PreviewSlot(NewSlotId(), type, attrs, code, "razor", childContentRaw);
}
private static (int Start, int End) FindMatchingClose(string text, string name, int fromIndex)
{
var depth = 1;
var searchFrom = fromIndex;
while (depth > 0)
{
var open = FindNextTag(OpeningTag, text, name, searchFrom);
var close = FindNextTag(ClosingTag, text, name, searchFrom);
if (close is null) return (-1, -1);
if (open is not null && open.Index < close.Index)
{
if (!open.Groups["self"].Success) depth++;
searchFrom = open.Index + open.Length;
}
else
{
depth--;
if (depth == 0) return (close.Index, close.Index + close.Length);
searchFrom = close.Index + close.Length;
}
}
return (-1, -1);
}
private static Match? FindNextTag(Regex regex, string text, string name, int fromIndex)
{
foreach (Match m in regex.Matches(text, fromIndex))
{
if (m.Groups["name"].Value == name) return m;
}
return null;
}
private static IReadOnlyDictionary<string, string> ParseAttributes(string attrsText)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (Match m in Attribute.Matches(attrsText ?? ""))
{
result[m.Groups["name"].Value] = m.Groups["value"].Value;
}
return result;
}
private static string PlaceholderHtml(string kind, string id) =>
$"<div data-shelldocs-slot=\"{kind}\" data-shelldocs-id=\"{id}\"></div>";
private static string NewSlotId() => "s" + Guid.NewGuid().ToString("N")[..12];
private static string NewMaskId() => "SHELLDOCS_MASK_" + Guid.NewGuid().ToString("N")[..12];
}