Skip to content

Commit

Permalink
Add example
Browse files Browse the repository at this point in the history
  • Loading branch information
jolexxa committed Jan 26, 2022
1 parent d1aa091 commit cb1fe7c
Show file tree
Hide file tree
Showing 10 changed files with 154 additions and 72 deletions.
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Don't need as much? Cut the recipe in half and bake for only 15 minutes!

Recipe-flavored markdown builds on Dart's [markdown] package by adding syntax extensions and a link resolver.

Unresolved links are marked as references, allowing developers utilizing this package to determine if the link refers to an ingredient, cooking supply, or even another recipe. Cooking quantities are recognized within the markdown document wherever they occur using syntax extensions, such as `1 3/4 cups`. Developers utilizing this package can walk through the markdown syntax tree nodes looking for `Quantity` and `Reference` nodes. For example, a `Quantity` node that occurs before a `Reference` node on the same line of text almost certainly represents the quantity for a specific ingredient.
Unresolved links (such as `- 3 tsp [onion powder]`) are marked as references, allowing developers utilizing this package to determine if the link refers to an ingredient, cooking supply, or even another recipe. Cooking scalars (measurements of time or quantity, such as `30 minutes` or `1/2 tbsp`) are recognized within the markdown document wherever they occur using syntax extensions. Developers utilizing this package can walk through the markdown syntax tree nodes looking for `Scalar` and `Reference` nodes. For example, a `Scalar` node that occurs before a `Reference` node on the same line of text almost certainly represents the quantity for a specific ingredient.

There are other plain text recipe formats, such as the markdown [Grocery Recipe Format] and the plain text format used in [Cooklang], but the author feels these do not read as easily. While the aforementioned formats are more specific, recipe-flavored markdown opts for a more open-ended approach, choosing minimal syntax over rigorous definition. Because of the flexibility of recipe-flavored markdown, applications utilizing this package are left with the complexity of determining what the user meant. The author feels this approach is better than forcing the user to be keenly aware of their recipe syntax.

Expand All @@ -87,15 +87,16 @@ Recipe-flavored markdown does not insist on a specific syntax for denoting "Ingr
To parse markdown-flavored recipe text, add the following code to your project:

```dart
import 'package:recipe_flavored_markdown/recipe_flavored_markdown.dart';
const recipe = '1 3/4 cups [sugar]';
final parser = RecipeMarkdownParser(
const parser = RecipeMarkdownParser(
markdown: recipe,
);
final nodes = parser.parse();
```

Like the standard markdown package, `nodes` will have the type `List<Node>`. You can then walk through the nodes, examining their children and looking for the `Quantity` and `Reference` nodes that are specific to recipe-flavored markdown. :)
Like the standard markdown package, `nodes` will have the type `List<Node>`. You can then walk through the nodes, examining their children and looking for the `Scalar` and `Reference` nodes that are specific to recipe-flavored markdown. :)

For a complete demonstration of how to parse recipe-flavored markdown strings within Dart, see [recipe_markdown_parser_test.dart](test/src/recipe_markdown_parser_test.dart)

Expand Down
9 changes: 9 additions & 0 deletions example/main.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import 'package:recipe_flavored_markdown/recipe_flavored_markdown.dart';

const recipe = '1 3/4 cups [sugar]';

const parser = RecipeMarkdownParser(
markdown: recipe,
);

final nodes = parser.parse();
4 changes: 2 additions & 2 deletions lib/src/recipe_html_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class RecipeHtmlRenderer extends HtmlRenderer implements RecipeNodeVisitor {
}

