Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 108 additions & 9 deletions MacDown/Code/Document/MPRenderer.m
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSString *> *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<NSTextCheckingResult *> *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<NSString *> *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.
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@
<font key="font" metaFont="smallSystem"/>
</buttonCell>
<connections>
<binding destination="-2" name="enabled" keyPath="self.preferences.htmlSyntaxHighlighting" id="EPx-Wd-dqb"/>
<binding destination="-2" name="value" keyPath="self.preferences.htmlMermaid" id="0Vt-zm-bOO"/>
</connections>
</button>
Expand Down
75 changes: 75 additions & 0 deletions MacDownTests/MPMarkdownRenderingTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -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>"],
@"A basic wiki link should render as a link");
XCTAssertTrue([html containsString:@">友好名称</a>"],
@"A Chinese alias should be used as the link label");
XCTAssertTrue([html containsString:@">Table label</a>"],
@"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:@"<a href="],
@"Missing, traversal, and non-Markdown targets must not link");
[[NSFileManager defaultManager] removeItemAtPath:directory error:NULL];
}

- (void)testWikiLinksInsideFencedCodeStayLiteral
{
NSString *directory = [NSTemporaryDirectory()
stringByAppendingPathComponent:NSUUID.UUID.UUIDString];
[[NSFileManager defaultManager] createDirectoryAtPath:directory
withIntermediateDirectories:YES
attributes:nil error:NULL];
[@"# Target" writeToFile:[directory stringByAppendingPathComponent:@"note.md"]
atomically:YES encoding:NSUTF8StringEncoding error:NULL];
self.delegate.baseURL = [NSURL fileURLWithPath:directory isDirectory:YES];

NSString *html = [self renderMarkdown:@"```text\n[[note]]\n```"
withExtensions:HOEDOWN_EXT_FENCED_CODE
rendererFlags:HOEDOWN_HTML_BLOCKCODE_INFORMATION];

XCTAssertTrue([html containsString:@"[[note]]"],
@"Wiki-link syntax in code fences should remain literal");
XCTAssertFalse([html containsString:@"<a href="],
@"Code fence content must not become links");
[[NSFileManager defaultManager] removeItemAtPath:directory error:NULL];
}

- (void)testImages
{
int extFlags = 0;
Expand Down
8 changes: 5 additions & 3 deletions MacDownTests/MPMermaidRenderingTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ - (void)testMermaidScriptsExcludedWhenDisabled
@"Should NOT include Mermaid init script when disabled");
}

- (void)testMermaidScriptsExcludedWhenSyntaxHighlightingDisabled
- (void)testMermaidScriptsIncludedWhenSyntaxHighlightingDisabled
{
self.delegate.mermaid = YES;
self.delegate.syntaxHighlighting = NO;
Expand All @@ -144,8 +144,10 @@ - (void)testMermaidScriptsExcludedWhenSyntaxHighlightingDisabled
[self.renderer parseMarkdown:self.dataSource.markdown];
[self.renderer render];

XCTAssertFalse([self.delegate.lastHTML containsString:@"mermaid.min.js"],
@"Mermaid requires syntax highlighting to be enabled");
XCTAssertTrue([self.delegate.lastHTML containsString:@"mermaid.min.js"],
@"Mermaid should work independently of syntax highlighting");
XCTAssertTrue([self.delegate.lastHTML containsString:@"mermaid.init.js"],
@"Mermaid initialization should work without Prism");
}


Expand Down