Add localized mod documentation with syntax highlighting
This commit is contained in:
1 parent
7ae31f180c
commit
55a3e9b157
24 files changed
+1255
-1
No files matched your search
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Mods.ModManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Lexical colouring for fenced documentation examples. Follows the site's
|
||||
/// explicit-language approach and custom scenario/filestree/jsonc rules,
|
||||
/// returning literal text spans rather than HTML or BBCode. Unknown languages,
|
||||
/// oversized examples and timeouts retain their complete uncoloured source.
|
||||
/// </summary>
|
||||
public static class ModCodeSyntax
|
||||
{
|
||||
private const string Strings = "(?<string>\"\"\"[\\s\\S]*?\"\"\"|'''[\\s\\S]*?'''|(?:\\$@|@\\$|@)\"(?:\"\"|[^\"])*\"|\\$?\"(?:\\\\.|[^\"\\\\\\r\\n])*\"|'(?:\\\\.|[^'\\\\\\r\\n])*')";
|
||||
private const string Tokens = @"(?<number>\b(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)[fFdDmM]?\b)|(?<variable>\$\{[^}\r\n]+\}|\$[A-Za-z_]\w*)|(?<name>[A-Za-z_]\w*)|(?<operator>[{}\[\](),:;=+*/<>!&|.\-?])";
|
||||
private static readonly Regex CStyle = Create(@"(?<comment>//[^\r\n]*|/\*[\s\S]*?\*/)|" + Strings + "|" + Tokens);
|
||||
private static readonly Regex Json = Create(Strings + "|" + Tokens);
|
||||
private static readonly Regex HashStyle = Create(@"(?<comment>\#[^\r\n]*)|" + Strings + "|" + Tokens);
|
||||
private static readonly Regex Scenario = Create(@"(?<meta>^---[ \t]*$)|(?<comment>\#[^\r\n]*|--[^\r\n]*)|" + Strings + @"|(?<label>:[A-Za-z_]\w*)|(?<operator>->)|" + Tokens);
|
||||
private static readonly Regex Glsl = Create(@"(?<meta>^[ \t]*\#[^\r\n]*)|(?<comment>//[^\r\n]*|/\*[\s\S]*?\*/)|" + Strings + "|" + Tokens);
|
||||
private static readonly Regex FileTree = Create(@"(?<treecomment>[#←][^\r\n]*)|(?<comment>[│├└][─ \t]*)|(?<directory>[\p{L}\p{N}_.{}-]+/|/)");
|
||||
private static readonly Regex Properties = Create(@"(?<comment>^[ \t]*[#!;][^\r\n]*)|(?<meta>^[ \t]*\[[^\]\r\n]+\])|(?<attr>^[ \t]*[^=\r\n]+(?==))|(?<string>(?<==)[^\r\n]*)");
|
||||
private static readonly Dictionary<string, HashSet<string>> Keywords = new(StringComparer.Ordinal)
|
||||
{
|
||||
["csharp"] = Words("abstract as async await base bool break byte case catch char checked class const continue decimal default delegate do double dynamic else enum event explicit extern false finally fixed float for foreach get global goto if implicit in init int interface internal is lock long namespace new null object operator out override params partial private protected public readonly record ref required return sbyte sealed set short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unmanaged unsafe ushort using value var virtual void volatile when where while with yield"),
|
||||
["gdscript"] = Words("and as assert await break breakpoint class class_name const continue elif else enum extends false for func if in is match not null or pass preload return self signal static super true var void while yield"),
|
||||
["scenario"] = Words("scene priority condition is_one_run save_allowed light_use_time background foreground background_color background_show_hide text p_text title select option switch_scene end action music music_play music_pause sound light_set delay effect animation interactive_add interactive_remove interactive_clear interactive_set show_dialog hide_dialog set flag empty goto"),
|
||||
["glsl"] = Words("attribute bool break bvec2 bvec3 bvec4 case centroid coherent const continue default discard do double else flat float for highp if in inout int invariant ivec2 ivec3 ivec4 layout lowp mat2 mat3 mat4 mediump out precision readonly restrict return sampler2D samplerCube smooth struct switch uniform uint uvec2 uvec3 uvec4 varying vec2 vec3 vec4 void volatile while writeonly shader_type render_mode spatial canvas_item particles sky fog"),
|
||||
["bash"] = Words("alias case cd do done echo elif else esac exit export fi for function if in local printf read readonly return set source then unset until while"),
|
||||
["python"] = Words("and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return True try while with yield"),
|
||||
};
|
||||
|
||||
/// <summary>Reads the first fence-info word and resolves the aliases used in mod documentation.</summary>
|
||||
public static string Language(string info)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(info))
|
||||
return "";
|
||||
var tag = info.Trim();
|
||||
var end = tag.IndexOfAny(new[] { ' ', '\t', '\r', '\n' });
|
||||
if (end >= 0)
|
||||
tag = tag.Substring(0, end);
|
||||
return tag.ToLowerInvariant() switch
|
||||
{
|
||||
"cs" or "c#" => "csharp",
|
||||
"gd" => "gdscript",
|
||||
"sh" or "shell" => "bash",
|
||||
"py" => "python",
|
||||
"yml" => "yaml",
|
||||
"cfg" or "toml" => "ini",
|
||||
"tscn" or "tres" => "ini",
|
||||
"gdshader" or "shader" => "glsl",
|
||||
var language => language,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns spans whose concatenation is exactly source, including newlines,
|
||||
/// tabs and trailing spaces. Kind names follow the site's syntax palette.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<(string Text, string Kind)> Tokenize(string info, string source)
|
||||
{
|
||||
source ??= "";
|
||||
var language = Language(info);
|
||||
var grammar = Grammar(language);
|
||||
if (grammar == null || source.Length > 65536)
|
||||
return Plain(source);
|
||||
var result = new List<(string Text, string Kind)>();
|
||||
try
|
||||
{
|
||||
var offset = 0;
|
||||
foreach (Match match in grammar.Matches(source))
|
||||
{
|
||||
if (match.Index > offset)
|
||||
result.Add((source.Substring(offset, match.Index - offset), ""));
|
||||
var kind = Kind(match, source, language);
|
||||
result.Add((match.Value, kind));
|
||||
offset = match.Index + match.Length;
|
||||
if (result.Count > 10000)
|
||||
return Plain(source);
|
||||
}
|
||||
if (offset < source.Length)
|
||||
result.Add((source.Substring(offset), ""));
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return Plain(source);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<(string Text, string Kind)> Plain(string source)
|
||||
=> new[] { (source, "") };
|
||||
|
||||
private static Regex Create(string pattern)
|
||||
=> new Regex(pattern, RegexOptions.Multiline | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private static HashSet<string> Words(string words)
|
||||
=> new HashSet<string>(words.Split(' '), StringComparer.Ordinal);
|
||||
|
||||
private static Regex Grammar(string language) => language switch
|
||||
{
|
||||
"json" => Json,
|
||||
"jsonc" or "csharp" => CStyle,
|
||||
"scenario" => Scenario,
|
||||
"glsl" => Glsl,
|
||||
"gdscript" or "bash" or "python" or "yaml" => HashStyle,
|
||||
"filestree" => FileTree,
|
||||
"ini" or "properties" => Properties,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string Kind(Match token, string source, string language)
|
||||
{
|
||||
foreach (var kind in new[] { "comment", "meta", "label", "directory", "treecomment", "attr" })
|
||||
if (token.Groups[kind].Success)
|
||||
return kind;
|
||||
var next = token.Index + token.Length;
|
||||
while (next < source.Length && (source[next] == ' ' || source[next] == '\t'))
|
||||
next++;
|
||||
var following = next < source.Length ? source[next] : '\0';
|
||||
if (token.Groups["string"].Success)
|
||||
return (language == "json" || language == "jsonc" || language == "yaml") && following == ':' ? "attr" : "string";
|
||||
if (token.Groups["number"].Success)
|
||||
return "number";
|
||||
if (token.Groups["operator"].Success)
|
||||
return "operator";
|
||||
if (token.Groups["variable"].Success)
|
||||
return language == "bash" ? "variable" : "";
|
||||
var word = token.Value;
|
||||
if (word == "true" || word == "false" || word == "null" || word == "True" || word == "False" || word == "None")
|
||||
return "literal";
|
||||
if (language == "scenario" && following == '=' || language == "yaml" && following == ':')
|
||||
return "attr";
|
||||
if (Keywords.TryGetValue(language, out var keywords) && keywords.Contains(word))
|
||||
return "keyword";
|
||||
if (language == "glsl" && word.StartsWith("gl_", StringComparison.Ordinal))
|
||||
return "built_in";
|
||||
if (language == "csharp" || language == "gdscript" || language == "python" || language == "glsl")
|
||||
{
|
||||
if (following == '(')
|
||||
return "function";
|
||||
if (char.IsUpper(word[0]))
|
||||
return "type";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Mods.ModManager
|
||||
{
|
||||
/// <summary>A readable, nonempty UTF-8 document bundled with an installed mod.</summary>
|
||||
public sealed class ModDocument
|
||||
{
|
||||
public string Language { get; }
|
||||
public string Path { get; }
|
||||
public string Text { get; }
|
||||
|
||||
public ModDocument(string language, string path, string text)
|
||||
{
|
||||
Language = language;
|
||||
Path = path;
|
||||
Text = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Cthangover.Core.Localization;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
namespace Mods.ModManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads documentation independently of mod activation and storage format.
|
||||
/// Only language-named files and default.md directly under docs participate
|
||||
/// in fallback; README/CHANGELOG and nested assets never become translations.
|
||||
/// </summary>
|
||||
public static class ModDocumentation
|
||||
{
|
||||
/// <summary>Finds valid documents in stable path order; broken files do not hide other translations.</summary>
|
||||
public static IReadOnlyList<ModDocument> Discover(IModFileProvider provider)
|
||||
{
|
||||
var result = new List<ModDocument>();
|
||||
if (provider == null)
|
||||
return result;
|
||||
try
|
||||
{
|
||||
foreach (var path in provider.ListFiles("docs").Order(StringComparer.Ordinal))
|
||||
{
|
||||
if (!path.EndsWith(".md", StringComparison.Ordinal) || path.EndsWith("/"))
|
||||
continue;
|
||||
var language = System.IO.Path.GetFileNameWithoutExtension(path);
|
||||
if (!IsLanguage(language))
|
||||
continue;
|
||||
var bytes = ReadResource(provider, path, 512 * 1024);
|
||||
if (bytes == null)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
var text = new UTF8Encoding(false, true).GetString(bytes).TrimStart('\uFEFF');
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
result.Add(new ModDocument(language, path, text));
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
GameLogger.Log("MODS", $"Documentation '{provider.Mod}/{path}' is not UTF-8", LogLevel.Warning);
|
||||
}
|
||||
if (result.Count >= 32)
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is InvalidDataException || ex is ObjectDisposedException)
|
||||
{
|
||||
GameLogger.Log("MODS", $"Cannot list documentation for '{provider.Mod}': {ex.Message}", LogLevel.Warning);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Requested tag, primary language, default.md, English, then stable first document.</summary>
|
||||
public static ModDocument Select(IReadOnlyList<ModDocument> documents, string language)
|
||||
{
|
||||
if (documents == null)
|
||||
return null;
|
||||
var tag = language?.Replace('_', '-');
|
||||
foreach (var candidate in new[] { tag, LanguageCode.Primary(tag), "default", "en" })
|
||||
foreach (var document in documents)
|
||||
if (string.Equals(document.Language, candidate, StringComparison.OrdinalIgnoreCase))
|
||||
return document;
|
||||
return documents.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>Resolves local media relative to a document, rejecting schemes, absolute paths and escapes from docs.</summary>
|
||||
public static string ResolveResource(string documentPath, string reference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reference) || reference.StartsWith('/') || reference.StartsWith('\\')
|
||||
|| reference.Contains(':') || reference.Contains('?') || reference.Contains('#'))
|
||||
return null;
|
||||
var path = ModPath.ResolveRelative(reference, documentPath);
|
||||
return path.StartsWith("docs/", StringComparison.Ordinal) ? path : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounded stream reads work for compressed entries too. Folder symlinks
|
||||
/// are rejected along the resource path before delegating to the provider.
|
||||
/// </summary>
|
||||
public static byte[] ReadResource(IModFileProvider provider, string path, int limit)
|
||||
{
|
||||
if (provider == null || string.IsNullOrEmpty(path) || !path.StartsWith("docs/", StringComparison.Ordinal) || ModPath.Normalize(path) != path)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
if (provider.RootPath != null && Directory.Exists(provider.RootPath))
|
||||
{
|
||||
var current = provider.RootPath;
|
||||
foreach (var segment in path.Split('/'))
|
||||
{
|
||||
current = System.IO.Path.Combine(current, segment);
|
||||
if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
using var stream = provider.OpenStream(path);
|
||||
if (stream == null)
|
||||
return null;
|
||||
using var output = new MemoryStream();
|
||||
var buffer = new byte[16384];
|
||||
int read;
|
||||
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
if (output.Length + read > limit)
|
||||
return null;
|
||||
output.Write(buffer, 0, read);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is InvalidDataException || ex is ObjectDisposedException)
|
||||
{
|
||||
GameLogger.Log("MODS", $"Cannot read documentation resource '{provider.Mod}/{path}': {ex.Message}", LogLevel.Warning);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLanguage(string code)
|
||||
{
|
||||
if (code == "default")
|
||||
return true;
|
||||
var parts = code.Split('-');
|
||||
if (parts[0].Length < 2 || parts[0].Length > 3 || parts.Length > 3)
|
||||
return false;
|
||||
foreach (var c in parts[0])
|
||||
if (!(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z'))
|
||||
return false;
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part.Length == 0 || part.Length > 8)
|
||||
return false;
|
||||
foreach (var c in part)
|
||||
if (!(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9'))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
@@ -38,6 +39,12 @@ namespace Mods.ModManager
|
||||
private Label _usedByText;
|
||||
private VBoxContainer _conflictsBox;
|
||||
private Label _conflictsText;
|
||||
private VBoxContainer _documentationBox;
|
||||
private OptionButton _documentationLanguages;
|
||||
private Label _documentationSource;
|
||||
private ModMarkdownView _documentationBody;
|
||||
private IReadOnlyList<ModDocument> _documents = Array.Empty<ModDocument>();
|
||||
private string _documentationLanguage;
|
||||
|
||||
/// <summary>Raised when the primary contextual action is pressed.</summary>
|
||||
public event Action<ModCardData> ActionPressed;
|
||||
@@ -57,10 +64,19 @@ namespace Mods.ModManager
|
||||
/// <summary>Displays the given mod; call again with refreshed data after actions.</summary>
|
||||
public void Open(ModCardData data)
|
||||
{
|
||||
if (_data?.MatchKey != data?.MatchKey)
|
||||
_documentationLanguage = null;
|
||||
_data = data;
|
||||
_documents = ModDocumentation.Discover(data?.LocalInfo?.FileProvider);
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
public override void _Notification(int what)
|
||||
{
|
||||
if (what == NotificationTranslationChanged && _nodesReady)
|
||||
BuildDocumentation();
|
||||
}
|
||||
|
||||
private void EnsureNodes()
|
||||
{
|
||||
if (_nodesReady)
|
||||
@@ -85,6 +101,14 @@ namespace Mods.ModManager
|
||||
_usedByText = GetNode<Label>("Scroll/Content/UsedByBox/Text");
|
||||
_conflictsBox = GetNode<VBoxContainer>("Scroll/Content/ConflictsBox");
|
||||
_conflictsText = GetNode<Label>("Scroll/Content/ConflictsBox/Text");
|
||||
_documentationBox = GetNode<VBoxContainer>("Scroll/Content/DocumentationBox");
|
||||
_documentationLanguages = GetNode<OptionButton>("Scroll/Content/DocumentationBox/Header/Languages");
|
||||
_documentationSource = GetNode<Label>("Scroll/Content/DocumentationBox/Source");
|
||||
_documentationBody = GetNode<ModMarkdownView>("Scroll/Content/DocumentationBox/Body");
|
||||
_documentationLanguages.ItemSelected += OnDocumentationLanguageSelected;
|
||||
_documentationLanguages.TooltipText = TranslationServer.Translate("mod_manager/hint_docs_language_body");
|
||||
ModHelpPanel.Attach(_documentationLanguages, "docs_language");
|
||||
_documentationSource.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||
|
||||
ModHelpPanel.Attach(_versions, "mod_version");
|
||||
|
||||
@@ -143,6 +167,7 @@ namespace Mods.ModManager
|
||||
BuildConflictBox();
|
||||
|
||||
_descriptionText.Text = NullIfEmpty(_data.Description) ?? "—";
|
||||
BuildDocumentation();
|
||||
_changelogText.Text = NullIfEmpty(_data.SelectedDownload?.Changelog) ?? "—";
|
||||
SetOptional(_dependsBox, _dependsText, JoinOrEmpty(ModQueries.GetDependencies(_data)));
|
||||
SetOptional(_usedByBox, _usedByText, JoinOrEmpty(ModQueries.GetDependents(_data.Id)));
|
||||
@@ -151,6 +176,44 @@ namespace Mods.ModManager
|
||||
LoadCover();
|
||||
}
|
||||
|
||||
private void BuildDocumentation()
|
||||
{
|
||||
_documentationBox.Visible = _documents.Count > 0;
|
||||
_documentationBody.Display(null, null);
|
||||
if (_documents.Count == 0 || _data == null)
|
||||
return;
|
||||
|
||||
SetCaption("Scroll/Content/DocumentationBox/Header/Caption", "mod_manager/documentation");
|
||||
_documentationLanguages.TooltipText = TranslationServer.Translate("mod_manager/hint_docs_language_body");
|
||||
_documentationLanguages.Clear();
|
||||
_documentationLanguages.AddItem(TranslationServer.Translate("mod_manager/docs_auto"));
|
||||
var selected = 0;
|
||||
for (var i = 0; i < _documents.Count; i++)
|
||||
{
|
||||
var language = _documents[i].Language;
|
||||
_documentationLanguages.AddItem(language == "default"
|
||||
? TranslationServer.Translate("mod_manager/docs_default").ToString() : language.ToUpperInvariant());
|
||||
if (language == _documentationLanguage)
|
||||
selected = i + 1;
|
||||
}
|
||||
_documentationLanguages.Selected = selected;
|
||||
var requested = selected == 0 ? TranslationServer.GetLocale() : _documentationLanguage;
|
||||
var document = ModDocumentation.Select(_documents, requested);
|
||||
_documentationSource.Text = string.Format(TranslationServer.Translate("mod_manager/docs_source"),
|
||||
_data.LocalVersion ?? "—", document.Language.ToUpperInvariant());
|
||||
if (!string.IsNullOrEmpty(_data.SelectedDownload?.Version) && _data.SelectedDownload.Version != _data.LocalVersion)
|
||||
_documentationSource.Text += "\n" + TranslationServer.Translate("mod_manager/docs_installed_notice");
|
||||
_documentationBody.Display(_data.LocalInfo?.FileProvider, document);
|
||||
}
|
||||
|
||||
private void OnDocumentationLanguageSelected(long index)
|
||||
{
|
||||
if (index < 0 || index > _documents.Count)
|
||||
return;
|
||||
_documentationLanguage = index == 0 ? null : _documents[(int)index - 1].Language;
|
||||
BuildDocumentation();
|
||||
}
|
||||
|
||||
private void PopulateVersions()
|
||||
{
|
||||
_versions.Clear();
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
using Markdig;
|
||||
using Markdig.Syntax;
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
namespace Mods.ModManager
|
||||
{
|
||||
/// <summary>
|
||||
/// CommonMark rendered through Godot's rich-text API, never as HTML or
|
||||
/// interpolated BBCode. Images come from the same provider as the document;
|
||||
/// percentage widths keep them inside the page when the window is resized.
|
||||
/// </summary>
|
||||
public partial class ModMarkdownView : RichTextLabel
|
||||
{
|
||||
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().DisableHtml().Build();
|
||||
private readonly Dictionary<string, ImageTexture> textures = new(StringComparer.Ordinal);
|
||||
private IModFileProvider provider;
|
||||
private ModDocument document;
|
||||
private bool connected;
|
||||
private FontFile codeFont;
|
||||
|
||||
/// <summary>Replaces the document and releases images owned by the previous page.</summary>
|
||||
public void Display(IModFileProvider source, ModDocument value)
|
||||
{
|
||||
Clear();
|
||||
ReleaseTextures();
|
||||
provider = source;
|
||||
document = value;
|
||||
if (!connected)
|
||||
{
|
||||
MetaClicked += OnLinkClicked;
|
||||
connected = true;
|
||||
AddThemeColorOverride("default_color", UiTokens.Body);
|
||||
AddThemeFontSizeOverride("normal_font_size", UiTokens.FontBody);
|
||||
}
|
||||
if (value != null)
|
||||
{
|
||||
EnsureCodeFont();
|
||||
RenderBlocks(Markdown.Parse(value.Text, Pipeline), 0);
|
||||
}
|
||||
}
|
||||
|
||||
public override void _ExitTree()
|
||||
{
|
||||
Clear();
|
||||
ReleaseTextures();
|
||||
provider = null;
|
||||
document = null;
|
||||
ReleaseCodeFont();
|
||||
}
|
||||
|
||||
public override void _Notification(int what)
|
||||
{
|
||||
// Detached preview/test controls may be freed without entering a tree.
|
||||
if (what == NotificationPredelete)
|
||||
ReleaseCodeFont();
|
||||
}
|
||||
|
||||
private void EnsureCodeFont()
|
||||
{
|
||||
if (codeFont != null || !ModRegistry.Instance.AllDiscoveredMods.TryGetValue("mod_manager", out var owner))
|
||||
return;
|
||||
var bytes = owner.FileProvider?.ReadFileBinary("fonts/DejaVuSansMono.ttf");
|
||||
if (bytes == null)
|
||||
return;
|
||||
// Read bytes through the provider so the font also works in ZIP mods,
|
||||
// without Godot import files or a system-installed monospace font.
|
||||
codeFont = new FontFile { Data = bytes };
|
||||
AddThemeFontOverride("mono_font", codeFont);
|
||||
}
|
||||
|
||||
private void ReleaseCodeFont()
|
||||
{
|
||||
if (codeFont == null)
|
||||
return;
|
||||
RemoveThemeFontOverride("mono_font");
|
||||
codeFont.Dispose();
|
||||
codeFont = null;
|
||||
}
|
||||
|
||||
private void ReleaseTextures()
|
||||
{
|
||||
foreach (var texture in textures.Values)
|
||||
texture.Dispose();
|
||||
textures.Clear();
|
||||
}
|
||||
|
||||
private void RenderBlocks(ContainerBlock blocks, int depth)
|
||||
{
|
||||
if (depth > 32)
|
||||
return;
|
||||
foreach (var block in blocks)
|
||||
{
|
||||
switch (block)
|
||||
{
|
||||
case HeadingBlock heading:
|
||||
PushFontSize(Math.Max(UiTokens.FontBody + 2, 30 - heading.Level * 2));
|
||||
PushBold();
|
||||
RenderInlines(heading.Inline, depth + 1);
|
||||
Pop();
|
||||
Pop();
|
||||
break;
|
||||
case ParagraphBlock paragraph:
|
||||
RenderInlines(paragraph.Inline, depth + 1);
|
||||
break;
|
||||
case CodeBlock code:
|
||||
PushMono();
|
||||
PushBgcolor(UiTokens.Surface0);
|
||||
foreach (var span in ModCodeSyntax.Tokenize(code is FencedCodeBlock fence ? fence.Info : null, code.Lines.ToString()))
|
||||
{
|
||||
if (span.Kind.Length > 0)
|
||||
PushColor(CodeColor(span.Kind));
|
||||
AddText(span.Text);
|
||||
if (span.Kind.Length > 0)
|
||||
Pop();
|
||||
}
|
||||
Pop();
|
||||
Pop();
|
||||
break;
|
||||
case ListBlock list:
|
||||
var number = int.TryParse(list.OrderedStart, out var start) ? start : 1;
|
||||
foreach (var item in list)
|
||||
{
|
||||
PushIndent(depth + 1);
|
||||
AddText(list.IsOrdered ? $"{number++}. " : "• ");
|
||||
RenderBlocks((ContainerBlock)item, depth + 1);
|
||||
Pop();
|
||||
}
|
||||
break;
|
||||
case QuoteBlock quote:
|
||||
PushIndent(1);
|
||||
PushColor(UiTokens.Muted);
|
||||
RenderBlocks(quote, depth + 1);
|
||||
Pop();
|
||||
Pop();
|
||||
break;
|
||||
case ThematicBreakBlock:
|
||||
AddText("────────────────────────");
|
||||
break;
|
||||
case ContainerBlock container:
|
||||
RenderBlocks(container, depth + 1);
|
||||
break;
|
||||
}
|
||||
AddText("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderInlines(ContainerInline container, int depth)
|
||||
{
|
||||
if (container == null || depth > 32)
|
||||
return;
|
||||
foreach (var inline in container)
|
||||
{
|
||||
switch (inline)
|
||||
{
|
||||
case LiteralInline literal:
|
||||
AddText(literal.Content.ToString());
|
||||
break;
|
||||
case CodeInline code:
|
||||
PushMono();
|
||||
AddText(code.Content);
|
||||
Pop();
|
||||
break;
|
||||
case LineBreakInline line:
|
||||
AddText(line.IsHard ? "\n" : " ");
|
||||
break;
|
||||
case EmphasisInline emphasis:
|
||||
if (emphasis.DelimiterCount >= 2)
|
||||
PushBold();
|
||||
else
|
||||
PushItalics();
|
||||
RenderInlines(emphasis, depth + 1);
|
||||
Pop();
|
||||
break;
|
||||
case LinkInline image when image.IsImage:
|
||||
RenderImage(image);
|
||||
break;
|
||||
case LinkInline link:
|
||||
var allowed = IsWebLink(link.Url);
|
||||
if (allowed)
|
||||
{
|
||||
PushColor(UiTokens.TypeTarget);
|
||||
PushMeta(link.Url);
|
||||
}
|
||||
RenderInlines(link, depth + 1);
|
||||
if (allowed)
|
||||
{
|
||||
Pop();
|
||||
Pop();
|
||||
}
|
||||
break;
|
||||
case AutolinkInline autoLink:
|
||||
if (IsWebLink(autoLink.Url))
|
||||
{
|
||||
PushMeta(autoLink.Url);
|
||||
AddText(autoLink.Url);
|
||||
Pop();
|
||||
}
|
||||
else
|
||||
AddText(autoLink.Url);
|
||||
break;
|
||||
case ContainerInline nested:
|
||||
RenderInlines(nested, depth + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderImage(LinkInline image)
|
||||
{
|
||||
var alt = image.FirstChild is LiteralInline literal ? literal.Content.ToString() : image.Title ?? "";
|
||||
var path = ModDocumentation.ResolveResource(document.Path, image.Url);
|
||||
var texture = path == null ? null : LoadImage(path);
|
||||
if (texture == null)
|
||||
{
|
||||
AddText($"[{alt}] ({TranslationServer.Translate("mod_manager/docs_image_unavailable")})");
|
||||
return;
|
||||
}
|
||||
AddImage(texture, width: 100, height: 0, widthInPercent: true, altText: alt, tooltip: image.Title ?? alt);
|
||||
}
|
||||
|
||||
private ImageTexture LoadImage(string path)
|
||||
{
|
||||
if (textures.TryGetValue(path, out var cached))
|
||||
return cached;
|
||||
// Do not allow a document to retain an unbounded number of textures.
|
||||
if (textures.Count >= 16)
|
||||
return null;
|
||||
var extension = System.IO.Path.GetExtension(path).ToLowerInvariant();
|
||||
if (extension != ".png" && extension != ".jpg" && extension != ".jpeg" && extension != ".webp")
|
||||
return null;
|
||||
var bytes = ModDocumentation.ReadResource(provider, path, 8 * 1024 * 1024);
|
||||
if (bytes == null || bytes.Length == 0)
|
||||
return null;
|
||||
using var image = new Image();
|
||||
var error = extension == ".png" ? image.LoadPngFromBuffer(bytes)
|
||||
: extension == ".webp" ? image.LoadWebpFromBuffer(bytes) : image.LoadJpgFromBuffer(bytes);
|
||||
if (error != Error.Ok || image.GetWidth() > 4096 || image.GetHeight() > 4096)
|
||||
return null;
|
||||
var texture = ImageTexture.CreateFromImage(image);
|
||||
textures[path] = texture;
|
||||
return texture;
|
||||
}
|
||||
|
||||
private static bool IsWebLink(string value)
|
||||
=> Uri.TryCreate(value, UriKind.Absolute, out var uri) && (uri.Scheme == "https" || uri.Scheme == "http");
|
||||
|
||||
// Same semantic palette as site/css/highlight-theme.css, independent of
|
||||
// the application's theme so examples remain recognisable on both UIs.
|
||||
private static Color CodeColor(string kind) => kind switch
|
||||
{
|
||||
"keyword" => new Color("ff7b72"),
|
||||
"function" or "type" => new Color("d2a8ff"),
|
||||
"string" => new Color("a5d6ff"),
|
||||
"comment" => new Color("8b949e"),
|
||||
"built_in" => new Color("ffa657"),
|
||||
"label" => new Color("1f6feb"),
|
||||
"directory" => new Color("e3b341"),
|
||||
"treecomment" => new Color("3fb950"),
|
||||
_ => new Color("79c0ff"),
|
||||
};
|
||||
|
||||
private void OnLinkClicked(Variant value)
|
||||
{
|
||||
var url = value.AsString();
|
||||
if (IsWebLink(url))
|
||||
OS.ShellOpen(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user