Repository navigation
Expand file tree
/
Copy pathShellDocsOptions.cs
More file actions
309 lines (275 loc) · 13.5 KB
/
Copy pathShellDocsOptions.cs
File metadata and controls
309 lines (275 loc) · 13.5 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
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
using System.Reflection;
using Microsoft.AspNetCore.Components;
using ShellDocs.Markdown;
namespace ShellDocs.Components;
public class ShellDocsOptions
{
public string ContentRoot { get; set; } = "content";
public string SiteName { get; set; } = "";
public string? SiteTagline { get; set; }
public string? GitHubRepo { get; set; }
// Absolute base URL ("https://shelldocs.dev") for sitemap.xml, robots.txt and
// og:url in `shelldocs build`; those are skipped when unset.
public string? SiteUrl { get; set; }
// Where the preview ⋯ menu's "Report a bug" / "Suggest something" open a new issue
// (title and body appended as query params). Defaults to GitHubRepo's issues/new;
// with neither set, those items are hidden.
public string? IssueTrackerUrl { get; set; }
// Default note in every preview's toolbar, e.g. "Rendered with ShellUI for Blazor".
// A preview's own Caption replaces it.
public string? PreviewCaption { get; set; }
// Page-level tab rows that drive a code-tab SyncKey site-wide (AddSyncSwitch).
public List<DocsSyncSwitch> SyncSwitches { get; } = new();
public string? LogoLight { get; set; }
public string? LogoDark { get; set; }
public string? LogoAlt { get; set; }
public double LogoHeight { get; set; } = 1.375;
// Rendered as MarkupString — must be trusted content the consumer authored, not user input.
public string? LogoSvg { get; set; }
// Colour preset from tokens.css. Overriding the variables in a later stylesheet still wins.
public ShellDocsTheme Theme { get; set; } = ShellDocsTheme.Graphite;
// The layouts' data-shelldocs-theme value; null for Graphite, the unmarked default.
internal string? ThemePresetName => Theme == ShellDocsTheme.Graphite ? null : Theme.ToString().ToLowerInvariant();
// Renders frontmatter `title` (and `description` as a lead) as the page header
// when the markdown body doesn't start with its own `# Heading`.
public bool RenderPageTitle { get; set; }
public DocsLayoutVariant LayoutVariant { get; set; } = DocsLayoutVariant.TopNav;
public List<NavLink> PrimaryNav { get; } = new();
// 0 or 1 entries hides the sidebar package selector entirely.
public List<DocsPackage> Packages { get; } = new();
// Fewer than 2 hides the version selector; any entry scopes sidebar, prev/next,
// search and breadcrumb to the current version.
public List<DocsVersion> Versions { get; } = new();
/* Redirects for URLs that aren't pages: content folders and version roots go to
their first page, unversioned URLs that exist in the latest version go there,
plus AddRedirect rules. Served by middleware that AddShellDocs registers, and
written as redirect pages by `shelldocs build`. */
public bool EnableRedirects { get; set; } = true;
public List<DocsRedirectRule> Redirects { get; } = new();
// Searched recursively for X.razor to show as <DemoPreview Component="X" />'s source.
public string? DemoSourceRoot { get; set; }
// Null until EnableLlmsTxt is called.
public LlmsTxtOptions? LlmsTxt { get; private set; }
public List<Type> RegisteredComponents { get; } = new();
// ShellDocs' own content primitives, filled by AddShellDocs. Registered before
// RegisteredComponents so a consumer component with the same name wins.
internal List<Type> BuiltInComponents { get; } = new();
// Markdown tag name per type, from RegisterComponent<T>(tagName). Last registration wins.
public Dictionary<Type, string> ComponentAliases { get; } = new();
public ShellDocsOptions RegisterComponent<T>() where T : ComponentBase
{
RegisteredComponents.Add(typeof(T));
return this;
}
public ShellDocsOptions RegisterComponent<T>(string tagName) where T : ComponentBase
{
if (string.IsNullOrWhiteSpace(tagName))
throw new ArgumentException("tagName must be non-empty.", nameof(tagName));
RegisteredComponents.Add(typeof(T));
ComponentAliases[typeof(T)] = tagName;
return this;
}
public ShellDocsOptions RegisterComponent(Type type)
{
if (!typeof(ComponentBase).IsAssignableFrom(type))
throw new ArgumentException($"{type.FullName} must derive from ComponentBase.", nameof(type));
RegisteredComponents.Add(type);
return this;
}
public ShellDocsOptions RegisterComponent(Type type, string tagName)
{
if (!typeof(ComponentBase).IsAssignableFrom(type))
throw new ArgumentException($"{type.FullName} must derive from ComponentBase.", nameof(type));
if (string.IsNullOrWhiteSpace(tagName))
throw new ArgumentException("tagName must be non-empty.", nameof(tagName));
RegisteredComponents.Add(type);
ComponentAliases[type] = tagName;
return this;
}
// Registers every public, concrete component in the assembly, skipping [ShellDocsIgnore].
// Generic components register under their bare name and are closed per use (TItem="…").
public ShellDocsOptions RegisterComponentsFromAssembly<TMarker>(Func<Type, bool>? filter = null)
=> RegisterComponentsFromAssembly(typeof(TMarker).Assembly, filter);
public ShellDocsOptions RegisterComponentsFromAssembly<TMarker>(string namespacePrefix)
{
if (string.IsNullOrEmpty(namespacePrefix))
throw new ArgumentException("namespacePrefix must be non-empty.", nameof(namespacePrefix));
return RegisterComponentsFromAssembly(typeof(TMarker).Assembly,
t => t.Namespace is not null && t.Namespace.StartsWith(namespacePrefix, StringComparison.Ordinal));
}
public ShellDocsOptions RegisterComponentsFromAssembly(Assembly assembly, string namespacePrefix)
{
if (assembly is null) throw new ArgumentNullException(nameof(assembly));
if (string.IsNullOrEmpty(namespacePrefix))
throw new ArgumentException("namespacePrefix must be non-empty.", nameof(namespacePrefix));
return RegisterComponentsFromAssembly(assembly,
t => t.Namespace is not null && t.Namespace.StartsWith(namespacePrefix, StringComparison.Ordinal));
}
public ShellDocsOptions RegisterComponentsFromAssembly(Assembly assembly, Func<Type, bool>? filter = null)
{
if (assembly is null) throw new ArgumentNullException(nameof(assembly));
foreach (var type in DiscoverComponentTypes(assembly))
{
if (filter is not null && !filter(type)) continue;
RegisteredComponents.Add(type);
}
return this;
}
private static IEnumerable<Type> DiscoverComponentTypes(Assembly assembly)
{
Type[] types;
try { types = assembly.GetTypes(); }
catch (ReflectionTypeLoadException ex)
{
// Partially loadable assemblies still expose their resolvable types.
types = ex.Types.Where(t => t is not null).ToArray()!;
}
foreach (var t in types)
{
if (t is null) continue;
if (!t.IsClass || t.IsAbstract) continue;
if (!t.IsPublic && !t.IsNestedPublic) continue;
if (!typeof(ComponentBase).IsAssignableFrom(t)) continue;
if (t.IsDefined(typeof(ShellDocsIgnoreAttribute), inherit: false)) continue;
yield return t;
}
}
// Segment-aware prefix rule: AddRedirect("/docs/v0.3.0", "/docs/v0.3") also sends
// /docs/v0.3.0/intro to /docs/v0.3/intro. Permanent rules answer 301, others 302.
public ShellDocsOptions AddRedirect(string from, string to, bool permanent = true)
{
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
throw new ArgumentException("Redirect paths must be non-empty.");
Redirects.Add(new DocsRedirectRule(from, to, permanent));
return this;
}
// AddSyncSwitch("platform", "MAUI", "Avalonia"): a tab row under the page header, on
// pages whose blocks use SyncKey="platform", that picks the tab every <CodeGroup>,
// <Tabs> and preview code panel with that key shows.
public ShellDocsOptions AddSyncSwitch(string syncKey, params string[] options)
{
if (string.IsNullOrWhiteSpace(syncKey))
throw new ArgumentException("syncKey must be non-empty.", nameof(syncKey));
if (options is not { Length: >= 2 })
throw new ArgumentException("A sync switch needs at least two options.", nameof(options));
SyncSwitches.Add(new DocsSyncSwitch(syncKey, options));
return this;
}
/* llms.txt (https://llmstxt.org) for AI agents: /llms.txt, /llms-full.txt, every
page as Markdown at its URL + ".md", the same files under each version root,
agent instruction files and a page actions menu. `shelldocs build` exports them. */
public ShellDocsOptions EnableLlmsTxt(Action<LlmsTxtOptions>? configure = null)
{
LlmsTxt ??= new LlmsTxtOptions();
configure?.Invoke(LlmsTxt);
return this;
}
public ShellDocsOptions AddNavLink(string label, string href)
{
PrimaryNav.Add(new NavLink(label, href));
return this;
}
public ShellDocsOptions AddNavMenu(string label, params NavMenuItem[] items)
{
PrimaryNav.Add(new NavLink(label, "#", Children: items.ToList()));
return this;
}
public ShellDocsOptions AddPackage(string id, string title, string description, string rootUrl, string? iconPath = null)
{
Packages.Add(new DocsPackage(id, title, description, rootUrl, iconPath));
return this;
}
// `id` replaces the "{version}" token in package RootUrls.
public ShellDocsOptions AddVersion(string id, string label, string rootUrl, string? description = null, bool latest = false)
{
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("id must be non-empty.", nameof(id));
if (string.IsNullOrWhiteSpace(rootUrl))
throw new ArgumentException("rootUrl must be non-empty.", nameof(rootUrl));
Versions.Add(new DocsVersion(id, label, rootUrl, description, latest));
return this;
}
public ShellDocsOptions SetLogo(string url, string? alt = null)
{
LogoLight = url;
LogoDark = url;
if (alt is not null) LogoAlt = alt;
return this;
}
public ShellDocsOptions SetLogo(string lightUrl, string darkUrl, string? alt = null)
{
LogoLight = lightUrl;
LogoDark = darkUrl;
if (alt is not null) LogoAlt = alt;
return this;
}
public const string BuiltInAliasPrefix = "Docs";
internal void AddBuiltInComponents(Assembly assembly, Func<Type, bool> filter)
{
foreach (var type in DiscoverComponentTypes(assembly))
{
if (filter(type) && !BuiltInComponents.Contains(type)) BuiltInComponents.Add(type);
}
}
/* Built-ins first, each also under a "Docs" alias (<DocsCard>) that stays
reachable when a consumer library shadows the short name; consumer
registrations last, so they win collisions. */
internal TypeRegistry BuildTypeRegistry()
{
var registry = new TypeRegistry();
foreach (var type in BuiltInComponents)
{
registry.Register(type);
registry.Register(BuiltInAliasPrefix + type.Name, type);
}
// A library shipping both Select and Select<T> keeps <Select> for the non-generic one.
var nonGenericTags = RegisteredComponents
.Where(t => !t.IsGenericTypeDefinition && !ComponentAliases.ContainsKey(t))
.Select(TypeRegistry.TagNameOf)
.ToHashSet(StringComparer.Ordinal);
foreach (var type in RegisteredComponents)
{
if (type.IsGenericTypeDefinition && !ComponentAliases.ContainsKey(type)
&& nonGenericTags.Contains(TypeRegistry.TagNameOf(type)))
continue;
if (ComponentAliases.TryGetValue(type, out var alias))
registry.Register(alias, type);
else
registry.Register(type);
}
return registry;
}
}
public record NavLink(string Label, string Href, List<NavMenuItem>? Children = null);
public record NavMenuItem(string Label, string Href, string? Description = null, string? IconSvg = null);
// IconPath is a raw SVG `d` attribute value on a 24×24 viewBox — not a URL.
// RootUrl may contain the "{version}" token (see DocsPackage.VersionToken).
public record DocsPackage(string Id, string Title, string Description, string RootUrl, string? IconPath = null)
{
public const string VersionToken = "{version}";
public bool IsVersioned => RootUrl.Contains(VersionToken, StringComparison.Ordinal);
}
public record DocsVersion(string Id, string Label, string RootUrl, string? Description, bool IsLatest);
public record DocsRedirectRule(string From, string To, bool Permanent = true);
public record DocsSyncSwitch(string SyncKey, IReadOnlyList<string> Options);
// Colour presets: Graphite is the neutral default, the others tint it with one hue.
public enum ShellDocsTheme
{
Graphite,
Harbor,
Lagoon,
Forest,
Savanna,
Ember,
Jacaranda,
[Obsolete("Use Graphite.")] Shadcn = Graphite,
[Obsolete("Never had its own palette. Use Graphite or a colour preset.")] Fuma = Graphite,
[Obsolete("Never had its own palette. Use Graphite or a colour preset.")] Nextra = Graphite,
}
// TopNav: DocsHeader across the top, sidebar below. Sidebar: no top nav — brand,
// search and collapse live in a floating sidebar.
public enum DocsLayoutVariant
{
TopNav = 0,
Sidebar = 1
}