@override
void visitQuantity(Quantity quantity) {
buffer.write(quantity.textContent);
void visitScalar(Scalar scalar) {
buffer.write(scalar.textContent);
}
}
6 changes: 3 additions & 3 deletions lib/src/recipe_markdown_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ class RecipeMarkdownParser {
// recipes, or cooking supplies.
linkResolver: (String name, [String? title]) => Reference(name),
inlineSyntaxes: [
QuantityMixedNumberSyntax(),
QuantityWholeNumberSyntax(),
QuantityFractionalSyntax(),
ScalarMixedNumberSyntax(),
ScalarWholeNumberSyntax(),
ScalarFractionalSyntax(),
],
);
// Replace windows line endings with unix line endings, and split.
Expand Down
47 changes: 38 additions & 9 deletions lib/src/recipe_nodes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ abstract class RecipeNodeVisitor implements NodeVisitor {
/// Called when a reference node has been reached.
void visitReference(Reference reference);

/// Called when a quantity node has been reached.
void visitQuantity(Quantity quantity);
/// Called when a scalar node has been reached.
void visitScalar(Scalar scalar);
}

/// {@template reference}
Expand All @@ -26,18 +26,47 @@ class Reference implements Node {
visitor.visitReference(this);
}

/// {@template quantity}
/// Represents a cooking quantity.
/// {@template scalar}
/// Represents a cooking scalar, such as `1 cup`, or `30 seconds`.
/// {@endtemplate}
class Quantity implements Node {
/// {@macro quantity}
class Scalar implements Node {
/// {@macro scalar}
const Scalar({
required this.textContent,
required this.unitString,
this.wholeNumberString = '',
this.numeratorString = '',
this.denominatorString = '',
});

const Quantity(this.textContent);
/// Matched "whole number" portion of the scalar —
/// can technically be a decimal number.
final String wholeNumberString;

/// Numerator portion of the scalar.
final String numeratorString;

/// Denominator portion of the scalar.
final String denominatorString;

/// The unit of measure of the scalar.
final String unitString;

/// Whole number portion of the scalar, if any.
double get wholeNumber => double.tryParse(wholeNumberString) ?? 0;

/// Numerator portion of the scalar, if any.
double get numerator => double.tryParse(numeratorString) ?? 0;

/// Denominator portion of the scalar, if any.
double get denominator => double.tryParse(denominatorString) ?? 1;

/// Value of the scalar.
double get value => wholeNumber + numerator / denominator;

@override
final String textContent;

@override
void accept(covariant RecipeNodeVisitor visitor) =>
visitor.visitQuantity(this);
void accept(covariant RecipeNodeVisitor visitor) => visitor.visitScalar(this);
}
76 changes: 50 additions & 26 deletions lib/src/recipe_syntaxes.dart
Original file line number Diff line number Diff line change
@@ -1,63 +1,87 @@
import 'package:markdown/markdown.dart';
import 'package:recipe_flavored_markdown/src/recipe_nodes.dart';

const String _units = '(?:'
const String _units = '('
'tsp(?:s?)|tbsp(?:s?)|oz|cup(?:s?)|pint(?:s?)|quart(?:s?)|'
'gal(?:s?)|gallon(?:s?)|lb(?:s?)|kg|g|ml|l'
'gal(?:s?)|gallon(?:s?)|lb(?:s?)|kg|g|ml|l|hr(?:s?)|'
'hour(?:s?)|min(?:s?)|minute(?:s?)|sec(?:s?)|second(?:s?)'
')';

/// {@template quantity_mixed_number_syntax}
/// Represents a cooking quantity given as a mixed number: i.e.,
/// `1 2/3 cups` or `1.5 2.3/4.5 tbsp` (you probably shouldn't use
/// decimals, but you can).
/// {@template scalar_mixed_number_syntax}
/// Represents a cooking scalar given as a mixed number: i.e.,
/// `1 2/3 cups`, `1.5 2.3/4.5 tbsp`, or `30 4/5 mins`.
/// Decimals are allowed, but not recommended.
/// {@endtemplate}
class QuantityMixedNumberSyntax extends InlineSyntax {
/// {@macro quantity_mixed_number_syntax}
QuantityMixedNumberSyntax() : super(_pattern);
class ScalarMixedNumberSyntax extends InlineSyntax {
/// {@macro scalar_mixed_number_syntax}
ScalarMixedNumberSyntax() : super(_pattern);

static const String _quantifier =
r'(\d+(?:\.\d+)?\s+(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?))';
r'(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)';
static const String _pattern = '$_quantifier\\s+$_units';

@override
bool onMatch(InlineParser parser, Match match) {
parser.addNode(Quantity(match.group(0)!));
parser.addNode(
Scalar(
textContent: match.group(0)!,
wholeNumberString: match.group(1)!,
numeratorString: match.group(2)!,
denominatorString: match.group(3)!,
unitString: match.group(4)!,
),
);
return true;
}
}

/// {@template quantity_whole_number_syntax}
/// Represents a cooking quantity given as a whole number: i.e.,
/// `1 cup` or `1.5 tbsp` (you probably shouldn't use decimals, but you can).
/// {@template scalar_whole_number_syntax}
/// Represents a cooking scalar given as a whole number: i.e.,
/// `1 cup`, `1.5 tbsp`, '35.6 seconds`.
/// Decimals are allowed, but not recommended.
/// {@endtemplate}
class QuantityWholeNumberSyntax extends InlineSyntax {
/// {@macro quantity_whole_number_syntax}
QuantityWholeNumberSyntax() : super(_pattern);
class ScalarWholeNumberSyntax extends InlineSyntax {
/// {@macro scalar_whole_number_syntax}
ScalarWholeNumberSyntax() : super(_pattern);

static const String _quantifier = r'(\d+(?:\.\d+)?)';
static const String _pattern = '$_quantifier\\s+$_units';

@override
bool onMatch(InlineParser parser, Match match) {
parser.addNode(Quantity(match.group(0)!));
parser.addNode(
Scalar(
textContent: match.group(0)!,
wholeNumberString: match.group(1)!,
unitString: match.group(2)!,
),
);
return true;
}
}

/// {@template quantity_fractional_syntax}
/// Represents a cooking quantity given as a fraction: i.e.,
/// `1/2 tsp` or `1.5/4.5 tbsp` (you probably shouldn't use decimals, but you can).
/// {@template scalar_fractional_syntax}
/// Represents a cooking scalar given as a fraction: i.e.,
/// `1/2 tsp`, `1.5/4.5 tbsp`, or `1/2 minute`.
/// Decimals are allowed, but not recommended.
/// {@endtemplate}
class QuantityFractionalSyntax extends InlineSyntax {
/// {@macro quantity_fractional_syntax}
QuantityFractionalSyntax() : super(_pattern);
class ScalarFractionalSyntax extends InlineSyntax {
/// {@macro scalar_fractional_syntax}
ScalarFractionalSyntax() : super(_pattern);

static const String _quantifier = r'(\d+(?:\.\d+)?\s*\/\s*\d+(?:\.\d+)?)';
static const String _quantifier = r'(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)';
static const String _pattern = '$_quantifier\\s+$_units';

@override
bool onMatch(InlineParser parser, Match match) {
parser.addNode(Quantity(match.group(0)!));
parser.addNode(
Scalar(
textContent: match.group(0)!,
numeratorString: match.group(1)!,
denominatorString: match.group(2)!,
unitString: match.group(3)!,
),
);
return true;
}
}
4 changes: 2 additions & 2 deletions test/src/recipe_html_renderer_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ void main() {
expect(html, text);
});

test('renders Quantity node', () {
test('renders Scalar node', () {
const text = '1 3/4 cups';
const nodes = [Quantity(text)];
const nodes = [Scalar(textContent: text, unitString: 'cups')];
final renderer = RecipeHtmlRenderer();
final html = renderer.render(nodes);
expect(html, text);
Expand Down
6 changes: 3 additions & 3 deletions test/src/recipe_markdown_parser_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,23 @@ void main() {
final nodes = (parser.parse()[0] as Element).children!.cast<Element>();
expect(
nodes[0].children![0],
isA<Quantity>().having(
isA<Scalar>().having(
(q) => q.textContent,
'textContent',
'2 tsp',
),
);
expect(
nodes[1].children![0],
isA<Quantity>().having(
isA<Scalar>().having(
(q) => q.textContent,
'textContent',
'1 3/4 cup',
),
);
expect(
nodes[2].children![0],
isA<Quantity>().having(
isA<Scalar>().having(
(q) => q.textContent,
'textContent',
'2/3 cup',
Expand Down
20 changes: 15 additions & 5 deletions test/src/recipe_nodes_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,24 @@ void main() {
verify(() => visitor.visitReference(reference));
});
});
group('Quantity', () {
group('Scalar', () {
test('initializes and accepts a recipe node visitor', () {
const text = '1 3/4 cups';
const quantity = Quantity(text);
expect(quantity.textContent, text);
const scalar = Scalar(
textContent: text,
unitString: 'cups',
wholeNumberString: '1',
numeratorString: '3',
denominatorString: '4',
);
expect(scalar.textContent, text);
expect(scalar.wholeNumber, 1);
expect(scalar.numerator, 3);
expect(scalar.denominator, 4);
expect(scalar.value, 1.75);
final visitor = MockVisitor();
quantity.accept(visitor);
verify(() => visitor.visitQuantity(quantity));
scalar.accept(visitor);
verify(() => visitor.visitScalar(scalar));
});
});
}
45 changes: 27 additions & 18 deletions test/src/recipe_syntaxes_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,51 +11,60 @@ class MockMatch extends Mock implements Match {}
void main() {
registerFallbackValue(Text(''));

group('QuantityMixedNumberSyntax', () {
group('ScalarMixedNumberSyntax', () {
test('adds node on match', () {
const text = '1 3/4 cups';
final syntax = QuantityMixedNumberSyntax();
final syntax = ScalarMixedNumberSyntax();
final parser = MockInlineParser();
final match = MockMatch();
when(() => match.group(0)).thenReturn(text);
when(() => match.group(1)).thenReturn('1');
when(() => match.group(2)).thenReturn('3');
when(() => match.group(3)).thenReturn('4');
when(() => match.group(4)).thenReturn('cups');
syntax.onMatch(parser, match);
final quantity = verify(
() => parser.addNode(captureAny(that: isA<Quantity>())),
).captured.single as Quantity;
final scalar = verify(
() => parser.addNode(captureAny(that: isA<Scalar>())),
).captured.single as Scalar;
verify(() => match.group(0)).called(1);
expect(quantity.textContent, text);
expect(scalar.textContent, text);
});
});

group('QuantityWholeNumberSyntax', () {
group('ScalarWholeNumberSyntax', () {
test('adds node on match', () {
const text = '1 cup';
final syntax = QuantityWholeNumberSyntax();
final syntax = ScalarWholeNumberSyntax();
final parser = MockInlineParser();
final match = MockMatch();
when(() => match.group(0)).thenReturn(text);
when(() => match.group(1)).thenReturn('1');
when(() => match.group(2)).thenReturn('cup');
syntax.onMatch(parser, match);
final quantity = verify(
() => parser.addNode(captureAny(that: isA<Quantity>())),
).captured.single as Quantity;
final scalar = verify(
() => parser.addNode(captureAny(that: isA<Scalar>())),
).captured.single as Scalar;
verify(() => match.group(0)).called(1);
expect(quantity.textContent, text);
expect(scalar.textContent, text);
});
});

group('QuantityFractionalSyntax', () {
group('ScalarFractionalSyntax', () {
test('adds node on match', () {
const text = '3/4 cup';
final syntax = QuantityFractionalSyntax();
final syntax = ScalarFractionalSyntax();
final parser = MockInlineParser();
final match = MockMatch();
when(() => match.group(0)).thenReturn(text);
when(() => match.group(1)).thenReturn('3');
when(() => match.group(2)).thenReturn('4');
when(() => match.group(3)).thenReturn('cup');
syntax.onMatch(parser, match);
final quantity = verify(
() => parser.addNode(captureAny(that: isA<Quantity>())),
).captured.single as Quantity;
final scalar = verify(
() => parser.addNode(captureAny(that: isA<Scalar>())),
).captured.single as Scalar;
verify(() => match.group(0)).called(1);
expect(quantity.textContent, text);
expect(scalar.textContent, text);
});
});
}

0 comments on commit cb1fe7c

Please sign in to comment.