diff --git a/Makefile b/Makefile index 3403953..2b844d2 100644 --- a/Makefile +++ b/Makefile @@ -3,18 +3,18 @@ ARCHS = arm64 arm64e include $(THEOS)/makefiles/common.mk TWEAK_NAME = tfdidthatsay -tfdidthatsay_FILES = $(wildcard tweak/*.xm) -tweak/Narwhal.xm_CFLAGS = -fobjc-arc -tfdidthatsay_EXTRA_FRAMEWORKS += Cephei - +tfdidthatsay_FILES = $(wildcard tweak/*.xm tweak/assets/*.m) +tfdidthatsay_CFLAGS = -fobjc-arc +tweak/Reddit.xm_CFLAGS = -fno-objc-arc +tweak/Apollo.xm_CFLAGS = -fno-objc-arc include $(THEOS_MAKE_PATH)/tweak.mk SUBPROJECTS += prefs include $(THEOS_MAKE_PATH)/aggregate.mk - after-install:: install.exec "killall -9 Reddit" install.exec "killall -9 Apollo" install.exec "killall -9 narwhal" + install.exec "killall -9 AlienBlue" diff --git a/prefs/Resources/Root.plist b/prefs/Resources/Root.plist index 74da805..fee1061 100644 --- a/prefs/Resources/Root.plist +++ b/prefs/Resources/Root.plist @@ -54,11 +54,27 @@ label Enable Narwhal + + PostNotification + com.lint.undelete.prefs.changed + cell + PSSwitchCell + default + + defaults + com.lint.undelete.prefs + key + isAlienBlueEnabled + label + Enable Alien Blue + cell PSGroupCell + footerText + On apps that have the eye button rather than in the menu, enable or disable that button from appearing on only deleted comments/posts. label - Apollo + visibility PostNotification @@ -72,7 +88,21 @@ key isApolloDeletedCommentsOnly label - Only display eye on deleted comments + Apollo | Only on deleted comments + + + PostNotification + com.lint.undelete.prefs.changed + cell + PSSwitchCell + default + + defaults + com.lint.undelete.prefs + key + isAlienBlueDeletedOnly + label + Alien Blue | Only on deleted comments/posts cell diff --git a/tfdidthatsay.plist b/tfdidthatsay.plist index 99887e3..6b4dd14 100644 Binary files a/tfdidthatsay.plist and b/tfdidthatsay.plist differ diff --git a/tweak/AlienBlue.h b/tweak/AlienBlue.h new file mode 100644 index 0000000..5f71bea --- /dev/null +++ b/tweak/AlienBlue.h @@ -0,0 +1,60 @@ + +/* -- Voteable Interfaces -- */ + +@interface VoteableElement +@property(strong, nonatomic) NSString *ident; +@property(strong, nonatomic) NSString *author; +@end + +@interface Comment : VoteableElement +@property(strong, nonatomic) NSString *body; +@property(strong, nonatomic) NSString *bodyHTML; +@end + +@interface Post : VoteableElement +@property(strong, nonatomic) NSString *selftext; +@property(strong, nonatomic) NSString *selftextHtml; +@property(assign, nonatomic) BOOL selfPost; +@end + +/* -- UI Interfaces -- */ + +@interface NCommentCell +@property(strong, nonatomic) Comment *comment; +@end + +@interface NCommentPostHeaderCell +@property(strong, nonatomic) Post *post; +@property(strong, nonatomic) id node; +@end + +@interface CommentsViewController +-(void) respondToStyleChange; +@end + +@interface CommentPostHeaderNode +@property(strong, nonatomic) Comment *comment; +@property(strong, nonatomic) Post *post; +@end + +@interface CommentOptionsDrawerView +@property(strong, nonatomic) NSMutableArray *buttons; +@property(assign, nonatomic) BOOL isPostHeader; +@property(strong, nonatomic) id delegate; +-(void) addButton:(id) arg1; + +//custom elements +@property(strong, nonatomic) Comment *comment; +@property(strong, nonatomic) Post *post; +@property(strong, nonatomic) CommentPostHeaderNode *commentNode; +@end + +/* -- Other Interfaces -- */ + +@interface MarkupEngine ++(id) markDownHTML:(id) arg1 forSubreddit:(id) arg2; +@end + +@interface Resources ++(BOOL) isNight; +@end diff --git a/tweak/AlienBlue.xm b/tweak/AlienBlue.xm new file mode 100644 index 0000000..239fe56 --- /dev/null +++ b/tweak/AlienBlue.xm @@ -0,0 +1,216 @@ + +#import "AlienBlue.h" +#import "assets/MMMarkdown.h" + +static BOOL isAlienBlueEnabled; +static BOOL isAlienBlueDeletedOnly; +static CGFloat pushshiftRequestTimeoutValue; + +%group AlienBlue + +%hook NCommentCell + +-(void) setDrawerView:(id) arg1 { + [arg1 setComment:[self comment]]; + %orig; +} + +%end + +%hook NCommentPostHeaderCell + +-(void) setDrawerView:(id) arg1 { + [arg1 setPost:[self post]]; + [arg1 setCommentNode:[self node]]; + %orig; +} + +%end + + +%hook CommentOptionsDrawerView +%property(strong, nonatomic) Comment *comment; +%property(strong, nonatomic) Post *post; +%property(strong, nonatomic) CommentPostHeaderNode *commentNode; + +-(id) initWithNode:(id) arg1 { + id orig = %orig; + + NSString *body; + + if ([self post]) { + body = [[self post] selftext]; + } else if ([self comment]){ + body = [[self comment] body]; + } + + if ((isAlienBlueDeletedOnly && ([body isEqualToString:@"[deleted]"] || [body isEqualToString:@"[removed]"])) || !isAlienBlueDeletedOnly) { + + CGSize refSize = [[self buttons][0] frame].size; + + UIButton *undeleteButton = [UIButton buttonWithType:UIButtonTypeCustom]; + [undeleteButton setFrame:CGRectMake(0, 0, refSize.width, refSize.height)]; + + if ([self isPostHeader]){ + [undeleteButton addTarget:self action:@selector(didTapPostUndeleteButton) forControlEvents:UIControlEventTouchUpInside]; + } else { + [undeleteButton addTarget:self action:@selector(didTapCommentUndeleteButton) forControlEvents:UIControlEventTouchUpInside]; + } + + if ([%c(Resources) isNight]) { + [undeleteButton setImage:[UIImage imageWithContentsOfFile:@"/var/mobile/Library/Application Support/TFDidThatSay/eye160dark.png"] forState:UIControlStateNormal]; + } else { + [undeleteButton setImage:[UIImage imageWithContentsOfFile:@"/var/mobile/Library/Application Support/TFDidThatSay/eye160light.png"] forState:UIControlStateNormal]; + } + + undeleteButton.imageView.contentMode = UIViewContentModeScaleAspectFit; + undeleteButton.imageEdgeInsets = UIEdgeInsetsMake(10, 10, 10, 10); + + [self addButton:undeleteButton]; + } + + return orig; + +} + +%new +-(void) didTapCommentUndeleteButton { + + Comment *comment = [self comment]; + + NSMutableURLRequest *request = [[NSMutableURLRequest alloc] init]; + NSOperationQueue *queue = [[NSOperationQueue alloc] init]; + + [request setURL:[NSURL URLWithString:[NSString stringWithFormat:@"https://api.pushshift.io/reddit/search/comment/?ids=%@&fields=author,body",[comment ident]]]]; + [request setHTTPMethod:@"GET"]; + [request setTimeoutInterval:pushshiftRequestTimeoutValue]; + + [NSURLConnection sendAsynchronousRequest:request queue:queue completionHandler:^(NSURLResponse *response, NSData *data, NSError *error) { + + NSString *author = @"[author]"; + NSString *body = @"[body]"; + + if (data != nil && error == nil){ + id jsonData = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + if ([[jsonData objectForKey:@"data"] count] != 0){ + author = [[jsonData objectForKey:@"data"][0] objectForKey:@"author"]; + body = [[jsonData objectForKey:@"data"][0] objectForKey:@"body"]; + if ([body isEqualToString:@"[deleted]"] || [body isEqualToString:@"[removed]"]){ + body = @"[pushshift was unable to archive this]"; + } + } else { + body = @"[pushshift has not archived this yet]"; + } + } else if (error != nil || data == nil){ + body = [NSString stringWithFormat:@"[an error occured while attempting to contact pushshift api (%@)]", [error localizedDescription]]; + } + + NSString *bodyHTML = [%c(MMMarkdown) HTMLStringWithMarkdown:body extensions:MMMarkdownExtensionsGitHubFlavored error:nil]; + + [comment setAuthor:author]; + [comment setBody:body]; + [comment setBodyHTML:bodyHTML]; + + [[self delegate] performSelectorOnMainThread:@selector(respondToStyleChange) withObject:nil waitUntilDone:NO]; + }]; +} + +%new +-(void) didTapPostUndeleteButton { + + Post *post = [self post]; + Comment *postComment = [[self commentNode] comment]; //Don't know why he used a comment to store info about a post, but it exists + + NSMutableURLRequest *request = [[NSMutableURLRequest alloc] init]; + NSOperationQueue *queue = [[NSOperationQueue alloc] init]; + + [request setURL:[NSURL URLWithString:[NSString stringWithFormat:@"https://api.pushshift.io/reddit/search/submission/?ids=%@&fields=author,selftext",[post ident]]]]; + [request setHTTPMethod:@"GET"]; + [request setTimeoutInterval:pushshiftRequestTimeoutValue]; + + [NSURLConnection sendAsynchronousRequest:request queue:queue completionHandler:^(NSURLResponse *response, NSData *data, NSError *error) { + + NSString *author = @"[author]"; + NSString *body = @"[body]"; + + if (data != nil && error == nil){ + id jsonData = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + if ([[jsonData objectForKey:@"data"] count] != 0){ + author = [[jsonData objectForKey:@"data"][0] objectForKey:@"author"]; + body = [[jsonData objectForKey:@"data"][0] objectForKey:@"selftext"]; + if ([body isEqualToString:@"[deleted]"] || [body isEqualToString:@"[removed]"]){ + body = @"[pushshift was unable to archive this]"; + } + } else { + body = @"[pushshift has not archived this yet]"; + } + } else if (error != nil || data == nil){ + body = [NSString stringWithFormat:@"[an error occured while attempting to contact pushshift api (%@)]", [error localizedDescription]]; + } + + NSString *bodyHTML = [%c(MMMarkdown) HTMLStringWithMarkdown:body extensions:MMMarkdownExtensionsGitHubFlavored error:nil]; + + [post setAuthor:author]; + [post setSelftext:body]; + [postComment setBodyHTML:bodyHTML]; + + [[self delegate] performSelectorOnMainThread:@selector(respondToStyleChange) withObject:nil waitUntilDone:NO]; + + }]; +} + +%end + +%end + + +static void loadPrefs(){ + NSMutableDictionary *prefs = [[NSMutableDictionary alloc] initWithContentsOfFile:@"/User/Library/Preferences/com.lint.undelete.prefs.plist"]; + + if (prefs){ + + if ([prefs objectForKey:@"isAlienBlueEnabled"] != nil){ + isAlienBlueEnabled = [[prefs objectForKey:@"isAlienBlueEnabled"] boolValue]; + } else { + isAlienBlueEnabled = YES; + } + + if ([prefs objectForKey:@"isAlienBlueDeletedOnly"] != nil){ + isAlienBlueDeletedOnly = [[prefs objectForKey:@"isAlienBlueDeletedOnly"] boolValue]; + } else { + isAlienBlueDeletedOnly = YES; + } + + if ([prefs objectForKey:@"requestTimeoutValue"] != nil){ + pushshiftRequestTimeoutValue = [[prefs objectForKey:@"requestTimeoutValue"] doubleValue]; + } else { + pushshiftRequestTimeoutValue = 10; + } + + } else { + isAlienBlueEnabled = YES; + isAlienBlueDeletedOnly = YES; + pushshiftRequestTimeoutValue = 10; + } +} + +static void prefsChanged(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) { + loadPrefs(); +} + + +%ctor { + loadPrefs(); + + NSString* processName = [[NSProcessInfo processInfo] processName]; + + if ([processName isEqualToString:@"AlienBlue"]){ + if (isAlienBlueEnabled){ + + CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), NULL, prefsChanged, CFSTR("com.lint.undelete.prefs.changed"), NULL, CFNotificationSuspensionBehaviorDeliverImmediately); + + %init(AlienBlue); + } + } +} + diff --git a/tweak/assets/MMDocument.h b/tweak/assets/MMDocument.h new file mode 100644 index 0000000..a2cbc6b --- /dev/null +++ b/tweak/assets/MMDocument.h @@ -0,0 +1,40 @@ +// +// MMDocument.h +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import + +@class MMElement; + +NS_ASSUME_NONNULL_BEGIN +@interface MMDocument : NSObject + +@property (copy, nonatomic, readonly) NSString *markdown; +@property (copy, nonatomic, readonly) NSArray *elements; + ++ (id)documentWithMarkdown:(NSString *)markdown; +- (id)initWithMarkdown:(NSString *)markdown; + +@end +NS_ASSUME_NONNULL_END diff --git a/tweak/assets/MMDocument.m b/tweak/assets/MMDocument.m new file mode 100644 index 0000000..aa2edf2 --- /dev/null +++ b/tweak/assets/MMDocument.m @@ -0,0 +1,70 @@ +// +// MMDocument.m +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import "MMDocument.h" +#import "MMDocument_Private.h" + + +@interface MMDocument () +@property (copy, nonatomic) NSArray *elements; +@end + +@implementation MMDocument +{ + NSMutableArray *_elements; +} + +#pragma mark - Public Methods + ++ (id)documentWithMarkdown:(NSString *)markdown +{ + return [[self.class alloc] initWithMarkdown:markdown]; +} + +- (id)initWithMarkdown:(NSString *)markdown +{ + self = [super init]; + + if (self) + { + _markdown = markdown; + _elements = [NSMutableArray new]; + } + + return self; +} + + +#pragma mark - Private Methods + +- (void)addElement:(MMElement *)anElement +{ + [self willChangeValueForKey:@"elements"]; + [_elements addObject:anElement]; + [self didChangeValueForKey:@"elements"]; +} + + +@end diff --git a/tweak/assets/MMDocument_Private.h b/tweak/assets/MMDocument_Private.h new file mode 100644 index 0000000..8bca466 --- /dev/null +++ b/tweak/assets/MMDocument_Private.h @@ -0,0 +1,39 @@ +// +// MMDocument_Private.h +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import "MMDocument.h" + + +@class MMElement; + +NS_ASSUME_NONNULL_BEGIN +@interface MMDocument (MMDocumentPrivate) + +@property (copy, nonatomic) NSArray *elements; + +- (void)addElement:(MMElement *)anElement; + +@end +NS_ASSUME_NONNULL_END \ No newline at end of file diff --git a/tweak/assets/MMElement.h b/tweak/assets/MMElement.h new file mode 100644 index 0000000..5492d74 --- /dev/null +++ b/tweak/assets/MMElement.h @@ -0,0 +1,92 @@ +// +// MMElement.h +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import + +@class MMElement; + +typedef enum +{ + MMElementTypeNone, + MMElementTypeHeader, + MMElementTypeParagraph, + MMElementTypeBlockquote, + MMElementTypeNumberedList, + MMElementTypeBulletedList, + MMElementTypeListItem, + MMElementTypeCodeBlock, + MMElementTypeHorizontalRule, + MMElementTypeHTML, + MMElementTypeLineBreak, + MMElementTypeStrikethrough, + MMElementTypeStrong, + MMElementTypeEm, + MMElementTypeCodeSpan, + MMElementTypeImage, + MMElementTypeLink, + MMElementTypeMailTo, + MMElementTypeDefinition, + MMElementTypeEntity, + MMElementTypeTable, + MMElementTypeTableHeader, + MMElementTypeTableHeaderCell, + MMElementTypeTableRow, + MMElementTypeTableRowCell, +} MMElementType; + +typedef NS_ENUM(NSInteger, MMTableCellAlignment) +{ + MMTableCellAlignmentNone, + MMTableCellAlignmentLeft, + MMTableCellAlignmentCenter, + MMTableCellAlignmentRight, +}; + +@interface MMElement : NSObject + +@property (assign, nonatomic) NSRange range; +@property (assign, nonatomic) MMElementType type; +@property (copy, nonatomic) NSArray *innerRanges; + +@property (assign, nonatomic) MMTableCellAlignment alignment; +@property (assign, nonatomic) NSUInteger level; +@property (copy, nonatomic) NSString *href; +@property (copy, nonatomic) NSString *title; +@property (copy, nonatomic) NSString *identifier; +@property (copy, nonatomic) NSString *stringValue; + +@property (weak, nonatomic) MMElement *parent; +@property (copy, nonatomic) NSArray *children; + +@property (copy, nonatomic) NSString *language; + +- (void)addInnerRange:(NSRange)aRange; +- (void)removeLastInnerRange; + +- (void)addChild:(MMElement *)aChild; +- (void)removeChild:(MMElement *)aChild; +- (MMElement *)removeLastChild; + +@end diff --git a/tweak/assets/MMElement.m b/tweak/assets/MMElement.m new file mode 100644 index 0000000..7884746 --- /dev/null +++ b/tweak/assets/MMElement.m @@ -0,0 +1,171 @@ +// +// MMElement.m +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import "MMElement.h" + + +static NSString * __MMStringFromElementType(MMElementType type) +{ + switch (type) + { + case MMElementTypeNone: + return @"none"; + case MMElementTypeHeader: + return @"header"; + case MMElementTypeParagraph: + return @"paragraph"; + case MMElementTypeBlockquote: + return @"blockquote"; + case MMElementTypeNumberedList: + return @"ol"; + case MMElementTypeBulletedList: + return @"ul"; + case MMElementTypeListItem: + return @"li"; + case MMElementTypeCodeBlock: + return @"code"; + case MMElementTypeHorizontalRule: + return @"hr"; + case MMElementTypeHTML: + return @"html"; + case MMElementTypeLineBreak: + return @"br"; + case MMElementTypeStrikethrough: + return @"del"; + case MMElementTypeStrong: + return @"strong"; + case MMElementTypeEm: + return @"em"; + case MMElementTypeCodeSpan: + return @"code"; + case MMElementTypeImage: + return @"image"; + case MMElementTypeLink: + return @"link"; + case MMElementTypeMailTo: + return @"mailto"; + case MMElementTypeEntity: + return @"entity"; + case MMElementTypeDefinition: + return @"definition"; + default: + return @"unknown"; + } +} + +@implementation MMElement +{ + NSMutableArray *_innerRanges; + NSMutableArray *_children; +} + +#pragma mark - NSObject + +- (id)init +{ + self = [super init]; + + if (self) + { + _innerRanges = [NSMutableArray new]; + _children = [NSMutableArray new]; + } + + return self; +} + +- (void)dealloc +{ + [self.children makeObjectsPerformSelector:@selector(setParent:) withObject:nil]; +} + +- (NSString *)description +{ + return [NSString stringWithFormat:@"<%@: %p; type=%@; range=%@>", + NSStringFromClass(self.class), self, __MMStringFromElementType(self.type), NSStringFromRange(self.range)]; + +} + + +#pragma mark - Public Methods + +- (void)addInnerRange:(NSRange)aRange +{ + [self willChangeValueForKey:@"innerRanges"]; + [_innerRanges addObject:[NSValue valueWithRange:aRange]]; + [self didChangeValueForKey:@"innerRanges"]; +} + +- (void)removeLastInnerRange +{ + [self willChangeValueForKey:@"innerRanges"]; + [_innerRanges removeLastObject]; + [self didChangeValueForKey:@"innerRanges"]; +} + +- (void)addChild:(MMElement *)aChild +{ + [self willChangeValueForKey:@"children"]; + [_children addObject:aChild]; + aChild.parent = self; + [self didChangeValueForKey:@"children"]; +} + +- (void)removeChild:(MMElement *)aChild +{ + [self willChangeValueForKey:@"children"]; + [_children removeObjectIdenticalTo:aChild]; + aChild.parent = nil; + [self didChangeValueForKey:@"children"]; +} + +- (MMElement *)removeLastChild +{ + MMElement *child = [self.children lastObject]; + [_children removeLastObject]; + return child; +} + + +#pragma mark - Public Properties + +- (void)setInnerRanges:(NSArray *)innerRanges +{ + _innerRanges = [innerRanges mutableCopy]; +} + +- (void)setChildren:(NSArray *)children +{ + for (MMElement *child in _children) { + child.parent = nil; + } + _children = [children mutableCopy]; + for (MMElement *child in _children) { + child.parent = self; + } +} + + +@end diff --git a/tweak/assets/MMGenerator.h b/tweak/assets/MMGenerator.h new file mode 100644 index 0000000..37d56c0 --- /dev/null +++ b/tweak/assets/MMGenerator.h @@ -0,0 +1,37 @@ +// +// MMGenerator.h +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import + + +@class MMDocument; + +NS_ASSUME_NONNULL_BEGIN +@interface MMGenerator : NSObject + +- (NSString *)generateHTML:(MMDocument *)aDocument; + +@end +NS_ASSUME_NONNULL_END \ No newline at end of file diff --git a/tweak/assets/MMGenerator.m b/tweak/assets/MMGenerator.m new file mode 100644 index 0000000..810015f --- /dev/null +++ b/tweak/assets/MMGenerator.m @@ -0,0 +1,282 @@ +// +// MMGenerator.m +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import "MMGenerator.h" + + +#import "MMDocument.h" +#import "MMElement.h" + +// This value is used to estimate the length of the HTML output. The length of the markdown document +// is multplied by it to create an NSMutableString with an initial capacity. +static const Float64 kHTMLDocumentLengthMultiplier = 1.25; + +static NSString * __HTMLEscapedString(NSString *aString) +{ + NSMutableString *result = [aString mutableCopy]; + + [result replaceOccurrencesOfString:@"&" + withString:@"&" + options:NSLiteralSearch + range:NSMakeRange(0, result.length)]; + [result replaceOccurrencesOfString:@"\"" + withString:@""" + options:NSLiteralSearch + range:NSMakeRange(0, result.length)]; + + return result; +} + +static NSString *__obfuscatedEmailAddress(NSString *anAddress) +{ + NSMutableString *result = [NSMutableString new]; + + NSString *(^decimal)(unichar c) = ^(unichar c){ return [NSString stringWithFormat:@"&#%d;", c]; }; + NSString *(^hex)(unichar c) = ^(unichar c){ return [NSString stringWithFormat:@"&#x%x;", c]; }; + NSString *(^raw)(unichar c) = ^(unichar c){ return [NSString stringWithCharacters:&c length:1]; }; + NSArray *encoders = @[ decimal, hex, raw ]; + + for (NSUInteger idx=0; idx= 90) ? 2 : (r >= 45) ? 1 : 0]; + } + [result appendString:encoder(character)]; + } + + return result; +} + +static NSString * __HTMLStartTagForElement(MMElement *anElement) +{ + switch (anElement.type) + { + case MMElementTypeHeader: + return [NSString stringWithFormat:@"", (unsigned int)anElement.level]; + case MMElementTypeParagraph: + return @"

"; + case MMElementTypeBulletedList: + return @"

    \n"; + case MMElementTypeNumberedList: + return @"
      \n"; + case MMElementTypeListItem: + return @"
    1. "; + case MMElementTypeBlockquote: + return @"
      \n"; + case MMElementTypeCodeBlock: + return anElement.language ? [NSString stringWithFormat:@"
      ", anElement.language] : @"
      ";
      +        case MMElementTypeLineBreak:
      +            return @"
      "; + case MMElementTypeHorizontalRule: + return @"\n
      \n"; + case MMElementTypeStrikethrough: + return @""; + case MMElementTypeStrong: + return @""; + case MMElementTypeEm: + return @""; + case MMElementTypeCodeSpan: + return @""; + case MMElementTypeImage: + if (anElement.title != nil) + { + return [NSString stringWithFormat:@"\"%@\"", + __HTMLEscapedString(anElement.href), + __HTMLEscapedString(anElement.stringValue), + __HTMLEscapedString(anElement.title)]; + } + return [NSString stringWithFormat:@"\"%@\"", + __HTMLEscapedString(anElement.href), + __HTMLEscapedString(anElement.stringValue)]; + case MMElementTypeLink: + if (anElement.title != nil) + { + return [NSString stringWithFormat:@"", + __HTMLEscapedString(anElement.title), __HTMLEscapedString(anElement.href)]; + } + return [NSString stringWithFormat:@"", __HTMLEscapedString(anElement.href)]; + case MMElementTypeMailTo: + return [NSString stringWithFormat:@"%@", + __obfuscatedEmailAddress([NSString stringWithFormat:@"mailto:%@", anElement.href]), + __obfuscatedEmailAddress(anElement.href)]; + case MMElementTypeEntity: + return anElement.stringValue; + case MMElementTypeTable: + return @""; + case MMElementTypeTableHeader: + return @""; + case MMElementTypeTableHeaderCell: + return anElement.alignment == MMTableCellAlignmentCenter ? @""; + case MMElementTypeTableRowCell: + return anElement.alignment == MMTableCellAlignmentCenter ? @"
      " + : anElement.alignment == MMTableCellAlignmentLeft ? @"" + : anElement.alignment == MMTableCellAlignmentRight ? @"" + : @""; + case MMElementTypeTableRow: + return @"
      " + : anElement.alignment == MMTableCellAlignmentLeft ? @"" + : anElement.alignment == MMTableCellAlignmentRight ? @"" + : @""; + default: + return nil; + } +} + +static NSString * __HTMLEndTagForElement(MMElement *anElement) +{ + switch (anElement.type) + { + case MMElementTypeHeader: + return [NSString stringWithFormat:@"\n", (unsigned int)anElement.level]; + case MMElementTypeParagraph: + return @"

      \n"; + case MMElementTypeBulletedList: + return @"\n"; + case MMElementTypeNumberedList: + return @"\n"; + case MMElementTypeListItem: + return @"\n"; + case MMElementTypeBlockquote: + return @"\n"; + case MMElementTypeCodeBlock: + return @"\n"; + case MMElementTypeStrikethrough: + return @""; + case MMElementTypeStrong: + return @""; + case MMElementTypeEm: + return @""; + case MMElementTypeCodeSpan: + return @""; + case MMElementTypeLink: + return @""; + case MMElementTypeTable: + return @"
      "; + case MMElementTypeTableHeader: + return @""; + case MMElementTypeTableHeaderCell: + return @""; + case MMElementTypeTableRow: + return @""; + case MMElementTypeTableRowCell: + return @""; + default: + return nil; + } +} + +@interface MMGenerator () +- (void) _generateHTMLForElement:(MMElement *)anElement + inDocument:(MMDocument *)aDocument + HTML:(NSMutableString *)theHTML + location:(NSUInteger *)aLocation; +@end + +@implementation MMGenerator + +#pragma mark - Public Methods + +- (NSString *)generateHTML:(MMDocument *)aDocument +{ + NSString *markdown = aDocument.markdown; + NSUInteger location = 0; + NSUInteger length = markdown.length; + + NSMutableString *HTML = [NSMutableString stringWithCapacity:length * kHTMLDocumentLengthMultiplier]; + + for (MMElement *element in aDocument.elements) + { + if (element.type == MMElementTypeHTML) + { + [HTML appendString:[aDocument.markdown substringWithRange:element.range]]; + } + else + { + [self _generateHTMLForElement:element + inDocument:aDocument + HTML:HTML + location:&location]; + } + } + + return HTML; +} + + +#pragma mark - Private Methods + +- (void)_generateHTMLForElement:(MMElement *)anElement + inDocument:(MMDocument *)aDocument + HTML:(NSMutableString *)theHTML + location:(NSUInteger *)aLocation +{ + NSString *startTag = __HTMLStartTagForElement(anElement); + NSString *endTag = __HTMLEndTagForElement(anElement); + + if (startTag) + [theHTML appendString:startTag]; + + for (MMElement *child in anElement.children) + { + if (child.type == MMElementTypeNone) + { + NSString *markdown = aDocument.markdown; + if (child.range.length == 0) + { + [theHTML appendString:@"\n"]; + } + else + { + [theHTML appendString:[markdown substringWithRange:child.range]]; + } + } + else if (child.type == MMElementTypeHTML) + { + [theHTML appendString:[aDocument.markdown substringWithRange:child.range]]; + } + else + { + [self _generateHTMLForElement:child + inDocument:aDocument + HTML:theHTML + location:aLocation]; + } + } + + if (endTag) + [theHTML appendString:endTag]; +} + + +@end diff --git a/tweak/assets/MMHTMLParser.h b/tweak/assets/MMHTMLParser.h new file mode 100644 index 0000000..a77fe20 --- /dev/null +++ b/tweak/assets/MMHTMLParser.h @@ -0,0 +1,40 @@ +// +// MMHTMLParser.h +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import + + +@class MMElement; +@class MMScanner; + +NS_ASSUME_NONNULL_BEGIN +@interface MMHTMLParser : NSObject + +- (nullable MMElement *)parseBlockTagWithScanner:(MMScanner *)scanner; +- (nullable MMElement *)parseCommentWithScanner:(MMScanner *)scanner; +- (nullable MMElement *)parseInlineTagWithScanner:(MMScanner *)scanner; + +@end +NS_ASSUME_NONNULL_END diff --git a/tweak/assets/MMHTMLParser.m b/tweak/assets/MMHTMLParser.m new file mode 100644 index 0000000..566c3ae --- /dev/null +++ b/tweak/assets/MMHTMLParser.m @@ -0,0 +1,324 @@ +// +// MMHTMLParser.m +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import "MMHTMLParser.h" + + +#import "MMElement.h" +#import "MMScanner.h" + +@implementation MMHTMLParser + +#pragma mark - Public Methods + +- (MMElement *)parseBlockTagWithScanner:(MMScanner *)scanner +{ + [scanner beginTransaction]; + MMElement *element = [self _parseStrictBlockTagWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + return [self _parseLenientBlockTagWithScanner:scanner]; +} + +- (MMElement *)parseCommentWithScanner:(MMScanner *)scanner +{ + if (![scanner matchString:@""]) + { + MMElement *element = [MMElement new]; + element.type = MMElementTypeHTML; + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + + return element; + } + [scanner advance]; + } + } + + return nil; +} + +- (MMElement *)parseInlineTagWithScanner:(MMScanner *)scanner +{ + if (scanner.nextCharacter != '<') + return nil; + [scanner advance]; + + if (scanner.nextCharacter == '/') + [scanner advance]; + + NSRange tagNameRange = [self _parseNameWithScanner:scanner]; + if (tagNameRange.length == 0) + return nil; + + [self _parseAttributesWithScanner:scanner]; + [scanner skipWhitespace]; + + if (scanner.nextCharacter == '/') + [scanner advance]; + + if (scanner.nextCharacter != '>') + return nil; + [scanner advance]; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeHTML; + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + element.stringValue = [scanner.string substringWithRange:tagNameRange]; + + return element; +} + + +#pragma mark - Private Methods + +- (MMElement *)_parseStrictBlockTagWithScanner:(MMScanner *)scanner +{ + // which starts with a '<' + if (scanner.nextCharacter != '<') + return nil; + [scanner advance]; + + NSSet *htmlBlockTags = [NSSet setWithObjects: + @"p", @"div", @"h1", @"h2", @"h3", @"h4", @"h5", @"h6", + @"blockquote", @"pre", @"table", @"dl", @"ol", @"ul", + @"script", @"noscript", @"form", @"fieldset", @"iframe", + @"math", @"ins", @"del", nil]; + NSString *tagName = [scanner nextWord]; + if (![htmlBlockTags containsObject:tagName]) + return nil; + scanner.location += tagName.length; + + [self _parseAttributesWithScanner:scanner]; + [scanner skipWhitespace]; + + if (scanner.nextCharacter != '>') + return nil; + [scanner advance]; + + NSCharacterSet *boringChars = [[NSCharacterSet characterSetWithCharactersInString:@"<"] invertedSet]; + while (1) + { + if (scanner.atEndOfString) + return nil; + + [scanner skipCharactersFromSet:boringChars]; + if (scanner.atEndOfLine) + { + [scanner advanceToNextLine]; + continue; + } + + [scanner beginTransaction]; + if ([self _parseEndTag:tagName withScanner:scanner]) + { + [scanner commitTransaction:YES]; + break; + } + [scanner commitTransaction:NO]; + + MMElement *element; + + [scanner beginTransaction]; + element = [self _parseStrictBlockTagWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + continue; + + [scanner beginTransaction]; + element = [self parseCommentWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + continue; + + [scanner beginTransaction]; + element = [self parseInlineTagWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + continue; + + return nil; + } + + MMElement *element = [MMElement new]; + element.type = MMElementTypeHTML; + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + + return element; +} + +- (BOOL)_parseEndTag:(NSString *)tagName withScanner:(MMScanner *)scanner +{ + if (scanner.nextCharacter != '<') + return NO; + [scanner advance]; + + if (scanner.nextCharacter != '/') + return NO; + [scanner advance]; + + [scanner skipWhitespace]; + if (![scanner matchString:tagName]) + return NO; + [scanner skipWhitespace]; + + if (scanner.nextCharacter != '>') + return NO; + [scanner advance]; + + return YES; +} + +- (MMElement *)_parseLenientBlockTagWithScanner:(MMScanner *)scanner +{ + // which starts with a '<' + if (scanner.nextCharacter != '<') + return nil; + [scanner advance]; + + NSSet *htmlBlockTags = [NSSet setWithObjects: + @"p", @"div", @"h1", @"h2", @"h3", @"h4", @"h5", @"h6", + @"blockquote", @"pre", @"table", @"dl", @"ol", @"ul", + @"script", @"noscript", @"form", @"fieldset", @"iframe", + @"math", @"ins", @"del", nil]; + NSString *tagName = scanner.nextWord; + if (![htmlBlockTags containsObject:tagName]) + return nil; + scanner.location += tagName.length; + + // Find a '>' + while (scanner.nextCharacter != '>') + { + if (scanner.atEndOfString) + return nil; + else if (scanner.atEndOfLine) + [scanner advanceToNextLine]; + else + [scanner advance]; + } + + // Skip lines until we come across a blank line + while (!scanner.atEndOfLine) + { + [scanner advanceToNextLine]; + } + + MMElement *element = [MMElement new]; + element.type = MMElementTypeHTML; + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + + return element; +} + +- (NSRange)_parseNameWithScanner:(MMScanner *)scanner +{ + NSMutableCharacterSet *nameSet = [NSMutableCharacterSet alphanumericCharacterSet]; + [nameSet addCharactersInString:@":-"]; + + NSRange result = NSMakeRange(scanner.location, 0); + result.length = [scanner skipCharactersFromSet:nameSet]; + + return result; +} + +- (BOOL)_parseStringWithScanner:(MMScanner *)scanner +{ + unichar nextChar = [scanner nextCharacter]; + if (nextChar != '"' && nextChar != '\'') + return NO; + [scanner advance]; + + while (scanner.nextCharacter != nextChar) + { + if (scanner.atEndOfString) + return NO; + else if (scanner.atEndOfLine) + [scanner advanceToNextLine]; + else + [scanner advance]; + } + + // skip over the closing quotation mark + [scanner advance]; + + return YES; +} + +- (BOOL)_parseAttributeValueWithScanner:(MMScanner *)scanner +{ + NSMutableCharacterSet *characters = [[NSCharacterSet.whitespaceCharacterSet invertedSet] mutableCopy]; + [characters removeCharactersInString:@"\"'=><`"]; + + return [scanner skipCharactersFromSet:characters] > 0; +} + +- (void)_parseAttributesWithScanner:(MMScanner *)scanner +{ + while ([scanner skipWhitespaceAndNewlines] > 0) + { + NSRange range; + + range = [self _parseNameWithScanner:scanner]; + if (range.length == 0) + break; + + [scanner beginTransaction]; + [scanner skipWhitespace]; + + if (scanner.nextCharacter == '=') + { + [scanner commitTransaction:YES]; + [scanner advance]; + + [scanner skipWhitespace]; + + if ([self _parseStringWithScanner:scanner]) + ; + else if ([self _parseAttributeValueWithScanner:scanner]) + ; + else + break; + } + else + { + [scanner commitTransaction:NO]; + } + } +} + + +@end diff --git a/tweak/assets/MMMarkdown-Prefix.pch b/tweak/assets/MMMarkdown-Prefix.pch new file mode 100644 index 0000000..c676a08 --- /dev/null +++ b/tweak/assets/MMMarkdown-Prefix.pch @@ -0,0 +1,7 @@ +// +// Prefix header for all source files of the 'MMMarkdown' target in the 'MMMarkdown' project +// + +#ifdef __OBJC__ + #import +#endif diff --git a/tweak/assets/MMMarkdown.h b/tweak/assets/MMMarkdown.h new file mode 100644 index 0000000..08b02a3 --- /dev/null +++ b/tweak/assets/MMMarkdown.h @@ -0,0 +1,82 @@ +// +// MMMarkdown.h +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import + +//! Project version number for MMMarkdown. +FOUNDATION_EXPORT double MMMarkdownVersionNumber; + +//! Project version string for MMMarkdown. +FOUNDATION_EXPORT const unsigned char MMMarkdownVersionString[]; + +typedef NS_OPTIONS(NSUInteger, MMMarkdownExtensions) +{ + MMMarkdownExtensionsNone = 0, + + MMMarkdownExtensionsAutolinkedURLs = 1 << 0, +// MMMarkdownExtensionsCrossReferences = 1 << 1, +// MMMarkdownExtensionsCustomAttributes = 1 << 2, + MMMarkdownExtensionsFencedCodeBlocks = 1 << 3, +// MMMarkdownExtensionsFootnotes = 1 << 4, + MMMarkdownExtensionsHardNewlines = 1 << 5, + MMMarkdownExtensionsStrikethroughs = 1 << 6, +// MMMarkdownExtensionsTableCaptions = 1 << 7, + MMMarkdownExtensionsTables = 1 << 8, + MMMarkdownExtensionsUnderscoresInWords = 1 << 9, + + MMMarkdownExtensionsGitHubFlavored = MMMarkdownExtensionsAutolinkedURLs|MMMarkdownExtensionsFencedCodeBlocks|MMMarkdownExtensionsHardNewlines|MMMarkdownExtensionsStrikethroughs|MMMarkdownExtensionsTables|MMMarkdownExtensionsUnderscoresInWords, +}; + +NS_ASSUME_NONNULL_BEGIN +@interface MMMarkdown : NSObject + +/*! + Convert a Markdown string to HTML. + + @param string + A Markdown string. Must not be nil. + @param error + Out parameter used if an error occurs while parsing the Markdown. May be NULL. + @result + Returns an HTML string. + */ ++ (NSString *)HTMLStringWithMarkdown:(NSString *)string error:(NSError * __autoreleasing * _Nullable)error; + +/*! + Convert a Markdown string to HTML. + + @param string + A Markdown string. Must not be nil. + @param extensions + The extensions to enable. + @param error + Out parameter used if an error occurs while parsing the Markdown. May be NULL. + @result + Returns an HTML string. + */ ++ (NSString *)HTMLStringWithMarkdown:(NSString *)string extensions:(MMMarkdownExtensions)extensions error:(NSError * __autoreleasing * _Nullable)error; + +@end +NS_ASSUME_NONNULL_END diff --git a/tweak/assets/MMMarkdown.m b/tweak/assets/MMMarkdown.m new file mode 100644 index 0000000..f8fe086 --- /dev/null +++ b/tweak/assets/MMMarkdown.m @@ -0,0 +1,73 @@ +// +// MMMarkdown.m +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import "MMMarkdown.h" + + +#import "MMParser.h" +#import "MMGenerator.h" + +@implementation MMMarkdown + +#pragma mark - Public Methods + ++ (NSString *)HTMLStringWithMarkdown:(NSString *)string error:(__autoreleasing NSError **)error +{ + return [self HTMLStringWithMarkdown:string extensions:MMMarkdownExtensionsNone fromSelector:_cmd error:error]; +} + ++ (NSString *)HTMLStringWithMarkdown:(NSString *)string extensions:(MMMarkdownExtensions)extensions error:(NSError *__autoreleasing *)error +{ + return [self HTMLStringWithMarkdown:string extensions:extensions fromSelector:_cmd error:error]; +} + + +#pragma mark - Private Methods + ++ (NSString *)HTMLStringWithMarkdown:(NSString *)string + extensions:(MMMarkdownExtensions)extensions + fromSelector:(SEL)selector + error:(__autoreleasing NSError **)error +{ + if (string == nil) + { + NSString *reason = [NSString stringWithFormat:@"[%@ %@]: nil argument for markdown", + NSStringFromClass(self.class), NSStringFromSelector(selector)]; + @throw [NSException exceptionWithName:NSInvalidArgumentException reason:reason userInfo:nil]; + } + + if (string.length == 0) + return @""; + + MMParser *parser = [[MMParser alloc] initWithExtensions:extensions]; + MMGenerator *generator = [MMGenerator new]; + + MMDocument *document = [parser parseMarkdown:string error:error]; + + return [generator generateHTML:document]; +} + + +@end diff --git a/tweak/assets/MMParser.h b/tweak/assets/MMParser.h new file mode 100644 index 0000000..72cd633 --- /dev/null +++ b/tweak/assets/MMParser.h @@ -0,0 +1,41 @@ +// +// MMParser.h +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import + + +#import "MMMarkdown.h" + +@class MMDocument; + +NS_ASSUME_NONNULL_BEGIN +@interface MMParser : NSObject + +- (id)initWithExtensions:(MMMarkdownExtensions)extensions; + +- (MMDocument *)parseMarkdown:(NSString *)markdown error:(NSError * __autoreleasing * _Nullable)error; + +@end +NS_ASSUME_NONNULL_END \ No newline at end of file diff --git a/tweak/assets/MMParser.m b/tweak/assets/MMParser.m new file mode 100644 index 0000000..663157b --- /dev/null +++ b/tweak/assets/MMParser.m @@ -0,0 +1,1321 @@ +// +// MMParser.m +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import "MMParser.h" + + +#import "MMDocument.h" +#import "MMDocument_Private.h" +#import "MMElement.h" +#import "MMHTMLParser.h" +#import "MMScanner.h" +#import "MMSpanParser.h" + +typedef NS_ENUM(NSInteger, MMListType) { + MMListTypeBulleted, + MMListTypeNumbered, +}; + +static NSString * __HTMLEntityForCharacter(unichar character) +{ + switch (character) + { + case '&': + return @"&"; + case '<': + return @"<"; + case '>': + return @">"; + default: + return @""; + } +} + +@interface MMParser () +@property (assign, nonatomic, readonly) MMMarkdownExtensions extensions; +@property (strong, nonatomic, readonly) MMHTMLParser *htmlParser; +@property (strong, nonatomic, readonly) MMSpanParser *spanParser; +@end + +@implementation MMParser + +#pragma mark - Public Methods + +- (id)initWithExtensions:(MMMarkdownExtensions)extensions +{ + self = [super init]; + + if (self) + { + _extensions = extensions; + _htmlParser = [MMHTMLParser new]; + _spanParser = [[MMSpanParser alloc] initWithExtensions:extensions]; + } + + return self; +} + +- (MMDocument *)parseMarkdown:(NSString *)markdown error:(__autoreleasing NSError **)error +{ + // It would be better to not replace all the tabs with spaces. But this will do for now. + markdown = [self _removeTabsFromString:markdown]; + + MMScanner *scanner = [MMScanner scannerWithString:markdown]; + MMDocument *document = [MMDocument documentWithMarkdown:markdown]; + + document.elements = [self _parseElementsWithScanner:scanner]; + [self _updateLinksFromDefinitionsInDocument:document]; + + return document; +} + + +#pragma mark - Private Methods + +// Add the remainder of the line as an inner range to the element. +// +// If the line contains the start of a multi-line HTML comment, then multiple lines will be added +// to the element. +- (void)_addTextLineToElement:(MMElement *)element withScanner:(MMScanner *)scanner +{ + NSCharacterSet *nonAngleSet = [[NSCharacterSet characterSetWithCharactersInString:@"<"] invertedSet]; + NSCharacterSet *nonDashSet = [[NSCharacterSet characterSetWithCharactersInString:@"-"] invertedSet]; + + NSRange lineRange = scanner.currentRange; + + // Check for an HTML comment, which could span blank lines + [scanner beginTransaction]; + NSMutableArray *commentRanges = [NSMutableArray new]; + // Look for the start of a comment on the current line + while (!scanner.atEndOfLine) + { + [scanner skipCharactersFromSet:nonAngleSet]; + if ([scanner matchString:@""]) + { + break; + } + [scanner advance]; + } + } + else + [scanner advance]; + } + [scanner commitTransaction:commentRanges.count > 0]; + if (commentRanges.count > 0) + { + for (NSValue *value in commentRanges) + { + [element addInnerRange:value.rangeValue]; + } + } + + [element addInnerRange:lineRange]; + [scanner advanceToNextLine]; +} + +- (NSString *)_removeTabsFromString:(NSString *)aString +{ + NSMutableString *result = [aString mutableCopy]; + + NSCharacterSet *tabAndNewline = [NSCharacterSet characterSetWithCharactersInString:@"\t\n"]; + + NSRange searchRange = NSMakeRange(0, aString.length); + NSRange resultRange; + NSUInteger lineLocation; + NSArray *strings = @[ @"", @" ", @" ", @" ", @" " ]; + + resultRange = [result rangeOfCharacterFromSet:tabAndNewline options:0 range:searchRange]; + lineLocation = 0; + while (resultRange.location != NSNotFound) + { + unichar character = [result characterAtIndex:resultRange.location]; + if (character == '\n') + { + lineLocation = 1 + resultRange.location; + searchRange = NSMakeRange(lineLocation, result.length-lineLocation); + } + else + { + NSUInteger numOfSpaces = 4 - ((resultRange.location - lineLocation) % 4); + [result replaceCharactersInRange:resultRange withString:[strings objectAtIndex:numOfSpaces]]; + searchRange = NSMakeRange(resultRange.location, result.length-resultRange.location); + } + resultRange = [result rangeOfCharacterFromSet:tabAndNewline options:0 range:searchRange]; + } + + return result; +} + +- (NSArray *)_parseElementsWithScanner:(MMScanner *)scanner +{ + NSMutableArray *result = [NSMutableArray new]; + + while (!scanner.atEndOfString) + { + MMElement *element = [self _parseBlockElementWithScanner:scanner]; + if (element) + { + [result addObject:element]; + } + else + { + [scanner skipCharactersFromSet:NSCharacterSet.whitespaceCharacterSet]; + if (scanner.atEndOfLine) + { + [scanner advanceToNextLine]; + } + } + } + + return result; +} + +- (MMElement *)_parseBlockElementWithScanner:(MMScanner *)scanner +{ + MMElement *element; + + [scanner beginTransaction]; + element = [self.htmlParser parseCommentWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseHTMLWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parsePrefixHeaderWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseUnderlinedHeaderWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseBlockquoteWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + // Check code first because its four-space behavior trumps most else + [scanner beginTransaction]; + element = [self _parseCodeBlockWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + if (self.extensions & MMMarkdownExtensionsFencedCodeBlocks) + { + [scanner beginTransaction]; + element = [self _parseFencedCodeBlockWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + } + + if (self.extensions & MMMarkdownExtensionsTables) + { + [scanner beginTransaction]; + element = [self _parseTableWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + } + + // Check horizontal rules before lists since they both start with * or - + [scanner beginTransaction]; + element = [self _parseHorizontalRuleWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseListWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseLinkDefinitionWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseParagraphWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + return nil; +} + +- (MMElement *)_parseHTMLWithScanner:(MMScanner *)scanner +{ + // At the beginning of the line + if (!scanner.atBeginningOfLine) + return nil; + + return [self.htmlParser parseBlockTagWithScanner:scanner]; +} + +- (MMElement *)_parsePrefixHeaderWithScanner:(MMScanner *)scanner +{ + NSUInteger level = 0; + while (scanner.nextCharacter == '#' && level < 6) + { + level++; + [scanner advance]; + } + + if (level == 0) + return nil; + + if ([scanner skipWhitespace] == 0) + return nil; + + NSRange headerRange = scanner.currentRange; + + // Check for trailing #s + while (headerRange.length > 0) + { + unichar character = [scanner.string characterAtIndex:NSMaxRange(headerRange)-1]; + if (character == '#') + headerRange.length--; + else + break; + } + + // Remove trailing whitespace + NSCharacterSet *whitespaceSet = NSCharacterSet.whitespaceCharacterSet; + while (headerRange.length > 0) + { + unichar character = [scanner.string characterAtIndex:NSMaxRange(headerRange)-1]; + if ([whitespaceSet characterIsMember:character]) + headerRange.length--; + else + break; + } + + [scanner advanceToNextLine]; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeHeader; + element.range = NSMakeRange(scanner.startLocation, NSMaxRange(scanner.currentRange)-scanner.startLocation); + element.level = level; + [element addInnerRange:headerRange]; + + if (element.innerRanges.count > 0) + { + MMScanner *innerScanner = [MMScanner scannerWithString:scanner.string lineRanges:element.innerRanges]; + element.children = [self.spanParser parseSpansInBlockElement:element withScanner:innerScanner]; + } + + return element; +} + +- (MMElement *)_parseUnderlinedHeaderWithScanner:(MMScanner *)scanner +{ + [scanner beginTransaction]; + + // Make sure that the first line isn't empty + [scanner skipCharactersFromSet:NSCharacterSet.whitespaceCharacterSet]; + if (scanner.atEndOfLine) + { + [scanner commitTransaction:NO]; + return nil; + } + + [scanner advanceToNextLine]; + + // There has to be more to the string + if (scanner.atEndOfString) + { + [scanner commitTransaction:NO]; + return nil; + } + + // The first character has to be a - or = + unichar character = scanner.nextCharacter; + if (character != '-' && character != '=') + { + [scanner commitTransaction:NO]; + return nil; + } + + // Every other character must also be a - or = + while (!scanner.atEndOfLine) + { + if (character != scanner.nextCharacter) + { + // If it's not a - or =, check if it's just optional whitespace before the newline + [scanner skipCharactersFromSet:NSCharacterSet.whitespaceCharacterSet]; + if (scanner.atEndOfLine) + break; + + [scanner commitTransaction:NO]; + return nil; + } + [scanner advance]; + } + [scanner commitTransaction:NO]; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeHeader; + element.level = character == '=' ? 1 : 2; + [element addInnerRange:scanner.currentRange]; + + [scanner advanceToNextLine]; // The header + [scanner advanceToNextLine]; // The underlines + + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + if (element.innerRanges.count > 0) + { + MMScanner *innerScanner = [MMScanner scannerWithString:scanner.string lineRanges:element.innerRanges]; + element.children = [self.spanParser parseSpansInBlockElement:element withScanner:innerScanner]; + } + + return element; +} + +- (MMElement *)_parseBlockquoteWithScanner:(MMScanner *)scanner +{ + // Skip up to 3 leading spaces + NSCharacterSet *spaceCharacterSet = [NSCharacterSet characterSetWithCharactersInString:@" "]; + [scanner skipCharactersFromSet:spaceCharacterSet max:3]; + + // Must have a > + if (scanner.nextCharacter != '>') + return nil; + [scanner advance]; + + // Can be followed by a space + if (scanner.nextCharacter == ' ') + [scanner advance]; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeBlockquote; + + [element addInnerRange:scanner.currentRange]; + [scanner advanceToNextLine]; + + // Parse each remaining line + NSCharacterSet *whitespaceSet = NSCharacterSet.whitespaceCharacterSet; + while (!scanner.atEndOfString) + { + [scanner beginTransaction]; + [scanner skipCharactersFromSet:whitespaceSet]; + + // It's a continuation of the blockquote unless it's a blank line + if (scanner.atEndOfLine) + { + [scanner commitTransaction:NO]; + break; + } + + // If there's a >, then skip it and an optional space + if (scanner.nextCharacter == '>') + { + [scanner advance]; + [scanner skipCharactersFromSet:whitespaceSet max:1]; + } + else + { + // + // If the following line is a list item + // then break the blockquote parsering. + // + [scanner beginTransaction]; + [scanner skipIndentationUpTo:2]; + BOOL hasListMarker = [self _parseListMarkerWithScanner:scanner listType:MMListTypeBulleted] + || [self _parseListMarkerWithScanner:scanner listType:MMListTypeNumbered]; + [scanner commitTransaction:NO]; + if (hasListMarker) + break; + } + + [element addInnerRange:scanner.currentRange]; + + [scanner commitTransaction:YES]; + [scanner advanceToNextLine]; + } + + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + + if (element.innerRanges.count > 0) + { + MMScanner *innerScanner = [MMScanner scannerWithString:scanner.string lineRanges:element.innerRanges]; + element.children = [self _parseElementsWithScanner:innerScanner]; + } + + return element; +} + +- (NSArray *)_parseCodeLinesWithScanner:(MMScanner *)scanner +{ + NSMutableArray *children = [NSMutableArray new]; + + // &, <, and > need to be escaped + NSCharacterSet *entities = [NSCharacterSet characterSetWithCharactersInString:@"&<>"]; + NSCharacterSet *nonEntities = [entities invertedSet]; + + while (!scanner.atEndOfString) + { + NSUInteger textLocation = scanner.location; + + [scanner skipCharactersFromSet:nonEntities]; + + if (textLocation != scanner.location) + { + MMElement *text = [MMElement new]; + text.type = MMElementTypeNone; + text.range = NSMakeRange(textLocation, scanner.location-textLocation); + [children addObject:text]; + } + + // Add the entity + if (!scanner.atEndOfLine) + { + unichar character = [scanner.string characterAtIndex:scanner.location]; + MMElement *entity = [MMElement new]; + entity.type = MMElementTypeEntity; + entity.range = NSMakeRange(scanner.location, 1); + entity.stringValue = __HTMLEntityForCharacter(character); + [children addObject:entity]; + [scanner advance]; + } + + if (scanner.atEndOfLine) + { + [scanner advanceToNextLine]; + + // Add a newline + MMElement *newline = [MMElement new]; + newline.type = MMElementTypeNone; + newline.range = NSMakeRange(scanner.location, 0); + [children addObject:newline]; + } + } + + return children; +} + +- (MMElement *)_parseCodeBlockWithScanner:(MMScanner *)scanner +{ + NSUInteger indentation = [scanner skipIndentationUpTo:4]; + if (indentation != 4 || scanner.atEndOfLine) + return nil; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeCodeBlock; + + [element addInnerRange:scanner.currentRange]; + [scanner advanceToNextLine]; + + while (!scanner.atEndOfString) + { + // Skip empty lines + NSUInteger numOfEmptyLines = [scanner skipEmptyLines]; + for (NSUInteger idx=0; idx 0 && [[element.innerRanges lastObject] rangeValue].length == 0) + { + [element removeLastInnerRange]; + } + + // Remove any trailing whitespace from the last line + if (element.innerRanges.count > 0) + { + NSRange lineRange = [[element.innerRanges lastObject] rangeValue]; + [element removeLastInnerRange]; + + NSCharacterSet *whitespaceSet = NSCharacterSet.whitespaceCharacterSet; + while (lineRange.length > 0) + { + unichar character = [scanner.string characterAtIndex:NSMaxRange(lineRange)-1]; + if ([whitespaceSet characterIsMember:character]) + lineRange.length--; + else + break; + } + + [element addInnerRange:lineRange]; + } + + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + + if (element.innerRanges.count > 0) + { + MMScanner *innerScanner = [MMScanner scannerWithString:scanner.string lineRanges:element.innerRanges]; + element.children = [self _parseCodeLinesWithScanner:innerScanner]; + } + + return element; +} + +- (MMElement *)_parseFencedCodeBlockWithScanner:(MMScanner *)scanner +{ + if (![scanner matchString:@"```"]) + return nil; + + // skip additional backticks and language + [scanner skipWhitespace]; + + NSMutableCharacterSet *languageNameSet = NSMutableCharacterSet.alphanumericCharacterSet; + [languageNameSet addCharactersInString:@"-_"]; + NSString *language = [scanner nextWordWithCharactersFromSet:languageNameSet]; + scanner.location += language.length; + + [scanner skipWhitespace]; + if (!scanner.atEndOfLine) + return nil; + [scanner advanceToNextLine]; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeCodeBlock; + element.language = (language.length == 0 ? nil : language); + + // block ends when it hints a line starting with ``` or the end of the string + while (!scanner.atEndOfString) + { + [scanner beginTransaction]; + if ([scanner matchString:@"```"]) + { + [scanner skipWhitespace]; + if (scanner.atEndOfLine) + { + [scanner commitTransaction:YES]; + break; + } + } + [scanner commitTransaction:NO]; + [element addInnerRange:scanner.currentRange]; + [scanner advanceToNextLine]; + } + + [scanner advanceToNextLine]; + + if (element.innerRanges.count > 0) + { + MMScanner *innerScanner = [MMScanner scannerWithString:scanner.string lineRanges:element.innerRanges]; + element.children = [self _parseCodeLinesWithScanner:innerScanner]; + } + + return element; +} + +- (MMElement *)_parseHorizontalRuleWithScanner:(MMScanner *)scanner +{ + // skip initial whitescape + [scanner skipCharactersFromSet:NSCharacterSet.whitespaceCharacterSet]; + + unichar character = scanner.nextCharacter; + if (character != '*' && character != '-' && character != '_') + return nil; + + unichar nextChar = character; + NSUInteger count = 0; + while (!scanner.atEndOfLine && nextChar == character) + { + count++; + + // The *, -, or _ + [scanner advance]; + nextChar = scanner.nextCharacter; + + // An optional space + if (nextChar == ' ') + { + [scanner advance]; + nextChar = scanner.nextCharacter; + } + } + + // There must be at least 3 *, -, or _ + if (count < 3) + return nil; + + // skip trailing whitespace + [scanner skipCharactersFromSet:NSCharacterSet.whitespaceCharacterSet]; + + // must be at the end of the line at this point + if (!scanner.atEndOfLine) + return nil; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeHorizontalRule; + element.range = NSMakeRange(scanner.startLocation, scanner.location - scanner.startLocation); + + return element; +} + +- (BOOL)_parseListMarkerWithScanner:(MMScanner *)scanner listType:(MMListType)listType +{ + switch (listType) + { + case MMListTypeBulleted: + [scanner beginTransaction]; + unichar nextChar = scanner.nextCharacter; + if (nextChar == '*' || nextChar == '-' || nextChar == '+') + { + [scanner advance]; + if (scanner.nextCharacter == ' ') + { + [scanner advance]; + [scanner commitTransaction:YES]; + return YES; + } + } + [scanner commitTransaction:NO]; + break; + + case MMListTypeNumbered: + [scanner beginTransaction]; + NSUInteger numOfNums = [scanner skipCharactersFromSet:[NSCharacterSet decimalDigitCharacterSet]]; + if (numOfNums != 0) + { + unichar nextChar = scanner.nextCharacter; + if (nextChar == '.') + { + [scanner advance]; + if (scanner.nextCharacter == ' ') + { + [scanner advance]; + [scanner commitTransaction:YES]; + return YES; + } + } + } + [scanner commitTransaction:NO]; + break; + } + + return NO; +} + +- (MMElement *)_parseListItemWithScanner:(MMScanner *)scanner listType:(MMListType)listType +{ + BOOL canContainBlocks = NO; + + if ([scanner skipEmptyLines]) + { + canContainBlocks = YES; + } + + [scanner skipIndentationUpTo:3]; // Optional space + + BOOL foundAnItem = [self _parseListMarkerWithScanner:scanner listType:listType]; + if (!foundAnItem) + return nil; + + [scanner skipCharactersFromSet:NSCharacterSet.whitespaceCharacterSet]; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeListItem; + + BOOL afterBlankLine = NO; + NSUInteger nestedListIndex = NSNotFound; + NSUInteger nestedListIndentation = 0; + while (!scanner.atEndOfString) + { + // Skip over any empty lines + [scanner beginTransaction]; + NSUInteger numOfEmptyLines = [scanner skipEmptyLines]; + afterBlankLine = numOfEmptyLines != 0; + + // Check for a horizontal rule + [scanner beginTransaction]; + BOOL newRule = [self _parseHorizontalRuleWithScanner:scanner] != nil; + [scanner commitTransaction:NO]; + if (newRule) + { + [scanner commitTransaction:NO]; + break; + } + + // Check for the start of a new list item + [scanner beginTransaction]; + [scanner skipIndentationUpTo:1]; + BOOL newMarker = [self _parseListMarkerWithScanner:scanner listType:listType]; + [scanner commitTransaction:NO]; + if (newMarker) + { + [scanner commitTransaction:NO]; + if (afterBlankLine) + { + canContainBlocks = YES; + } + break; + } + + // Check for a nested list + [scanner beginTransaction]; + NSUInteger indentation = [scanner skipIndentationUpTo:4]; + [scanner beginTransaction]; + BOOL newList = [self _parseListMarkerWithScanner:scanner listType:MMListTypeBulleted] + || [self _parseListMarkerWithScanner:scanner listType:MMListTypeNumbered]; + [scanner commitTransaction:NO]; + if (indentation >= 2 && newList && nestedListIndex == NSNotFound) + { + [element addInnerRange:NSMakeRange(scanner.location, 0)]; + nestedListIndex = element.innerRanges.count; + [element addInnerRange:scanner.currentRange]; + + [scanner commitTransaction:YES]; + [scanner commitTransaction:YES]; + [scanner advanceToNextLine]; + nestedListIndentation = indentation; + continue; + } + [scanner commitTransaction:NO]; + + if (afterBlankLine) + { + // Must be 4 spaces past the indentation level to start a new paragraph + [scanner beginTransaction]; + NSUInteger indentation = [scanner skipIndentationUpTo:4]; + if (indentation < 4) + { + [scanner commitTransaction:NO]; + [scanner commitTransaction:NO]; + break; + } + [scanner commitTransaction:YES]; + [scanner commitTransaction:YES]; + + [element addInnerRange:NSMakeRange(scanner.location, 0)]; + canContainBlocks = YES; + } + else + { + [scanner commitTransaction:YES]; + + // Don't skip past where a nested list would start because that list + // could have its own nested list, so the whitespace will be needed. + [scanner skipIndentationUpTo:nestedListIndentation]; + } + + if (nestedListIndex != NSNotFound) + { + [element addInnerRange:scanner.currentRange]; + [scanner advanceToNextLine]; + } + else + { + [self _addTextLineToElement:element withScanner:scanner]; + } + + [scanner beginTransaction]; + [scanner skipIndentationUpTo:4]; + if (scanner.nextCharacter == '>') + { + // + // If next line is start with blockquote mark + // then break current list parsering. + // + // for example: + // + // > 123 + // + abc + // + // "+ abs" should not consider as part of blockquote + // + // > 234 + // 567 + // + // "567" is part of the blockquote + // + [scanner commitTransaction:NO]; + break; + } + [scanner commitTransaction:NO]; + + } + + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + + if (element.innerRanges.count > 0) + { + if (nestedListIndex != NSNotFound) + { + NSArray *preListRanges = [element.innerRanges subarrayWithRange:NSMakeRange(0, nestedListIndex)]; + NSArray *postListRanges = [element.innerRanges subarrayWithRange:NSMakeRange(nestedListIndex, element.innerRanges.count - nestedListIndex)]; + MMScanner *preListScanner = [MMScanner scannerWithString:scanner.string lineRanges:preListRanges]; + MMScanner *postListScanner = [MMScanner scannerWithString:scanner.string lineRanges:postListRanges]; + + if (canContainBlocks) + { + element.children = [self _parseElementsWithScanner:preListScanner]; + } + else + { + element.children = [self.spanParser parseSpansInBlockElement:element withScanner:preListScanner]; + } + + element.children = [element.children arrayByAddingObjectsFromArray:[self _parseElementsWithScanner:postListScanner]]; + } + else + { + MMScanner *innerScanner = [MMScanner scannerWithString:scanner.string lineRanges:element.innerRanges]; + if (canContainBlocks) + { + element.children = [self _parseElementsWithScanner:innerScanner]; + } + else + { + element.children = [self.spanParser parseSpansInBlockElement:element withScanner:innerScanner]; + } + } + } + + return element; +} + +- (MMElement *)_parseListWithScanner:(MMScanner *)scanner +{ + [scanner beginTransaction]; + + [scanner skipIndentationUpTo:3]; // Optional space + unichar nextChar = scanner.nextCharacter; + BOOL isBulleted = (nextChar == '*' || nextChar == '-' || nextChar == '+'); + MMListType listType = isBulleted ? MMListTypeBulleted : MMListTypeNumbered; + BOOL hasMarker = [self _parseListMarkerWithScanner:scanner listType:listType]; + [scanner commitTransaction:NO]; + + if (!hasMarker) + return nil; + + MMElement *element = [MMElement new]; + element.type = isBulleted ? MMElementTypeBulletedList : MMElementTypeNumberedList; + + while (!scanner.atEndOfString) + { + [scanner beginTransaction]; + + // Check for a horizontal rule first -- they look like a list marker + [scanner skipEmptyLines]; + MMElement *rule = [self _parseHorizontalRuleWithScanner:scanner]; + + [scanner commitTransaction:NO]; + if (rule) + break; + + [scanner beginTransaction]; + MMElement *item = [self _parseListItemWithScanner:scanner listType:listType]; + if (!item) + { + [scanner commitTransaction:NO]; + break; + } + [scanner commitTransaction:YES]; + + [element addChild:item]; + } + + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + + return element; +} + +- (MMElement *)_parseLinkDefinitionWithScanner:(MMScanner *)scanner +{ + NSUInteger location; + NSUInteger length; + NSCharacterSet *whitespaceSet = NSCharacterSet.whitespaceCharacterSet; + + [scanner skipIndentationUpTo:3]; + + // find the identifier + location = scanner.location; + length = [scanner skipNestedBracketsWithDelimiter:'[']; + if (length == 0) + return nil; + + NSRange idRange = NSMakeRange(location+1, length-2); + + // and the semicolon + if (scanner.nextCharacter != ':') + return nil; + [scanner advance]; + + // skip any whitespace + [scanner skipCharactersFromSet:whitespaceSet]; + + // find the url + location = scanner.location; + [scanner skipCharactersFromSet:[whitespaceSet invertedSet]]; + + NSRange urlRange = NSMakeRange(location, scanner.location-location); + NSString *urlString = [scanner.string substringWithRange:urlRange]; + + // Check if the URL is surrounded by angle brackets + if ([urlString hasPrefix:@"<"] && [urlString hasSuffix:@">"]) + { + urlString = [urlString substringWithRange:NSMakeRange(1, urlString.length-2)]; + } + + // skip trailing whitespace + [scanner skipCharactersFromSet:whitespaceSet]; + + // If at the end of the line, then try to find the title on the next line + [scanner beginTransaction]; + if (scanner.atEndOfLine) + { + [scanner advanceToNextLine]; + [scanner skipCharactersFromSet:whitespaceSet]; + } + + // check for a title + NSRange titleRange = NSMakeRange(NSNotFound, 0); + unichar nextChar = scanner.nextCharacter; + if (nextChar == '"' || nextChar == '\'' || nextChar == '(') + { + [scanner advance]; + unichar endChar = (nextChar == '(') ? ')' : nextChar; + NSUInteger titleLocation = scanner.location; + NSUInteger titleLength = [scanner skipToLastCharacterOfLine]; + if (scanner.nextCharacter == endChar) + { + [scanner advance]; + titleRange = NSMakeRange(titleLocation, titleLength); + } + } + + [scanner commitTransaction:titleRange.location != NSNotFound]; + + // skip trailing whitespace + [scanner skipCharactersFromSet:whitespaceSet]; + + // make sure we're at the end of the line + if (!scanner.atEndOfLine) + return nil; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeDefinition; + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + element.identifier = [scanner.string substringWithRange:idRange]; + element.href = urlString; + + if (titleRange.location != NSNotFound) + { + element.title = [scanner.string substringWithRange:titleRange]; + } + + return element; +} + +- (MMElement *)_parseParagraphWithScanner:(MMScanner *)scanner +{ + MMElement *element = [MMElement new]; + element.type = MMElementTypeParagraph; + + NSCharacterSet *whitespaceSet = NSCharacterSet.whitespaceCharacterSet; + while (!scanner.atEndOfString) + { + [scanner skipWhitespace]; + if (scanner.atEndOfLine) + { + [scanner advanceToNextLine]; + break; + } + + // Check for a blockquote + [scanner beginTransaction]; + [scanner skipCharactersFromSet:whitespaceSet]; + if (scanner.nextCharacter == '>') + { + [scanner commitTransaction:YES]; + break; + } + [scanner commitTransaction:NO]; + + BOOL hasElement; + + // Check for a link definition + [scanner beginTransaction]; + hasElement = [self _parseLinkDefinitionWithScanner:scanner] != nil; + [scanner commitTransaction:NO]; + if (hasElement) + break; + + // Check for an underlined header + [scanner beginTransaction]; + hasElement = [self _parseUnderlinedHeaderWithScanner:scanner] != nil; + [scanner commitTransaction:NO]; + if (hasElement) + break; + + // Also check for a prefixed header + [scanner beginTransaction]; + hasElement = [self _parsePrefixHeaderWithScanner:scanner] != nil; + [scanner commitTransaction:NO]; + if (hasElement) + break; + + // Check for a fenced code block under GFM + if (self.extensions & MMMarkdownExtensionsFencedCodeBlocks) + { + [scanner beginTransaction]; + hasElement = [self _parseFencedCodeBlockWithScanner:scanner] != nil; + [scanner commitTransaction:NO]; + if (hasElement) + break; + } + + // Check for a list item + [scanner beginTransaction]; + [scanner skipIndentationUpTo:2]; + hasElement = [self _parseListMarkerWithScanner:scanner listType:MMListTypeBulleted] + || [self _parseListMarkerWithScanner:scanner listType:MMListTypeNumbered]; + [scanner commitTransaction:NO]; + if (hasElement) + break; + + [self _addTextLineToElement:element withScanner:scanner]; + } + + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + + if (element.innerRanges.count == 0) + return nil; + + MMScanner *innerScanner = [MMScanner scannerWithString:scanner.string lineRanges:element.innerRanges]; + element.children = [self.spanParser parseSpansInBlockElement:element withScanner:innerScanner]; + + return element; +} + +- (NSArray *)_parseTableHeaderWithScanner:(MMScanner *)scanner +{ + NSCharacterSet *dashSet = [NSCharacterSet characterSetWithCharactersInString:@"-"]; + + [scanner skipWhitespace]; + if (scanner.nextCharacter == '|') + [scanner advance]; + [scanner skipWhitespace]; + + NSMutableArray *alignments = [NSMutableArray new]; + + while (!scanner.atEndOfLine) + { + BOOL left = NO; + if (scanner.nextCharacter == ':') + { + left = YES; + [scanner advance]; + } + + NSUInteger dashes = [scanner skipCharactersFromSet:dashSet]; + if (dashes < 3) + return nil; + + BOOL right = NO; + if (scanner.nextCharacter == ':') + { + right = YES; + [scanner advance]; + } + + MMTableCellAlignment alignment + = left && right ? MMTableCellAlignmentCenter + : left ? MMTableCellAlignmentLeft + : right ? MMTableCellAlignmentRight + : MMTableCellAlignmentNone; + [alignments addObject:@(alignment)]; + + [scanner skipWhitespace]; + if (scanner.nextCharacter != '|') + break; + [scanner advance]; + [scanner skipWhitespace]; + } + + if (!scanner.atEndOfLine) + return nil; + + return alignments; +} + +- (MMElement *)_parseTableRowWithScanner:(MMScanner *)scanner columns:(NSArray *)columns +{ + NSMutableCharacterSet *trimmingSet = NSMutableCharacterSet.whitespaceCharacterSet; + [trimmingSet addCharactersInString:@"|"]; + + NSValue *lineRange = [NSValue valueWithRange:scanner.currentRange]; + MMScanner *lineScanner = [MMScanner scannerWithString:scanner.string lineRanges:@[ lineRange ]]; + + [lineScanner skipCharactersFromSet:trimmingSet]; + NSArray *cells = [self.spanParser parseSpansInTableColumns:columns withScanner:lineScanner]; + [lineScanner skipCharactersFromSet:trimmingSet]; + + if (!cells || !lineScanner.atEndOfLine) + return nil; + [scanner advanceToNextLine]; + + MMElement *row = [MMElement new]; + row.type = MMElementTypeTableRow; + row.children = cells; + row.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + return row; +} + +- (MMElement *)_parseTableWithScanner:(MMScanner *)scanner +{ + // Look for the header first + [scanner advanceToNextLine]; + NSArray *alignments = [self _parseTableHeaderWithScanner:scanner]; + if (!alignments) + return nil; + + // Undo the outer transaction to begin at the header content again + [scanner commitTransaction:NO]; + [scanner beginTransaction]; + + MMElement *header = [self _parseTableRowWithScanner:scanner columns:alignments]; + if (!header) + return nil; + + header.type = MMElementTypeTableHeader; + for (MMElement *cell in header.children) + cell.type = MMElementTypeTableHeaderCell; + + [scanner advanceToNextLine]; + + NSMutableArray *rows = [NSMutableArray arrayWithObject:header]; + while (!scanner.atEndOfString) + { + [scanner beginTransaction]; + MMElement *row = [self _parseTableRowWithScanner:scanner columns:alignments]; + [scanner commitTransaction:row != nil]; + if (row == nil) + break; + [rows addObject:row]; + } + + if (rows.count < 2) + return nil; + + MMElement *table = [MMElement new]; + table.type = MMElementTypeTable; + table.children = rows; + table.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + return table; +} + +- (void)_updateLinksFromDefinitionsInDocument:(MMDocument *)document +{ + NSMutableArray *references = [NSMutableArray new]; + NSMutableDictionary *definitions = [NSMutableDictionary new]; + NSMutableArray *queue = [NSMutableArray new]; + + [queue addObjectsFromArray:document.elements]; + + // First, find the references and definitions + while (queue.count > 0) + { + MMElement *element = [queue objectAtIndex:0]; + [queue removeObjectAtIndex:0]; + [queue addObjectsFromArray:element.children]; + + switch (element.type) + { + case MMElementTypeDefinition: + definitions[element.identifier.lowercaseString] = element; + break; + case MMElementTypeImage: + case MMElementTypeLink: + if (element.identifier && !element.href) + { + [references addObject:element]; + } + break; + default: + break; + } + } + + // Set the hrefs for all the references + for (MMElement *link in references) + { + MMElement *definition = definitions[link.identifier.lowercaseString]; + + // If there's no definition, change the link to a text element and remove its children + if (!definition) + { + link.type = MMElementTypeNone; + while (link.children.count > 0) + { + [link removeLastChild]; + } + } + // otherwise, set the href and title + { + link.href = definition.href; + link.title = definition.title; + } + } +} + + +@end diff --git a/tweak/assets/MMScanner.h b/tweak/assets/MMScanner.h new file mode 100644 index 0000000..6a95b3c --- /dev/null +++ b/tweak/assets/MMScanner.h @@ -0,0 +1,79 @@ +// +// MMScanner.h +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import + + +@interface MMScanner : NSObject + +// Constant info +@property (strong, nonatomic, readonly) NSString *string; +@property (copy, nonatomic, readonly) NSArray *lineRanges; + +// Changing +@property (assign, nonatomic, readonly) NSUInteger startLocation; +@property (assign, nonatomic, readonly) NSRange currentRange; + +// Settable +@property (assign, nonatomic) NSUInteger location; + ++ (id)scannerWithString:(NSString *)aString; +- (id)initWithString:(NSString *)aString; + ++ (id)scannerWithString:(NSString *)aString lineRanges:(NSArray *)theLineRanges; +- (id)initWithString:(NSString *)aString lineRanges:(NSArray *)theLineRanges; + +- (void)beginTransaction; +- (void)commitTransaction:(BOOL)shouldSave; + +- (BOOL)atBeginningOfLine; +- (BOOL)atEndOfLine; +- (BOOL)atEndOfString; + +- (unichar)previousCharacter; +- (unichar)nextCharacter; + +- (NSString *)previousWord; +- (NSString *)nextWord; + +- (NSString *)previousWordWithCharactersFromSet:(NSCharacterSet *)set; +- (NSString *)nextWordWithCharactersFromSet:(NSCharacterSet *)set; + +- (void)advance; +- (void)advanceToNextLine; + +- (BOOL)matchString:(NSString *)string; + +- (NSUInteger)skipCharactersFromSet:(NSCharacterSet *)aSet; +- (NSUInteger)skipCharactersFromSet:(NSCharacterSet *)aSet max:(NSUInteger)maxToSkip; +- (NSUInteger)skipEmptyLines; +- (NSUInteger)skipIndentationUpTo:(NSUInteger)maxSpacesToSkip; +- (NSUInteger)skipNestedBracketsWithDelimiter:(unichar)delimiter; +- (NSUInteger)skipToEndOfLine; +- (NSUInteger)skipToLastCharacterOfLine; +- (NSUInteger)skipWhitespace; +- (NSUInteger)skipWhitespaceAndNewlines; + +@end diff --git a/tweak/assets/MMScanner.m b/tweak/assets/MMScanner.m new file mode 100644 index 0000000..0ba2d09 --- /dev/null +++ b/tweak/assets/MMScanner.m @@ -0,0 +1,499 @@ +// +// MMScanner.m +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import "MMScanner.h" + + +static NSString *__delimitersForCharacter(unichar character) +{ + switch (character) + { + case '[': + case ']': + return @"[]"; + case '(': + case ')': + return @"()"; + case '<': + case '>': + return @"<>"; + case '{': + case '}': + return @"{}"; + default: + [NSException raise:@"Invalid delimiter character" + format:@"Character '%C' is not a valid delimiter", character]; + return nil; + } +} + +@interface MMScanner () +@property (assign, nonatomic) NSUInteger startLocation; +@property (assign, nonatomic) NSRange currentRange; + +@property (assign, nonatomic, readonly) NSRange currentLineRange; +@property (assign, nonatomic) NSUInteger rangeIndex; +@property (strong, nonatomic, readonly) NSMutableArray *transactions; +@end + +@implementation MMScanner + +#pragma mark - Public Methods + ++ (id)scannerWithString:(NSString *)aString +{ + return [[self.class alloc] initWithString:aString]; +} + +- (id)initWithString:(NSString *)aString +{ + NSArray *lineRanges = [self _lineRangesForString:aString]; + return [self initWithString:aString lineRanges:lineRanges]; +} + ++ (id)scannerWithString:(NSString *)aString lineRanges:(NSArray *)theLineRanges +{ + return [[self.class alloc] initWithString:aString lineRanges:theLineRanges]; +} + +- (id)initWithString:(NSString *)aString lineRanges:(NSArray *)theLineRanges +{ + NSParameterAssert(theLineRanges.count > 0); + + self = [super init]; + + if (self) + { + _string = aString; + _lineRanges = theLineRanges; + + _rangeIndex = 0; + _transactions = [NSMutableArray new]; + + self.startLocation = 0; + self.currentRange = self.currentLineRange; + } + + return self; +} + +- (void)beginTransaction +{ + NSDictionary *transaction = @{ + @"rangeIndex": @(self.rangeIndex), + @"location": @(self.location), + @"startLocation": @(self.startLocation), + }; + [self.transactions addObject:transaction]; + self.startLocation = self.location; +} + +- (void)commitTransaction:(BOOL)shouldSave +{ + if (!self.transactions.count) + [NSException raise:@"Transaction underflow" format:@"Could not commit transaction because the stack is empty"]; + + NSDictionary *transaction = [self.transactions lastObject]; + [self.transactions removeLastObject]; + + self.startLocation = [[transaction objectForKey:@"startLocation"] unsignedIntegerValue]; + if (!shouldSave) + { + self.rangeIndex = [[transaction objectForKey:@"rangeIndex"] unsignedIntegerValue]; + self.location = [[transaction objectForKey:@"location"] unsignedIntegerValue]; + } +} + +- (BOOL)atBeginningOfLine +{ + return self.location == self.currentLineRange.location; +} + +- (BOOL)atEndOfLine +{ + return self.location == NSMaxRange(self.currentLineRange); +} + +- (BOOL)atEndOfString +{ + return self.atEndOfLine && self.rangeIndex == self.lineRanges.count - 1; +} + +- (unichar)previousCharacter +{ + if (self.atBeginningOfLine) + return '\0'; + + return [self.string characterAtIndex:self.location - 1]; +} + +- (unichar)nextCharacter +{ + if (self.atEndOfLine) + return '\n'; + return [self.string characterAtIndex:self.location]; +} + +- (NSString *)previousWord +{ + return [self previousWordWithCharactersFromSet:NSCharacterSet.alphanumericCharacterSet]; +} + +- (NSString *)nextWord +{ + return [self nextWordWithCharactersFromSet:NSCharacterSet.alphanumericCharacterSet]; +} + +- (NSString *)previousWordWithCharactersFromSet:(NSCharacterSet *)set +{ + NSUInteger start = MAX(self.currentLineRange.location, self.startLocation); + NSUInteger end = self.currentRange.location; + NSRange range = NSMakeRange(start, end-start); + + NSRange result = [self.string rangeOfCharacterFromSet:set.invertedSet + options:NSBackwardsSearch + range:range]; + + if (result.location == NSNotFound) + return [self.string substringWithRange:range]; + + NSUInteger wordLocation = NSMaxRange(result); + NSRange wordRange = NSMakeRange(wordLocation, end-wordLocation); + return [self.string substringWithRange:wordRange]; +} + +- (NSString *)nextWordWithCharactersFromSet:(NSCharacterSet *)set +{ + NSRange result = [self.string rangeOfCharacterFromSet:set.invertedSet + options:0 + range:self.currentRange]; + + if (result.location == NSNotFound) + return [self.string substringWithRange:self.currentRange]; + + NSRange wordRange = self.currentRange; + wordRange.length = result.location - wordRange.location; + + return [self.string substringWithRange:wordRange]; +} + +- (void)advance +{ + if (self.atEndOfLine) + return; + self.location += 1; +} + +- (void)advanceToNextLine +{ + // If at the last line, just go to the end of the line + if (self.rangeIndex == self.lineRanges.count - 1) + { + self.location = NSMaxRange(self.currentLineRange); + } + // Otherwise actually go to the next line + else + { + self.rangeIndex += 1; + self.currentRange = self.currentLineRange; + } +} + +- (BOOL)matchString:(NSString *)string +{ + if (self.currentRange.length < string.length) + return NO; + + NSUInteger location = self.location; + for (NSUInteger idx=0; idx 0) + { + if (self.atEndOfLine) + { + [self commitTransaction:NO]; + return 0; + } + + [self skipCharactersFromSet:boringChars]; + + unichar nextChar = self.nextCharacter; + [self advance]; + + if (nextChar == openDelimiter) + { + nestingLevel++; + } + else if (nextChar == closeDelimeter) + { + nestingLevel--; + } + else if (nextChar == '\\') + { + // skip a second character after a backslash + [self advance]; + } + } + + [self commitTransaction:YES]; + return self.location - location; +} + +- (NSUInteger)skipToEndOfLine +{ + NSUInteger length = self.currentRange.length; + self.location = NSMaxRange(self.currentRange); + return length; +} + +- (NSUInteger)skipToLastCharacterOfLine +{ + NSUInteger length = self.currentRange.length - 1; + self.location = NSMaxRange(self.currentRange) - 1; + return length; +} + +- (NSUInteger)skipWhitespace +{ + return [self skipCharactersFromSet:NSCharacterSet.whitespaceCharacterSet]; +} + +- (NSUInteger)skipWhitespaceAndNewlines +{ + NSCharacterSet *whitespaceSet = NSCharacterSet.whitespaceCharacterSet; + NSUInteger length = 0; + + while (!self.atEndOfString) + { + if (self.atEndOfLine) + { + [self advanceToNextLine]; + length++; + } + else + { + NSUInteger spaces = [self skipCharactersFromSet:whitespaceSet]; + if (spaces == 0) + break; + + length += spaces; + } + } + + return length; +} + + +#pragma mark - Public Properties + +- (NSUInteger)location +{ + return self.currentRange.location; +} + +- (void)setLocation:(NSUInteger)location +{ + // If the new location isn't a part of the current range, then find the range it belongs to. + if (!NSLocationInRange(location, self.currentLineRange)) + { + __block NSUInteger index = 0; + [self.lineRanges enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop){ + NSRange range = [obj rangeValue]; + *stop = NSLocationInRange(location, range) || location == NSMaxRange(range); + index = idx; + }]; + self.rangeIndex = index; + } + + self.currentRange = NSMakeRange(location, NSMaxRange(self.currentLineRange)-location); +} + + +#pragma mark - Private Methods + +- (NSArray *)_lineRangesForString:(NSString *)aString +{ + NSMutableArray *result = [NSMutableArray array]; + + NSUInteger location = 0; + NSUInteger idx; + for (idx=0; idx + + +#import "MMMarkdown.h" +#import "MMParser.h" + +@class MMElement; +@class MMScanner; + +NS_ASSUME_NONNULL_BEGIN +@interface MMSpanParser : NSObject + +- (id)initWithExtensions:(MMMarkdownExtensions)extensions; + +- (nullable NSArray *)parseSpansInBlockElement:(MMElement *)block withScanner:(MMScanner *)scanner; + +- (nullable NSArray *)parseSpansInTableColumns:(NSArray *)columns withScanner:(MMScanner *)scanner; + +@end +NS_ASSUME_NONNULL_END \ No newline at end of file diff --git a/tweak/assets/MMSpanParser.m b/tweak/assets/MMSpanParser.m new file mode 100644 index 0000000..7c32922 --- /dev/null +++ b/tweak/assets/MMSpanParser.m @@ -0,0 +1,1298 @@ +// +// MMSpanParser.m +// MMMarkdown +// +// Copyright (c) 2012 Matt Diephouse. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import "MMSpanParser.h" + + +#import "MMElement.h" +#import "MMHTMLParser.h" +#import "MMScanner.h" + +static NSString * const ESCAPABLE_CHARS = @"\\`*_{}[]()#+-.!>"; + +@interface MMSpanParser () +@property (assign, nonatomic, readonly) MMMarkdownExtensions extensions; +@property (strong, nonatomic, readonly) MMHTMLParser *htmlParser; + +@property (strong, nonatomic) NSMutableArray *elements; +@property (strong, nonatomic) NSMutableArray *openElements; + +@property (strong, nonatomic) MMElement *blockElement; +@property (assign, nonatomic) BOOL parseEm; +@property (assign, nonatomic) BOOL parseImages; +@property (assign, nonatomic) BOOL parseLinks; +@property (assign, nonatomic) BOOL parseStrong; +@end + +@implementation MMSpanParser + +#pragma mark - Public Methods + +- (id)initWithExtensions:(MMMarkdownExtensions)extensions +{ + self = [super init]; + + if (self) + { + _extensions = extensions; + _htmlParser = [MMHTMLParser new]; + self.parseEm = YES; + self.parseImages = YES; + self.parseLinks = YES; + self.parseStrong = YES; + } + + return self; +} + +- (NSArray *)parseSpansInBlockElement:(MMElement *)block withScanner:(MMScanner *)scanner +{ + self.blockElement = block; + [scanner skipWhitespace]; + return [self _parseWithScanner:scanner untilTestPasses:^{ return scanner.atEndOfString; }]; +} + +- (NSArray *)parseSpansInTableColumns:(NSArray *)columns withScanner:(MMScanner *)scanner +{ + NSMutableArray *cells = [NSMutableArray new]; + + for (NSNumber *alignment in columns) + { + [scanner skipWhitespace]; + + NSUInteger startLocation = scanner.location; + NSArray *spans = scanner.nextCharacter == '|' ? @[] : [self _parseWithScanner:scanner untilTestPasses:^ BOOL { + [scanner skipWhitespace]; + return scanner.nextCharacter == '|' || scanner.atEndOfLine; + }]; + + if (!spans) + return nil; + + MMElement *cell = [MMElement new]; + cell.type = MMElementTypeTableRowCell; + cell.children = spans; + cell.range = NSMakeRange(startLocation, scanner.location-startLocation); + cell.alignment = alignment.integerValue; + [cells addObject:cell]; + + if (scanner.nextCharacter == '|') + [scanner advance]; + } + + return cells; +} + + +#pragma mark - Private Methods + +- (NSArray *)_parseWithScanner:(MMScanner *)scanner untilTestPasses:(BOOL (^)(void))test +{ + NSMutableArray *result = [NSMutableArray array]; + + NSCharacterSet *specialChars = [NSCharacterSet characterSetWithCharactersInString:@"\\`*_<&[! ~w:@|"]; + NSCharacterSet *boringChars = [specialChars invertedSet]; + + [scanner beginTransaction]; + while (!scanner.atEndOfString) + { + MMElement *element = [self _parseNextElementWithScanner:scanner]; + if (element) + { + if (scanner.startLocation != element.range.location) + { + MMElement *text = [MMElement new]; + text.type = MMElementTypeNone; + text.range = NSMakeRange(scanner.startLocation, element.range.location-scanner.startLocation); + [result addObject:text]; + } + + [result addObject:element]; + + [scanner commitTransaction:YES]; + [scanner beginTransaction]; + } + else if (scanner.atEndOfLine) + { + // This is done here (and not in _parseNextElementWithScanner:) + // because it can result in 2 elements. + + if (scanner.startLocation != scanner.location) + { + MMElement *text = [MMElement new]; + text.type = MMElementTypeNone; + text.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + [result addObject:text]; + } + + if (self.extensions & MMMarkdownExtensionsHardNewlines && self.blockElement.type == MMElementTypeParagraph) + { + MMElement *lineBreak = [MMElement new]; + lineBreak.range = NSMakeRange(scanner.location, 1); + lineBreak.type = MMElementTypeLineBreak; + [result addObject:lineBreak]; + } + + // Add a newline + MMElement *newline = [MMElement new]; + newline.range = NSMakeRange(scanner.location, 1); + newline.type = MMElementTypeEntity; + newline.stringValue = @"\n"; + [result addObject:newline]; + + [scanner advanceToNextLine]; + [scanner commitTransaction:YES]; + [scanner beginTransaction]; + } + else if ([scanner skipCharactersFromSet:boringChars]) + { + } + else + { + [scanner advance]; + } + + // Check for the end character + [scanner beginTransaction]; + NSUInteger location = scanner.location; + if (test()) + { + [scanner commitTransaction:YES]; + + if (scanner.startLocation != location) + { + MMElement *text = [MMElement new]; + text.type = MMElementTypeNone; + text.range = NSMakeRange(scanner.startLocation, location-scanner.startLocation); + [result addObject:text]; + } + + [scanner commitTransaction:YES]; + + return result; + } + [scanner commitTransaction:NO]; + } + [scanner commitTransaction:NO]; + + return nil; +} + +- (MMElement *)_parseNextElementWithScanner:(MMScanner *)scanner +{ + MMElement *element; + + if (self.extensions & MMMarkdownExtensionsStrikethroughs) + { + [scanner beginTransaction]; + element = [self _parseStrikethroughWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + } + + // URL Autolinking + if (self.parseLinks && self.extensions & MMMarkdownExtensionsAutolinkedURLs) + { + [scanner beginTransaction]; + element = [self _parseAutolinkEmailAddressWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseAutolinkURLWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseAutolinkWWWURLWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + } + + [scanner beginTransaction]; + element = [self _parseBackslashWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseEmAndStrongWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseCodeSpanWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseLineBreakWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + if (self.parseLinks) + { + [scanner beginTransaction]; + element = [self _parseAutomaticLinkWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseAutomaticEmailLinkWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseLinkWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + } + + if (self.parseImages) + { + [scanner beginTransaction]; + element = [self _parseImageWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + } + + [scanner beginTransaction]; + element = [self.htmlParser parseInlineTagWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self.htmlParser parseCommentWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseAmpersandWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + [scanner beginTransaction]; + element = [self _parseLeftAngleBracketWithScanner:scanner]; + [scanner commitTransaction:element != nil]; + if (element) + return element; + + return nil; +} + +- (BOOL)_parseAutolinkDomainWithScanner:(MMScanner *)scanner +{ + NSCharacterSet *alphanumerics = NSCharacterSet.alphanumericCharacterSet; + NSMutableCharacterSet *domainChars = [alphanumerics mutableCopy]; + [domainChars addCharactersInString:@"-:"]; + + // Domain should be at least one alphanumeric + if (![alphanumerics characterIsMember:scanner.nextCharacter]) + return NO; + [scanner skipCharactersFromSet:domainChars]; + + // Dot between domain and TLD + if (scanner.nextCharacter != '.') + return NO; + [scanner advance]; + + // TLD must be at least 1 character + if ([scanner skipCharactersFromSet:domainChars] == 0) + return NO; + + return YES; +} + +- (void)_parseAutolinkPathWithScanner:(MMScanner *)scanner +{ + NSCharacterSet *alphanumerics = NSCharacterSet.alphanumericCharacterSet; + NSMutableCharacterSet *boringChars = [alphanumerics mutableCopy]; + [boringChars addCharactersInString:@",_-/:?&;%~!#+=@"]; + + NSUInteger parenLevel = 0; + while (1) + { + if ([scanner skipCharactersFromSet:boringChars] > 0) + { + continue; + } + else if (scanner.nextCharacter == '\\') + { + [scanner advance]; + if (scanner.nextCharacter == '(' || scanner.nextCharacter == ')') + [scanner advance]; + } + else if (scanner.nextCharacter == '(') + { + parenLevel++; + [scanner advance]; + } + else if (scanner.nextCharacter == ')' && parenLevel > 0) + { + parenLevel--; + [scanner advance]; + } + else if (scanner.nextCharacter == '.') + { + // Can't end on a '.' + [scanner beginTransaction]; + [scanner advance]; + if ([boringChars characterIsMember:scanner.nextCharacter]) + { + [scanner commitTransaction:YES]; + } + else + { + [scanner commitTransaction:NO]; + break; + } + } + else + { + break; + } + } +} + +- (MMElement *)_parseAutolinkEmailAddressWithScanner:(MMScanner *)scanner +{ + if (scanner.nextCharacter != '@') + return nil; + + NSCharacterSet *alphanumerics = NSCharacterSet.alphanumericCharacterSet; + NSMutableCharacterSet *localChars = [alphanumerics mutableCopy]; + [localChars addCharactersInString:@"._-+"]; + NSMutableCharacterSet *domainChars = [alphanumerics mutableCopy]; + [domainChars addCharactersInString:@"._-"]; + + // Look for the previous word outside of the current transaction + [scanner commitTransaction:NO]; + NSString *localPart = [scanner previousWordWithCharactersFromSet:localChars]; + [scanner beginTransaction]; + + if (localPart.length == 0) + return nil; + + // '@' + [scanner advance]; + + NSString *domainPart = [scanner nextWordWithCharactersFromSet:localChars]; + + // Must end on a letter or number + NSRange lastAlphanum = [domainPart rangeOfCharacterFromSet:alphanumerics options:NSBackwardsSearch]; + if (lastAlphanum.location == NSNotFound) + return nil; + domainPart = [domainPart substringToIndex:NSMaxRange(lastAlphanum)]; + + // Must contain at least one . + if ([domainPart rangeOfString:@"."].location == NSNotFound) + return nil; + + scanner.location += domainPart.length; + + NSUInteger startLocation = scanner.startLocation - localPart.length; + NSRange range = NSMakeRange(startLocation, scanner.location-startLocation); + + MMElement *element = [MMElement new]; + element.type = MMElementTypeMailTo; + element.range = range; + element.href = [scanner.string substringWithRange:range]; + + return element; +} + +- (MMElement *)_parseAutolinkURLWithScanner:(MMScanner *)scanner +{ + if (scanner.nextCharacter != ':') + return nil; + + NSArray *protocols = @[ @"https", @"http", @"ftp" ]; + + // Look for the previous word outside of the current transaction + [scanner commitTransaction:NO]; + NSString *previousWord = scanner.previousWord; + [scanner beginTransaction]; + + if (![protocols containsObject:previousWord.lowercaseString]) + return nil; + + if (![scanner matchString:@"://"]) + return nil; + + if (![self _parseAutolinkDomainWithScanner:scanner]) + return nil; + [self _parseAutolinkPathWithScanner:scanner]; + + NSUInteger startLocation = scanner.startLocation - previousWord.length; + NSRange range = NSMakeRange(startLocation, scanner.location-startLocation); + + MMElement *element = [MMElement new]; + element.type = MMElementTypeLink; + element.range = range; + element.href = [scanner.string substringWithRange:range]; + + MMElement *text = [MMElement new]; + text.type = MMElementTypeNone; + text.range = range; + [element addChild:text]; + + return element; +} + +- (MMElement *)_parseAutolinkWWWURLWithScanner:(MMScanner *)scanner +{ + if (![scanner matchString:@"www."]) + return nil; + + if (![self _parseAutolinkDomainWithScanner:scanner]) + return nil; + [self _parseAutolinkPathWithScanner:scanner]; + + NSRange range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + NSString *link = [scanner.string substringWithRange:range]; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeLink; + element.range = range; + element.href = [@"http://" stringByAppendingString:link]; + + MMElement *text = [MMElement new]; + text.type = MMElementTypeNone; + text.range = range; + [element addChild:text]; + + return element; +} + +- (MMElement *)_parseStrikethroughWithScanner:(MMScanner *)scanner +{ + if (![scanner matchString:@"~~"]) + return nil; + + NSCharacterSet *whitespaceSet = NSCharacterSet.whitespaceCharacterSet; + NSArray *children = [self _parseWithScanner:scanner untilTestPasses:^{ + // Can't be at the beginning of the line + if (scanner.atBeginningOfLine) + return NO; + + // Must follow the end of a word + if ([whitespaceSet characterIsMember:scanner.previousCharacter]) + return NO; + + if (![scanner matchString:@"~~"]) + return NO; + + return YES; + }]; + + if (!children) + return nil; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeStrikethrough; + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + element.children = children; + + return element; +} + +- (MMElement *)_parseEmAndStrongWithScanner:(MMScanner *)scanner +{ + // Must have 1-3 *s or _s + unichar character = scanner.nextCharacter; + if (!(character == '*' || character == '_')) + return nil; + + NSCharacterSet *alphanumericSet = NSCharacterSet.alphanumericCharacterSet; + if (self.extensions & MMMarkdownExtensionsUnderscoresInWords && character == '_') + { + // GFM doesn't italicize parts of words + + [scanner commitTransaction:NO]; + // Look for the previous char outside of the current transaction + unichar prevChar = scanner.previousCharacter; + [scanner beginTransaction]; + + BOOL isWordChar = [alphanumericSet characterIsMember:prevChar]; + if (isWordChar) + return nil; + } + + // Must not be preceded by one of the same + if (scanner.previousCharacter == character) + return nil; + + NSUInteger numberOfChars = 0; + while (scanner.nextCharacter == character) + { + numberOfChars++; + [scanner advance]; + } + + if (numberOfChars > 3) + return nil; + + BOOL parseEm = numberOfChars == 1 || numberOfChars == 3; + BOOL parseStrong = numberOfChars == 2 || numberOfChars == 3; + + if ((parseEm && !self.parseEm) || (parseStrong && !self.parseStrong)) + return nil; + + NSCharacterSet *whitespaceSet = NSCharacterSet.whitespaceCharacterSet; + __block NSUInteger remainingChars = numberOfChars; + BOOL (^atEnd)(void) = ^{ + // Can't be at the beginning of the line + if (scanner.atBeginningOfLine) + return NO; + + // Must follow the end of a word + if ([whitespaceSet characterIsMember:scanner.previousCharacter]) + return NO; + + // Must have 1-3 *s or _s + NSUInteger numberOfEndChars = 0; + while (scanner.nextCharacter == character && numberOfEndChars < remainingChars) + { + numberOfEndChars++; + [scanner advance]; + } + + if (numberOfEndChars == 0 || (numberOfEndChars != remainingChars && remainingChars != 3)) + return NO; + + if (self.extensions & MMMarkdownExtensionsUnderscoresInWords && character == '_') + { + // GFM doesn't italicize parts of words + unichar nextChar = scanner.nextCharacter; + + BOOL isWordChar = [alphanumericSet characterIsMember:nextChar]; + if (isWordChar) + return NO; + } + + remainingChars -= numberOfEndChars; + + return YES; + }; + + if (parseEm) + self.parseEm = NO; + if (parseStrong) + self.parseStrong = NO; + NSArray *children = [self _parseWithScanner:scanner untilTestPasses:atEnd]; + if (parseEm && (!children || remainingChars != 1)) + self.parseEm = YES; + if (parseStrong && (!children || remainingChars != 2)) + self.parseStrong = YES; + + if (!children) + return nil; + + BOOL isEm = (numberOfChars == 1) || (numberOfChars == 3 && remainingChars != 1); + NSUInteger startLocation = scanner.startLocation + remainingChars; + MMElement *element = [MMElement new]; + element.type = isEm ? MMElementTypeEm : MMElementTypeStrong; + element.range = NSMakeRange(startLocation, scanner.location-startLocation); + element.children = children; + + if (numberOfChars == 3 && remainingChars == 0) + { + NSArray *outerChildren = @[ element ]; + element = [MMElement new]; + element.type = MMElementTypeStrong; + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + element.children = outerChildren; + } + else if (remainingChars > 0) + { + NSMutableArray *outerChildren = [[self _parseWithScanner:scanner untilTestPasses:atEnd] mutableCopy]; + if (parseEm) + self.parseEm = YES; + if (parseStrong) + self.parseStrong = YES; + if (!outerChildren) + return nil; + + [outerChildren insertObject:element atIndex:0]; + + element = [MMElement new]; + element.type = !isEm ? MMElementTypeEm : MMElementTypeStrong; + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + element.children = outerChildren; + } + + return element; +} + +- (MMElement *)_parseCodeSpanWithScanner:(MMScanner *)scanner +{ + if (scanner.nextCharacter != '`') + return nil; + [scanner advance]; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeCodeSpan; + + // Check for more `s + NSUInteger level = 1; + while (scanner.nextCharacter == '`') + { + level++; + [scanner advance]; + } + + // skip leading whitespace + [scanner skipCharactersFromSet:NSCharacterSet.whitespaceCharacterSet]; + + // Skip to the next '`' + NSCharacterSet *boringChars = [[NSCharacterSet characterSetWithCharactersInString:@"`&<>"] invertedSet]; + NSUInteger textLocation = scanner.location; + while (1) + { + if (scanner.atEndOfString) + return nil; + + // Skip other characters + [scanner skipCharactersFromSet:boringChars]; + + // Add the code as text + if (textLocation != scanner.location) + { + MMElement *text = [MMElement new]; + text.type = MMElementTypeNone; + text.range = NSMakeRange(textLocation, scanner.location-textLocation); + [element addChild:text]; + } + + // Check for closing `s + if (scanner.nextCharacter == '`') + { + // Set the text location to catch the ` in case it isn't the closing `s + textLocation = scanner.location; + + NSUInteger idx; + for (idx=0; idx= level) + break; + else + continue; + } + + unichar nextChar = scanner.nextCharacter; + // Check for entities + if (nextChar == '&') + { + MMElement *entity = [MMElement new]; + entity.type = MMElementTypeEntity; + entity.range = NSMakeRange(scanner.location, 1); + entity.stringValue = @"&"; + [element addChild:entity]; + [scanner advance]; + } + else if (nextChar == '<') + { + MMElement *entity = [MMElement new]; + entity.type = MMElementTypeEntity; + entity.range = NSMakeRange(scanner.location, 1); + entity.stringValue = @"<"; + [element addChild:entity]; + [scanner advance]; + } + else if (nextChar == '>') + { + MMElement *entity = [MMElement new]; + entity.type = MMElementTypeEntity; + entity.range = NSMakeRange(scanner.location, 1); + entity.stringValue = @">"; + [element addChild:entity]; + [scanner advance]; + } + // Or did we hit the end of the line? + else if (scanner.atEndOfLine) + { + textLocation = scanner.location; + [scanner advanceToNextLine]; + continue; + } + + textLocation = scanner.location; + } + + // remove trailing whitespace + if (element.children.count > 0) + { + MMElement *lastText = element.children.lastObject; + unichar lastCharacter = [scanner.string characterAtIndex:NSMaxRange(lastText.range)-1]; + while ([NSCharacterSet.whitespaceCharacterSet characterIsMember:lastCharacter]) + { + NSRange range = lastText.range; + range.length -= 1; + lastText.range = range; + + lastCharacter = [scanner.string characterAtIndex:NSMaxRange(lastText.range)-1]; + } + } + + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + + return element; +} + +- (MMElement *)_parseLineBreakWithScanner:(MMScanner *)scanner +{ + NSCharacterSet *spaces = [NSCharacterSet characterSetWithCharactersInString:@" "]; + if ([scanner skipCharactersFromSet:spaces] < 2) + return nil; + + if (!scanner.atEndOfLine) + return nil; + + // Don't ever add a line break to the last line + if (scanner.atEndOfString) + return nil; + + NSUInteger startLocation = scanner.startLocation + 1; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeLineBreak; + element.range = NSMakeRange(startLocation, scanner.location-startLocation); + + return element; +} + +- (MMElement *)_parseAutomaticLinkWithScanner:(MMScanner *)scanner +{ + // Leading < + if (scanner.nextCharacter != '<') + return nil; + [scanner advance]; + + NSUInteger textLocation = scanner.location; + + // Find the trailing > + [scanner skipCharactersFromSet:[[NSCharacterSet characterSetWithCharactersInString:@">"] invertedSet]]; + if (scanner.atEndOfLine) + return nil; + [scanner advance]; + + NSRange linkRange = NSMakeRange(textLocation, (scanner.location-1)-textLocation); + NSString *linkText = [scanner.string substringWithRange:linkRange]; + + // Make sure it looks like a link + static NSRegularExpression *regex; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + regex = [NSRegularExpression regularExpressionWithPattern:@"^(\\w+)://" options:0 error:nil]; + }); + NSRange matchRange; + matchRange = [regex rangeOfFirstMatchInString:linkText options:0 range:NSMakeRange(0, linkText.length)]; + if (matchRange.location == NSNotFound) + return nil; + NSURL *url = [NSURL URLWithString:linkText]; + if (!url) + return nil; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeLink; + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + element.href = linkText; + + // Do the text the hard way to take care of ampersands + NSRange textRange = NSMakeRange(textLocation, NSMaxRange(linkRange)-textLocation); + NSCharacterSet *ampersands = [NSCharacterSet characterSetWithCharactersInString:@"&"]; + while (textRange.length > 0) + { + NSRange result = [scanner.string rangeOfCharacterFromSet:ampersands + options:0 + range:textRange]; + + if (result.location != NSNotFound) + { + if (textRange.location != result.location) + { + MMElement *text = [MMElement new]; + text.type = MMElementTypeNone; + text.range = NSMakeRange(textRange.location, result.location-textRange.location); + [element addChild:text]; + } + + MMElement *ampersand = [MMElement new]; + ampersand.type = MMElementTypeEntity; + ampersand.range = NSMakeRange(textRange.location, 1); + ampersand.stringValue = @"&"; + [element addChild:ampersand]; + + textRange = NSMakeRange(result.location+1, NSMaxRange(textRange)-(result.location+1)); + } + else + { + if (textRange.length > 0) + { + MMElement *text = [MMElement new]; + text.type = MMElementTypeNone; + text.range = textRange; + [element addChild:text]; + } + break; + } + } + + return element; +} + +- (MMElement *)_parseAutomaticEmailLinkWithScanner:(MMScanner *)scanner +{ + // Leading < + if (scanner.nextCharacter != '<') + return nil; + [scanner advance]; + + NSUInteger textLocation = scanner.location; + + // Find the trailing > + [scanner skipCharactersFromSet:[[NSCharacterSet characterSetWithCharactersInString:@">"] invertedSet]]; + if (scanner.atEndOfLine) + return nil; + [scanner advance]; + + NSRange linkRange = NSMakeRange(textLocation, (scanner.location-1)-textLocation); + NSString *linkText = [scanner.string substringWithRange:linkRange]; + + // Make sure it looks like a link + static NSRegularExpression *regex; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + regex = [NSRegularExpression regularExpressionWithPattern:@"^[-._0-9\\p{L}]+@[-\\p{L}0-9][-.\\p{L}0-9]*\\.\\p{L}+$" + options:NSRegularExpressionCaseInsensitive + error:nil]; + }); + NSRange matchRange; + matchRange = [regex rangeOfFirstMatchInString:linkText options:0 range:NSMakeRange(0, linkText.length)]; + if (matchRange.location == NSNotFound) + return nil; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeMailTo; + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + element.href = linkText; + + return element; +} + +- (NSArray *)_parseLinkTextBodyWithScanner:(MMScanner *)scanner +{ + NSMutableArray *ranges = [NSMutableArray new]; + NSCharacterSet *boringChars; + NSUInteger level; + + if (scanner.nextCharacter != '[') + return nil; + [scanner advance]; + + boringChars = [[NSCharacterSet characterSetWithCharactersInString:@"[]\\"] invertedSet]; + level = 1; + NSRange textRange = scanner.currentRange; + while (level > 0) + { + if (scanner.atEndOfString) + return nil; + + if (scanner.atEndOfLine) + { + if (textRange.length > 0) + { + [ranges addObject:[NSValue valueWithRange:textRange]]; + } + [scanner advanceToNextLine]; + textRange = scanner.currentRange; + } + + [scanner skipCharactersFromSet:boringChars]; + + unichar character = scanner.nextCharacter; + if (character == '[') + { + level += 1; + } + else if (character == ']') + { + level -= 1; + } + else if (character == '\\') + { + [scanner advance]; + } + + textRange.length = scanner.location - textRange.location; + [scanner advance]; + } + + if (textRange.length > 0) + { + [ranges addObject:[NSValue valueWithRange:textRange]]; + } + + return ranges; +} + +- (MMElement *)_parseInlineLinkWithScanner:(MMScanner *)scanner +{ + NSCharacterSet *boringChars; + NSUInteger level; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeLink; + + // Find the [] + element.innerRanges = [self _parseLinkTextBodyWithScanner:scanner]; + if (!element.innerRanges) + return nil; + + // Find the () + if (scanner.nextCharacter != '(') + return nil; + [scanner advance]; + [scanner skipWhitespace]; + + NSUInteger urlLocation = scanner.location; + NSUInteger urlEnd = urlLocation; + boringChars = [[NSCharacterSet characterSetWithCharactersInString:@"()\\ \t"] invertedSet]; + level = 1; + while (level > 0) + { + [scanner skipCharactersFromSet:boringChars]; + if (scanner.atEndOfLine) + return nil; + urlEnd = scanner.location; + + unichar character = scanner.nextCharacter; + if (character == '(') + { + level += 1; + } + else if (character == ')') + { + level -= 1; + } + else if (character == '\\') + { + [scanner advance]; // skip over the backslash + // skip over the next character below + } + else if ([NSCharacterSet.whitespaceCharacterSet characterIsMember:character]) + { + if (level != 1) + return nil; + + [scanner skipWhitespace]; + if (scanner.nextCharacter == ')') + { + [scanner advance]; + level -= 1; + } + break; + } + urlEnd = scanner.location; + [scanner advance]; + } + + NSUInteger titleLocation = NSNotFound; + NSUInteger titleEnd = NSNotFound; + + // If the level is still 1, then we hit a space. + if (level == 1) + { + // make sure there's a " + if (scanner.nextCharacter != '"') + return nil; + [scanner advance]; + + titleLocation = scanner.location; + boringChars = [[NSCharacterSet characterSetWithCharactersInString:@"\""] invertedSet]; + while (1) + { + [scanner skipCharactersFromSet:boringChars]; + + if (scanner.atEndOfLine) + return nil; + + [scanner advance]; + if (scanner.nextCharacter == ')') + { + titleEnd = scanner.location - 1; + [scanner advance]; + break; + } + } + } + + NSRange urlRange = NSMakeRange(urlLocation, urlEnd-urlLocation); + NSString *href = [scanner.string substringWithRange:urlRange]; + + // If the URL is surrounded by angle brackets, ditch them + if ([href hasPrefix:@"<"] && [href hasSuffix:@">"]) + { + href = [href substringWithRange:NSMakeRange(1, href.length-2)]; + } + + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + element.href = [self _stringWithBackslashEscapesRemoved:href]; + + if (titleLocation != NSNotFound) + { + NSRange titleRange = NSMakeRange(titleLocation, titleEnd-titleLocation); + element.title = [scanner.string substringWithRange:titleRange]; + } + + return element; +} + +- (MMElement *)_parseReferenceLinkWithScanner:(MMScanner *)scanner +{ + MMElement *element = [MMElement new]; + element.type = MMElementTypeLink; + + // Find the [] + element.innerRanges = [self _parseLinkTextBodyWithScanner:scanner]; + if (!element.innerRanges.count) + return nil; + + // Skip optional whitespace + if (scanner.nextCharacter == ' ') + [scanner advance]; + // or possible newline + else if (scanner.atEndOfLine) + [scanner advanceToNextLine]; + + // Look for the second [] + NSArray *idRanges = [self _parseLinkTextBodyWithScanner:scanner]; + if (!idRanges) + return nil; + if (!idRanges.count) + { + idRanges = element.innerRanges; + } + + NSMutableString *idString = [NSMutableString new]; + for (NSValue *value in idRanges) + { + NSRange range = [value rangeValue]; + [idString appendString:[scanner.string substringWithRange:range]]; + [idString appendString:@" "]; // newlines are replaced by spaces for the id + } + // Delete the last space + [idString deleteCharactersInRange:NSMakeRange(idString.length-1, 1)]; + + element.range = NSMakeRange(scanner.startLocation, scanner.location-scanner.startLocation); + element.identifier = idString; + + return element; +} + +- (MMElement *)_parseLinkWithScanner:(MMScanner *)scanner +{ + MMElement *element; + + element = [self _parseInlineLinkWithScanner:scanner]; + + if (element == nil) + { + // Assume that this method will already be wrapped in a transaction + [scanner commitTransaction:NO]; + [scanner beginTransaction]; + element = [self _parseReferenceLinkWithScanner:scanner]; + } + + if (element != nil && element.innerRanges.count > 0) + { + self.parseLinks = NO; + MMScanner *innerScanner = [MMScanner scannerWithString:scanner.string lineRanges:element.innerRanges]; + element.children = [self _parseWithScanner:innerScanner untilTestPasses:^{ return [innerScanner atEndOfString]; }]; + self.parseLinks = YES; + } + + return element; +} + +- (MMElement *)_parseImageWithScanner:(MMScanner *)scanner +{ + MMElement *element; + + // An image starts with a !, but then is a link + if (scanner.nextCharacter != '!') + return nil; + [scanner advance]; + + // Add a transaction to protect the ! that was scanned + [scanner beginTransaction]; + + self.parseImages = NO; + element = [self _parseInlineLinkWithScanner:scanner]; + self.parseImages = YES; + + if (element == nil) + { + // Assume that this method will already be wrapped in a transaction + [scanner commitTransaction:NO]; + [scanner beginTransaction]; + element = [self _parseReferenceLinkWithScanner:scanner]; + } + + [scanner commitTransaction:YES]; + + if (element != nil) + { + element.type = MMElementTypeImage; + + // Adjust the range to include the ! + NSRange range = element.range; + range.location -= 1; + range.length += 1; + element.range = range; + + NSMutableString *altText = [NSMutableString new]; + for (NSValue *value in element.innerRanges) + { + NSRange range = [value rangeValue]; + [altText appendString:[scanner.string substringWithRange:range]]; + } + element.stringValue = altText; + } + + return element; +} + +- (MMElement *)_parseAmpersandWithScanner:(MMScanner *)scanner +{ + if (scanner.nextCharacter != '&') + return nil; + [scanner advance]; + + // check if this is an html entity + [scanner beginTransaction]; + + if (scanner.nextCharacter == '#') + [scanner advance]; + [scanner skipCharactersFromSet:NSCharacterSet.alphanumericCharacterSet]; + if (scanner.nextCharacter == ';') + { + [scanner commitTransaction:NO]; + return nil; + } + [scanner commitTransaction:NO]; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeEntity; + element.range = NSMakeRange(scanner.location-1, 1); + element.stringValue = @"&"; + + return element; +} + +- (MMElement *)_parseBackslashWithScanner:(MMScanner *)scanner +{ + if (scanner.nextCharacter != '\\') + return nil; + [scanner advance]; + + NSCharacterSet *escapable = [NSCharacterSet characterSetWithCharactersInString:ESCAPABLE_CHARS]; + if (![escapable characterIsMember:scanner.nextCharacter]) + return nil; + + // Return the character + + MMElement *character = [MMElement new]; + character.type = MMElementTypeEntity; + character.range = NSMakeRange(scanner.location-1, 2); + character.stringValue = [scanner.string substringWithRange:NSMakeRange(scanner.location, 1)]; + + [scanner advance]; + + return character; +} + +- (MMElement *)_parseLeftAngleBracketWithScanner:(MMScanner *)scanner +{ + if (scanner.nextCharacter != '<') + return nil; + [scanner advance]; + + MMElement *element = [MMElement new]; + element.type = MMElementTypeEntity; + element.range = NSMakeRange(scanner.location-1, 1); + element.stringValue = @"<"; + + return element; +} + +- (NSString *)_stringWithBackslashEscapesRemoved:(NSString *)string +{ + NSMutableString *result = [string mutableCopy]; + + NSCharacterSet *escapableChars = [NSCharacterSet characterSetWithCharactersInString:ESCAPABLE_CHARS]; + + NSRange searchRange = NSMakeRange(0, result.length); + while (searchRange.length > 0) + { + NSRange range = [result rangeOfString:@"\\" options:0 range:searchRange]; + + if (range.location == NSNotFound || NSMaxRange(range) == NSMaxRange(searchRange)) + break; + + // If it is escapable, than remove the backslash + unichar nextChar = [result characterAtIndex:range.location + 1]; + if ([escapableChars characterIsMember:nextChar]) + { + [result replaceCharactersInRange:range withString:@""]; + } + + searchRange.location = range.location + 1; + searchRange.length = result.length - searchRange.location; + } + + return result; +} + + +@end