groue / GRMustache

Flexible and production-ready Mustache templates for MacOS Cocoa and iOS
http://mustache.github.com/
MIT License
1.44k stars 190 forks source link

Pluralizing/singularizing strings? #50

Closed ryanmasondavies closed 11 years ago

ryanmasondavies commented 11 years ago

It seems like a fairly common requirement to have a word that needs to be singular or plural based on some value, e.g:

// would expect, in this case, for the output to be "I have 3 flavors."
NSArray *data = @{@"flavors": @[@"vanilla", @"strawberry", @"chocolate"]};
GRMustacheTemplate *template = [GRMustacheTemplate templateFromString:@"I have {{flavors.count}} flavors" error:NULL];

// would expect, in this case, for the output to be "I have 1 flavor."
NSArray *data = @{@"flavors": @[@"flapjack"]};
GRMustacheTemplate *template = [GRMustacheTemplate templateFromString:@"I have {{flavors.count}} flavor" error:NULL];

Right now the only way I've got this working is to create an 'isOne' filter. I don't like this workaround because it introduces a conditional into the template, which seems like it negates the idea of 'logicless templates':

I have {{flavors.count}} flavor{{^isOne}}s{{/}}.

Another issue here is that not all words are pluralized simply by adding an 's', lengthening the template into having to deal with singular and plural cases individually.

Ideally I'd like to pass the count to a rendered block:

I have {{flavors.count}} {{#pluralize(flavors.count)}}flavor{{/}}.

The issue with this at the moment is that, as far as I can tell, rendered blocks can't accept arguments. Variadic filters can, but they can't accept literals:

I have {{flavors.count}} {{pluralize(flavors.count, "flavor")}}.

Any ideas on how to move ahead with this? Perhaps someone else has run into this scenario?

Also, thanks for the library. Usually I avoid depending on third parties, but your library is so well implemented that I couldn't pass it up. :+1:

groue commented 11 years ago

Hi Ryan!

Thanks for the nice comments!

I'd follow the same idea as the localize standard helper: it localizes the content of the template itself, not an argument: {{#localize}}Hello{{/}} renders Bonjour in French.

This pattern fits well your own template: "flavor" is part of the template content, just as "Hello" above. But instead of rendering "goût", we want to render "flavor" or "flavors" depending on the flavors count.

So we'll render the following template: {{# pluralize(flavors.count) }}flavor{{/}}.

pluralize(flavors.count) will evaluate to a rendering object (aka "lambda" in orthodox Mustache lingo) that renders the inner rendering with the correct spelling, depending on flavors.count. Now we know how to write our pluralize filter:

// Let's first assume a category on NSString that performs the pluralization:

@interface NSString(Pluralize)
- (NSString *)pluralize:(NSUInteger)count;
@end

@implementation NSString(Pluralize)
- (NSString *)pluralize:(NSUInteger)count
{
    // Naive implementation
    if (count < 2) return self;
    return [self stringByAppendingString:@"s"];
}

@end

// Now the actual rendering:

id data = @{
    @"dogCount": @1,
    @"catCount": @2,

    // The pluralize filter...
    @"pluralize": [GRMustacheFilter filterWithBlock:^id(NSNumber *countNumber) {

        // ... extracts the count from its argument...
        NSUInteger count = [countNumber unsignedIntegerValue];

        // ... and actually returns an object that processes the section it renders for:
        return [GRMustache renderingObjectWithBlock:^NSString *(GRMustacheTag *tag, GRMustacheContext *context, BOOL *HTMLSafe, NSError *__autoreleasing *error) {

            // First get the word to pluralize by performing a first "classic" rendering of the section...
            NSString *word = [tag renderContentWithContext:context HTMLSafe:HTMLSafe error:error];

            // ... and finally use our count
            return [word pluralize:count];
        }];
    }],
};

NSString *templateString = @"I have {{dogCount}} {{#pluralize(dogCount)}}dog{{/}} and {{catCount}} {{#pluralize(catCount)}}cat{{/}}.";
NSString *rendering = [GRMustacheTemplate renderObject:data fromString:templateString error:NULL];

The result is I have 1 dog and 2 cats. as expected.

This technique is generally very useful in GRMustache: having filters that return rendering objects.

Note that you can now even pluralize dynamic words: {{# pluralize(n) }}{{ word }}{{/}} would work well also, and render "dogs" or "cats" depending on the value of word.

I hope this approach looks sensible to you!

groue commented 11 years ago

Better reading your initial message, I realize {{#pluralize(flavors.count)}}flavor{{/}} was exactly your goal. Phew, we have an agreement here :-)

groue commented 11 years ago

Extending the topic:

Such general purpose tools may want to enter your own library of reusable components, that would extend the standard library.

The way to achieve this is to use GRMustacheConfiguration class:

// Add `pluralize` to the standard GRMustache library,
// once and for all for the whole application:

id myLibrary = @{
    @"pluralize": [GRMustacheFilter filterWithBlock:^id(NSNumber *countNumber) {
        NSUInteger count = [countNumber unsignedIntegerValue];
        return [GRMustache renderingObjectWithBlock:^NSString *(GRMustacheTag *tag, GRMustacheContext *context, BOOL *HTMLSafe, NSError *__autoreleasing *error) {
            NSString *word = [tag renderContentWithContext:context HTMLSafe:HTMLSafe error:error];
            return [word pluralize:count];
        }];
    }],
};
GRMustacheContext *baseContext = [GRMustacheConfiguration defaultConfiguration].baseContext;
baseContext = [baseContext contextByAddingObject:myLibrary];
[GRMustacheConfiguration defaultConfiguration].baseContext = baseContext;

// And now render, assuming `pluralize` is generally available.

id data = @{
    @"dogCount": @1,
    @"catCount": @2,
};

NSString *templateString = @"I have {{dogCount}} {{#pluralize(dogCount)}}dog{{/}} and {{catCount}} {{#pluralize(catCount)}}cat{{/}}.";
NSString *rendering = [GRMustacheTemplate renderObject:data fromString:templateString error:NULL];
ryanmasondavies commented 11 years ago

Thanks very much for the quick and extensive response.

Yes, {{#pluralize(flavors.count)}}flavor{{/}} was exactly what I was looking for. I didn't think to combine both a filter and rendering object. Since you've given a wonderfully thorough explanation, perhaps it's worth putting this example in a guide? :smiley:

I've implemented it in combination with @mattt's InflectorKit, and it works great:

@"pluralize": [GRMustacheFilter filterWithBlock:^id(NSNumber *count) {
    return [GRMustache renderingObjectWithBlock:^NSString *(GRMustacheTag *tag, GRMustacheContext *context, BOOL *HTMLSafe, NSError *__autoreleasing *error) {
        NSString *word = [tag renderContentWithContext:context HTMLSafe:HTMLSafe error:error];
        return [count isEqualToNumber:@1] ? word : [word pluralizedString];
    }];
}]

Thanks for the info about extending the standard library. That'll come in handy down the line. :+1:

groue commented 11 years ago

It's not easy to find a good enough title for this kind of guide. Why would anybody click a "combining filters and rendering objects" without any clue on what he can achieve with such a combination? Anyway, I may use your use case in the FAQ : "Can I pluralize/singularize words? Yesir!" -> boom link to our sample code :-)

I'm quite honored that GRMustache is member of the happy few third-party libraries you dare integrate, along with the amazingly useful ones of @mattt :-)

Happy Mustache, Ryan!

ryanmasondavies commented 11 years ago

That sounds like a better idea. Thanks again! :man: