diff --git a/MacDown/Code/Document/MPRenderer.m b/MacDown/Code/Document/MPRenderer.m index 4811b713..4a42a158 100644 --- a/MacDown/Code/Document/MPRenderer.m +++ b/MacDown/Code/Document/MPRenderer.m @@ -93,6 +93,103 @@ return urls; } +NS_INLINE NSString *MPEscapeMarkdownLinkLabel(NSString *label) +{ + label = [label stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"]; + return [label stringByReplacingOccurrencesOfString:@"]" withString:@"\\]"]; +} + +/** Convert basic same-directory wiki links when the Markdown target exists. */ +NS_INLINE NSString *MPPreprocessWikiLinks(NSString *text, NSURL *baseURL) +{ + if (!text.length) + return text; + + static NSRegularExpression *wikiLinkRegex = nil; + static dispatch_once_t token; + dispatch_once(&token, ^{ + wikiLinkRegex = [NSRegularExpression + regularExpressionWithPattern:@"\\[\\[([^\\[\\]\\n]+)\\]\\]" + options:0 error:NULL]; + }); + + NSURL *directoryURL = baseURL.hasDirectoryPath + ? baseURL : baseURL.URLByDeletingLastPathComponent; + NSCharacterSet *forbiddenPathCharacters = [NSCharacterSet + characterSetWithCharactersInString:@"/\\\\"]; + NSCharacterSet *controlCharacters = [NSCharacterSet controlCharacterSet]; + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSMutableArray *processedLines = [NSMutableArray array]; + __block NSString *activeFence = nil; + + for (NSString *line in [text componentsSeparatedByString:@"\n"]) + { + NSString *trimmed = [line stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceCharacterSet]]; + NSString *fence = [trimmed hasPrefix:@"```"] ? @"```" + : ([trimmed hasPrefix:@"~~~"] ? @"~~~" : nil); + if (fence) + { + if (!activeFence) + activeFence = fence; + else if ([activeFence isEqualToString:fence]) + activeFence = nil; + [processedLines addObject:line]; + continue; + } + if (activeFence) + { + [processedLines addObject:line]; + continue; + } + + NSMutableString *processed = [line mutableCopy]; + NSArray *matches = + [wikiLinkRegex matchesInString:line options:0 + range:NSMakeRange(0, line.length)]; + for (NSTextCheckingResult *match in [matches reverseObjectEnumerator]) + { + NSString *contents = [line substringWithRange:[match rangeAtIndex:1]]; + NSArray *parts = [contents componentsSeparatedByString:@"|"]; + NSString *target = [parts.firstObject + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + NSString *label = parts.count > 1 + ? [[parts subarrayWithRange:NSMakeRange(1, parts.count - 1)] + componentsJoinedByString:@"|"] : target; + label = [label stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceCharacterSet]]; + if (!label.length) + label = target; + + BOOL safeTarget = target.length > 0 + && [target rangeOfCharacterFromSet:forbiddenPathCharacters].location == NSNotFound + && [target rangeOfCharacterFromSet:controlCharacters].location == NSNotFound + && ![target isEqualToString:@"."] + && ![target isEqualToString:@".."]; + NSString *filename = target; + if (safeTarget && !filename.pathExtension.length) + filename = [filename stringByAppendingPathExtension:@"md"]; + if (safeTarget && ![filename.pathExtension.lowercaseString isEqualToString:@"md"]) + safeTarget = NO; + + NSURL *targetURL = safeTarget && directoryURL.isFileURL + ? [directoryURL URLByAppendingPathComponent:filename] : nil; + BOOL isDirectory = NO; + BOOL exists = targetURL + && [fileManager fileExistsAtPath:targetURL.path isDirectory:&isDirectory] + && !isDirectory; + NSString *escapedLabel = MPEscapeMarkdownLinkLabel(label); + NSString *replacement = exists + ? [NSString stringWithFormat:@"[%@](%@)", escapedLabel, + targetURL.absoluteString] + : escapedLabel; + [processed replaceCharactersInRange:match.range withString:replacement]; + } + [processedLines addObject:processed]; + } + return [processedLines componentsJoinedByString:@"\n"]; +} + /** * Preprocess markdown to work around Hoedown parser limitations. * @@ -688,17 +785,14 @@ - (NSArray *)scripts if ([d rendererHasSyntaxHighlighting:self]) { [scripts addObjectsFromArray:self.prismScripts]; - // mermaid - if ([d rendererHasMermaid:self]) - { - [scripts addObjectsFromArray:self.mermaidScripts]; - } // graphviz if ([d rendererHasGraphviz:self]) { [scripts addObjectsFromArray:self.graphvizScripts]; } } + if ([d rendererHasMermaid:self]) + [scripts addObjectsFromArray:self.mermaidScripts]; if ([d rendererHasMathJax:self]) [scripts addObjectsFromArray:self.mathjaxScripts]; return scripts; @@ -776,6 +870,10 @@ - (void)parseMarkdown:(NSString *)markdown { [markdown frontMatter:&offset]; markdown = [markdown substringFromIndex:offset]; } + NSURL *baseURL = nil; + if ([delegate respondsToSelector:@selector(rendererBaseURL:)]) + baseURL = [delegate rendererBaseURL:self]; + markdown = MPPreprocessWikiLinks(markdown, baseURL); int tocLevel = hasTOC ? kMPRendererTOCLevel : 0; hoedown_renderer *htmlRenderer = MPCreateHTMLRenderer(self, tocLevel); hoedown_renderer *tocRenderer = NULL; @@ -888,16 +986,17 @@ - (NSString *)HTMLForExportWithStyles:(BOOL)withStyles scriptsOption = MPAssetEmbedded; [styles addObjectsFromArray:self.prismStylesheets]; [scripts addObjectsFromArray:self.prismScripts]; - if ([self.delegate rendererHasMermaid:self]) - { - [scripts addObjectsFromArray:self.mermaidScripts]; - } if ([self.delegate rendererHasGraphviz:self]) { [scripts addObjectsFromArray:self.graphvizScripts]; } } + if ([self.delegate rendererHasMermaid:self]) + { + scriptsOption = MPAssetEmbedded; + [scripts addObjectsFromArray:self.mermaidScripts]; + } if ([self.delegate rendererHasMathJax:self]) { scriptsOption = MPAssetEmbedded; diff --git a/MacDown/Localization/Base.lproj/MPHtmlPreferencesViewController.xib b/MacDown/Localization/Base.lproj/MPHtmlPreferencesViewController.xib index 920a4adf..a3c76980 100644 --- a/MacDown/Localization/Base.lproj/MPHtmlPreferencesViewController.xib +++ b/MacDown/Localization/Base.lproj/MPHtmlPreferencesViewController.xib @@ -147,7 +147,6 @@ - diff --git a/MacDownTests/MPMarkdownRenderingTests.m b/MacDownTests/MPMarkdownRenderingTests.m index 81ed1a45..6faa91ed 100644 --- a/MacDownTests/MPMarkdownRenderingTests.m +++ b/MacDownTests/MPMarkdownRenderingTests.m @@ -178,6 +178,81 @@ - (void)testLinks rendererFlags:rendFlags]; } +- (void)testWikiLinksInBodyAndTable +{ + NSString *directory = [NSTemporaryDirectory() + stringByAppendingPathComponent:NSUUID.UUID.UUIDString]; + [[NSFileManager defaultManager] createDirectoryAtPath:directory + withIntermediateDirectories:YES + attributes:nil error:NULL]; + for (NSString *name in @[@"note.md", @"中文 笔记.md"]) + { + [@"# Target" writeToFile:[directory stringByAppendingPathComponent:name] + atomically:YES encoding:NSUTF8StringEncoding error:NULL]; + } + self.delegate.baseURL = [NSURL fileURLWithPath:directory isDirectory:YES]; + + NSString *markdown = @"See [[note]] and [[中文 笔记|友好名称]].\n\n" + @"| Link |\n| --- |\n| [[note|Table label]] |"; + NSString *html = [self renderMarkdown:markdown + withExtensions:HOEDOWN_EXT_TABLES + rendererFlags:0]; + + XCTAssertTrue([html containsString:@">note"], + @"A basic wiki link should render as a link"); + XCTAssertTrue([html containsString:@">友好名称"], + @"A Chinese alias should be used as the link label"); + XCTAssertTrue([html containsString:@">Table label"], + @"Wiki links should render inside tables"); + XCTAssertTrue([html containsString:@"%E4%B8%AD%E6%96%87%20%E7%AC%94%E8%AE%B0.md"], + @"Chinese names and spaces should be safely URL encoded"); + [[NSFileManager defaultManager] removeItemAtPath:directory error:NULL]; +} + +- (void)testMissingAndUnsafeWikiLinksStayReadable +{ + NSString *directory = [NSTemporaryDirectory() + stringByAppendingPathComponent:NSUUID.UUID.UUIDString]; + [[NSFileManager defaultManager] createDirectoryAtPath:directory + withIntermediateDirectories:YES + attributes:nil error:NULL]; + self.delegate.baseURL = [NSURL fileURLWithPath:directory isDirectory:YES]; + + NSString *html = [self renderMarkdown: + @"[[missing|Missing note]] [[../secret|Unsafe note]] [[image.png]]" + withExtensions:0 rendererFlags:0]; + + XCTAssertTrue([html containsString:@"Missing note"], + @"Missing targets should keep a readable label"); + XCTAssertTrue([html containsString:@"Unsafe note"], + @"Unsafe targets should keep a readable label"); + XCTAssertFalse([html containsString:@"