refactor: full rewrite of app logic (better, faster, type-safe)

This commit is contained in:
relikd
2023-02-27 01:02:14 +01:00
parent d30a3449f3
commit 507b31e72c
32 changed files with 856 additions and 670 deletions

View File

@@ -1,9 +1,3 @@
sort_key: 10 sort_key: 10
--- ---
group_key: difficulty group_key: difficulty
---
xdata:
easy
medium
hard

View File

@@ -3,3 +3,7 @@ sort_key: 15
group_key: rating group_key: rating
--- ---
reverse_order: yes reverse_order: yes
---
null_fallback: ☆☆☆
---
name:

View File

@@ -1,13 +1,3 @@
sort_key: 20 sort_key: 20
--- ---
group_key: time group_key: time
---
xdata:
15
30
60
120
180
360
1440

View File

@@ -5,3 +5,22 @@ _hidden: yes
replace_frac: yes replace_frac: yes
--- ---
replace_temp: yes replace_temp: yes
---
replace_frac_map:
1/2 ½
1/3 ⅓
2/3 ⅔
1/4 ¼
3/4 ¾
1/8 ⅛
---
duration_cluster:
15
30
60
120
180
360
1440

View File

@@ -1,5 +1,11 @@
[duration] [duration]
label = Zeit label = Zeit
day = Tag
days = Tage
hour = Std
hours = Std
min = Min
mins = Min
[yield] [yield]
label = Menge label = Menge

View File

@@ -1,5 +1,11 @@
[duration] [duration]
label = Time label = Time
day = day
days = days
hour = hour
hours = hours
min = min
mins = min
[yield] [yield]
label = Yield label = Yield

View File

@@ -39,9 +39,3 @@ label = Reverse Sort
width = 1/5 width = 1/5
type = boolean type = boolean
alts_enabled = false alts_enabled = false
[fields.xdata]
label = Extended Data
description = Used for ordinal sort order or merging integer cluster
width = 1/2
type = strings

View File

@@ -51,7 +51,7 @@ type = string
label = Ingredients / Zutaten label = Ingredients / Zutaten
description = 42 g Ingredient, Notes (add additional measures in settings) description = 42 g Ingredient, Notes (add additional measures in settings)
width = 2/3 width = 2/3
type = strings type = ingredientslist
[fields.tags] [fields.tags]
label = Tags / Kategorie label = Tags / Kategorie

View File

@@ -28,3 +28,19 @@ label = Replace 1/2 with ½, ⅔, etc.
width = 1/5 width = 1/5
type = boolean type = boolean
alts_enabled = false alts_enabled = false
[fields.replace_frac_map]
label = Ingredient replacements for numbers and fractions
description = "Space separated list (default: 1/2 ½ 1/3 ⅓ 2/3 ⅔ 1/4 ¼ 3/4 ¾ 1/8 ⅛)"
width = 2/5
type = text
default = 1/2 ½ 1/3 ⅓ 2/3 ⅔ 1/4 ¼ 3/4 ¾ 1/8 ⅛
alts_enabled = false
[fields.duration_cluster]
label = Time duration splits
description = "list of int ('10 30' creates three clusters: 0-9, 10-29, >30)"
width = 2/5
type = text
default = 15, 30, 60, 120, 180, 360, 1440
alts_enabled = false

View File

@@ -1,3 +0,0 @@
# Helper
Just some python functions that are necessary for the project.

View File

@@ -1,227 +0,0 @@
# -*- coding: utf-8 -*-
from lektor.pluginsystem import Plugin, get_plugin
from lektor.databags import Databags
from markupsafe import Markup
from datetime import datetime
import unicodedata
import lektor_html_to_tex as tex
# -------
# Sorting
def sorted_images(obj, attr='record_label'):
return sorted(obj.attachments.images, key=lambda x: getattr(x, attr))
def title_image(self, attr='record_label', small=False):
img = (sorted_images(self, attr) or [None])[0]
if img and small:
img = img.thumbnail(200, 150, mode='crop')
return img
def sortKeyInt(x):
return int(x[0]) if x[0] else 0
def sortKeyStr(x):
return noUmlaut(x[0]).lower()
def groupByDictSort(dic, sorter=None, reverse=False):
if type(sorter) == list: # sort by pre-defined, ordered list
return sorted(dic, reverse=bool(reverse), key=lambda x:
sorter.index(x[0]) if x[0] in sorter else 0)
fn = sortKeyInt if sorter == 'int' else sortKeyStr
return sorted(dic, reverse=bool(reverse), key=fn)
# -----------------------
# Pure text manupulations
def noUmlaut(text):
try:
text = unicode(text, 'utf-8')
except (TypeError, NameError):
pass
text = unicodedata.normalize('NFD', text)
text = text.encode('ascii', 'ignore')
text = text.decode("utf-8")
return str(text)
def replaceFractions(txt):
res = ' '
for c in u'-–—':
if c in txt:
txt = txt.replace(c, ' ')
for x in txt.split():
if x == '':
continue
try:
i = ['1/2', '1/3', '2/3', '1/4', '3/4', '1/8'].index(x)
res += [u'½', u'', u'', u'¼', u'¾', u''][i]
except ValueError:
if x == '':
res += ''
elif res[-1:] == '':
res += x
else:
res += ' ' + x
return res.lstrip(' ')
def numFillWithText(num, fill=u'', empty=u'', total=3):
num = int(num) if num else 0
return fill * num + empty * (total - num)
# ------------------
# Array manipulation
def updateSet_if(dic, parent, parentkey, value):
try:
key = parent[parentkey]
except KeyError:
return
if not key:
key = ''
try:
dic[key]
except KeyError:
dic[key] = set()
dic[key].add(value)
# --------------------
# Ingredient splitting
def splitIngredientLine(line):
state = 1
capture = False
indices = [0, len(line)]
for i, char in enumerate(line):
if char.isspace():
if capture:
capture = False
indices[state] = i
state += 1
continue
elif capture:
continue
elif state == 1 and char in u'0123456789-–—.,':
state -= 1
elif state > 1:
break
capture = True
return indices
def parseIngredientLine(line, measureList=[], rep_frac=False):
idx = splitIngredientLine(line)
val = line[:idx[0]]
if rep_frac:
val = replaceFractions(val)
measure = line[idx[0]:idx[1]].lstrip()
if measure.lower() in measureList:
name = line[idx[1]:].lstrip()
# if name.startswith('of '):
# measure += ' of'
# name = name[3:]
else:
measure = ''
name = line[idx[0]:].lstrip()
note = ''
name_note = name.split(',', 1)
if len(name_note) > 1:
name, note = [x.strip() for x in name_note]
return {'value': val, 'measure': measure, 'name': name, 'note': note}
def replace_atref_urls(text, label=None):
if '@' not in text:
return text
result = list()
for x in text.split():
if x[0] == '@':
x = x[1:]
result.append(u'<a href="{}">{}</a>'.format(x, label or x))
else:
result.append(x)
return Markup(' '.join(result))
# ----------------
# Main entry point
class HelperPlugin(Plugin):
name = u'Helper'
description = u'Some helper methods, filters, and templates.'
buildTime = None
settings = dict()
translations = dict()
# -----------
# Event hooks
# -----------
def on_before_build_all(self, builder, **extra):
# update project settings once per build
bag = Databags(self.env)
pad = self.env.new_pad()
for alt in self.env.load_config().iter_alternatives():
set = pad.get('settings', alt=alt)
self.translations[alt] = bag.lookup('i18n+' + alt)
self.settings[alt] = {
'measures': set['measures'].lower().split(),
'replFrac': set['replace_frac']
}
# def on_process_template_context(self, context, **extra):
# pass
def on_setup_env(self, **extra):
def localizeDic(alt, partA, partB=None):
if alt not in self.translations:
raise RuntimeError(
'localize() expects first parameter to be an alternate')
if partB is None:
partA, partB = partA.split('.', 1)
return self.translations[alt][partA][partB]
def ingredientsForRecipe(recipe, alt='en', mode='raw'):
meaList = self.settings[alt]['measures']
repFrac = self.settings[alt]['replFrac']
for line in recipe['ingredients']:
line = line.strip()
if mode == 'tex':
line = tex.raw_text_to_tex(line)
if not line:
continue
elif line.endswith(':'):
yield {'group': line}
else:
yield parseIngredientLine(line, meaList, repFrac)
def groupByAttribute(recipeList, attribute, alt='en'):
groups = dict()
for recipe in recipeList:
if attribute == 'ingredients':
for ing in ingredientsForRecipe(recipe, alt):
updateSet_if(groups, ing, 'name', recipe)
else:
updateSet_if(groups, recipe, attribute, recipe)
# groups[undefinedKey].update(groups.pop('_undefined'))
return groups.items()
self.env.jinja_env.filters['sorted_images'] = sorted_images
self.env.jinja_env.filters['title_image'] = title_image
self.env.jinja_env.filters['rating'] = numFillWithText
self.env.jinja_env.filters['replaceFractions'] = replaceFractions
self.env.jinja_env.filters['enumIngredients'] = ingredientsForRecipe
self.env.jinja_env.filters['replaceAtRefURLs'] = replace_atref_urls
self.env.jinja_env.filters['groupByAttribute'] = groupByAttribute
self.env.jinja_env.filters['groupSort'] = groupByDictSort
self.env.jinja_env.globals['localize'] = localizeDic

View File

@@ -1,38 +0,0 @@
import ast
import io
import re
from setuptools import setup, find_packages
with io.open('README.md', 'rt', encoding="utf8") as f:
readme = f.read()
_description_re = re.compile(r'description\s+=\s+(?P<description>.*)')
with open('lektor_helper.py', 'rb') as f:
description = str(ast.literal_eval(_description_re.search(
f.read().decode('utf-8')).group(1)))
setup(
author=u'relikd',
author_email='oleg@relikd.de',
description=description,
keywords='Lektor plugin',
license='MIT',
long_description=readme,
long_description_content_type='text/markdown',
name='lektor-helper',
packages=find_packages(),
py_modules=['lektor_helper'],
# url='[link to your repository]',
version='0.1',
classifiers=[
'Framework :: Lektor',
'Environment :: Plugins',
],
entry_points={
'lektor.plugins': [
'helper = lektor_helper:HelperPlugin',
]
}
)

View File

@@ -1,5 +0,0 @@
dist
build
*.pyc
*.pyo
*.egg-info

View File

@@ -1,61 +0,0 @@
# character set translations for LaTex special chars
s?&gt.?>?g
s?&lt.?<?g
s?\\?\\backslash ?g
s?{?\\{?g
s?}?\\}?g
s?%?\\%?g
s?\$?\\$?g
s?&?\\&?g
s?#?\\#?g
s?_?\\_?g
s?~?\\~?g
s?\^?\\^?g
s? ?~?g
# Paragraph borders
s?<p>?\\par ?g
s?</p>??g
# Headings
s?<title>\([^<]*\)</title>?\\section*{\1}?g
s?<hn>?\\part{?g
s?</hn>?}?g
s?<h1>?\\section*{?g
s?</h[0-9]>?}?g
s?<h2>?\\subsection*{?g
s?<h3>?\\subsubsection*{?g
s?<h4>?\\paragraph*{?g
s?<h5>?\\paragraph*{?g
s?<h6>?\\subparagraph*{?g
# UL is itemize
s?<ul>?\\begin{itemize}?g
s?</ul>?\\end{itemize}?g
s?<ol>?\\begin{enumerate}?g
s?</ol>?\\end{enumerate}?g
s?<li>?\\item ?g
s?</li>??g
# DL is description
s?<dl>?\\begin{description}?g
s?</dl>?\\end{description}?g
# closing delimiter for DT is first < or end of line which ever comes first NO
#s?<dt>\([^<]*\)<?\\item[\1]<?g
#s?<dt>\([^<]*\)$?\\item[\1]?g
#s?<dd>??g
#s?<dt>?\\item[?g
#s?<dd>?]?g
s?<dt>\([^<]*\)</dt>?\\item[\1]?g
s?<dd>??g
s?</dd>??g
# Italics
s?<it>\([^<]*\)</it>?{\\it \1}?g
s?<em>\([^<]*\)</em>?{\\it \1}?g
s?<b>\([^<]*\)</b>?{\\bf \1}?g
s?<strong>\([^<]*\)</strong>?{\\bf \1}?g
# recipe specific
s?<a href="../\([^"/]*\)/*">\([^<]*\)</a>?\\recipelink{\1}{\2}?g
# Get rid of Anchors
s?<a href="\(http[^"]*\)">\([^<]*\)</a>?\\external{\1}{\2}?g
s?<a[^>]*>??g
s?</a>??g
# quotes (replace after href)
s?\([[:space:]]\)"\([^[:space:]]\)?\1``\2?g
s?"?''?g

View File

@@ -1,131 +0,0 @@
# -*- coding: utf-8 -*-
from lektor.pluginsystem import Plugin # , get_plugin
import mistune
import os
import time
import subprocess as shell
import lektor_helper as helper
import lektor_time_duration as timedur
my_dir = os.path.dirname(os.path.realpath(__file__))
f_sed_a = os.path.join(my_dir, 'html2latex.sed')
def sed_repl_tex(f_sed, content):
with shell.Popen(('echo', content), stdout=shell.PIPE) as ps:
o = shell.check_output(['sed', '-f', f_sed], stdin=ps.stdout)
ps.wait()
return o.decode('utf-8')
def html_to_tex(html):
return sed_repl_tex(f_sed_a, html)
def raw_text_to_tex(text):
text = text.replace('\\', '\\backslash ').replace(' ', '~')
for c in '}{%$&#_~^':
if c in text:
text = text.replace(c, '\\' + c)
return text
# return sed_repl_tex(f_sed_b, text)
class RecipeToTex(object):
def __init__(self, enumerator):
super(RecipeToTex, self).__init__()
bases = tuple([]) + (mistune.Renderer,)
renderer_cls = type('renderer_cls', bases, {})
renderer = renderer_cls(escape=False)
self.mdparser = mistune.Markdown(renderer, escape=False)
self.enumerator = enumerator
def make(self, recipe, alt):
ingredients = ''
for x in self.enumerator(recipe, alt, mode='tex'):
ingredients += '\n' + self.process_ingredient(x, alt)
ingredients = ingredients[1:] or '\\item '
self.mdparser.renderer.record = recipe
instructions_html = self.mdparser(recipe['directions'].source)
instructions = sed_repl_tex(f_sed_a, instructions_html)
return self.process_recipe(recipe, alt, ingredients, instructions)
pass
def process_recipe(self, recipe, alt, ingredients, instructions):
img = helper.title_image(recipe)
if img:
img = img.path[:-4] + '@200x150_crop' + img.path[-4:]
duration = recipe['time']
host = recipe['source'].host
time = timedur.to_duration(duration, alt) if duration else ''
srcUrl = raw_text_to_tex(str(recipe['source'])) or ''
srcHost = raw_text_to_tex(host) if host else ''
return f'''
\\newrecipe{{{recipe['_slug']}}}{{{raw_text_to_tex(recipe['name'])}}}
\\meta{{{time}}}{{{recipe['yield'] or ''}}}
\\footer{{{srcUrl}}}{{{srcHost}}}
\\begin{{ingredients}}{{{img or ''}}}
{ingredients}
\\end{{ingredients}}
{instructions}
'''
def process_ingredient(self, ing, alt):
grp = ing.get('group')
if grp:
return f'\\ingGroup{{{grp}}}'
ret = ''
val = ing['value']
meas = ing['measure']
note = ing['note']
ret += '\\item'
if val or meas:
sep = '~' if val and meas else ''
ret += '[{}{}{}]'.format(val or '', sep, meas or '')
ret += f' \\ingName{{{ ing["name"] }}}' # keep space in front
if note:
ret += '\\ingDetail{'
for prt in note.split():
if prt.startswith('@../'):
ret += f' \\pagelink{{{ prt[4:].rstrip("/") }}}'
else:
ret += ' ' + prt
ret += '}'
return ret
class HtmlToTex(Plugin):
name = u'HTML to TEX converter'
description = u'Will convert html formatted text to (la)tex format.'
def on_after_prune(self, builder, **extra):
maketex = bool(builder.extra_flags.get('ENABLE_PDF_EXPORT'))
print('PDF Export: ' + ('ENABLED' if maketex else 'DISABLED'))
if not maketex:
return
dest_dir = my_dir
for x in range(3):
dest_dir = os.path.dirname(dest_dir)
dest_dir = os.path.join(dest_dir, 'extras', 'pdf-export')
start_time = time.time()
print('PDF Export: generate tex files')
with open(os.path.join(dest_dir, 'dyn-builddir.tex'), 'w') as f:
# Export current build dir (for image search)
f.write('\\def\\builddir{' + builder.destination_path + '}')
parser = RecipeToTex(self.env.jinja_env.filters['enumIngredients'])
for alt in self.env.load_config().list_alternatives():
tex = ''
for recipe in builder.pad.get('/recipes', alt=alt).children:
tex += parser.make(recipe, alt)
fname = os.path.join(dest_dir, f'dyn-recipes-{alt}.tex')
with open(fname, 'w') as f:
f.write(tex)
print('PDF Export: done in %.2f sec' % (time.time() - start_time))
def on_setup_env(self, **extra):
self.env.jinja_env.filters['raw_text_to_tex'] = raw_text_to_tex

View File

@@ -1,12 +0,0 @@
from setuptools import setup
setup(
name='lektor-html-to-tex',
py_modules=['lektor_html-to-tex'],
version='1.0',
entry_points={
'lektor.plugins': [
'html-to-tex = lektor_html_to_tex:HtmlToTex',
]
}
)

View File

@@ -0,0 +1 @@
from .plugin import MainPlugin # noqa: F401

View File

@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
from typing import List, Optional, Dict, Union
def int_to_cluster(time: Union[str, int, None], clusters: List[int]) \
-> Optional[int]:
'''
Choose cluster where time >= X and time < X+1
Example: `[30, 60, 120]` -> (0-29, 30-59, 60-119, 120+)
Return values are: 36 -> 60, 12 -> 30, 120 -> 121.
'''
time = int(time) if time else 0
if not time:
return None
for x in clusters:
if x > time:
return x
return clusters[-1] + 1
def cluster_as_str(
time: Union[str, int, None],
clusters: List[int],
translations: Dict[str, str],
) -> Optional[str]:
''' Return descriptive duration range; 30 -> "15 min 29 min". '''
time = int(time) if time else 0
if not time:
return None
for idx, x in enumerate(clusters):
if x == time:
if idx == 0:
return '<' + human_readable_duration(time, translations)
timeA = human_readable_duration(clusters[idx - 1], translations)
timeB = human_readable_duration(time - 1, translations)
return u'{} {}'.format(timeA, timeB)
return '>' + human_readable_duration(clusters[-1], translations)
def human_readable_duration(
time: Union[str, int, None], translations: Dict[str, str]
) -> str:
'''
Take an arbitrary int and return readable duration string.
For example, 16 -> "16 mins", 121 -> "2 hours 1 min"
'''
time = int(time) if time else 0
if (time <= 0):
return ''
days = time // (60 * 24)
time -= days * (60 * 24)
hours = time // 60
mins = time - hours * 60
ret = ''
if days:
ret += f'{days} {translations["day" if days == 1 else "days"]}'
if hours:
if ret:
ret += ' '
ret += f'{hours} {translations["hour" if hours == 1 else "hours"]}'
if mins:
if ret:
ret += ' '
ret += f'{mins} {translations["min" if mins == 1 else "mins"]}'
return ret

View File

@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
from lektor.types import Type
from typing import TYPE_CHECKING, List, Optional, Any
if TYPE_CHECKING:
from lektor.db import Record
from lektor.types.base import RawValue
from .utils import replaceFractions
from .settings import IngredientConfig
class IngredientEntry:
@property
def isGroup(self) -> bool:
return False
@property
def isIngredient(self) -> bool:
return False
class IngredientGroup(IngredientEntry):
@property
def isGroup(self) -> bool:
return True
def __init__(self, line: str) -> None:
self.name = line
def __repr__(self) -> str:
return '<IngredientGroup name="{}">'.format(self.name)
class Ingredient(IngredientEntry):
@property
def isIngredient(self) -> bool:
return True
def __init__(self, line: str, conf: IngredientConfig) -> None:
idx = Ingredient.split_raw(line)
# parse quantity
self.quantity = line[:idx[0]]
if conf.frac_map:
self.quantity = replaceFractions(self.quantity, conf.frac_map)
# parse unit
unit = line[idx[0]:idx[1]].lstrip()
if unit in conf.units:
name = line[idx[1]:].lstrip()
else:
unit, name = '', line[idx[0]:].lstrip()
self.unit = unit
# parse ingredient name + note
note = ''
name_note = name.split(',', 1)
if len(name_note) > 1:
name, note = [x.strip() for x in name_note]
self.name = name
self.note = note
@staticmethod
def split_raw(line: str) -> List[int]:
state = 1
capture = False
indices = [0, len(line)]
for i, char in enumerate(line):
if char.isspace():
if capture:
capture = False
indices[state] = i
state += 1
continue
elif capture:
continue
elif state == 1 and char in u'0123456789-–—.,':
state -= 1
elif state > 1:
break
capture = True
return indices
def __repr__(self) -> str:
return '<Ingredient "{}" qty="{}" unit="{}" note="{}">'.format(
self.name, self.quantity, self.unit, self.note)
class IngredientsDescriptor:
def __init__(self, raw: Optional[str]) -> None:
self.raw = raw
def parse(self, raw: str, record: 'Record') -> List[IngredientEntry]:
conf = IngredientConfig.of(record)
ret = [] # type: List[IngredientEntry]
for line in raw.splitlines(True): # we need to strip anyway
line = line.strip()
if line:
if line.endswith(':'):
ret.append(IngredientGroup(line.rstrip(':')))
else:
ret.append(Ingredient(line, conf))
return ret
def __get__(self, record: 'Record', _: Any = None) -> Any:
if record is None:
return self
if not self.raw:
return []
data = self.parse(self.raw, record)
del self.raw
return data
class IngredientsListType(Type):
widget = 'multiline-text'
def value_from_raw(self, raw: 'RawValue') -> IngredientsDescriptor:
return IngredientsDescriptor(raw.value or None)

View File

@@ -0,0 +1,302 @@
# -*- coding: utf-8 -*-
from lektor.build_programs import BuildProgram # subclass
from lektor.reporter import reporter # build, verbosity
from lektor.sourceobj import VirtualSourceObject # subclass
import click
import os
import re
import shutil # which, copyfile, rmtree
import subprocess as shell # run, DEVNULL
from weakref import ref
from typing import TYPE_CHECKING, List, Tuple, Generator
if TYPE_CHECKING:
from lektor.builder import Artifact, Builder, BuildState
from lektor.db import Record
from lektor.environment import Environment
from .utils import lookup_template_path
VPATH = 'LatexPDF'
TEXER = 'lualatex'
PDF_OUT_DIR = 'out'
# ----------------------------------------------------
# Sed re-format html as latex
# ----------------------------------------------------
HTML_TO_LATEX_RULES = [
# character set translations for LaTex special chars
(r'&gt.', r'>'),
(r'&lt.', r'<'),
(r'\\', r'\\backslash '),
(r'{', r'\\{'),
(r'}', r'\\}'),
(r'%', r'\\%'),
(r'\$', r'\\$'),
(r'&', r'\\&'),
(r'#', r'\\#'),
(r'_', r'\\_'),
(r'~', r'\\~'),
(r'\^', r'\\^'),
(r' ', r'~'),
# Paragraph borders
(r'<p>', r'\\par '),
(r'</p>', r''),
# Headings
(r'<title>([^<]*)</title>', r'\\section*{\1}'),
(r'<hn>', r'\\part{'),
(r'</hn>', r'}'),
(r'<h1>', r'\\section*{'),
(r'</h[0-9]>', r'}'),
(r'<h2>', r'\\subsection*{'),
(r'<h3>', r'\\subsubsection*{'),
(r'<h4>', r'\\paragraph*{'),
(r'<h5>', r'\\paragraph*{'),
(r'<h6>', r'\\subparagraph*{'),
# UL is itemize
(r'<ul>', r'\\begin{itemize}'),
(r'</ul>', r'\\end{itemize}'),
(r'<ol>', r'\\begin{enumerate}'),
(r'</ol>', r'\\end{enumerate}'),
(r'<li>', r'\\item '),
(r'</li>', r''),
# DL is description
(r'<dl>', r'\\begin{description}'),
(r'</dl>', r'\\end{description}'),
# closing delimiter for DT is first < or end of line which ever comes first
# (r'<dt>([^<]*)<', r'\\item[\1]<'),
# (r'<dt>([^<]*)$', r'\\item[\1]'),
# (r'<dd>', r''),
# (r'<dt>', r'\\item['),
# (r'<dd>', r']'),
(r'<dt>([^<]*)</dt>', r'\\item[\1]'),
(r'<dd>', r''),
(r'</dd>', r''),
# Italics
(r'<it>([^<]*)</it>', r'{\\it \1}'),
(r'<em>([^<]*)</em>', r'{\\it \1}'),
(r'<b>([^<]*)</b>', r'{\\bf \1}'),
(r'<strong>([^<]*)</strong>', r'{\\bf \1}'),
# recipe specific
(r'<a href="\.\./([^"/]*)/*">([^<]*)</a>', r'\\recipelink{\1}{\2}'),
# Get rid of Anchors
(r'<a href="(http[^"]*)">([^<]*)</a>', r'\\external{\1}{\2}'),
(r'<a[^>]*>', r''),
(r'</a>', r''),
# quotes (replace after href)
(r'(\s)"([^\s])', r'\1``\2'),
(r'"', r"''"),
]
def __test__() -> None:
TEST_DATA = '''
Hello &gt; is &lt; for
chars: " " & # _ ~ ^ % $ { }
\\.
<p>paragraph</p>
<title>this is a title</title>
<hn>this is a part</hn>
<h1>this is a section</h1>
<h2>this is a subsection</h2>
<h3>this is a subsubsection</h3>
<h4>this is a paragraph</h4>
<h5>this is a paragraph</h5>
<h6>this is a subparagraph</h6>
<ul>
<li>unordered one</li>
<li>unordered two</li>
</ul>
<ol>
<li>ordered one</li>
<li>ordered two</li>
</ol>
<dl>
<dt>definition title</dt>
<dd>definition value</dd>
</dl>
<it>this is it</it>
<em>this is em</em>
<b>this is b</b>
<strong>this is strong</strong>
<a href="http://example.org">external anchor</a>
<a href="../recipe">recipe anchor</a>
<a href="language">other anchor</a>
between "some" text
<p class="name">test</p>
'''
print(Sed.apply(HTML_TO_LATEX_RULES, TEST_DATA))
exit(0)
class Sed:
def __init__(self, rules: List[Tuple[str, str]]) -> None:
self._rules = [(re.compile(pattern), sub) for pattern, sub in rules]
def replace(self, data: str) -> str:
ret = data
for regx, repl in self._rules:
ret = regx.sub(repl, ret)
return ret
@staticmethod
def apply(rules: List[Tuple[str, str]], data: str) -> str:
ret = data
for pattern, repl in rules:
ret = re.sub(pattern, repl, ret)
return ret
def html_to_tex(html: str) -> str:
return Sed.apply(HTML_TO_LATEX_RULES, html)
def raw_text_to_tex(text: str) -> str:
if not text:
return ''
text = text.replace('\\', '\\backslash ')
for c in '}{%$&#_~^':
text = text.replace(c, '\\' + c)
return text.replace(' ', '~')
# ----------------------------------------------------
# Helper methods
# ----------------------------------------------------
def _report_updated(msg: str) -> None:
click.echo('{} {}'.format(click.style('U', fg='green'), msg))
def _report_error(msg: str) -> None:
click.echo('{} {}'.format(click.style('E', fg='red'), msg))
# ----------------------------------------------------
# PDF Build Program & Source
# ----------------------------------------------------
class TexSources:
enabled: bool = False
@staticmethod
def registerBuilder(env: 'Environment', enabled: bool) -> None:
TexSources.enabled = enabled
env.add_build_program(PdfSource, PdfBuildProgram)
@env.virtualpathresolver(VPATH)
def resolvePDF(record: 'Record', pieces: List[str]) \
-> PdfSource:
return PdfSource(record)
@staticmethod
def add(builder: 'Builder', record: 'Record') -> None:
if TexSources.enabled:
try:
refs = builder.__tex_files # type: ignore[attr-defined]
except AttributeError:
refs = list()
builder.__tex_files = refs # type: ignore[attr-defined]
refs.append(ref(record))
@staticmethod
def build(builder: 'Builder') -> None:
if not TexSources.enabled:
print(' * PDF Export: DISABLED')
return
try:
sources = builder.__tex_files # type: ignore[attr-defined]
del builder.__tex_files # type: ignore[attr-defined]
except AttributeError:
sources = []
if sources:
msg = f'PDF builder ({TEXER})'
with reporter.build(msg, builder): # type: ignore[attr-defined]
for rec_ref in sources:
builder.build(PdfSource(rec_ref()))
# ----------------------------------------------------
# PDF Build Program & Source
# ----------------------------------------------------
class PdfBuildProgram(BuildProgram):
source: 'PdfSource'
def produce_artifacts(self) -> None:
self.declare_artifact(
self.source.url_path,
sources=list(self.source.iter_source_filenames()))
def build_artifact(self, artifact: 'Artifact') -> None:
self.source.build(self.build_state)
class PdfSource(VirtualSourceObject):
@property
def path(self) -> str: # type: ignore[override]
return self.record.path + '@' + VPATH # type: ignore[no-any-return]
@property
def url_path(self) -> str: # type: ignore[override]
return self.record.url_path[:-4] + '.pdf' # type:ignore[no-any-return]
def iter_source_filenames(self) -> Generator[str, None, None]:
template = lookup_template_path(self.record['_template'], self.pad.env)
if template:
yield template
yield from self.record.iter_source_filenames()
def build(self, build_state: 'BuildState') -> None:
cmd_tex = shutil.which(TEXER)
if not cmd_tex:
_report_error(f'Skip PDF export. {TEXER} not found.')
return
# filename / path variables
tex_src = build_state.get_destination_filename(self.record.url_path)
pdf_dest = build_state.get_destination_filename(self.url_path)
build_dir = build_state.builder.destination_path
tex_root = os.path.join(build_state.env.root_path, '_tex-to-pdf')
tmp_dir = os.path.join(tex_root, PDF_OUT_DIR)
pdf_src = os.path.join(tmp_dir, os.path.basename(tex_src)[:-3] + 'pdf')
# create temporary output directory
os.makedirs(tmp_dir, exist_ok=True)
# store build destination to resolve image paths in setup.tex
with open(os.path.join(tmp_dir, 'builddir.tex'), 'w') as fp:
fp.write('\\def\\buildDir{' + build_dir + '}')
# run lualatex
silent = reporter.verbosity == 0 # type: ignore[attr-defined]
for i in range(1, 3):
if i > 1:
_report_updated(self.url_path.lstrip('/') + f' [{i}/2]')
p = shell.run([
cmd_tex, # lualatex
'--halt-on-error',
'--output-directory', tmp_dir,
tex_src # tex file
],
cwd=tex_root, # change work dir so lualatex can find setup.tex
stdout=shell.DEVNULL if silent else None, # dont spam console
input=b'') # auto-reply to stdin on error
if p.returncode == 0:
shutil.copyfile(pdf_src, pdf_dest)
else:
_report_error(f'{TEXER} returned error code {p.returncode}')
break
# cleanup
shutil.rmtree(tmp_dir, ignore_errors=True)

View File

@@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
from lektor.databags import Databags
from lektor.db import Page # isinstance
from lektor.pluginsystem import Plugin
from datetime import datetime
from typing import TYPE_CHECKING, Any, List, Dict, Tuple, Set, Iterator
if TYPE_CHECKING:
from lektor.builder import Builder
from lektor.db import Record
from .durationcluster import (
int_to_cluster, cluster_as_str, human_readable_duration
)
from .ingredients import IngredientsListType
from .latex import TexSources, raw_text_to_tex, html_to_tex
from .settings import Settings
from .utils import (
fillupText, replace_atref_urls, sorted_images, title_image, noUmlauts
)
class MainPlugin(Plugin):
name = 'Main Plugin'
description = 'Code snippets for recipe lekture.'
def on_setup_env(self, **extra: Any) -> None:
# Register custom field type
self.env.add_type(IngredientsListType)
# Add jinja filter & globals
self.env.jinja_env.filters.update({
'asDuration': self.as_duration, # needs alt
'asRating': self.as_rating,
'replaceAtRefURLs': replace_atref_urls,
'performGroupBy': self.performGroupBy,
'sorted_images': sorted_images,
'title_image': title_image,
'latexStr': raw_text_to_tex,
'latexHtml': html_to_tex,
})
# self.env.jinja_env.globals.update({
# 'str': str,
# 'dir': dir,
# 'type': type,
# 'len': len,
# 'now': datetime.now
# })
# Latex -> PDF Build program
make_pdf = extra.get('extra_flags', {}).get('ENABLE_PDF_EXPORT', False)
TexSources.registerBuilder(self.env, enabled=make_pdf)
def on_after_build(
self, builder: 'Builder', source: 'Record', **extra: Any
) -> None:
if not isinstance(source, Page):
return # ignore Asset, Directory, etc.
if source.path.endswith('.tex'): # type: ignore[attr-defined]
TexSources.add(builder, source)
def on_after_build_all(self, builder: 'Builder', **extra: Any) -> None:
# must run after all sources are built
# or else latex fails because it cannot find referenced images
TexSources.build(builder)
##############
# Duration #
##############
def _i18n(self, alt: str, key: str) -> Dict[str, str]:
# used for dependency tracking
return Databags(self.env).lookup( # type: ignore[no-any-return]
f'i18n+{alt}.{key}')
def as_duration(self, time: int, alt: str) -> str:
return human_readable_duration(time, self._i18n(alt, 'duration'))
def as_rating(self, x: str) -> str:
return fillupText(x, u'', u'', 3)
#####################
# Group by filter #
#####################
def performGroupBy(
self,
recipes: List['Page'],
attribute: str,
reverse: bool = False,
alt: str = 'en',
) -> Iterator[Tuple[str, Set['Page']]]:
# Pre-Processing
if attribute == 'time':
time_clusters = Settings.duration_cluster()
time_translations = self._i18n(alt, 'duration')
elif attribute == 'difficulty':
difficulty_translations = self._i18n(alt, 'difficulty')
# Grouping
ret = dict() # type: Dict[Any, Set[Page]]
for recipe in recipes:
try:
data = recipe[attribute] or None
except KeyError:
continue
if attribute == 'time':
data = [int_to_cluster(data, time_clusters)]
elif attribute == 'ingredients' and data:
data = [x.name for x in data or [] if x.isIngredient]
else:
data = [data]
for x in data:
if x not in ret:
ret[x] = set()
ret[x].add(recipe)
# Sorting
reverse = bool(reverse)
if attribute == 'difficulty':
order = ['easy', 'medium', 'hard']
none_diff = -1 if reverse else 99
def _fn(x: Tuple) -> Any:
return order.index(x[0]) if x[0] in order else none_diff
elif attribute == 'ingredients': # sort by: str
none_ingr = 'aaaa' if reverse else 'zzzz'
def _fn(x: Tuple) -> Any:
return noUmlauts(x[0]).lower() if x[0] else none_ingr
else: # sort by: int
none_int = 0 if reverse else 999999999
def _fn(x: Tuple) -> Any:
return int(x[0]) if x[0] else none_int
result = sorted(ret.items(), reverse=bool(reverse), key=_fn)
# Post-Processing
for group, recipe_list in result:
if attribute == 'time':
group = cluster_as_str(group, time_clusters, time_translations)
elif attribute == 'rating':
group = self.as_rating(group)
elif attribute == 'difficulty':
group = difficulty_translations.get(group)
yield group, recipe_list

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
from lektor.context import get_ctx
from typing import TYPE_CHECKING, List, Dict, Optional, NamedTuple
if TYPE_CHECKING:
from lektor.db import Pad, Page, Record
class IngredientConfig(NamedTuple):
units: List[str]
frac_map: Dict[str, str]
@staticmethod
def of(record: 'Record') -> 'IngredientConfig':
return Settings.ingredient_config(record.pad, record.alt)
class Settings:
@staticmethod
def load(pad: Optional['Pad'] = None, alt: Optional[str] = None) -> 'Page':
if not pad:
ctx = get_ctx()
if not ctx:
raise RuntimeError('Should never happen, missing context.')
pad = ctx.pad
if not alt:
alt = ctx.source.alt
assert(pad is not None)
# used for dependency tracking
return pad.get('/settings', alt=alt) # type: ignore[no-any-return]
@staticmethod
def ingredient_config(pad: 'Pad', alt: str) -> IngredientConfig:
set = Settings.load(pad, alt)
mea = set['measures'].split()
frac = {}
if set['replace_frac']:
it = iter(set['replace_frac_map'].split())
frac = dict(zip(it, it))
return IngredientConfig(mea, frac)
# def ingredient_config_old(record: 'Record') -> IngredientConfig:
# plugin = record.pad.env.plugins['main'] # type: MainPlugin
# cfg = plugin.get_config() # used for dependency tracking
# mea = cfg.get('measures.' + alt, '').split()
# frac = {}
# if cfg.get_bool('general.replace_frac'):
# frac = cfg.section_as_dict('replace_frac')
# return IngredientConfig(mea, frac)
@staticmethod
def duration_cluster() -> List[int]:
# split() without args takes care of double spaces and trims each item
splits = Settings.load()['duration_cluster'].replace(',', ' ').split()
return [int(x) for x in splits]

View File

@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
from jinja2.loaders import split_template_path # lookup_template_name()
from markupsafe import Markup
import os
import unicodedata
from typing import TYPE_CHECKING, Dict, Union, Optional, List
if TYPE_CHECKING:
from lektor.db import Page, Image
from lektor.environment import Environment
def fillupText(
numerator: Union[str, int, None],
empty: str = u'',
filled: str = u'',
total: int = 3
) -> str:
'''
Create a progress-bar-like string from int or number string.
0 -> "☆☆☆", 1 -> "★☆☆", 2 -> "★★☆", 3 -> "★★★", etc.
'''
x = int(numerator) if numerator else 0
return filled * x + empty * (total - x)
def replaceFractions(txt: str, repl_map: Dict[str, str]) -> str:
''' Replace `1 1/2 - 3/4` with `1½¾`, etc. '''
res = ' '
for c in u'-–—':
txt = txt.replace(c, ' ')
# NOTE: `split(' ')` can contain empty values but `split()` does not!
for x in txt.split():
if x == '':
res += x
else:
res += repl_map.get(x) or (x if res[-1] == '' else ' ' + x)
return res.lstrip(' ')
def replace_atref_urls(text: str, label: Optional[str] = None) -> str:
''' Replace `@../recipe/` with `<a href="../recipe/">label</a>` '''
if '@' not in text:
return text
result = list()
for x in text.split():
if x[0] == '@':
x = x[1:]
result.append(u'<a href="{}">{}</a>'.format(x, label or x))
else:
result.append(x)
return Markup(' '.join(result))
def sorted_images(obj: 'Page', attr: str = 'record_label') -> List['Image']:
return sorted(obj.attachments.images,
key=lambda x: getattr(x, attr)) # type:ignore[no-any-return]
def title_image(obj: 'Page', attr: str = 'record_label', small: bool = False) \
-> Optional['Image']:
imgs = sorted_images(obj, attr)
img = imgs[0] if imgs else None
if img and small:
img = img.thumbnail(200, 150, mode='crop')
return img
def noUmlauts(text: str) -> str:
# try:
# data = unicode(text, 'utf-8')
# except (TypeError, NameError):
# pass
text = unicodedata.normalize('NFD', text)
data = text.encode('ascii', 'ignore')
text = data.decode('utf-8')
return str(text)
def lookup_template_path(name: str, env: 'Environment') -> Optional[str]:
pieces = split_template_path(name)
for base in env.jinja_env.loader.searchpath:
path = os.path.join(base, *pieces)
if os.path.isfile(path):
return path
return None

View File

@@ -0,0 +1,21 @@
from setuptools import setup
setup(
name='lektor-main',
packages=['lektor_main'],
entry_points={
'lektor.plugins': [
'main = lektor_main:MainPlugin',
]
},
author='relikd',
version='0.1',
description='Main code for this repository',
license='MIT',
python_requires='>=3.6',
keywords=['lektor', 'plugin'],
classifiers=[
'Environment :: Plugins',
'Framework :: Lektor',
],
)

View File

@@ -1,5 +0,0 @@
dist
build
*.pyc
*.pyo
*.egg-info

View File

@@ -1,4 +0,0 @@
# Time Duration
This plugin converts integer numbers to a human readable duration.
E.g. 90 -> 1 hour 30 minutes

View File

@@ -1,87 +0,0 @@
# -*- coding: utf-8 -*-
from lektor.pluginsystem import Plugin
durationLocale = {
'de': {'day': 'Tag', 'hour': 'Std', 'min': 'Min',
'days': 'Tage', 'hours': 'Std', 'mins': 'Min'},
'en': {'day': 'day', 'hour': 'hour', 'min': 'min',
'days': 'days', 'hours': 'hours', 'mins': 'min'}
}
# -----------
# Single Time
def pluralize(n, single, multi):
if n == 0:
return ''
return u'{} {}'.format(n, single if n == 1 else multi)
def to_duration(time, alt='en'):
time = int(time) if time else 0
if (time <= 0):
return ''
days = time // (60 * 24)
time -= days * (60 * 24)
L = durationLocale[alt]
return ' '.join([
pluralize(days, L['day'], L['days']),
pluralize(time // 60, L['hour'], L['hours']),
pluralize(time % 60, L['min'], L['mins'])]).strip()
# ------------
# Time Cluster
def to_time_in_cluster(time, cluster, alt='en'):
for idx, x in enumerate(cluster):
x = int(x)
if x == time:
if idx == 0:
timeB = to_duration(time, alt)
return '<' + timeB
else:
timeA = to_duration(cluster[idx - 1], alt)
timeB = to_duration(time - 1, alt)
return u'{} {}'.format(timeA, timeB)
else:
return '>' + to_duration(cluster[-1], alt)
def find_in_cluster(key, clusterList=[30, 60, 120]):
key = int(key) if key else 0
if key > 0:
for cluster in clusterList:
if key < cluster:
key = cluster
break
else:
key = clusterList[-1] + 1
return key
def group_by_time_cluster(dic, arr=[30, 60, 120], reverse=False):
arr = sorted([int(x) for x in arr])
groups = dict()
for key, recipes in dic:
key = find_in_cluster(key, arr)
if key == 0 and not reverse:
key = ''
try:
groups[key]
except KeyError:
groups[key] = set()
groups[key].update(recipes)
return sorted(groups.items(), reverse=bool(reverse),
key=lambda x: x[0] if x[0] != '' else 999999999)
class TimeDurationPlugin(Plugin):
name = u'Time Duration'
description = u'Convert int to duration. E.g., 90 -> "1hr 30min".'
def on_setup_env(self, **extra):
self.env.jinja_env.filters['duration'] = to_duration
self.env.jinja_env.filters['durationCluster'] = to_time_in_cluster
self.env.jinja_env.filters['groupTimeCluster'] = group_by_time_cluster

View File

@@ -1,38 +0,0 @@
import ast
import io
import re
from setuptools import setup, find_packages
with io.open('README.md', 'rt', encoding="utf8") as f:
readme = f.read()
_description_re = re.compile(r'description\s+=\s+(?P<description>.*)')
with open('lektor_time_duration.py', 'rb') as f:
description = str(ast.literal_eval(_description_re.search(
f.read().decode('utf-8')).group(1)))
setup(
author=u'relikd',
author_email='oleg@relikd.de',
description=description,
keywords='Lektor plugin',
license='MIT',
long_description=readme,
long_description_content_type='text/markdown',
name='lektor-time-duration',
packages=find_packages(),
py_modules=['lektor_time_duration'],
# url='[link to your repository]',
version='0.1',
classifiers=[
'Framework :: Lektor',
'Environment :: Plugins',
],
entry_points={
'lektor.plugins': [
'time-duration = lektor_time_duration:TimeDurationPlugin',
]
}
)

View File

@@ -1,33 +1,10 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}{{ this.name }}{% endblock %} {% block title %}{{ this.name }}{% endblock %}
{% block body %} {% block body %}
{%- if this.group_key in ['rating', 'time'] -%}
{%- set sortType = 'int' -%}
{%- elif this.xdata -%}
{%- set sortType = this.xdata + [''] -%}
{%- endif -%}
{%- set all = site.query('/recipes', this.alt) | groupByAttribute(this.group_key, this.alt) | groupSort(sortType, this.reverse_order) -%}
{%- if this.group_key == 'time' -%}
{%- set all = all | groupTimeCluster(this.xdata, this.reverse_order) -%}
{%- endif -%}
<h1>{{ this.name }}</h1> <h1>{{ this.name }}</h1>
<dl class="cluster"> <dl class="cluster">
{%- for attrib, recipes in all -%} {%- for group_name, recipes in site.query('/recipes', this.alt) | performGroupBy(this.group_key, this.reverse_order, this.alt) -%}
<dt>{%- if this.group_key == 'rating' -%} <dt>{{ group_name or this.null_fallback }}</dt>
{{ attrib | rating }}
{%- elif not attrib -%}
{{ this.null_fallback }}
{%- elif this.group_key == 'time' -%}
{{ attrib | durationCluster(this.xdata, this.alt) }}
{%- elif this.group_key == 'difficulty' -%}
{{ bag('i18n+' + this.alt, 'difficulty', attrib) }}
{%- else -%}
{{ attrib }}
{%- endif -%}</dt>
<dd> <dd>
{%- set pipe = joiner(' | ') -%} {%- set pipe = joiner(' | ') -%}
{%- for recipe in recipes | sort(attribute='name') -%} {%- for recipe in recipes | sort(attribute='name') -%}

View File

@@ -8,7 +8,7 @@
{#- overlay on hover and always-visible icons #} {#- overlay on hover and always-visible icons #}
<div class="overlay"> <div class="overlay">
<div class="hover"><div class="time">{{ recipe.time|duration(recipe.alt) }}</div></div> <div class="hover"><div class="time">{{ recipe.time | asDuration(recipe.alt) }}</div></div>
<div class="icon-bar"> <div class="icon-bar">
{%- if 'raw' in recipe.tags -%}<i class="icon raw"></i>{%- endif -%} {%- if 'raw' in recipe.tags -%}<i class="icon raw"></i>{%- endif -%}
{%- if 'glutenfree' in recipe.tags -%}<i class="icon gf"></i>{%- endif -%} {%- if 'glutenfree' in recipe.tags -%}<i class="icon gf"></i>{%- endif -%}

View File

@@ -21,29 +21,29 @@
<h1>{{ this.name }}</h1> <h1>{{ this.name }}</h1>
<section id="metrics" class="small"> <section id="metrics" class="small">
<div id="rating" class="xlarge">{{ this.rating|rating }}</div> <div id="rating" class="xlarge">{{ this.rating | asRating }}</div>
<div class="difficulty {{this.difficulty}}"> <div class="difficulty {{this.difficulty}}">
<div></div><div></div><div></div> <div></div><div></div><div></div>
<span {% if not this.difficulty %}class="small"{%- endif %}>{{ <span {% if not this.difficulty %}class="small"{%- endif %}>{{
bag('i18n+' + this.alt, 'difficulty', this.difficulty or '_unset') }}</span> bag('i18n+' + this.alt, 'difficulty', this.difficulty or '_unset') }}</span>
</div> </div>
<div>{{ bag('i18n+' + this.alt, 'duration.label') }}: {{ this.time|duration(this.alt) if this.time else '—' }}</div> <div>{{ bag('i18n+' + this.alt, 'duration.label') }}: {{ this.time | asDuration(this.alt) or '—' }}</div>
<div>{{ bag('i18n+' + this.alt, 'yield.label') }}: {{ this.yield or '—' }}</div> <div>{{ bag('i18n+' + this.alt, 'yield.label') }}: {{ this.yield or '—' }}</div>
</section> </section>
<section id="ingredients"> <section id="ingredients">
<h2>{{ bag('i18n+' + this.alt, 'title.ingredients') }}:</h2> <h2>{{ bag('i18n+' + this.alt, 'title.ingredients') }}:</h2>
<ul class="no-bullets li-lg-space"> <ul class="no-bullets li-lg-space">
{%- for ing in this|enumIngredients(this.alt) %} {%- for ing in this.ingredients %}
{%- if ing['group'] %} {%- if ing.isGroup %}
<li class="dark-red bold mrgTopMd">{{ ing['group'] }}</li> <li class="dark-red bold mrgTopMd">{{ ing.name }}:</li>
{%- else %} {%- else %}
<li> <li>
{%- if ing['value'] %}{{ ing['value'] }} {% endif -%} {%- if ing.quantity %}{{ ing.quantity }} {% endif -%}
{%- if ing['measure'] %}{{ ing['measure'] }} {% endif -%} {%- if ing.unit %}{{ ing.unit }} {% endif -%}
<span class="light-red">{{ ing['name'] }}</span> <span class="light-red">{{ ing.name }}</span>
{%- if ing['note'] -%} {%- if ing.note -%}
<span class="small italic">{{ ', ' ~ ing['note'] | replaceAtRefURLs(label=bag('i18n+' + this.alt, 'ingredients.recipeLink')) }}</span> <span class="small italic">{{ ', ' ~ ing.note | replaceAtRefURLs(label=bag('i18n+' + this.alt, 'ingredients.recipeLink')) }}</span>
{%- endif -%} {%- endif -%}
</li> </li>
{%- endif %} {%- endif %}