diff --git a/README.md b/README.md index 1cd9eb8..c87e7ad 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ Cross-references become plain text ("See Section 1.(a)"), with the same `reftype Inline constructs are recognized at any nesting depth with the parser's own grammar, so code spans, links, and escapes are honored, and `use {braces} freely` stays literal. Block constructs are rewritten wherever their lines carry no container marker, which includes fenced divs; a heading or table caption inside a blockquote or list passes through unchanged, with a warning when it needed numbering or stripping. -`fill_tokens(src, values, classify, templates)` is the companion filler: it resolves template tokens from a plain dict and touches nothing else, so the result is still-symbolic Markdown ready for any exporter. The `classify` callable defines the grammar, mapping a token's `(body, syntax)` to `('var', name)`, `('open', name, inverted)`, or `('close', name)`. Variables take `str(values[name])`; sections keep or drop their span by the value's truthiness (kept sections just lose their markers; no iteration). By default a field missing in either direction raises; with `strict=False` the mismatches land in `.warnings` and unfilled variables stay in place, so a document can be filled in stages. `mdhtml.mustache.fill_md` and `mdhtml.jinja.fill_md` are the shipped instantiations - mustache's classifier reads `#`/`^`/`/` sigils from bodies, jinja's discriminates by delimiter pair (`{% if x %}`/`{% if not x %}`/`{% endif %}`) - and `examples/filldemo.py` shows the mustache one in use. +`fill_tokens(src, values, classify, templates)` is the companion filler: it resolves template tokens from a plain dict and touches nothing else, so the result is still-symbolic Markdown ready for any exporter. The `classify` callable defines the grammar, mapping a token's `(body, syntax)` to `('var', name)`, `('open', name, inverted)` or `('open', name, inverted, bind)`, or `('close', name)`. Variables take `str(values[name])`, with names as dotted paths resolved innermost-first through the enclosing sections' frames; sections keep or drop their span by the value's truthiness. `bind` says what a kept section pushes as the innermost frame: nothing for a pure conditional (jinja's `if`), `'.'` for the section's own value (mustache, so its fields are visible and `{{.}}` names it), or a name for `{bind: value}` (jinja's `for bind in name`). Under a binding open, a list value repeats the span once per item with that item's frame pushed, and an empty list drops it like any other falsy value. By default a field missing in either direction raises; with `strict=False` the mismatches land in `.warnings` and unfilled variables stay in place, so a document can be filled in stages. `mdhtml.mustache.fill_md` and `mdhtml.jinja.fill_md` are the shipped instantiations - mustache's classifier reads `#`/`^`/`/` sigils from bodies, jinja's discriminates by delimiter pair (`{% if x %}`/`{% if not x %}`/`{% endif %}` and `{% for x in xs %}`/`{% endfor %}`) - and `examples/filldemo.py` shows the mustache one in use, iterating a grant table and a list of contingencies. Command-line usage (the `mdhtml` script is installed with the package): diff --git a/examples/README.md b/examples/README.md index 332d1eb..98ef9dd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,8 +10,12 @@ notes in an `.ipynb`). It exercises the dialect features that matter across conv - Headings with ids (`## Compensation {#sec-comp}`) referenced from *other* notes: single refs (`[@sec-offer]`), a group (`[@sec-comp; @sec-equity; @sec-atwill]`), and custom text (`[your cash compensation @sec-comp]`). -- Mustache template tokens: inline variables (`{{base_salary}}`) and block section markers - (`{{#equity.options}}` ... `{{/equity.options}}`). +- Mustache template tokens: inline variables (`{{base_salary}}`), conditional section markers + (`{{#equity.options}}` ... `{{/equity.options}}`), and list sections that repeat their span once + per item: a table of `{{#grants}}` rows, and `{{#contingencies}}` bullets naming each item with + `{{.}}`. Names inside a repeated span resolve innermost-first, so a row sees the grant's own + fields, falls back to the letter-wide `{{vesting_schedule}}`, and a grant carrying its own + `class_of_stock` shadows the outer one. - A footnote, for id-namespacing to exercise. ## The build script diff --git a/examples/filldemo.py b/examples/filldemo.py index ed53ef3..8aa3101 100644 --- a/examples/filldemo.py +++ b/examples/filldemo.py @@ -8,8 +8,13 @@ from mdhtml.mustache import MUSTACHE, fill_md values = {'company_common_name': 'Acme Robotics, Inc.', 'candidate_name': 'Alex Rivera', 'job_title': 'Senior Research Engineer', - 'base_salary': '$185,000', 'equity.options': True, 'shares_subject_to_option': '25,000', 'class_of_stock': 'Common Stock', - 'vesting_schedule': 'four years, with a one-year cliff', 'equity.restricted_stock': False, + 'base_salary': '$185,000', 'equity': {'options': True, 'restricted_stock': False}, + 'shares_subject_to_option': '25,000', 'class_of_stock': 'Common Stock', + 'vesting_schedule': 'four years, with a one-year cliff', + 'grants': [{'grant_date': 'September 1, 2026', 'shares': '25,000'}, + {'grant_date': 'March 1, 2027', 'shares': '5,000', 'class_of_stock': 'Series A Preferred'}], + 'contingencies': ['satisfactory completion of a background check', 'your signed confidentiality agreement', + 'documentation of your eligibility to work'], 'offer_expiration_date': 'August 1, 2026', 'hiring_manager_name': 'Sam Devlin', 'offer_date': 'July 23, 2026'} d = Path(__file__).parent diff --git a/examples/legal_demo-bound.docx b/examples/legal_demo-bound.docx index 1675de4..18cd396 100644 Binary files a/examples/legal_demo-bound.docx and b/examples/legal_demo-bound.docx differ diff --git a/examples/legal_demo-filled.md b/examples/legal_demo-filled.md index 5022840..3ce536c 100644 --- a/examples/legal_demo-filled.md +++ b/examples/legal_demo-filled.md @@ -12,8 +12,23 @@ Your base salary will be $185,000 per year, paid on Acme Robotics, Inc.'s normal Subject to approval by Acme Robotics, Inc.'s Board of Directors, you will be granted an option to purchase 25,000 shares of Common Stock at a strike price equal to fair market value on the date of grant. The option will vest over four years, with a one-year cliff. +Your grants under this offer are: + +| Grant date | Shares | Class | Vesting | +|---|---|---|---| +| September 1, 2026 | 25,000 | Common Stock | four years, with a one-year cliff | +| March 1, 2027 | 5,000 | Series A Preferred | four years, with a one-year cliff | + Tax treatment is your responsibility; see also [your cash compensation @sec-comp]. +## Contingencies {#sec-conting} + +This offer is contingent on each of the following: + +- satisfactory completion of a background check +- your signed confidentiality agreement +- documentation of your eligibility to work + ## At-Will Employment {#sec-atwill} Your employment with Acme Robotics, Inc. is at will: either you or the Company may end it at any time, with or without cause. Nothing in [@sec-comp] or [@sec-equity] changes that. diff --git a/examples/legal_demo-filled.pdf b/examples/legal_demo-filled.pdf index 7e5a8cb..b686c36 100644 Binary files a/examples/legal_demo-filled.pdf and b/examples/legal_demo-filled.pdf differ diff --git a/examples/legal_demo-form.docx b/examples/legal_demo-form.docx index 1dac919..ee30a61 100644 Binary files a/examples/legal_demo-form.docx and b/examples/legal_demo-form.docx differ diff --git a/examples/legal_demo-render.md b/examples/legal_demo-render.md index c265a1b..0440725 100644 --- a/examples/legal_demo-render.md +++ b/examples/legal_demo-render.md @@ -1,6 +1,6 @@ # 1. Offer of Employment -`{{company_common_name}}` (the "Company") is pleased to offer `{{candidate_name}}` the position of `{{job_title}}`. This letter summarizes the key terms: Sections 1.(a), 1.(b) and 1.(c). +`{{company_common_name}}` (the "Company") is pleased to offer `{{candidate_name}}` the position of `{{job_title}}`. This letter summarizes the key terms: Sections 1.(a), 1.(b) and 1.(d). ## (a) Compensation @@ -18,9 +18,25 @@ Subject to approval by `{{company_common_name}}`'s Board of Directors, you will Subject to Board approval, you will be granted the right to purchase `{{number_shares}}` shares of `{{class_of_stock}}` under a Restricted Stock Purchase Agreement, vesting over `{{vesting_schedule}}`. `{{/equity.restricted_stock}}` +Your grants under this offer are: + +| Grant date | Shares | Class | Vesting | +|---|---|---|---| +`{{#grants}}` +| `{{grant_date}}` | `{{shares}}` | `{{class_of_stock}}` | `{{vesting_schedule}}` | +`{{/grants}}` + Tax treatment is your responsibility; see also your cash compensation 1.(a). -## (c) At-Will Employment +## (c) Contingencies + +This offer is contingent on each of the following: + +`{{#contingencies}}` +- `{{.}}` +`{{/contingencies}}` + +## (d) At-Will Employment Your employment with `{{company_common_name}}` is at will: either you or the Company may end it at any time, with or without cause. Nothing in Section 1.(a) or Section 1.(b) changes that. @@ -36,4 +52,4 @@ To accept, sign below by `{{offer_expiration_date}}`. Date: `{{offer_date}}`Date: `{{signature_date}}` -Please retain a copy of this letter for your records; the terms in Sections 1.(a), 1.(b) and 1.(c) are the entire agreement. +Please retain a copy of this letter for your records; the terms in Sections 1.(a), 1.(b) and 1.(d) are the entire agreement. diff --git a/examples/legal_demo.docx b/examples/legal_demo.docx index 340902b..396eda0 100644 Binary files a/examples/legal_demo.docx and b/examples/legal_demo.docx differ diff --git a/examples/legal_demo.html b/examples/legal_demo.html index c9c3fa8..f0a4ae2 100644 --- a/examples/legal_demo.html +++ b/examples/legal_demo.html @@ -1,12 +1,29 @@

