diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e7e1d..d38b3ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ 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] + +## [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` (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 + +### Security + +- **URL Scheme Allowlist** — The preview's `OnBeforeNavigation` handler now default-denies any scheme that is not explicitly supported (`data:`, `about:`, `mdasset://`, `ueasset://`, `class://`, `http(s)://`); unknown schemes such as `javascript:` and `file:` are blocked to prevent script execution or local file access from untrusted Markdown content +- **Content Security Policy** — Preview HTML now includes a `Content-Security-Policy` meta tag (`default-src 'none'; style-src 'unsafe-inline'; img-src data:`) that blocks all external network requests (remote images, fetch/XHR, frames, fonts), mitigating IP tracking and SSRF against local services from crafted Markdown assets +- **External URL Confirmation Dialog** — Clicking an `http(s)://` link in the preview now presents a localized confirmation dialog showing the full URL before launching the system browser, mitigating phishing via the address-bar-less preview + +### Fixed + +- **Localization Gather Path** — Corrected `SearchDirectoryPaths` in `Config/Localization/MarkdownEditor_Gather.ini` which pointed to a non-existent directory (`Plugins/MarkdownEditor/Source`) left over from an earlier plugin rename; now targets `Plugins/MarkdownAsset/Source/MarkdownAssetEditor` so `LOCTEXT` / `NSLOCTEXT` strings are collected correctly by the Localization Dashboard + ## [1.1.0] - 2026-04-04 ### Added diff --git a/Config/DefaultEditor.ini b/Config/DefaultEditor.ini index 67bca4f..c41b375 100644 --- a/Config/DefaultEditor.ini +++ b/Config/DefaultEditor.ini @@ -14,7 +14,7 @@ +EngineTargetsSettings=(Name="Keywords",Guid=AE89AECB47475F420D0D69A5547515DC,TargetDependencies=(33482D004789784C9DA695A682ACCA1B,AC8BFD2A41A2FB2893BB8EA0AF903E6D),AdditionalManifestDependencies=,RequiredModuleNames=,GatherFromTextFiles=(IsEnabled=False,SearchDirectories=,ExcludePathWildcards=,FileExtensions=((Pattern="h"),(Pattern="cpp"),(Pattern="ini")),ShouldGatherFromEditorOnlyData=False),GatherFromPackages=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=,FileExtensions=((Pattern="umap"),(Pattern="uasset")),Collections=,ExcludeClasses=,ShouldExcludeDerivedClasses=False,ShouldGatherFromEditorOnlyData=True,SkipGatherCache=False),GatherFromMetaData=(IsEnabled=True,IncludePathWildcards=((Pattern="Source/Editor/*"),(Pattern="Source/Runtime/*"),(Pattern="Source/Developer/*")),ExcludePathWildcards=((Pattern="Source/Developer/NoRedist/CommunityPortalServices/*")),KeySpecifications=((MetaDataKey=(Name="Keywords"),TextNamespace="UObjectKeywords",TextKeyPattern=(Pattern="{FieldPath}"))),FieldTypesToInclude=,FieldTypesToExclude=,FieldOwnerTypesToInclude=,FieldOwnerTypesToExclude=,ShouldGatherFromEditorOnlyData=True),ExportSettings=(CollapseMode=IdenticalTextIdAndSource,POFormat=Unreal,ShouldPersistCommentsOnExport=False,ShouldAddSourceLocationsAsComments=True),CompileSettings=(SkipSourceCheck=False,ValidateFormatPatterns=True,ValidateSafeWhitespace=False),ImportDialogueSettings=(RawAudioPath=(Path=""),ImportedDialogueFolder="ImportedDialogue",bImportNativeAsSource=False),NativeCultureIndex=0,SupportedCulturesStatistics=((CultureName="en"),(CultureName="es"),(CultureName="ja"),(CultureName="ko"),(CultureName="pt-BR"),(CultureName="zh-CN"))) +EngineTargetsSettings=(Name="Category",Guid=14B8DEE642A6A7AFEB5A28B959EC373A,TargetDependencies=,AdditionalManifestDependencies=,RequiredModuleNames=,GatherFromTextFiles=(IsEnabled=False,SearchDirectories=,ExcludePathWildcards=,FileExtensions=((Pattern="h"),(Pattern="cpp"),(Pattern="ini")),ShouldGatherFromEditorOnlyData=False),GatherFromPackages=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=,FileExtensions=((Pattern="umap"),(Pattern="uasset")),Collections=,ExcludeClasses=,ShouldExcludeDerivedClasses=False,ShouldGatherFromEditorOnlyData=False,SkipGatherCache=False),GatherFromMetaData=(IsEnabled=True,IncludePathWildcards=((Pattern="Source/Editor/*"),(Pattern="Source/Runtime/*"),(Pattern="Source/Developer/*")),ExcludePathWildcards=((Pattern="Source/Developer/NoRedist/CommunityPortalServices/*")),KeySpecifications=((MetaDataKey=(Name="Category"),TextNamespace="UObjectCategory",TextKeyPattern=(Pattern="{FieldPath}"))),FieldTypesToInclude=,FieldTypesToExclude=,FieldOwnerTypesToInclude=,FieldOwnerTypesToExclude=,ShouldGatherFromEditorOnlyData=True),ExportSettings=(CollapseMode=IdenticalTextIdAndSource,POFormat=Unreal,ShouldPersistCommentsOnExport=False,ShouldAddSourceLocationsAsComments=True),CompileSettings=(SkipSourceCheck=False,ValidateFormatPatterns=True,ValidateSafeWhitespace=False),ImportDialogueSettings=(RawAudioPath=(Path=""),ImportedDialogueFolder="ImportedDialogue",bImportNativeAsSource=False),NativeCultureIndex=0,SupportedCulturesStatistics=((CultureName="en"),(CultureName="es"),(CultureName="ja"),(CultureName="ko"),(CultureName="pt-BR"),(CultureName="zh-CN"))) -GameTargetsSettings=(Name="Game",Guid=AE0EA34A45461A25BA65A391026F19F8,TargetDependencies=(33482D004789784C9DA695A682ACCA1B,AC8BFD2A41A2FB2893BB8EA0AF903E6D),AdditionalManifestDependencies=,RequiredModuleNames=,GatherFromTextFiles=(IsEnabled=False,SearchDirectories=,ExcludePathWildcards=,FileExtensions=((Pattern="h"),(Pattern="cpp"),(Pattern="ini"))),GatherFromPackages=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=,FileExtensions=((Pattern="umap"),(Pattern="uasset")),ShouldGatherFromEditorOnlyData=False),GatherFromMetaData=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=,KeySpecifications=,ShouldGatherFromEditorOnlyData=False),NativeCultureIndex=-1,SupportedCulturesStatistics=((CultureName="en"))) -+GameTargetsSettings=(Name="MarkdownEditor",Guid=6E9E53F641319F56D59BD1A79AF36E6A,TargetDependencies=(AC8BFD2A41A2FB2893BB8EA0AF903E6D,33482D004789784C9DA695A682ACCA1B),AdditionalManifestDependencies=,RequiredModuleNames=,GatherFromTextFiles=(IsEnabled=True,SearchDirectories=((Path="Plugins/MarkdownEditor/Source")),ExcludePathWildcards=,FileExtensions=((Pattern="h"),(Pattern="cpp"),(Pattern="ini")),ShouldGatherFromEditorOnlyData=False),GatherFromPackages=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=((Pattern="Content/L10N/*")),FileExtensions=((Pattern="umap"),(Pattern="uasset")),Collections=,ExcludeClasses=,ShouldExcludeDerivedClasses=False,ShouldGatherFromEditorOnlyData=False,SkipGatherCache=False),GatherFromMetaData=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=,KeySpecifications=,FieldTypesToInclude=,FieldTypesToExclude=,FieldOwnerTypesToInclude=,FieldOwnerTypesToExclude=,ShouldGatherFromEditorOnlyData=False),ExportSettings=(CollapseMode=IdenticalTextIdAndSource,POFormat=Unreal,ShouldPersistCommentsOnExport=False,ShouldAddSourceLocationsAsComments=True),CompileSettings=(SkipSourceCheck=False,ValidateFormatPatterns=True,ValidateSafeWhitespace=False),ImportDialogueSettings=(RawAudioPath=(Path=""),ImportedDialogueFolder="ImportedDialogue",bImportNativeAsSource=False),NativeCultureIndex=0,SupportedCulturesStatistics=((CultureName="en"),(CultureName="ja"))) ++GameTargetsSettings=(Name="MarkdownEditor",Guid=6E9E53F641319F56D59BD1A79AF36E6A,TargetDependencies=(AC8BFD2A41A2FB2893BB8EA0AF903E6D,33482D004789784C9DA695A682ACCA1B),AdditionalManifestDependencies=,RequiredModuleNames=,GatherFromTextFiles=(IsEnabled=True,SearchDirectories=((Path="Plugins/MarkdownAsset/Source/MarkdownAssetEditor")),ExcludePathWildcards=,FileExtensions=((Pattern="h"),(Pattern="cpp"),(Pattern="ini")),ShouldGatherFromEditorOnlyData=False),GatherFromPackages=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=((Pattern="Content/L10N/*")),FileExtensions=((Pattern="umap"),(Pattern="uasset")),Collections=,ExcludeClasses=,ShouldExcludeDerivedClasses=False,ShouldGatherFromEditorOnlyData=False,SkipGatherCache=False),GatherFromMetaData=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=,KeySpecifications=,FieldTypesToInclude=,FieldTypesToExclude=,FieldOwnerTypesToInclude=,FieldOwnerTypesToExclude=,ShouldGatherFromEditorOnlyData=False),ExportSettings=(CollapseMode=IdenticalTextIdAndSource,POFormat=Unreal,ShouldPersistCommentsOnExport=False,ShouldAddSourceLocationsAsComments=True),CompileSettings=(SkipSourceCheck=False,ValidateFormatPatterns=True,ValidateSafeWhitespace=False),ImportDialogueSettings=(RawAudioPath=(Path=""),ImportedDialogueFolder="ImportedDialogue",bImportNativeAsSource=False),NativeCultureIndex=0,SupportedCulturesStatistics=((CultureName="en"),(CultureName="ja"))) [Internationalization] +LocalizationPaths=%GAMEDIR%Content/Localization/MarkdownEditor diff --git a/Config/Localization/MarkdownEditor_Gather.ini b/Config/Localization/MarkdownEditor_Gather.ini index fc34dfd..f83ecf9 100644 --- a/Config/Localization/MarkdownEditor_Gather.ini +++ b/Config/Localization/MarkdownEditor_Gather.ini @@ -1,5 +1,4 @@ -; THESE ARE GENERATED FILES, DO NOT EDIT DIRECTLY! -; USE THE LOCALIZATION DASHBOARD IN THE UNREAL EDITOR TO EDIT THE CONFIGURATION +;METADATA=(Diff=true, UseCommands=true) [CommonSettings] ManifestDependencies=../../../../../../Program Files/Epic Games/UE_5.5/Engine/Content/Localization/Editor/Editor.manifest ManifestDependencies=../../../../../../Program Files/Epic Games/UE_5.5/Engine/Content/Localization/Engine/Engine.manifest @@ -13,7 +12,7 @@ CulturesToGenerate=ja [GatherTextStep0] CommandletClass=GatherTextFromSource -SearchDirectoryPaths=Plugins/MarkdownEditor/Source +SearchDirectoryPaths=Plugins/MarkdownAsset/Source/MarkdownAssetEditor ExcludePathFilters=Config/Localization/* FileNameFilters=*.h FileNameFilters=*.cpp diff --git a/Content/BP_LinkTest.uasset b/Content/BP_LinkTest.uasset new file mode 100644 index 0000000..d4f519b Binary files /dev/null and b/Content/BP_LinkTest.uasset differ diff --git a/Content/Localization/MarkdownEditor/MarkdownEditor.csv b/Content/Localization/MarkdownEditor/MarkdownEditor.csv index 09c3423..7497a67 100644 --- a/Content/Localization/MarkdownEditor/MarkdownEditor.csv +++ b/Content/Localization/MarkdownEditor/MarkdownEditor.csv @@ -1,3 +1,7 @@ Date/Time,Word Count,en,ja 2026.03.11-15.05.19,99,99,0 2026.03.11-15.15.14,99,99,99 +2026.04.19-21.11.13,0,0,0 +2026.04.19-21.14.36,111,111,0 +2026.04.19-21.16.48,111,111,99 +2026.04.19-21.24.03,111,111,111 diff --git a/Content/Localization/MarkdownEditor/MarkdownEditor.manifest b/Content/Localization/MarkdownEditor/MarkdownEditor.manifest index f345daf..88ccdf4 100644 Binary files a/Content/Localization/MarkdownEditor/MarkdownEditor.manifest and b/Content/Localization/MarkdownEditor/MarkdownEditor.manifest differ diff --git a/Content/Localization/MarkdownEditor/en/MarkdownEditor.archive b/Content/Localization/MarkdownEditor/en/MarkdownEditor.archive index cbe7bdf..7ac4985 100644 Binary files a/Content/Localization/MarkdownEditor/en/MarkdownEditor.archive and b/Content/Localization/MarkdownEditor/en/MarkdownEditor.archive differ diff --git a/Content/Localization/MarkdownEditor/en/MarkdownEditor.locres b/Content/Localization/MarkdownEditor/en/MarkdownEditor.locres index bf9821c..493ca59 100644 Binary files a/Content/Localization/MarkdownEditor/en/MarkdownEditor.locres and b/Content/Localization/MarkdownEditor/en/MarkdownEditor.locres differ diff --git a/Content/Localization/MarkdownEditor/ja/MarkdownEditor.archive b/Content/Localization/MarkdownEditor/ja/MarkdownEditor.archive index 9738283..f0605f2 100644 Binary files a/Content/Localization/MarkdownEditor/ja/MarkdownEditor.archive and b/Content/Localization/MarkdownEditor/ja/MarkdownEditor.archive differ diff --git a/Content/Localization/MarkdownEditor/ja/MarkdownEditor.locres b/Content/Localization/MarkdownEditor/ja/MarkdownEditor.locres index 0b9dac6..bcffc80 100644 Binary files a/Content/Localization/MarkdownEditor/ja/MarkdownEditor.locres and b/Content/Localization/MarkdownEditor/ja/MarkdownEditor.locres differ diff --git a/Content/MD_LinkTest.uasset b/Content/MD_LinkTest.uasset new file mode 100644 index 0000000..20193f7 Binary files /dev/null and b/Content/MD_LinkTest.uasset differ 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/MarkdownAsset/Private/MarkdownAsset.cpp b/Plugins/MarkdownAsset/Source/MarkdownAsset/Private/MarkdownAsset.cpp index f32ee21..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 @@ -72,6 +102,74 @@ 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" "\n" "\n" + "\n" "\n%s\n" @@ -89,26 +106,134 @@ static FString GenerateStyledHtml(const FString& ParsedHtml) } /** - * Checks each mdasset:// link against the AssetRegistry and adds the - * "md-broken-link" CSS class to links whose target asset does not exist. + * 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 FString MarkBrokenWikilinks(const FString& Html) +static TArray BuildClassNameCandidates(const FString& ClassName) { - // Collect existing MarkdownAsset names + 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) +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); + + // 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; + } + + // Secondary: legacy object-path query in case the caller supplied a non-package form. + if (AssetRegistry.GetAssetByObjectPath(FSoftObjectPath(ObjectPath)).IsValid()) + { + return true; + } + + // 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. */ +static bool DoesClassExist(const FString& ClassName) +{ + if (ClassName.IsEmpty()) + { + return false; + } + + // Native class lookup against reflection names (UHT strips A/U/I prefixes). + for (const FString& Candidate : BuildClassNameCandidates(ClassName)) + { + 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; +} + +/** + * 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 MarkBrokenLinks(const FString& Html) +{ + // 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 @@ -604,30 +728,64 @@ 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; } // 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; } - // Open external URLs in the system browser + // 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 after user confirmation. + // The preview has no address bar, so we always prompt with the full URL + // to let the user inspect it before launching (phishing mitigation). if (Url.StartsWith(TEXT("http://")) || Url.StartsWith(TEXT("https://"))) { - FPlatformProcess::LaunchURL(*Url, nullptr, nullptr); + const FText Prompt = LOCTEXT("ConfirmExternalUrlMessage", "Open this URL in your default browser?"); + const FText Message = FText::FromString( + FString::Printf(TEXT("%s\n\n%s"), *Prompt.ToString(), *Url) + ); + const EAppReturnType::Type Response = FMessageDialog::Open( + EAppMsgType::YesNo, + Message, + LOCTEXT("ConfirmExternalUrlTitle", "Open External URL") + ); + if (Response == EAppReturnType::Yes) + { + FPlatformProcess::LaunchURL(*Url, nullptr, nullptr); + } 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) @@ -657,4 +815,162 @@ 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(); + + // 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; + FString LeafName; + if (PackagePath.FindLastChar(TEXT('/'), SlashIndex)) + { + LeafName = PackagePath.Mid(SlashIndex + 1); + } + const FString FullObjectPath = LeafName.IsEmpty() ? ObjectPath : FString::Printf(TEXT("%s.%s"), *PackagePath, *LeafName); + + UObject* LoadedAsset = nullptr; + + // 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); + if (PackageAssets.Num() > 0) + { + LoadedAsset = PackageAssets[0].GetAsset(); + } + + // 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)); + if (AssetData.IsValid()) + { + LoadedAsset = AssetData.GetSoftObjectPath().TryLoad(); + if (LoadedAsset) break; + } + } + } + + // StaticLoadObject picks up packages that have not been indexed yet. + if (!LoadedAsset) + { + LoadedAsset = StaticLoadObject(UObject::StaticClass(), nullptr, *FullObjectPath); + } + + // 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)) + { + if (UPackage* LoadedPackage = LoadPackage(nullptr, *PackagePath, LOAD_None)) + { + ForEachObjectWithPackage(LoadedPackage, [&LoadedAsset](UObject* Obj) + { + if (Obj && Obj->IsAsset() +#if UE_VERSION_OLDER_THAN(5, 6, 0) + && !Obj->IsA() +#endif + ) + { + LoadedAsset = Obj; + return false; + } + return true; + }, false); + } + } + + if (!LoadedAsset) + { + UE_LOG(LogMarkdownAssetEditor, Warning, TEXT("Asset link target not found: '%s'"), *ObjectPath); + return; + } + + if (UAssetEditorSubsystem* AssetEditorSubsystem = GEditor ? GEditor->GetEditorSubsystem() : nullptr) + { + AssetEditorSubsystem->OpenEditorForAsset(LoadedAsset); + } +} + +void FMarkdownAssetEditorToolkit::OpenLinkedClass(const FString& ClassName) +{ + if (ClassName.IsEmpty()) + { + return; + } + + // Native UClass lookup against reflection names (UHT strips A/U/I prefixes). + for (const FString& Candidate : BuildClassNameCandidates(ClassName)) + { + UClass* FoundClass = FindFirstObject(*Candidate, EFindFirstObjectOptions::NativeFirst); + if (FoundClass && 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()); + } + 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()) + { + if (UAssetEditorSubsystem* AssetEditorSubsystem = GEditor ? GEditor->GetEditorSubsystem() : nullptr) + { + AssetEditorSubsystem->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..a153e86 100644 --- a/README.ja.md +++ b/README.ja.md @@ -21,10 +21,11 @@ - **インポート / エクスポート** — `.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) -- **セキュリティ** — ユーザー提供のMarkdownレンダリング時のXSSを防止するため、生のHTMLブロックおよびインラインHTMLはデフォルトで無効化しています。 +- **セキュリティ** — ユーザー提供のMarkdownレンダリング時のXSSを防止するため、生のHTMLブロックおよびインラインHTMLはデフォルトで無効化しています。また、プレビューブラウザのナビゲーションはホワイトリスト方式を採用しており、`data:`・`about:`・`mdasset://`・`ueasset://`・`class://`・`http(s)://` のみを許可し、`javascript:` や `file:` などの未知スキームをブロックすることで、信頼できないMarkdownによるスクリプト実行やローカルファイルアクセスを防いでいます。さらに、プレビューページには `Content-Security-Policy`(`default-src 'none'; style-src 'unsafe-inline'; img-src data:`)を注入し、外部ネットワークへのリクエスト(外部画像・fetch/XHR・フレーム等)をブロックすることで、IP追跡やローカルサービスへのSSRFを防止しています。外部 `http(s)://` リンクをクリックした際は必ず確認ダイアログで完全なURLを表示してからシステムブラウザで開くため、細工されたMarkdownアセットによるフィッシングを軽減します。 - **ローカライズ** — エディタUIは英語と日本語に対応しています。 - **ニバイト文字対応** — 日本語などのニバイト文字を含むMarkdownテキストを正しく処理・表示できます。 @@ -81,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 23f3a67..f4eb839 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,11 @@ 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) -- **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. A `Content-Security-Policy` header (`default-src 'none'; style-src 'unsafe-inline'; img-src data:`) is injected into every preview page, blocking external network requests (images, fetch/XHR, frames) to prevent IP tracking and SSRF against local services. External `http(s)://` links always prompt a confirmation dialog displaying the full URL before opening in the system browser, mitigating phishing via crafted Markdown assets - **Localization** — Editor UI is fully localized for English and Japanese ![Dark Theme Preview](docs/images/dark-theme-preview.png) @@ -81,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: