From 7d8aa1c4497551f5c4249c56a161f37b2d31cdf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 08:25:28 +0000 Subject: [PATCH 01/21] Support linking to UE assets, Blueprints, and C++ classes from Markdown Implements issue #61. Standard Markdown links whose target begins with a UE package root (/Game/, /Engine/, /Plugins/, /Script/) are rewritten to the ueasset:// scheme and open in their asset editor. The class:// scheme resolves to a native UClass (opened in the IDE via FSourceCodeNavigation) or a Blueprint asset (opened in the Blueprint editor). Broken-link styling now covers ueasset:// and class:// targets. https://claude.ai/code/session_01TNergLaAd33WFGjABwPLpE --- CHANGELOG.md | 8 + .../MarkdownAsset/Private/MarkdownAsset.cpp | 67 +++++ .../MarkdownAssetEditor.Build.cs | 3 +- .../Private/MarkdownAssetEditorToolkit.cpp | 259 +++++++++++++++--- .../Public/MarkdownAssetEditorToolkit.h | 6 + README.ja.md | 1 + README.md | 1 + 7 files changed, 313 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e7e1d..ce6974e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- **Asset & Class Links** — Standard Markdown links whose target begins with a UE package root (`/Game/`, `/Engine/`, `/Plugins/`, `/Script/`) are now opened in the corresponding asset editor from the HTML preview +- **Class Link Scheme** — `[Label](class://ClassName)` resolves the target via UClass lookup; native C++ classes open in the IDE via `FSourceCodeNavigation::NavigateToClass`, Blueprint classes open in the Blueprint editor +- **Broken Link Styling for New Schemes** — `ueasset://` and `class://` targets that cannot be resolved are highlighted in red in the preview, matching existing wikilink behavior + ## [1.1.0] - 2026-04-04 ### Added diff --git a/Plugins/MarkdownAsset/Source/MarkdownAsset/Private/MarkdownAsset.cpp b/Plugins/MarkdownAsset/Source/MarkdownAsset/Private/MarkdownAsset.cpp index f32ee21..3a86241 100644 --- a/Plugins/MarkdownAsset/Source/MarkdownAsset/Private/MarkdownAsset.cpp +++ b/Plugins/MarkdownAsset/Source/MarkdownAsset/Private/MarkdownAsset.cpp @@ -72,6 +72,72 @@ static FString PostProcessWikilinks(const FString& Html) return Result; } +/** Decodes HTML numeric/named entities that md4c emits inside href values (e.g. &, /). */ +static FString DecodeHtmlEntitiesInHref(const FString& Input) +{ + FString Output = Input; + Output.ReplaceInline(TEXT("&"), TEXT("&")); + Output.ReplaceInline(TEXT("/"), TEXT("/")); + Output.ReplaceInline(TEXT("/"), TEXT("/")); + Output.ReplaceInline(TEXT("<"), TEXT("<")); + Output.ReplaceInline(TEXT(">"), TEXT(">")); + Output.ReplaceInline(TEXT("""), TEXT("\"")); + return Output; +} + +/** + * Rewrites anchor tags whose href targets a UE package path or class reference + * into custom URL schemes intercepted by the editor toolkit: + * [Label](/Game/Foo/Bar) -> + * [Label](class://MyClass) -> (normalized) + * http/https/mdasset/data URLs are left untouched. + */ +static FString PostProcessAssetAndClassLinks(const FString& Html) +{ + // Capture every (non-greedy) so we can classify the href. + const FRegexPattern AnchorPattern(TEXT("\n%s\n" ), *ParsedHtml); } +/** Returns true if an Unreal asset exists at the given object path. */ +static bool DoesAssetExistAtPath(const FString& ObjectPath) +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); + + const FSoftObjectPath SoftPath(ObjectPath); + if (AssetRegistry.GetAssetByObjectPath(SoftPath).IsValid()) + { + return true; + } + + // Content Browser paths often omit the trailing ".AssetName"; try appending it. + int32 SlashIndex; + if (!ObjectPath.Contains(TEXT(".")) && ObjectPath.FindLastChar(TEXT('/'), SlashIndex)) + { + const FString LeafName = ObjectPath.Mid(SlashIndex + 1); + const FString FullPath = FString::Printf(TEXT("%s.%s"), *ObjectPath, *LeafName); + if (AssetRegistry.GetAssetByObjectPath(FSoftObjectPath(FullPath)).IsValid()) + { + return true; + } + } + + return false; +} + +/** Returns true if a native UClass or Blueprint class matching ClassName can be resolved. */ +static bool DoesClassExist(const FString& ClassName) +{ + if (ClassName.IsEmpty()) + { + return false; + } + + // Native class lookup (tries exact name, then common A/U prefixes). + TArray Candidates = { ClassName }; + if (!ClassName.StartsWith(TEXT("A")) && !ClassName.StartsWith(TEXT("U"))) + { + Candidates.Add(FString::Printf(TEXT("A%s"), *ClassName)); + Candidates.Add(FString::Printf(TEXT("U%s"), *ClassName)); + } + for (const FString& Candidate : Candidates) + { + if (FindFirstObject(*Candidate, EFindFirstObjectOptions::NativeFirst) != nullptr) + { + return true; + } + } + + // Blueprint fallback via Asset Registry (accepts both "BP_Foo" and "BP_Foo_C"). + FString BlueprintAssetName = ClassName; + if (BlueprintAssetName.EndsWith(TEXT("_C"))) + { + BlueprintAssetName.LeftChopInline(2); + } + + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); + + TArray BlueprintAssets; + AssetRegistry.GetAssetsByClass(UBlueprint::StaticClass()->GetClassPathName(), BlueprintAssets); + for (const FAssetData& Asset : BlueprintAssets) + { + if (Asset.AssetName.ToString() == BlueprintAssetName) + { + return true; + } + } + + return false; +} + /** - * Checks each mdasset:// link against the AssetRegistry and adds the - * "md-broken-link" CSS class to links whose target asset does not exist. + * Walks every tag and adds the "md-broken-link" CSS class when + * the target of an mdasset://, ueasset://, or class:// scheme cannot be resolved. + * External URLs and other schemes are left untouched. */ -static FString MarkBrokenWikilinks(const FString& Html) +static FString MarkBrokenLinks(const FString& Html) { - // Collect existing MarkdownAsset names + // Cache MarkdownAsset names once for mdasset:// lookups. FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); TArray AllMarkdownAssets; AssetRegistry.GetAssetsByClass(UMarkdownAsset::StaticClass()->GetClassPathName(), AllMarkdownAssets); - TSet ExistingNames; + TSet MarkdownAssetNames; for (const FAssetData& Asset : AllMarkdownAssets) { - ExistingNames.Add(Asset.AssetName.ToString()); + MarkdownAssetNames.Add(Asset.AssetName.ToString()); } - // Find mdasset:// links and add md-broken-link class if target not found - const FRegexPattern LinkPattern(TEXT(" Bytes; - for (int32 i = 0; i < EncodedTarget.Len(); ++i) + bool bTargetExists = false; + if (Scheme == TEXT("mdasset")) { - if (EncodedTarget[i] == TEXT('%') && i + 2 < EncodedTarget.Len()) - { - FString HexStr = EncodedTarget.Mid(i + 1, 2); - Bytes.Add(static_cast(FCString::Strtoi(*HexStr, nullptr, 16))); - i += 2; - } - else - { - Bytes.Add(static_cast(EncodedTarget[i] & 0xFF)); - } + bTargetExists = MarkdownAssetNames.Contains(DecodedTarget); + } + else if (Scheme == TEXT("ueasset")) + { + bTargetExists = DoesAssetExistAtPath(DecodedTarget); + } + else // class + { + bTargetExists = DoesClassExist(DecodedTarget); } - FUTF8ToTCHAR Converter(reinterpret_cast(Bytes.GetData()), Bytes.Num()); - FString AssetName(Converter.Length(), Converter.Get()); - if (ExistingNames.Contains(AssetName)) + if (bTargetExists) { Result += Html.Mid(Matcher.GetMatchBeginning(), Matcher.GetMatchEnding() - Matcher.GetMatchBeginning()); } else { - Result += FString::Printf(TEXT("GetParsedHTML(); - ParsedHtml = MarkBrokenWikilinks(ParsedHtml); + ParsedHtml = MarkBrokenLinks(ParsedHtml); FString StyledHtml = GenerateStyledHtml(ParsedHtml); // Explicitly convert FString (UTF-16) to UTF-8 bytes before Base64 encoding @@ -611,15 +690,32 @@ bool FMarkdownAssetEditorToolkit::HandleBeforeNavigation(const FString& Url, con } // Handle mdasset:// scheme for wikilinks - static const FString Scheme = TEXT("mdasset://"); - if (Url.StartsWith(Scheme)) + static const FString MdAssetScheme = TEXT("mdasset://"); + if (Url.StartsWith(MdAssetScheme)) { - FString AssetName = Url.Mid(Scheme.Len()); - AssetName = PercentDecode(AssetName); + FString AssetName = PercentDecode(Url.Mid(MdAssetScheme.Len())); OpenLinkedMarkdownAsset(AssetName); return true; } + // Handle ueasset:// scheme for Content Browser asset / Blueprint object paths + static const FString UEAssetScheme = TEXT("ueasset://"); + if (Url.StartsWith(UEAssetScheme)) + { + FString ObjectPath = PercentDecode(Url.Mid(UEAssetScheme.Len())); + OpenLinkedUnrealAsset(ObjectPath); + return true; + } + + // Handle class:// scheme for C++ or Blueprint class references + static const FString ClassScheme = TEXT("class://"); + if (Url.StartsWith(ClassScheme)) + { + FString ClassName = PercentDecode(Url.Mid(ClassScheme.Len())); + OpenLinkedClass(ClassName); + return true; + } + // Open external URLs in the system browser if (Url.StartsWith(TEXT("http://")) || Url.StartsWith(TEXT("https://"))) { @@ -657,4 +753,105 @@ void FMarkdownAssetEditorToolkit::OpenLinkedMarkdownAsset(const FString& AssetNa } } +void FMarkdownAssetEditorToolkit::OpenLinkedUnrealAsset(const FString& ObjectPath) +{ + if (ObjectPath.IsEmpty()) + { + return; + } + + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); + + // Try the path as provided, then the common "/Game/Foo/Bar.Bar" form. + TArray CandidatePaths; + CandidatePaths.Add(ObjectPath); + + int32 SlashIndex; + if (!ObjectPath.Contains(TEXT(".")) && ObjectPath.FindLastChar(TEXT('/'), SlashIndex)) + { + const FString LeafName = ObjectPath.Mid(SlashIndex + 1); + CandidatePaths.Add(FString::Printf(TEXT("%s.%s"), *ObjectPath, *LeafName)); + } + + for (const FString& Candidate : CandidatePaths) + { + const FSoftObjectPath SoftPath(Candidate); + FAssetData AssetData = AssetRegistry.GetAssetByObjectPath(SoftPath); + if (!AssetData.IsValid()) + { + continue; + } + + if (UObject* LoadedAsset = AssetData.GetAsset()) + { + GEditor->GetEditorSubsystem()->OpenEditorForAsset(LoadedAsset); + return; + } + } + + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("Asset link target not found: '%s'"), *ObjectPath); +} + +void FMarkdownAssetEditorToolkit::OpenLinkedClass(const FString& ClassName) +{ + if (ClassName.IsEmpty()) + { + return; + } + + // Native UClass lookup; try common UE prefixes if the bare name misses. + TArray NativeCandidates = { ClassName }; + if (!ClassName.StartsWith(TEXT("A")) && !ClassName.StartsWith(TEXT("U"))) + { + NativeCandidates.Add(FString::Printf(TEXT("A%s"), *ClassName)); + NativeCandidates.Add(FString::Printf(TEXT("U%s"), *ClassName)); + } + + for (const FString& Candidate : NativeCandidates) + { + if (UClass* FoundClass = FindFirstObject(*Candidate, EFindFirstObjectOptions::NativeFirst)) + { + if (FoundClass->HasAnyClassFlags(CLASS_Native)) + { + if (!FSourceCodeNavigation::NavigateToClass(FoundClass)) + { + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("Failed to open source for native class '%s'"), *FoundClass->GetName()); + } + return; + } + } + } + + // Blueprint class fallback: accept both "BP_Foo" and "BP_Foo_C". + FString BlueprintAssetName = ClassName; + if (BlueprintAssetName.EndsWith(TEXT("_C"))) + { + BlueprintAssetName.LeftChopInline(2); + } + + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); + + TArray BlueprintAssets; + AssetRegistry.GetAssetsByClass(UBlueprint::StaticClass()->GetClassPathName(), BlueprintAssets); + + const FAssetData* MatchedBlueprint = BlueprintAssets.FindByPredicate( + [&BlueprintAssetName](const FAssetData& Asset) + { + return Asset.AssetName.ToString() == BlueprintAssetName; + }); + + if (MatchedBlueprint) + { + if (UObject* LoadedBlueprint = MatchedBlueprint->GetAsset()) + { + GEditor->GetEditorSubsystem()->OpenEditorForAsset(LoadedBlueprint); + return; + } + } + + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("Class link target not found: '%s'"), *ClassName); +} + #undef LOCTEXT_NAMESPACE diff --git a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Public/MarkdownAssetEditorToolkit.h b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Public/MarkdownAssetEditorToolkit.h index 5e57015..7c290bb 100644 --- a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Public/MarkdownAssetEditorToolkit.h +++ b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Public/MarkdownAssetEditorToolkit.h @@ -146,6 +146,12 @@ class FMarkdownAssetEditorToolkit : public FAssetEditorToolkit /** Opens a linked Markdown asset by name via the Asset Registry. */ void OpenLinkedMarkdownAsset(const FString& AssetName); + /** Opens an Unreal asset referenced by a package / object path (e.g. /Game/Path/To/Asset). */ + void OpenLinkedUnrealAsset(const FString& ObjectPath); + + /** Opens a C++ class in the IDE or a Blueprint class in its asset editor by class name. */ + void OpenLinkedClass(const FString& ClassName); + /** Timer handle for debouncing preview updates after text changes. */ FTimerHandle PreviewUpdateTimerHandle; diff --git a/README.ja.md b/README.ja.md index 37125a9..77eada7 100644 --- a/README.ja.md +++ b/README.ja.md @@ -21,6 +21,7 @@ - **インポート / エクスポート** — `.md` / `.markdown` ファイルをコンテンツブラウザにドラッグ&ドロップしてインポート、ソースファイルからのリインポート、および `.md` ファイルへのエクスポートに対応しています。 - **GitHub Flavored Markdown** — `MD_DIALECT_GITHUB` フラグにより、テーブル、タスクリスト、取り消し線などの GFM 拡張構文をサポートしました。 - **Wikilink** — `[[アセット名]]` と記述するだけでアセット間リンクを作成できます。プレビュー内のリンクをクリックすると、対象のMarkdownアセットが新しいタブで開きます。存在しないアセットへのリンクは赤色で表示されます。 +- **アセット・クラスリンク** — 標準Markdown構文 `[ラベル](/Game/Path/To/Asset)` でContent Browserのアセット(Blueprintを含む)、`[ラベル](class://クラス名)` でC++またはBlueprintクラスへのリンクを記述できます。クリックすると対応するアセットエディタが開き、C++クラスの場合はIDEでソースファイルが開きます。解決できないリンクは赤色で表示されます。 - **Blueprint サポート** — Blueprint から `RawMarkdownText` の読み書きと `GetParsedHTML()`、`GetRawMarkdownText()`、`GetPlainText()` の呼び出しが可能です。 - **ツールバーとキーボードショートカット** — 一般的なMarkdown操作のためのキーボードショートカットを備えた組み込みのフォーマットツールバーを用意しています。 - **元に戻す / やり直し** — Unreal Editorのトランザクションシステムと統合された完全なUndo/Redoサポート(Ctrl+Z / Ctrl+Y) diff --git a/README.md b/README.md index 23f3a67..fcbe296 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ An Unreal Engine 5.5+ plugin that adds a custom Markdown asset type with a live- - **Import / Export** — Drag-and-drop `.md` / `.markdown` files into the Content Browser to import, reimport from source files, or export assets back to `.md` - **GitHub Flavored Markdown** — Supports GFM extensions such as tables, task lists, and strikethrough via the `MD_DIALECT_GITHUB` flag - **Wikilinks** — Write `[[AssetName]]` to create inter-asset links; clicking a wikilink in the preview opens the target Markdown asset in a new editor tab. Broken links (pointing to non-existent assets) are highlighted in red +- **Asset & Class Links** — Use standard Markdown syntax `[Label](/Game/Path/To/Asset)` to link to any Content Browser asset (including Blueprints), and `[Label](class://ClassName)` to link to a C++ or Blueprint class. Clicking opens the target in its asset editor or jumps to the C++ source in your IDE; unresolved targets are highlighted in red - **Blueprint Support** — Read/write `RawMarkdownText` and call `GetParsedHTML()`, `GetRawMarkdownText()`, and `GetPlainText()` from Blueprints - **Toolbar & Keyboard Shortcuts** — Built-in formatting toolbar with keyboard shortcuts for common Markdown operations - **Undo / Redo** — Full undo/redo support integrated with the Unreal Editor transaction system (Ctrl+Z / Ctrl+Y) From 2d4cc69a58e776c5019e0c16b708c1291956db3f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 11:36:38 +0000 Subject: [PATCH 02/21] Open .cpp instead of .h for native class links FSourceCodeNavigation::NavigateToClass opens the header by default, but clicking class://Actor is expected to land in the implementation. Try FindClassSourcePath + OpenSourceFile first and fall back to the header only when no .cpp exists (header-only classes / interfaces). https://claude.ai/code/session_01TNergLaAd33WFGjABwPLpE --- .../Private/MarkdownAssetEditorToolkit.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp index 1f2d3c0..22fdeee 100644 --- a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp +++ b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp @@ -814,6 +814,17 @@ void FMarkdownAssetEditorToolkit::OpenLinkedClass(const FString& ClassName) { if (FoundClass->HasAnyClassFlags(CLASS_Native)) { + // Prefer the implementation file (.cpp); fall back to the header when no + // .cpp is available (header-only classes, interfaces, etc.). + FString SourcePath; + if (FSourceCodeNavigation::FindClassSourcePath(FoundClass, SourcePath) && !SourcePath.IsEmpty()) + { + if (FSourceCodeNavigation::OpenSourceFile(SourcePath)) + { + return; + } + } + if (!FSourceCodeNavigation::NavigateToClass(FoundClass)) { UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("Failed to open source for native class '%s'"), *FoundClass->GetName()); From d665117a3825d30ef0318a072eae4493bb15a8ea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 11:58:46 +0000 Subject: [PATCH 03/21] Resolve native class names with A/U/I prefix stripped UHT strips the leading A/U/I prefix from native class reflection names (AActor -> "Actor", UObject -> "Object"), so class://AActor previously failed to resolve and was flagged as a broken link. Extract candidate generation into BuildClassNameCandidates which both strips and adds prefixes, letting the detector and opener accept either form. https://claude.ai/code/session_01TNergLaAd33WFGjABwPLpE --- .../Private/MarkdownAssetEditorToolkit.cpp | 57 +++++++++++++------ 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp index 22fdeee..d979525 100644 --- a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp +++ b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp @@ -96,6 +96,42 @@ static FString GenerateStyledHtml(const FString& ParsedHtml) ), *ParsedHtml); } +/** + * Builds reflection-name candidates to try when resolving a user-supplied class name. + * UHT strips the leading A/U/I prefix from native class names, so "class://AActor" + * must be matched against the UClass named "Actor". Conversely, a bare "Actor" may + * need to be resolved as "AActor" for a hypothetical class in some projects. + */ +static TArray BuildClassNameCandidates(const FString& ClassName) +{ + TArray Candidates; + if (ClassName.IsEmpty()) + { + return Candidates; + } + + Candidates.Add(ClassName); + + // Strip a single-letter A/U/I prefix when followed by an uppercase letter. + if (ClassName.Len() > 1 && FChar::IsUpper(ClassName[1])) + { + const TCHAR First = ClassName[0]; + if (First == TEXT('A') || First == TEXT('U') || First == TEXT('I')) + { + Candidates.AddUnique(ClassName.Mid(1)); + } + } + + // Add A/U-prefixed variants for bare names. + if (FChar::IsUpper(ClassName[0])) + { + Candidates.AddUnique(FString::Printf(TEXT("A%s"), *ClassName)); + Candidates.AddUnique(FString::Printf(TEXT("U%s"), *ClassName)); + } + + return Candidates; +} + /** Returns true if an Unreal asset exists at the given object path. */ static bool DoesAssetExistAtPath(const FString& ObjectPath) { @@ -131,14 +167,8 @@ static bool DoesClassExist(const FString& ClassName) return false; } - // Native class lookup (tries exact name, then common A/U prefixes). - TArray Candidates = { ClassName }; - if (!ClassName.StartsWith(TEXT("A")) && !ClassName.StartsWith(TEXT("U"))) - { - Candidates.Add(FString::Printf(TEXT("A%s"), *ClassName)); - Candidates.Add(FString::Printf(TEXT("U%s"), *ClassName)); - } - for (const FString& Candidate : Candidates) + // Native class lookup against reflection names (UHT strips A/U/I prefixes). + for (const FString& Candidate : BuildClassNameCandidates(ClassName)) { if (FindFirstObject(*Candidate, EFindFirstObjectOptions::NativeFirst) != nullptr) { @@ -800,15 +830,8 @@ void FMarkdownAssetEditorToolkit::OpenLinkedClass(const FString& ClassName) return; } - // Native UClass lookup; try common UE prefixes if the bare name misses. - TArray NativeCandidates = { ClassName }; - if (!ClassName.StartsWith(TEXT("A")) && !ClassName.StartsWith(TEXT("U"))) - { - NativeCandidates.Add(FString::Printf(TEXT("A%s"), *ClassName)); - NativeCandidates.Add(FString::Printf(TEXT("U%s"), *ClassName)); - } - - for (const FString& Candidate : NativeCandidates) + // Native UClass lookup against reflection names (UHT strips A/U/I prefixes). + for (const FString& Candidate : BuildClassNameCandidates(ClassName)) { if (UClass* FoundClass = FindFirstObject(*Candidate, EFindFirstObjectOptions::NativeFirst)) { From 2ce79b71050dd5344d07d98d1ca189aa54552498 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 12:14:02 +0000 Subject: [PATCH 04/21] Preserve path separators in ueasset:// URLs Previously the full package path was percent-encoded including every '/', producing URLs like "ueasset://%2FGame%2FFoo%2FBar" with no real path component. CEF could not parse that as navigable, so clicking a Blueprint link blanked the preview and the asset never opened. Switch to a path-aware encoder that leaves '/' literal, yielding well-formed "ueasset:///Game/Foo/Bar" URLs that reach HandleBeforeNavigation. https://claude.ai/code/session_01TNergLaAd33WFGjABwPLpE --- .../MarkdownAsset/Private/MarkdownAsset.cpp | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/Plugins/MarkdownAsset/Source/MarkdownAsset/Private/MarkdownAsset.cpp b/Plugins/MarkdownAsset/Source/MarkdownAsset/Private/MarkdownAsset.cpp index 3a86241..fb73ccc 100644 --- a/Plugins/MarkdownAsset/Source/MarkdownAsset/Private/MarkdownAsset.cpp +++ b/Plugins/MarkdownAsset/Source/MarkdownAsset/Private/MarkdownAsset.cpp @@ -37,6 +37,36 @@ static FString PercentEncode(const FString& Input) return Encoded; } +/** + * Percent-encodes a URI path while preserving the path-separator '/' so the result + * forms a well-formed scheme:///path URL (e.g. "ueasset:///Game/Foo/Bar"). Encoding + * every '/' produced an invalid authority-only URL that CEF failed to navigate. + */ +static FString PercentEncodePath(const FString& Input) +{ + FTCHARToUTF8 Utf8(*Input); + const char* Data = Utf8.Get(); + int32 Len = Utf8.Length(); + + FString Encoded; + Encoded.Reserve(Len * 3); + + for (int32 i = 0; i < Len; ++i) + { + uint8 Ch = static_cast(Data[i]); + if ((Ch >= 'A' && Ch <= 'Z') || (Ch >= 'a' && Ch <= 'z') || (Ch >= '0' && Ch <= '9') + || Ch == '-' || Ch == '_' || Ch == '.' || Ch == '~' || Ch == '/') + { + Encoded.AppendChar(static_cast(Ch)); + } + else + { + Encoded += FString::Printf(TEXT("%%%02X"), Ch); + } + } + return Encoded; +} + /** * Converts md4c-html wikilink elements to anchor tags with the mdasset:// scheme. * Input: Name @@ -117,7 +147,9 @@ static FString PostProcessAssetAndClassLinks(const FString& Html) if (bIsPackagePath) { - const FString Encoded = PercentEncode(DecodedHref); + // Produce "ueasset:///Game/Foo/Bar" (scheme + empty authority + path); + // path separators must stay literal for CEF to parse the URL. + const FString Encoded = PercentEncodePath(DecodedHref); Processed += FString::Printf(TEXT(" Date: Fri, 17 Apr 2026 12:31:18 +0000 Subject: [PATCH 05/21] Add [LinkDebug] logs to diagnose link navigation Every emitted by the preview, the URL received by HandleBeforeNavigation, and each resolution step inside OpenLinkedUnrealAsset / OpenLinkedClass now emit Display-level logs in the LogMarkdownAssetEditor category. Temporary diagnostics for tracking down why Blueprint link clicks blank the preview. https://claude.ai/code/session_01TNergLaAd33WFGjABwPLpE --- .../Private/MarkdownAssetEditorToolkit.cpp | 83 ++++++++++++++++--- 1 file changed, 72 insertions(+), 11 deletions(-) diff --git a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp index d979525..037401e 100644 --- a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp +++ b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp @@ -410,6 +410,21 @@ void FMarkdownAssetEditorToolkit::UpdatePreview() { FString ParsedHtml = MarkdownAsset->GetParsedHTML(); ParsedHtml = MarkBrokenLinks(ParsedHtml); + + // [LinkDebug] Log every in the rendered HTML so we can see + // exactly what the web browser receives for each link. + { + const FRegexPattern HrefPattern(TEXT("]*href=\"([^\"]*)\"")); + FRegexMatcher HrefMatcher(HrefPattern, ParsedHtml); + int32 HrefIndex = 0; + while (HrefMatcher.FindNext()) + { + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] rendered href[%d] = '%s'"), + HrefIndex++, *HrefMatcher.GetCaptureGroup(1)); + } + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] Preview refresh: %d link(s) emitted"), HrefIndex); + } + FString StyledHtml = GenerateStyledHtml(ParsedHtml); // Explicitly convert FString (UTF-16) to UTF-8 bytes before Base64 encoding @@ -719,11 +734,14 @@ bool FMarkdownAssetEditorToolkit::HandleBeforeNavigation(const FString& Url, con return false; } + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] HandleBeforeNavigation: Url='%s' (len=%d)"), *Url, Url.Len()); + // Handle mdasset:// scheme for wikilinks static const FString MdAssetScheme = TEXT("mdasset://"); if (Url.StartsWith(MdAssetScheme)) { FString AssetName = PercentDecode(Url.Mid(MdAssetScheme.Len())); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] Matched mdasset:// -> AssetName='%s'"), *AssetName); OpenLinkedMarkdownAsset(AssetName); return true; } @@ -733,6 +751,7 @@ bool FMarkdownAssetEditorToolkit::HandleBeforeNavigation(const FString& Url, con if (Url.StartsWith(UEAssetScheme)) { FString ObjectPath = PercentDecode(Url.Mid(UEAssetScheme.Len())); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] Matched ueasset:// -> ObjectPath='%s'"), *ObjectPath); OpenLinkedUnrealAsset(ObjectPath); return true; } @@ -742,6 +761,7 @@ bool FMarkdownAssetEditorToolkit::HandleBeforeNavigation(const FString& Url, con if (Url.StartsWith(ClassScheme)) { FString ClassName = PercentDecode(Url.Mid(ClassScheme.Len())); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] Matched class:// -> ClassName='%s'"), *ClassName); OpenLinkedClass(ClassName); return true; } @@ -749,10 +769,12 @@ bool FMarkdownAssetEditorToolkit::HandleBeforeNavigation(const FString& Url, con // Open external URLs in the system browser if (Url.StartsWith(TEXT("http://")) || Url.StartsWith(TEXT("https://"))) { + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] External URL -> launching in system browser")); FPlatformProcess::LaunchURL(*Url, nullptr, nullptr); return true; } + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] No scheme matched; navigation will be denied (returning false)")); return false; } @@ -785,8 +807,11 @@ void FMarkdownAssetEditorToolkit::OpenLinkedMarkdownAsset(const FString& AssetNa void FMarkdownAssetEditorToolkit::OpenLinkedUnrealAsset(const FString& ObjectPath) { + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] OpenLinkedUnrealAsset: ObjectPath='%s'"), *ObjectPath); + if (ObjectPath.IsEmpty()) { + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] OpenLinkedUnrealAsset: empty path, aborting")); return; } @@ -806,51 +831,77 @@ void FMarkdownAssetEditorToolkit::OpenLinkedUnrealAsset(const FString& ObjectPat for (const FString& Candidate : CandidatePaths) { + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] candidate='%s'"), *Candidate); const FSoftObjectPath SoftPath(Candidate); FAssetData AssetData = AssetRegistry.GetAssetByObjectPath(SoftPath); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] AssetData.IsValid()=%d"), AssetData.IsValid() ? 1 : 0); if (!AssetData.IsValid()) { continue; } - if (UObject* LoadedAsset = AssetData.GetAsset()) + UObject* LoadedAsset = AssetData.GetAsset(); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] LoadedAsset=%s (class=%s)"), + LoadedAsset ? *LoadedAsset->GetPathName() : TEXT("nullptr"), + LoadedAsset ? *LoadedAsset->GetClass()->GetName() : TEXT("n/a")); + + if (LoadedAsset) { - GEditor->GetEditorSubsystem()->OpenEditorForAsset(LoadedAsset); + UAssetEditorSubsystem* AssetEditorSubsystem = GEditor ? GEditor->GetEditorSubsystem() : nullptr; + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] AssetEditorSubsystem=%s"), AssetEditorSubsystem ? TEXT("valid") : TEXT("nullptr")); + if (AssetEditorSubsystem) + { + const bool bOpened = AssetEditorSubsystem->OpenEditorForAsset(LoadedAsset); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] OpenEditorForAsset returned %d"), bOpened ? 1 : 0); + } return; } } - UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("Asset link target not found: '%s'"), *ObjectPath); + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] Asset link target not found: '%s'"), *ObjectPath); } void FMarkdownAssetEditorToolkit::OpenLinkedClass(const FString& ClassName) { + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] OpenLinkedClass: ClassName='%s'"), *ClassName); + if (ClassName.IsEmpty()) { + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] OpenLinkedClass: empty name, aborting")); return; } // Native UClass lookup against reflection names (UHT strips A/U/I prefixes). for (const FString& Candidate : BuildClassNameCandidates(ClassName)) { - if (UClass* FoundClass = FindFirstObject(*Candidate, EFindFirstObjectOptions::NativeFirst)) + UClass* FoundClass = FindFirstObject(*Candidate, EFindFirstObjectOptions::NativeFirst); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] native candidate='%s' -> %s"), + *Candidate, FoundClass ? *FoundClass->GetPathName() : TEXT("(null)")); + + if (FoundClass) { if (FoundClass->HasAnyClassFlags(CLASS_Native)) { // Prefer the implementation file (.cpp); fall back to the header when no // .cpp is available (header-only classes, interfaces, etc.). FString SourcePath; - if (FSourceCodeNavigation::FindClassSourcePath(FoundClass, SourcePath) && !SourcePath.IsEmpty()) + const bool bFoundSource = FSourceCodeNavigation::FindClassSourcePath(FoundClass, SourcePath); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] FindClassSourcePath bFound=%d path='%s'"), bFoundSource ? 1 : 0, *SourcePath); + if (bFoundSource && !SourcePath.IsEmpty()) { - if (FSourceCodeNavigation::OpenSourceFile(SourcePath)) + const bool bOpened = FSourceCodeNavigation::OpenSourceFile(SourcePath); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] OpenSourceFile returned %d"), bOpened ? 1 : 0); + if (bOpened) { return; } } - if (!FSourceCodeNavigation::NavigateToClass(FoundClass)) + const bool bNavigated = FSourceCodeNavigation::NavigateToClass(FoundClass); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] NavigateToClass returned %d"), bNavigated ? 1 : 0); + if (!bNavigated) { - UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("Failed to open source for native class '%s'"), *FoundClass->GetName()); + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] Failed to open source for native class '%s'"), *FoundClass->GetName()); } return; } @@ -863,12 +914,14 @@ void FMarkdownAssetEditorToolkit::OpenLinkedClass(const FString& ClassName) { BlueprintAssetName.LeftChopInline(2); } + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] blueprint asset name='%s'"), *BlueprintAssetName); FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); TArray BlueprintAssets; AssetRegistry.GetAssetsByClass(UBlueprint::StaticClass()->GetClassPathName(), BlueprintAssets); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] registry reports %d Blueprint asset(s)"), BlueprintAssets.Num()); const FAssetData* MatchedBlueprint = BlueprintAssets.FindByPredicate( [&BlueprintAssetName](const FAssetData& Asset) @@ -878,14 +931,22 @@ void FMarkdownAssetEditorToolkit::OpenLinkedClass(const FString& ClassName) if (MatchedBlueprint) { - if (UObject* LoadedBlueprint = MatchedBlueprint->GetAsset()) + UObject* LoadedBlueprint = MatchedBlueprint->GetAsset(); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] matched BP asset -> %s"), + LoadedBlueprint ? *LoadedBlueprint->GetPathName() : TEXT("nullptr")); + if (LoadedBlueprint) { - GEditor->GetEditorSubsystem()->OpenEditorForAsset(LoadedBlueprint); + UAssetEditorSubsystem* AssetEditorSubsystem = GEditor ? GEditor->GetEditorSubsystem() : nullptr; + if (AssetEditorSubsystem) + { + const bool bOpened = AssetEditorSubsystem->OpenEditorForAsset(LoadedBlueprint); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] OpenEditorForAsset returned %d"), bOpened ? 1 : 0); + } return; } } - UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("Class link target not found: '%s'"), *ClassName); + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] Class link target not found: '%s'"), *ClassName); } #undef LOCTEXT_NAMESPACE From 7374acd22139a0c74196d4c7bb89e5d6ddb444f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 13:40:24 +0000 Subject: [PATCH 06/21] Resolve Blueprint assets via package-name lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous FSoftObjectPath-based query returned invalid FAssetData for Blueprint targets even when the asset existed on disk, so clicking a /Game/... link silently failed. Replace the lookup with a three-tier chain: 1. AssetRegistry::GetAssetsByPackageName — the canonical package-based query that reliably hits indexed Content Browser assets. 2. Legacy GetAssetByObjectPath for both raw and dotted forms, preserved as a compatibility fallback. 3. StaticLoadObject as a last resort so on-disk packages that have not been indexed yet still open. DoesAssetExistAtPath mirrors the same strategy plus FPackageName::DoesPackageExist so broken-link highlighting matches runtime resolution. https://claude.ai/code/session_01TNergLaAd33WFGjABwPLpE --- .../Private/MarkdownAssetEditorToolkit.cpp | 118 ++++++++++++------ 1 file changed, 78 insertions(+), 40 deletions(-) diff --git a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp index 037401e..6553fc6 100644 --- a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp +++ b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp @@ -138,25 +138,30 @@ static bool DoesAssetExistAtPath(const FString& ObjectPath) FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); - const FSoftObjectPath SoftPath(ObjectPath); - if (AssetRegistry.GetAssetByObjectPath(SoftPath).IsValid()) + // Strip a trailing ".ObjectName" to derive the package path; the registry indexes by package. + FString PackagePath = ObjectPath; + int32 DotIndex; + if (PackagePath.FindChar(TEXT('.'), DotIndex)) + { + PackagePath.LeftInline(DotIndex); + } + + TArray PackageAssets; + AssetRegistry.GetAssetsByPackageName(FName(*PackagePath), PackageAssets); + if (PackageAssets.Num() > 0) { return true; } - // Content Browser paths often omit the trailing ".AssetName"; try appending it. - int32 SlashIndex; - if (!ObjectPath.Contains(TEXT(".")) && ObjectPath.FindLastChar(TEXT('/'), SlashIndex)) + // Secondary: legacy object-path query in case the caller supplied a non-package form. + if (AssetRegistry.GetAssetByObjectPath(FSoftObjectPath(ObjectPath)).IsValid()) { - const FString LeafName = ObjectPath.Mid(SlashIndex + 1); - const FString FullPath = FString::Printf(TEXT("%s.%s"), *ObjectPath, *LeafName); - if (AssetRegistry.GetAssetByObjectPath(FSoftObjectPath(FullPath)).IsValid()) - { - return true; - } + return true; } - return false; + // Last resort: does the package file exist on disk? Catches assets the registry has not + // yet indexed (e.g. newly added plugin content). + return FPackageName::DoesPackageExist(PackagePath); } /** Returns true if a native UClass or Blueprint class matching ClassName can be resolved. */ @@ -818,47 +823,80 @@ void FMarkdownAssetEditorToolkit::OpenLinkedUnrealAsset(const FString& ObjectPat FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); - // Try the path as provided, then the common "/Game/Foo/Bar.Bar" form. - TArray CandidatePaths; - CandidatePaths.Add(ObjectPath); + // Derive the package path (strip any trailing ".ObjectName") and the full object path. + FString PackagePath = ObjectPath; + int32 DotIndex; + if (PackagePath.FindChar(TEXT('.'), DotIndex)) + { + PackagePath.LeftInline(DotIndex); + } int32 SlashIndex; - if (!ObjectPath.Contains(TEXT(".")) && ObjectPath.FindLastChar(TEXT('/'), SlashIndex)) + FString LeafName; + if (PackagePath.FindLastChar(TEXT('/'), SlashIndex)) { - const FString LeafName = ObjectPath.Mid(SlashIndex + 1); - CandidatePaths.Add(FString::Printf(TEXT("%s.%s"), *ObjectPath, *LeafName)); + LeafName = PackagePath.Mid(SlashIndex + 1); } + const FString FullObjectPath = LeafName.IsEmpty() ? ObjectPath : FString::Printf(TEXT("%s.%s"), *PackagePath, *LeafName); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] package='%s' full='%s'"), *PackagePath, *FullObjectPath); - for (const FString& Candidate : CandidatePaths) - { - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] candidate='%s'"), *Candidate); - const FSoftObjectPath SoftPath(Candidate); - FAssetData AssetData = AssetRegistry.GetAssetByObjectPath(SoftPath); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] AssetData.IsValid()=%d"), AssetData.IsValid() ? 1 : 0); - if (!AssetData.IsValid()) - { - continue; - } + UObject* LoadedAsset = nullptr; - UObject* LoadedAsset = AssetData.GetAsset(); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] LoadedAsset=%s (class=%s)"), - LoadedAsset ? *LoadedAsset->GetPathName() : TEXT("nullptr"), - LoadedAsset ? *LoadedAsset->GetClass()->GetName() : TEXT("n/a")); + // 1) Package-name query — most reliable for standard Content Browser paths. + TArray PackageAssets; + AssetRegistry.GetAssetsByPackageName(FName(*PackagePath), PackageAssets); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] GetAssetsByPackageName returned %d"), PackageAssets.Num()); + if (PackageAssets.Num() > 0) + { + LoadedAsset = PackageAssets[0].GetAsset(); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] package[0].GetAsset -> %s"), + LoadedAsset ? *LoadedAsset->GetPathName() : TEXT("nullptr")); + } - if (LoadedAsset) + // 2) SoftObjectPath query (legacy fallback) for both raw and dotted forms. + if (!LoadedAsset) + { + for (const FString& Candidate : { ObjectPath, FullObjectPath }) { - UAssetEditorSubsystem* AssetEditorSubsystem = GEditor ? GEditor->GetEditorSubsystem() : nullptr; - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] AssetEditorSubsystem=%s"), AssetEditorSubsystem ? TEXT("valid") : TEXT("nullptr")); - if (AssetEditorSubsystem) + FAssetData AssetData = AssetRegistry.GetAssetByObjectPath(FSoftObjectPath(Candidate)); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] GetAssetByObjectPath('%s') valid=%d"), + *Candidate, AssetData.IsValid() ? 1 : 0); + if (AssetData.IsValid()) { - const bool bOpened = AssetEditorSubsystem->OpenEditorForAsset(LoadedAsset); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] OpenEditorForAsset returned %d"), bOpened ? 1 : 0); + LoadedAsset = AssetData.GetAsset(); + if (LoadedAsset) break; } - return; } } - UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] Asset link target not found: '%s'"), *ObjectPath); + // 3) Direct load as a last resort (forces on-disk packages to load even when + // the registry has not indexed them yet). + if (!LoadedAsset) + { + LoadedAsset = StaticLoadObject(UObject::StaticClass(), nullptr, *FullObjectPath); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] StaticLoadObject('%s') -> %s"), + *FullObjectPath, LoadedAsset ? *LoadedAsset->GetPathName() : TEXT("nullptr")); + } + + if (!LoadedAsset) + { + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] Asset link target not found: '%s'"), *ObjectPath); + return; + } + + // Blueprint assets need their generated class opened if the asset itself is a UBlueprint. + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] opening asset of class %s"), *LoadedAsset->GetClass()->GetName()); + + UAssetEditorSubsystem* AssetEditorSubsystem = GEditor ? GEditor->GetEditorSubsystem() : nullptr; + if (AssetEditorSubsystem) + { + const bool bOpened = AssetEditorSubsystem->OpenEditorForAsset(LoadedAsset); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] OpenEditorForAsset returned %d"), bOpened ? 1 : 0); + } + else + { + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] AssetEditorSubsystem unavailable")); + } } void FMarkdownAssetEditorToolkit::OpenLinkedClass(const FString& ClassName) From 70d67e385c9bee07fee94ed137bef5fb14fc1bbc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 13:56:49 +0000 Subject: [PATCH 07/21] Load assets via registry's canonical path and package fallback The earlier log showed FAssetData::IsValid() returning true for the Blueprint target while GetAsset() still returned nullptr, so the lookup silently fell through. Switch to FAssetData::GetSoftObjectPath().TryLoad() so we use the canonical object path stored in the registry and force a synchronous load, and add logging for AssetName/PackageName/ClassPath so we can see which asset the registry actually matched. Also add a LoadPackage + ForEachObjectWithPackage tier after StaticLoadObject: when the leaf name inside a package does not match the file name we can still recover the first real asset contained in the package. https://claude.ai/code/session_01TNergLaAd33WFGjABwPLpE --- .../Private/MarkdownAssetEditorToolkit.cpp | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp index 6553fc6..33fab20 100644 --- a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp +++ b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp @@ -24,6 +24,9 @@ #include "Engine/Blueprint.h" #include "UObject/UObjectGlobals.h" #include "UObject/SoftObjectPath.h" +#include "UObject/Package.h" +#include "UObject/MetaData.h" +#include "Misc/PackageName.h" #define LOCTEXT_NAMESPACE "MarkdownAssetEditor" @@ -853,7 +856,9 @@ void FMarkdownAssetEditorToolkit::OpenLinkedUnrealAsset(const FString& ObjectPat LoadedAsset ? *LoadedAsset->GetPathName() : TEXT("nullptr")); } - // 2) SoftObjectPath query (legacy fallback) for both raw and dotted forms. + // 2) AssetRegistry lookup — use the canonical path stored in FAssetData and load + // it via FSoftObjectPath::TryLoad (GetAsset() was observed to return nullptr + // in some UE5 builds even when IsValid() reports true). if (!LoadedAsset) { for (const FString& Candidate : { ObjectPath, FullObjectPath }) @@ -861,16 +866,28 @@ void FMarkdownAssetEditorToolkit::OpenLinkedUnrealAsset(const FString& ObjectPat FAssetData AssetData = AssetRegistry.GetAssetByObjectPath(FSoftObjectPath(Candidate)); UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] GetAssetByObjectPath('%s') valid=%d"), *Candidate, AssetData.IsValid() ? 1 : 0); - if (AssetData.IsValid()) + if (!AssetData.IsValid()) { - LoadedAsset = AssetData.GetAsset(); - if (LoadedAsset) break; + continue; } + + const FSoftObjectPath Resolved = AssetData.GetSoftObjectPath(); + UE_LOG(LogMarkdownAssetEditor, Display, + TEXT("[LinkDebug] AssetData: AssetName='%s' PackageName='%s' ClassPath='%s' Resolved='%s'"), + *AssetData.AssetName.ToString(), + *AssetData.PackageName.ToString(), + *AssetData.AssetClassPath.ToString(), + *Resolved.ToString()); + + LoadedAsset = Resolved.TryLoad(); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] Resolved.TryLoad() -> %s"), + LoadedAsset ? *LoadedAsset->GetPathName() : TEXT("nullptr")); + if (LoadedAsset) break; } } - // 3) Direct load as a last resort (forces on-disk packages to load even when - // the registry has not indexed them yet). + // 3) StaticLoadObject — forces on-disk packages to load even when the + // registry has not indexed them yet. if (!LoadedAsset) { LoadedAsset = StaticLoadObject(UObject::StaticClass(), nullptr, *FullObjectPath); @@ -878,6 +895,29 @@ void FMarkdownAssetEditorToolkit::OpenLinkedUnrealAsset(const FString& ObjectPat *FullObjectPath, LoadedAsset ? *LoadedAsset->GetPathName() : TEXT("nullptr")); } + // 4) LoadPackage as a final fallback — finds the first real asset in the + // package even when the object name inside differs from the package leaf. + if (!LoadedAsset && FPackageName::DoesPackageExist(PackagePath)) + { + UPackage* LoadedPackage = LoadPackage(nullptr, *PackagePath, LOAD_None); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] LoadPackage('%s') -> %s"), + *PackagePath, LoadedPackage ? *LoadedPackage->GetName() : TEXT("nullptr")); + if (LoadedPackage) + { + ForEachObjectWithPackage(LoadedPackage, [&LoadedAsset](UObject* Obj) + { + if (Obj && Obj->IsAsset() && !Obj->IsA()) + { + LoadedAsset = Obj; + return false; + } + return true; + }, false); + UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] ForEachObjectWithPackage -> %s"), + LoadedAsset ? *LoadedAsset->GetPathName() : TEXT("nullptr")); + } + } + if (!LoadedAsset) { UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] Asset link target not found: '%s'"), *ObjectPath); From 1f72a4bb0c63745c16dbf6232bbb4fe3808addd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 00:39:31 +0000 Subject: [PATCH 08/21] Release v1.2.0 with asset and class linking - Promote the Unreleased changelog entry to [1.2.0] - 2026-04-18 - Bump MarkdownAsset.uplugin to VersionName 1.2.0 (Version 3) - Document the new linking syntax with a dedicated table in both README.md and README.ja.md (mdasset://, ueasset://, class://, https://) - Drop the temporary [LinkDebug] diagnostic logs now that the feature is verified; keep Warning-level logs for genuine lookup failures https://claude.ai/code/session_01TNergLaAd33WFGjABwPLpE --- CHANGELOG.md | 4 +- Plugins/MarkdownAsset/MarkdownAsset.uplugin | 4 +- .../Private/MarkdownAssetEditorToolkit.cpp | 141 ++++-------------- README.ja.md | 15 ++ README.md | 15 ++ 5 files changed, 65 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce6974e..0d89fe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.2.0] - 2026-04-18 + ### Added - **Asset & Class Links** — Standard Markdown links whose target begins with a UE package root (`/Game/`, `/Engine/`, `/Plugins/`, `/Script/`) are now opened in the corresponding asset editor from the HTML preview -- **Class Link Scheme** — `[Label](class://ClassName)` resolves the target via UClass lookup; native C++ classes open in the IDE via `FSourceCodeNavigation::NavigateToClass`, Blueprint classes open in the Blueprint editor +- **Class Link Scheme** — `[Label](class://ClassName)` resolves the target via UClass lookup; native C++ classes open in the IDE via `FSourceCodeNavigation` (preferring the `.cpp` file, falling back to the header), Blueprint classes open in the Blueprint editor - **Broken Link Styling for New Schemes** — `ueasset://` and `class://` targets that cannot be resolved are highlighted in red in the preview, matching existing wikilink behavior ## [1.1.0] - 2026-04-04 diff --git a/Plugins/MarkdownAsset/MarkdownAsset.uplugin b/Plugins/MarkdownAsset/MarkdownAsset.uplugin index fcf7350..8fc4da9 100644 --- a/Plugins/MarkdownAsset/MarkdownAsset.uplugin +++ b/Plugins/MarkdownAsset/MarkdownAsset.uplugin @@ -1,7 +1,7 @@ { "FileVersion": 3, - "Version": 2, - "VersionName": "1.1.0", + "Version": 3, + "VersionName": "1.2.0", "FriendlyName": "MarkdownAsset", "Description": "A custom Markdown asset type with a split-pane live preview editor. Supports GFM (tables, task lists, strikethrough). Import/export .md files directly from Content Browser.", "Category": "Editor", diff --git a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp index 33fab20..4cebdcc 100644 --- a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp +++ b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp @@ -419,20 +419,6 @@ void FMarkdownAssetEditorToolkit::UpdatePreview() FString ParsedHtml = MarkdownAsset->GetParsedHTML(); ParsedHtml = MarkBrokenLinks(ParsedHtml); - // [LinkDebug] Log every in the rendered HTML so we can see - // exactly what the web browser receives for each link. - { - const FRegexPattern HrefPattern(TEXT("]*href=\"([^\"]*)\"")); - FRegexMatcher HrefMatcher(HrefPattern, ParsedHtml); - int32 HrefIndex = 0; - while (HrefMatcher.FindNext()) - { - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] rendered href[%d] = '%s'"), - HrefIndex++, *HrefMatcher.GetCaptureGroup(1)); - } - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] Preview refresh: %d link(s) emitted"), HrefIndex); - } - FString StyledHtml = GenerateStyledHtml(ParsedHtml); // Explicitly convert FString (UTF-16) to UTF-8 bytes before Base64 encoding @@ -742,14 +728,11 @@ bool FMarkdownAssetEditorToolkit::HandleBeforeNavigation(const FString& Url, con return false; } - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] HandleBeforeNavigation: Url='%s' (len=%d)"), *Url, Url.Len()); - // Handle mdasset:// scheme for wikilinks static const FString MdAssetScheme = TEXT("mdasset://"); if (Url.StartsWith(MdAssetScheme)) { FString AssetName = PercentDecode(Url.Mid(MdAssetScheme.Len())); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] Matched mdasset:// -> AssetName='%s'"), *AssetName); OpenLinkedMarkdownAsset(AssetName); return true; } @@ -759,7 +742,6 @@ bool FMarkdownAssetEditorToolkit::HandleBeforeNavigation(const FString& Url, con if (Url.StartsWith(UEAssetScheme)) { FString ObjectPath = PercentDecode(Url.Mid(UEAssetScheme.Len())); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] Matched ueasset:// -> ObjectPath='%s'"), *ObjectPath); OpenLinkedUnrealAsset(ObjectPath); return true; } @@ -769,7 +751,6 @@ bool FMarkdownAssetEditorToolkit::HandleBeforeNavigation(const FString& Url, con if (Url.StartsWith(ClassScheme)) { FString ClassName = PercentDecode(Url.Mid(ClassScheme.Len())); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] Matched class:// -> ClassName='%s'"), *ClassName); OpenLinkedClass(ClassName); return true; } @@ -777,12 +758,10 @@ bool FMarkdownAssetEditorToolkit::HandleBeforeNavigation(const FString& Url, con // Open external URLs in the system browser if (Url.StartsWith(TEXT("http://")) || Url.StartsWith(TEXT("https://"))) { - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] External URL -> launching in system browser")); FPlatformProcess::LaunchURL(*Url, nullptr, nullptr); return true; } - UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] No scheme matched; navigation will be denied (returning false)")); return false; } @@ -815,11 +794,8 @@ void FMarkdownAssetEditorToolkit::OpenLinkedMarkdownAsset(const FString& AssetNa void FMarkdownAssetEditorToolkit::OpenLinkedUnrealAsset(const FString& ObjectPath) { - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] OpenLinkedUnrealAsset: ObjectPath='%s'"), *ObjectPath); - if (ObjectPath.IsEmpty()) { - UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] OpenLinkedUnrealAsset: empty path, aborting")); return; } @@ -841,68 +817,44 @@ void FMarkdownAssetEditorToolkit::OpenLinkedUnrealAsset(const FString& ObjectPat LeafName = PackagePath.Mid(SlashIndex + 1); } const FString FullObjectPath = LeafName.IsEmpty() ? ObjectPath : FString::Printf(TEXT("%s.%s"), *PackagePath, *LeafName); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] package='%s' full='%s'"), *PackagePath, *FullObjectPath); UObject* LoadedAsset = nullptr; - // 1) Package-name query — most reliable for standard Content Browser paths. + // Package-name query hits the registry's primary index and is the most reliable + // path for standard Content Browser assets. TArray PackageAssets; AssetRegistry.GetAssetsByPackageName(FName(*PackagePath), PackageAssets); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] GetAssetsByPackageName returned %d"), PackageAssets.Num()); if (PackageAssets.Num() > 0) { LoadedAsset = PackageAssets[0].GetAsset(); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] package[0].GetAsset -> %s"), - LoadedAsset ? *LoadedAsset->GetPathName() : TEXT("nullptr")); } - // 2) AssetRegistry lookup — use the canonical path stored in FAssetData and load - // it via FSoftObjectPath::TryLoad (GetAsset() was observed to return nullptr - // in some UE5 builds even when IsValid() reports true). + // GetAsset() has been observed to return nullptr on some UE5 builds despite + // IsValid() reporting true. Fall back to loading the canonical path directly. if (!LoadedAsset) { for (const FString& Candidate : { ObjectPath, FullObjectPath }) { FAssetData AssetData = AssetRegistry.GetAssetByObjectPath(FSoftObjectPath(Candidate)); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] GetAssetByObjectPath('%s') valid=%d"), - *Candidate, AssetData.IsValid() ? 1 : 0); - if (!AssetData.IsValid()) + if (AssetData.IsValid()) { - continue; + LoadedAsset = AssetData.GetSoftObjectPath().TryLoad(); + if (LoadedAsset) break; } - - const FSoftObjectPath Resolved = AssetData.GetSoftObjectPath(); - UE_LOG(LogMarkdownAssetEditor, Display, - TEXT("[LinkDebug] AssetData: AssetName='%s' PackageName='%s' ClassPath='%s' Resolved='%s'"), - *AssetData.AssetName.ToString(), - *AssetData.PackageName.ToString(), - *AssetData.AssetClassPath.ToString(), - *Resolved.ToString()); - - LoadedAsset = Resolved.TryLoad(); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] Resolved.TryLoad() -> %s"), - LoadedAsset ? *LoadedAsset->GetPathName() : TEXT("nullptr")); - if (LoadedAsset) break; } } - // 3) StaticLoadObject — forces on-disk packages to load even when the - // registry has not indexed them yet. + // StaticLoadObject picks up packages that have not been indexed yet. if (!LoadedAsset) { LoadedAsset = StaticLoadObject(UObject::StaticClass(), nullptr, *FullObjectPath); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] StaticLoadObject('%s') -> %s"), - *FullObjectPath, LoadedAsset ? *LoadedAsset->GetPathName() : TEXT("nullptr")); } - // 4) LoadPackage as a final fallback — finds the first real asset in the - // package even when the object name inside differs from the package leaf. + // Last-resort package load so we can locate the first asset even when the + // object name inside the package differs from the package leaf. if (!LoadedAsset && FPackageName::DoesPackageExist(PackagePath)) { - UPackage* LoadedPackage = LoadPackage(nullptr, *PackagePath, LOAD_None); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] LoadPackage('%s') -> %s"), - *PackagePath, LoadedPackage ? *LoadedPackage->GetName() : TEXT("nullptr")); - if (LoadedPackage) + if (UPackage* LoadedPackage = LoadPackage(nullptr, *PackagePath, LOAD_None)) { ForEachObjectWithPackage(LoadedPackage, [&LoadedAsset](UObject* Obj) { @@ -913,39 +865,25 @@ void FMarkdownAssetEditorToolkit::OpenLinkedUnrealAsset(const FString& ObjectPat } return true; }, false); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] ForEachObjectWithPackage -> %s"), - LoadedAsset ? *LoadedAsset->GetPathName() : TEXT("nullptr")); } } if (!LoadedAsset) { - UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] Asset link target not found: '%s'"), *ObjectPath); + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("Asset link target not found: '%s'"), *ObjectPath); return; } - // Blueprint assets need their generated class opened if the asset itself is a UBlueprint. - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] opening asset of class %s"), *LoadedAsset->GetClass()->GetName()); - - UAssetEditorSubsystem* AssetEditorSubsystem = GEditor ? GEditor->GetEditorSubsystem() : nullptr; - if (AssetEditorSubsystem) + if (UAssetEditorSubsystem* AssetEditorSubsystem = GEditor ? GEditor->GetEditorSubsystem() : nullptr) { - const bool bOpened = AssetEditorSubsystem->OpenEditorForAsset(LoadedAsset); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] OpenEditorForAsset returned %d"), bOpened ? 1 : 0); - } - else - { - UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] AssetEditorSubsystem unavailable")); + AssetEditorSubsystem->OpenEditorForAsset(LoadedAsset); } } void FMarkdownAssetEditorToolkit::OpenLinkedClass(const FString& ClassName) { - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] OpenLinkedClass: ClassName='%s'"), *ClassName); - if (ClassName.IsEmpty()) { - UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] OpenLinkedClass: empty name, aborting")); return; } @@ -953,36 +891,24 @@ void FMarkdownAssetEditorToolkit::OpenLinkedClass(const FString& ClassName) for (const FString& Candidate : BuildClassNameCandidates(ClassName)) { UClass* FoundClass = FindFirstObject(*Candidate, EFindFirstObjectOptions::NativeFirst); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] native candidate='%s' -> %s"), - *Candidate, FoundClass ? *FoundClass->GetPathName() : TEXT("(null)")); - - if (FoundClass) + if (FoundClass && FoundClass->HasAnyClassFlags(CLASS_Native)) { - if (FoundClass->HasAnyClassFlags(CLASS_Native)) + // Prefer the implementation file (.cpp); fall back to the header when no + // .cpp is available (header-only classes, interfaces, etc.). + FString SourcePath; + if (FSourceCodeNavigation::FindClassSourcePath(FoundClass, SourcePath) && !SourcePath.IsEmpty()) { - // Prefer the implementation file (.cpp); fall back to the header when no - // .cpp is available (header-only classes, interfaces, etc.). - FString SourcePath; - const bool bFoundSource = FSourceCodeNavigation::FindClassSourcePath(FoundClass, SourcePath); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] FindClassSourcePath bFound=%d path='%s'"), bFoundSource ? 1 : 0, *SourcePath); - if (bFoundSource && !SourcePath.IsEmpty()) + if (FSourceCodeNavigation::OpenSourceFile(SourcePath)) { - const bool bOpened = FSourceCodeNavigation::OpenSourceFile(SourcePath); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] OpenSourceFile returned %d"), bOpened ? 1 : 0); - if (bOpened) - { - return; - } + return; } + } - const bool bNavigated = FSourceCodeNavigation::NavigateToClass(FoundClass); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] NavigateToClass returned %d"), bNavigated ? 1 : 0); - if (!bNavigated) - { - UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] Failed to open source for native class '%s'"), *FoundClass->GetName()); - } - return; + if (!FSourceCodeNavigation::NavigateToClass(FoundClass)) + { + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("Failed to open source for native class '%s'"), *FoundClass->GetName()); } + return; } } @@ -992,14 +918,12 @@ void FMarkdownAssetEditorToolkit::OpenLinkedClass(const FString& ClassName) { BlueprintAssetName.LeftChopInline(2); } - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] blueprint asset name='%s'"), *BlueprintAssetName); FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); TArray BlueprintAssets; AssetRegistry.GetAssetsByClass(UBlueprint::StaticClass()->GetClassPathName(), BlueprintAssets); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] registry reports %d Blueprint asset(s)"), BlueprintAssets.Num()); const FAssetData* MatchedBlueprint = BlueprintAssets.FindByPredicate( [&BlueprintAssetName](const FAssetData& Asset) @@ -1009,22 +933,17 @@ void FMarkdownAssetEditorToolkit::OpenLinkedClass(const FString& ClassName) if (MatchedBlueprint) { - UObject* LoadedBlueprint = MatchedBlueprint->GetAsset(); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] matched BP asset -> %s"), - LoadedBlueprint ? *LoadedBlueprint->GetPathName() : TEXT("nullptr")); - if (LoadedBlueprint) + if (UObject* LoadedBlueprint = MatchedBlueprint->GetAsset()) { - UAssetEditorSubsystem* AssetEditorSubsystem = GEditor ? GEditor->GetEditorSubsystem() : nullptr; - if (AssetEditorSubsystem) + if (UAssetEditorSubsystem* AssetEditorSubsystem = GEditor ? GEditor->GetEditorSubsystem() : nullptr) { - const bool bOpened = AssetEditorSubsystem->OpenEditorForAsset(LoadedBlueprint); - UE_LOG(LogMarkdownAssetEditor, Display, TEXT("[LinkDebug] OpenEditorForAsset returned %d"), bOpened ? 1 : 0); + AssetEditorSubsystem->OpenEditorForAsset(LoadedBlueprint); } return; } } - UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("[LinkDebug] Class link target not found: '%s'"), *ClassName); + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("Class link target not found: '%s'"), *ClassName); } #undef LOCTEXT_NAMESPACE diff --git a/README.ja.md b/README.ja.md index 77eada7..b9ae592 100644 --- a/README.ja.md +++ b/README.ja.md @@ -82,6 +82,21 @@ - **リインポート**: インポートしたアセットを右クリックし、**Reimport** を選択すると元のソースファイルから再読み込みできます。 - **エクスポート**: Markdownアセットを右クリックし、**Asset Actions > Export** を選択すると `.md` ファイルとして保存できます。 +### リンク構文 + +Markdownアセットは他のMarkdownノート、Content Browserアセット、Blueprint、C++クラスへクロスリンクできます。ライブプレビューでリンクをクリックすると対象が開きます: + +| 構文 | 対象 | 開かれるもの | +|------|------|-------------| +| `[[ノート名]]` | 他の `UMarkdownAsset` | Markdownエディタタブ | +| `[ラベル](/Game/Path/To/Asset)` | Content Browserの任意のアセット | アセットエディタ | +| `[ラベル](/Engine/BasicShapes/Cube)` | Engine同梱アセット | アセットエディタ | +| `[ラベル](class://クラス名)` | ネイティブC++クラス | IDEのソースファイル(`.cpp` 優先、`.h` フォールバック) | +| `[ラベル](class://BP_MyActor)` | Blueprintクラス | Blueprintエディタ | +| `[ラベル](https://...)` | 外部URL | システムブラウザ | + +Unrealアセットリンクとして認識されるパスルート: `/Game/`, `/Engine/`, `/Plugins/`, `/Script/`。クラス名はUHTのリフレクション名と照合されるため、プレフィックス付き (`AActor`) と除去済み (`Actor`) の両形式が正しく解決されます。解決できない対象は赤色で表示されます。 + ### Blueprint ノード `UMarkdownAsset` は以下の Blueprint から呼び出し可能な関数を公開しています: diff --git a/README.md b/README.md index fcbe296..37b571d 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,21 @@ An Unreal Engine 5.5+ plugin that adds a custom Markdown asset type with a live- - **Reimport**: Right-click an imported asset and select **Reimport** to reload from the original source file. - **Export**: Right-click a Markdown asset and select **Asset Actions > Export** to save it as a `.md` file. +### Linking Syntax + +Markdown assets can cross-link to other Markdown notes, Content Browser assets, Blueprints, and C++ classes. Clicking any of the links below in the live preview jumps to the target: + +| Syntax | Target | Opens | +|--------|--------|-------| +| `[[NoteName]]` | Another `UMarkdownAsset` | Markdown editor tab | +| `[Label](/Game/Path/To/Asset)` | Any Content Browser asset | Asset editor | +| `[Label](/Engine/BasicShapes/Cube)` | Engine-bundled asset | Asset editor | +| `[Label](class://ClassName)` | Native C++ class | IDE source file (`.cpp` preferred, `.h` fallback) | +| `[Label](class://BP_MyActor)` | Blueprint class | Blueprint editor | +| `[Label](https://...)` | External URL | System browser | + +Path roots recognised as Unreal asset links: `/Game/`, `/Engine/`, `/Plugins/`, `/Script/`. Class names are matched against UHT reflection names, so both prefixed forms (`AActor`) and stripped forms (`Actor`) resolve correctly. Targets that cannot be resolved are rendered in red. + ### Blueprint Nodes `UMarkdownAsset` exposes the following Blueprint-callable functions: From 72397b1ccae24963f0bff94f26383e5b7a40ee65 Mon Sep 17 00:00:00 2001 From: EmbarrassingMoment <96903403+EmbarrassingMoment@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:44:40 +0900 Subject: [PATCH 09/21] Add new samples for v1.2.0 --- Content/BP_LinkTest.uasset | Bin 0 -> 23414 bytes Content/MD_LinkTest.uasset | Bin 0 -> 4056 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 Content/BP_LinkTest.uasset create mode 100644 Content/MD_LinkTest.uasset diff --git a/Content/BP_LinkTest.uasset b/Content/BP_LinkTest.uasset new file mode 100644 index 0000000000000000000000000000000000000000..d4f519b1a3003ded350dad64c132213bb92c9e12 GIT binary patch literal 23414 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 KF;gTS(f Date: Sun, 19 Apr 2026 10:10:21 +0000 Subject: [PATCH 10/21] Guard UMetaData filter for UE 5.6+ build UE 5.6 removed UMetaData (UObject) in favor of FMetaData (struct), so the IsA() filter no longer compiles. Wrap the include and check with UE_VERSION_OLDER_THAN(5, 6, 0) to keep 5.5 support. https://claude.ai/code/session_01844YrCL6CQoEydz2kt476F --- .../Private/MarkdownAssetEditorToolkit.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp index 4cebdcc..3b36da6 100644 --- a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp +++ b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp @@ -25,7 +25,10 @@ #include "UObject/UObjectGlobals.h" #include "UObject/SoftObjectPath.h" #include "UObject/Package.h" +#include "Misc/EngineVersionComparison.h" +#if UE_VERSION_OLDER_THAN(5, 6, 0) #include "UObject/MetaData.h" +#endif #include "Misc/PackageName.h" #define LOCTEXT_NAMESPACE "MarkdownAssetEditor" @@ -858,7 +861,11 @@ void FMarkdownAssetEditorToolkit::OpenLinkedUnrealAsset(const FString& ObjectPat { ForEachObjectWithPackage(LoadedPackage, [&LoadedAsset](UObject* Obj) { - if (Obj && Obj->IsAsset() && !Obj->IsA()) + if (Obj && Obj->IsAsset() +#if UE_VERSION_OLDER_THAN(5, 6, 0) + && !Obj->IsA() +#endif + ) { LoadedAsset = Obj; return false; From 888e517939ab2ae900dc39a185f574e6d6cab5b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 11:14:11 +0000 Subject: [PATCH 11/21] Block unknown URL schemes in preview navigation Default-deny any scheme that is not explicitly recognized (data:, about:, mdasset://, ueasset://, class://, http(s)://). md4c-html does not sanitize URL schemes, so a crafted link such as [click](javascript:...) would previously execute script in the preview WebBrowser when clicked, allowing exfiltration of rendered content via fetch() or SSRF against localhost services. --- .../Private/MarkdownAssetEditorToolkit.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp index 3b36da6..1912abf 100644 --- a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp +++ b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp @@ -725,8 +725,8 @@ TSharedRef FMarkdownAssetEditorToolkit::SpawnTab_Main(const FSpawnTabA bool FMarkdownAssetEditorToolkit::HandleBeforeNavigation(const FString& Url, const FWebNavigationRequest& Request) { - // Allow data: URLs for preview loading - if (Url.StartsWith(TEXT("data:"))) + // Allow data: URLs for preview loading, and about:blank used by CEF during init. + if (Url.StartsWith(TEXT("data:")) || Url.StartsWith(TEXT("about:"))) { return false; } @@ -765,7 +765,10 @@ bool FMarkdownAssetEditorToolkit::HandleBeforeNavigation(const FString& Url, con return true; } - return false; + // Default-deny: block unknown schemes (javascript:, file:, vbscript:, etc.) + // to prevent script execution or local-file access from untrusted Markdown. + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("Blocked navigation to unsupported URL: '%s'"), *Url); + return true; } void FMarkdownAssetEditorToolkit::OpenLinkedMarkdownAsset(const FString& AssetName) From b0a994c3e2677eef0f673ddfe97ec35b2400486e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 11:16:32 +0000 Subject: [PATCH 12/21] Update Security section in README to document URL scheme allowlist Document that the preview browser's navigation handler now uses a strict allowlist and blocks unknown schemes such as javascript: and file: to prevent script execution from untrusted Markdown content. --- README.ja.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.ja.md b/README.ja.md index b9ae592..9e0592b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -25,7 +25,7 @@ - **Blueprint サポート** — Blueprint から `RawMarkdownText` の読み書きと `GetParsedHTML()`、`GetRawMarkdownText()`、`GetPlainText()` の呼び出しが可能です。 - **ツールバーとキーボードショートカット** — 一般的なMarkdown操作のためのキーボードショートカットを備えた組み込みのフォーマットツールバーを用意しています。 - **元に戻す / やり直し** — Unreal Editorのトランザクションシステムと統合された完全なUndo/Redoサポート(Ctrl+Z / Ctrl+Y) -- **セキュリティ** — ユーザー提供のMarkdownレンダリング時のXSSを防止するため、生のHTMLブロックおよびインラインHTMLはデフォルトで無効化しています。 +- **セキュリティ** — ユーザー提供のMarkdownレンダリング時のXSSを防止するため、生のHTMLブロックおよびインラインHTMLはデフォルトで無効化しています。また、プレビューブラウザのナビゲーションはホワイトリスト方式を採用しており、`data:`・`about:`・`mdasset://`・`ueasset://`・`class://`・`http(s)://` のみを許可し、`javascript:` や `file:` などの未知スキームをブロックすることで、信頼できないMarkdownによるスクリプト実行やローカルファイルアクセスを防いでいます。 - **ローカライズ** — エディタUIは英語と日本語に対応しています。 - **ニバイト文字対応** — 日本語などのニバイト文字を含むMarkdownテキストを正しく処理・表示できます。 diff --git a/README.md b/README.md index 37b571d..8ea9686 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ An Unreal Engine 5.5+ plugin that adds a custom Markdown asset type with a live- - **Blueprint Support** — Read/write `RawMarkdownText` and call `GetParsedHTML()`, `GetRawMarkdownText()`, and `GetPlainText()` from Blueprints - **Toolbar & Keyboard Shortcuts** — Built-in formatting toolbar with keyboard shortcuts for common Markdown operations - **Undo / Redo** — Full undo/redo support integrated with the Unreal Editor transaction system (Ctrl+Z / Ctrl+Y) -- **Security** — Raw HTML blocks and inline HTML are disabled by default to prevent XSS when rendering user-supplied Markdown +- **Security** — Raw HTML blocks and inline HTML are disabled by default to prevent XSS when rendering user-supplied Markdown. The preview browser's navigation handler uses a strict allowlist — only `data:`, `about:`, `mdasset://`, `ueasset://`, `class://`, and `http(s)://` are permitted; unknown schemes such as `javascript:` and `file:` are blocked to prevent script execution or local file access from untrusted Markdown content - **Localization** — Editor UI is fully localized for English and Japanese ![Dark Theme Preview](docs/images/dark-theme-preview.png) From 32efe3cf7263deb68d4fb07d69ec66b20f688ad8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 11:18:18 +0000 Subject: [PATCH 13/21] Add Content-Security-Policy to preview HTML to block external requests Injects a CSP meta tag that allows only inline styles and data: URIs for images, blocking all other network requests (scripts, external images, fetch/XHR, frames). This prevents SSRF against localhost services and IP/UA tracking via image pixels embedded in untrusted Markdown assets. --- .../MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp index 1912abf..8ddaba7 100644 --- a/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp +++ b/Plugins/MarkdownAsset/Source/MarkdownAssetEditor/Private/MarkdownAssetEditorToolkit.cpp @@ -76,6 +76,8 @@ static FString GenerateStyledHtml(const FString& ParsedHtml) "\n" "\n" "\n" + "\n" "