1. Offer of Employment

-

(the "Company") is pleased to offer the position of . This letter summarizes the key terms: Sections 1.(a), 1.(b) and 1.(c).

+

(the "Company") is pleased to offer the position of . This letter summarizes the key terms: Sections 1.(a), 1.(b) and 1.(d).

(a) Compensation

Your base salary will be per year, paid on 's normal payroll schedule and subject to all withholdings required by law.1 Salary is reviewed annually as part of the process described in Section 1..

(b) Equity

{{#equity.options}}

Subject to approval by 's Board of Directors, you will be granted an option to purchase shares of at a strike price equal to fair market value on the date of grant. The option will vest over .

{{/equity.options}}{{#equity.restricted_stock}}

Subject to Board approval, you will be granted the right to purchase shares of under a Restricted Stock Purchase Agreement, vesting over .

-{{/equity.restricted_stock}}

Tax treatment is your responsibility; see also your cash compensation 1.(a).

-

(c) At-Will Employment

+{{/equity.restricted_stock}}

Your grants under this offer are:

+ + + + + + + + + +
Grant dateSharesClassVesting
{{#grants}}
{{/grants}}
+

Tax treatment is your responsibility; see also your cash compensation 1.(a).

+

(c) Contingencies

+

This offer is contingent on each of the following:

+{{#contingencies}} +{{/contingencies}}

(d) At-Will Employment

Your employment with is at will: either you or the Company may end it at any time, with or without cause. Nothing in Section 1.(a) or Section 1.(b) changes that.


To accept, sign below by .

@@ -17,7 +34,7 @@

(c) At-Will EmploymentName: Name: Date: Date: -

Please retain a copy of this letter for your records; the terms in Sections 1.(a), 1.(b) and 1.(c) are the entire agreement.

+

Please retain a copy of this letter for your records; the terms in Sections 1.(a), 1.(b) and 1.(d) are the entire agreement.

  1. diff --git a/examples/legal_demo.ipynb b/examples/legal_demo.ipynb index 5e09473..3cb7959 100644 --- a/examples/legal_demo.ipynb +++ b/examples/legal_demo.ipynb @@ -37,9 +37,31 @@ "Subject to Board approval, you will be granted the right to purchase {{number_shares}} shares of {{class_of_stock}} under a Restricted Stock Purchase Agreement, vesting over {{vesting_schedule}}.\n", "{{/equity.restricted_stock}}\n", "\n", + "Your grants under this offer are:\n", + "\n", + "| Grant date | Shares | Class | Vesting |\n", + "|---|---|---|---|\n", + "{{#grants}}\n", + "| {{grant_date}} | {{shares}} | {{class_of_stock}} | {{vesting_schedule}} |\n", + "{{/grants}}\n", + "\n", "Tax treatment is your responsibility; see also [your cash compensation @sec-comp]." ] }, + { + "cell_type": "markdown", + "id": "8647cece", + "metadata": {}, + "source": [ + "## Contingencies {#sec-conting}\n", + "\n", + "This offer is contingent on each of the following:\n", + "\n", + "{{#contingencies}}\n", + "- {{.}}\n", + "{{/contingencies}}" + ] + }, { "cell_type": "markdown", "id": "bb626ab6", diff --git a/examples/legal_demo.md b/examples/legal_demo.md index df8d608..f91f3c3 100644 --- a/examples/legal_demo.md +++ b/examples/legal_demo.md @@ -18,8 +18,24 @@ Subject to approval by {{company_common_name}}'s Board of Directors, you will be Subject to Board approval, you will be granted the right to purchase {{number_shares}} shares of {{class_of_stock}} under a Restricted Stock Purchase Agreement, vesting over {{vesting_schedule}}. {{/equity.restricted_stock}} +Your grants under this offer are: + +| Grant date | Shares | Class | Vesting | +|---|---|---|---| +{{#grants}} +| {{grant_date}} | {{shares}} | {{class_of_stock}} | {{vesting_schedule}} | +{{/grants}} + Tax treatment is your responsibility; see also [your cash compensation @sec-comp]. +## Contingencies {#sec-conting} + +This offer is contingent on each of the following: + +{{#contingencies}} +- {{.}} +{{/contingencies}} + ## At-Will Employment {#sec-atwill} Your employment with {{company_common_name}} is at will: either you or the Company may end it at any time, with or without cause. Nothing in [@sec-comp] or [@sec-equity] changes that. diff --git a/examples/legal_demo.pdf b/examples/legal_demo.pdf index c349d9f..5bd6685 100644 Binary files a/examples/legal_demo.pdf and b/examples/legal_demo.pdf differ diff --git a/examples/legal_demo.typ b/examples/legal_demo.typ index 7537ca3..ce07fc7 100644 --- a/examples/legal_demo.typ +++ b/examples/legal_demo.typ @@ -31,8 +31,28 @@ Subject to Board approval, you will be granted the right to purchase #raw("{{num #raw("{{/equity.restricted_stock}}") +Your grants under this offer are: + +#table( + columns: 4, + table.header([Grant date], [Shares], [Class], [Vesting]), + [#raw("{{#grants}}")], [], [], [], + [#raw("{{grant_date}}")], [#raw("{{shares}}")], [#raw("{{class_of_stock}}")], [#raw("{{vesting_schedule}}")], + [#raw("{{/grants}}")], [], [], [], +) + Tax treatment is your responsibility; see also #ref(, supplement: [your cash compensation]). +== Contingencies + +This offer is contingent on each of the following: + +#raw("{{#contingencies}}") + +- #raw("{{.}}") + +#raw("{{/contingencies}}") + == At-Will Employment Your employment with #raw("{{company_common_name}}") is at will: either you or the Company may end it at any time, with or without cause. Nothing in #ref(, supplement: [Section]) or #ref(, supplement: [Section]) changes that. diff --git a/examples/render_demo.py b/examples/render_demo.py index 14c4c45..f6c585a 100644 --- a/examples/render_demo.py +++ b/examples/render_demo.py @@ -18,16 +18,16 @@ def _tok(n, h): def _control(body, syntax, form): - "Interactive form register: variables become click-and-type content controls, section markers stay literal" - if mustache_kind(body) == 'section': return '{{' + body + '}}' + "Interactive form register: variables become click-and-type content controls, section markers and the `{{.}}` item placeholder stay literal" + if mustache_kind(body) == 'section' or body.strip() == '.': return '{{' + body + '}}' return 'control', body convert(to_mdhtml(src, templates=MUSTACHE), d/'legal_demo-form.docx', tmpl=_control, number_headings='legal') def _bound(body, syntax, form): - "Synced form register: every control for a variable is a live view of one shared XML node" - if mustache_kind(body) == 'section': return '{{' + body + '}}' + "Synced form register: every control for a variable is a live view of one shared XML node; the `{{.}}` item placeholder has no named node to bind to, so it stays literal" + if mustache_kind(body) == 'section' or body.strip() == '.': return '{{' + body + '}}' return 'bound', body convert(to_mdhtml(src, templates=MUSTACHE), d/'legal_demo-bound.docx', tmpl=_bound, number_headings='legal') diff --git a/python/mdhtml/jinja.py b/python/mdhtml/jinja.py index 3613567..5d2a147 100644 --- a/python/mdhtml/jinja.py +++ b/python/mdhtml/jinja.py @@ -3,10 +3,10 @@ mdhtml's neutral seams. Where mustache's classifier reads sigils from token bodies, jinja's discriminates by delimiter pair: `{{ }}` (syntax `jinja`) is always a variable, `{% %}` (syntax `jinja-stmt`) always a statement. The filler covers `{% if x %}`/`{% if not x %}`... -`{% endif %}` sections only, by design: a document using real jinja features (`for`, `else`, -filters, expressions) should be rendered by jinja2 itself, which also leaves non-template text -intact. This module is for the fill-while-symbolic workflow: strict bidirectional checking, -staged fills, and results that remain valid mdhtml source.""" +`{% endif %}` sections and `{% for x in xs %}`...`{% endfor %}` iteration, by design: a document +using any other jinja feature (`else`, `elif`, filters, expressions) should be rendered by jinja2 +itself, which also leaves non-template text intact. This module is for the fill-while-symbolic +workflow: strict bidirectional checking, staged fills, and results that remain valid mdhtml source.""" from html import escape from . import TemplateDelimiter @@ -31,20 +31,25 @@ def jinja_literal(body, syntax, form): return f'{o} {body.strip()} {c}' def _classify(body, syntax): - "The jinja if-grammar as a `fill_tokens` classifier: statements by delimiter pair, not body sigils." + "The jinja statement grammar as a `fill_tokens` classifier: `if`/`for` told apart by delimiter pair, not body sigils; `for x in xs` binds `x` as the item name inside its span." if syntax != "jinja-stmt": return ("var", body.strip()) b = body.strip() if b.startswith("if not "): return ("open", b.removeprefix("if not ").strip(), True) if b.startswith("if "): return ("open", b.removeprefix("if ").strip(), False) if b == "endif": return ("close", "") - raise ValueError(f"unsupported jinja statement {body!r}: only if/if not/endif sections are fillable (render real jinja templates with jinja2)") + if b.startswith("for "): + v, _, it = b.removeprefix("for ").partition(" in ") + return ("open", it.strip(), False, v.strip()) + if b == "endfor": return ("close", "") + raise ValueError(f"unsupported jinja statement {body!r}: only if/if not/endif and for/endfor sections are fillable (render real jinja templates with jinja2)") def fill_md(src, values, dest=None, templates=None, strict=True): """Fill jinja-style template tokens in Markdown source with `values`, leaving all other - source (refs, attributes, everything symbolic) byte-identical. Variables take `str(values[name])`; - `{% if name %}`/`{% if not name %}`...`{% endif %}` sections keep or drop their whole span by the - truthiness of `values[name]` (no iteration; a kept section just loses its markers). `templates` - defaults to `JINJA`. With `strict`, fields missing in either direction raise; otherwise they are + source (refs, attributes, everything symbolic) byte-identical. Names are dotted paths; + `{% if name %}`/`{% if not name %}`...`{% endif %}` keeps or drops its span by truthiness, and + `{% for x in name %}`...`{% endfor %}` repeats its span per item of the list `name`, binding + the item to `x` inside (an `if` binds nothing: names stay lexical). `templates` defaults to + `JINJA`. With `strict`, fields missing in either direction raise; otherwise they are reported in `.warnings` and unfilled variables stay in place, ready for a later pass.""" return fill_tokens(src, values, _classify, JINJA if templates is None else templates, dest=dest, strict=strict) diff --git a/python/mdhtml/md.py b/python/mdhtml/md.py index 7015e95..c10401b 100644 --- a/python/mdhtml/md.py +++ b/python/mdhtml/md.py @@ -222,13 +222,32 @@ def _token_spans(normalized, tmpls, starts, srcb): return sorted(toks, key=lambda t: t["start"]) +def _resolve(scopes, name): + "Innermost-first lookup of dotted `name` through the frame stack (`.` names the innermost frame)" + if name == ".": return True, scopes[-1] + for s in reversed(scopes): + v = s + for part in name.split("."): + if not (isinstance(v, dict) and part in v): break + v = v[part] + else: return True, v + return False, None + + def fill_tokens(src, values, classify, templates, dest=None, strict=True) -> Md: """Fill template tokens in Markdown source from the `values` dict, leaving all other source (refs, attributes, everything symbolic) byte-identical. `classify` defines the grammar: it maps - a token `(body, syntax)` to `('var', name)`, `('open', name, inverted)`, or `('close', name)` (an empty - close name matches the innermost open section, for grammars whose close marker is unnamed). - Variables take `str(values[name])`; sections keep or drop their whole span by the truthiness of - `values[name]` (`inverted` flips it; no iteration - a kept section just loses its markers). + a token `(body, syntax)` to `('var', name)`, `('open', name, inverted)` or + `('open', name, inverted, bind)`, or `('close', name)` (an empty close name matches the + innermost open section, for grammars whose close marker is unnamed). + + Names are dotted paths resolved through a stack of frames, innermost first; the root frame is + `values`. What a kept section pushes is the grammar's choice, carried by `bind`: absent or None + pushes nothing (a pure conditional, jinja's `if`); `'.'` pushes the section's value itself + (mustache: the value's fields become visible, and `{{.}}` names the value); any other string + `b` pushes `{b: value}` (jinja's `for b in name`). A section whose value is a list, under a + binding open, repeats its span once per item with the item's frame pushed; other truthy values + keep the span once, falsy values (and empty lists) drop it, and `inverted` flips the decision. With `strict`, fields missing in either direction raise; otherwise they are reported in `.warnings` and unfilled variables stay in place, ready for a later pass. `mdhtml.mustache.fill_md` is the mustache instantiation, and the worked example for @@ -238,41 +257,100 @@ def fill_tokens(src, values, classify, templates, dest=None, strict=True) -> Md: srcb = normalized.encode() starts = [0] for line in normalized.split("\n"): starts.append(starts[-1] + len(line.encode()) + 1) - edits, removals, stack, seen, unfilled = [], [], [], set(), [] - - def rm(cs, ce): - "Remove `cs..ce`, consuming following blank lines when the removal sits at a paragraph boundary." - if cs < 2 or srcb[cs - 2:cs] == b"\n\n": - while srcb[ce:ce + 1] == b"\n": ce += 1 - edits.append((cs, ce, "")) - return cs, ce - + def standalone(t): + "A marker token alone on its line owns the whole line, newline included" + if t["block"]: return t + i = bisect_right(starts, t["start"]) + ls, le = starts[i - 1], starts[i] + if srcb[ls:t["start"]].strip() or srcb[t["end"]:le].strip(): return t + return dict(t, start=ls, end=le) + + root, stack = [], [] for t in _token_spans(normalized, tmpls, starts, srcb): kind, name, *rest = classify(t["body"], t["syntax"]) - if kind == "open": stack.append((name, rest[0], t)) + if kind in ("open", "close"): t = standalone(t) + if kind == "open": stack.append(dict(name=name, inv=rest[0], bind=rest[1] if len(rest) > 1 else None, ot=t, kids=[])) elif kind == "close": - if not stack or (name and stack[-1][0] != name): raise ValueError(f"unmatched section close {t['body'].strip()!r}") - oname, inverted, ot = stack.pop() - seen.add(oname) - if oname not in values: unfilled.append((oname, ot["start"])) - if bool(values.get(oname)) != inverted: - rm(ot["start"], ot["end"]) - rm(t["start"], t["end"]) - else: removals.append(rm(ot["start"], t["end"])) - else: - seen.add(name) - if name in values: - v = str(values[name]) - edits.append((t["start"], t["end"], v + "\n" if t["block"] else v)) - else: unfilled.append((name, t["start"])) - if stack: raise ValueError(f"unclosed section {stack[-1][0]!r}") - gone = lambda s, e=None: any(rs <= s and (e or s) <= re for rs, re in removals) + if not stack or (name and stack[-1]["name"] != name): raise ValueError(f"unmatched section close {t['body'].strip()!r}") + node = stack.pop() + node["ct"] = t + (stack[-1]["kids"] if stack else root).append(node) + else: (stack[-1]["kids"] if stack else root).append(dict(name=name, t=t)) + if stack: raise ValueError(f"unclosed section {stack[-1]['name']!r}") + edits, seen, unfilled = [], set(), [] + + def mark(name): seen.update((name, name.split(".")[0])) + + def past_blanks(cs, ce): + "Extend removal `cs..ce` over following blank lines when it sits at a paragraph boundary" + if cs < 2 or srcb[cs - 2:cs] == b"\n\n": + while srcb[ce:ce + 1] == b"\n": ce += 1 + return ce + + def sect(node, scopes): + "Section decision `(keep, items, frame)`; a missing name warns only when its section keeps" + mark(node["name"]) + ok, v = _resolve(scopes, node["name"]) + keep = bool(v) != node["inv"] + if not ok and keep: unfilled.append(node["name"]) + if not keep or node["inv"] or node["bind"] is None: return keep, None, None + if isinstance(v, list): return keep, v, None + return keep, None, _bound(node, v) + + def _bound(node, v): return v if node["bind"] == "." else {node["bind"]: v} + + def var(n, scopes): + "A variable node's replacement text, or None to leave the token in place" + mark(n["name"]) + ok, v = _resolve(scopes, n["name"]) + if not ok: + unfilled.append(n["name"]) + return None + return str(v) + "\n" if n["t"]["block"] else str(v) + + def build(s, e, kids, scopes): + "Render `srcb[s:e]` against `scopes` as a string, for spans repeated per item" + out, pos = [], s + for n in kids: + if "kids" not in n: + if (v := var(n, scopes)) is None: continue + out.append(srcb[pos:n["t"]["start"]].decode()) + out.append(v) + pos = n["t"]["end"] + continue + keep, items, fr = sect(n, scopes) + out.append(srcb[pos:n["ot"]["start"]].decode()) + if not keep: pos = past_blanks(n["ot"]["start"], n["ct"]["end"]) + else: + s2, e2 = n["ot"]["end"], n["ct"]["start"] + if items is not None: out += [build(s2, e2, n["kids"], scopes + [_bound(n, it)]) for it in items] + else: out.append(build(s2, e2, n["kids"], scopes + ([fr] if fr is not None else []))) + pos = n["ct"]["end"] + out.append(srcb[pos:e].decode()) + return "".join(out) + + def walk(kids, scopes): + "Emit edits for `kids` against `scopes`; a repeated span becomes one edit built by `build`" + for n in kids: + if "kids" not in n: + if (v := var(n, scopes)) is not None: edits.append((n["t"]["start"], n["t"]["end"], v)) + continue + keep, items, fr = sect(n, scopes) + ot, ct = n["ot"], n["ct"] + if not keep: edits.append((ot["start"], past_blanks(ot["start"], ct["end"]), "")) + elif items is not None: + body = "".join(build(ot["end"], ct["start"], n["kids"], scopes + [_bound(n, it)]) for it in items) + edits.append((ot["start"], ct["end"], body)) + else: + edits.append((ot["start"], past_blanks(ot["start"], ot["end"]), "")) + edits.append((ct["start"], past_blanks(ct["start"], ct["end"]), "")) + walk(n["kids"], scopes + ([fr] if fr is not None else [])) + + walk(root, [values]) warnings = [] - if missing := list(dict.fromkeys(n for n, pos in unfilled if not gone(pos))): - warnings.append("fields not in values: " + ", ".join(missing)) + if missing := list(dict.fromkeys(unfilled)): warnings.append("fields not in values: " + ", ".join(missing)) if unused := [k for k in values if k not in seen]: warnings.append("values not in document: " + ", ".join(unused)) if warnings and strict: raise ValueError("; ".join(warnings)) - edits = [e for e in edits if e[2] == "" and (e[0], e[1]) in removals or not gone(e[0], e[1])] for cs, ce, repl in sorted(edits, reverse=True): src = src[:offsets[cs]] + repl + src[offsets[ce]:] res = Md(src, warnings) if dest is not None: Path(dest).write_text(res, encoding="utf-8") diff --git a/python/mdhtml/mustache.py b/python/mdhtml/mustache.py index 04752e5..85ac6fa 100644 --- a/python/mdhtml/mustache.py +++ b/python/mdhtml/mustache.py @@ -34,19 +34,20 @@ def mustache_code(body, syntax, form): def _classify(body, syntax): - "The mustache sigil grammar as a `fill_tokens` classifier." + "The mustache sigil grammar as a `fill_tokens` classifier: a section binds `.`, so a kept section pushes its own value as the innermost frame." body = body.strip() sig, name = body[:1], body[1:].strip() - if sig in "#^": return ("open", name, sig == "^") + if sig in "#^": return ("open", name, sig == "^", ".") if sig == "/": return ("close", name) return ("var", body) def fill_md(src, values, dest=None, templates=None, strict=True): """Fill mustache-style template tokens in Markdown source with `values`, leaving all other - source (refs, attributes, everything symbolic) byte-identical. Variables take `str(values[name])`; - `{{#name}}`/`{{^name}}`...`{{/name}}` sections keep or drop their whole span by the truthiness of - `values[name]` (no iteration; a kept section just loses its markers). `templates` defaults to + source (refs, attributes, everything symbolic) byte-identical. Names are dotted paths resolved + mustache-style through the enclosing sections' values, innermost first. `{{#name}}`...`{{/name}}` + keeps or drops its span by truthiness; a list repeats the span per item, with the item's fields + visible inside and `{{.}}` naming the item; `{{^name}}` inverts. `templates` defaults to `MUSTACHE`. With `strict`, fields missing in either direction raise; otherwise they are reported in `.warnings` and unfilled variables stay in place, ready for a later pass.""" return fill_tokens(src, values, _classify, MUSTACHE if templates is None else templates, dest=dest, strict=strict) diff --git a/tests/test_export.py b/tests/test_export.py index bc223db..adf380e 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -283,7 +283,7 @@ def test_jinja_module(): md = 'Hi {{ who }}.\n\n{% if not skip %}\n\nShown.\n\n{% endif %}\n' assert fill_md(md, dict(who='Sam', skip=False)) == 'Hi Sam.\n\nShown.\n\n' assert fill_md(md, dict(who='Sam', skip=True)) == 'Hi Sam.\n\n' # `if not` inverts - with pytest.raises(ValueError, match='unsupported'): fill_md('{% for x in y %}\n', dict()) + with pytest.raises(ValueError, match='unsupported'): fill_md('{% while x %}\n', dict()) h = to_html(to_mdhtml('V {{ v }} S {% if x %}', templates=JINJA, callbacks={'template_token': jinja_pill})) assert '{{ v }}' in h # classed by syntax, not sigils assert '{% if x %}' in h @@ -291,6 +291,33 @@ def test_jinja_module(): assert jinja_literal('if x', 'jinja-stmt', 'block') == '{% if x %}' +def test_fill_iteration_and_scopes(): + from mdhtml.mustache import fill_md + md = 'Contingencies:\n\n{{#items}}\n- {{.}}\n{{/items}}\n\nDone.\n' + assert fill_md(md, dict(items=['a', 'b'])) == 'Contingencies:\n\n- a\n- b\n\nDone.\n' + assert fill_md(md, dict(items=[])) == 'Contingencies:\n\nDone.\n' # empty list drops like falsy + out = fill_md('{{#grants}}\n{{n}} shares of {{co}}.\n{{/grants}}\n', dict(grants=[dict(n=1), dict(n=2)], co='J&J')) + assert out == '1 shares of J&J.\n2 shares of J&J.\n' # item fields implicit; root stays visible + out = fill_md('{{#equity.options}}\nGranted {{shares}} at {{strike}}.\n{{/equity.options}}\n', + dict(equity=dict(options=dict(shares=5, strike='$1')))) + assert out == 'Granted 5 at $1.\n' # dotted paths traverse; a dict section pushes its fields + assert fill_md('{{#opt}}G {{n}}.{{/opt}}\n', dict(opt=dict(n=9), n=7)) == 'G 9.\n' # innermost frame wins + part = fill_md('{{#xs}}\n- {{.}} for {{who}}\n{{/xs}}\n', dict(xs=['a']), strict=False) + assert part == '- a for {{who}}\n' # staged fill inside an iterated span + assert part.warnings == ['fields not in values: who'] + tbl = '|A|B|\n|---|---|\n{{#rows}}\n| {{a}} | {{b}} |\n{{/rows}}\nEnd.\n' + out = fill_md(tbl, dict(rows=[dict(a=1, b=2), dict(a=3, b=4)])) + assert out == '|A|B|\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\nEnd.\n' # standalone marker lines vanish inside table context too + + +def test_jinja_for(): + from mdhtml.jinja import fill_md + md = '{% for g in grants %}\n{{ g.n }} shares to {{ who }}.\n{% endfor %}\n' + assert fill_md(md, dict(grants=[dict(n=1), dict(n=2)], who='Sam')) == '1 shares to Sam.\n2 shares to Sam.\n' + md2 = '{% if opt %}\nGranted {{ n }}.\n{% endif %}\n' + assert fill_md(md2, dict(opt=dict(n=9), n=7)) == 'Granted 7.\n' # jinja `if` pushes no frame: names stay lexical + + def test_resolver_registries_are_read_only(): from mdhtml.export import Resolver r = Resolver()