Tag Blocks & Anchors¶
Tag blocks — repolish:start[name] … repolish:end[name] pairs in a template —
mark a region whose content the provider fills with an anchor value.
Unlike every other directive family, the developer's project file is never read:
the provider (or repolish.yaml) owns what goes between the markers.
These were the first markers in repolish — the escape hatch for templates that need project-specific statements (extra packages in a Dockerfile, optional install extras in a Makefile). All marker lines are stripped from the final file; what remains is the replacement content, ready for Jinja2.
Pre-render only
Tag blocks are always resolved in the pre-render
phase — anchor content must be in place before Jinja2 sees the template, so
they carry no |after-render suffix. See Phases.
Syntax¶
A block is a pair of marker lines sharing the same name:
The comment style is flexible — any prefix before repolish:start[name] is
accepted, so use whatever comment syntax fits the file type:
# repolish:start[block] ← Python / TOML / YAML
// repolish:start[block] ← JavaScript / CSS
<!-- repolish:start[block] --> ← HTML / Markdown
/* repolish:start[block] */ ← CSS / C
The content between the markers is the default: it ships in the template and is used when nothing overrides the anchor.
Content sources¶
Repolish resolves each anchor name in this order — later sources win:
- Template default — the content between the markers.
- Provider code — the provider's
create_anchors(context)method returns a dict of names → replacement strings:
def create_anchors(self, context: Ctx) -> dict[str, str]:
extras = ','.join(['dev', *context.extra_groups])
return {'install-extras': f'\tpip install -e ".[{extras}]"'}
- Project config — an
anchors:mapping inrepolish.yaml, declared under a provider entry:
Config-level anchors are merged on top of the provider's return value, so you only list the keys you want to override. The value is the full replacement string — no markers.
!!! warning "Overrides are global, despite the provider-scoped syntax"
anchors: is declared under a provider entry, but all overrides are merged
into one global map that every template resolves against. An override under
mylib will also fill a same-named anchor in another provider's templates.
Namespacing anchor names per provider (see Naming) is currently
the only isolation.
If no source provides a replacement, the template default is kept — and the markers are still stripped.
Providers: document your anchors
Anchor names only exist in the
provider's create_anchors(), so project maintainers can't discover them. List
the keys you support and the expected format of each replacement string in your
provider's docs.
Worked example¶
In a provider-managed Makefile. Note there is no your file tab here — tag
blocks never read it; the content comes from the provider or repolish.yaml.
repolish/Makefile.jinja — with a default between the markers:
The provider assembles the value from context:
def create_anchors(self, context: Ctx) -> dict[str, str]:
extras = ','.join(['dev', *context.extra_groups])
return {'install-extras': f'\tpip install -e ".[{extras}]"'}
With context.extra_groups == ['docs', 'gpu'] the anchor value becomes
pip install -e ".[dev,docs,gpu]".
Save this as scratch.yaml — in preview, config.anchors holds the
final anchor map (provider code and YAML overrides already merged):
template: |
.PHONY: install
install:
## repolish:start[install-extras]
pip install -e ".[dev]"
## repolish:end[install-extras]
config:
anchors:
install-extras: "\tpip install -e \".[dev,docs,gpu]\""
and run:
When to use¶
Anchors are the right tool in a narrow case: the provider must compute content at apply time (assembling values from context) and project developers override it deliberately via config.
For most "preserve the project's local state" needs, prefer the other families — they keep the project file as the source of truth and the content visible where it lives:
- A single value (version, author, URL) → regex
- A structured section (
[tools], dependency lists) → multiregex - A whole developer-owned region → keep blocks
The anchor tradeoff to accept: to customize the injected content you edit
repolish.yaml (or the provider computes it), because editing the file
directly won't stick — the next apply overwrites the region with whatever the
anchor sources resolve to.
Naming¶
Directive names are global identifiers across all templates in a run: anchor
values from every provider are merged into one map, and two templates using
repolish:start[init] share that one replacement — the provider processed later
silently wins, even in a template owned by another provider.
Until the naming question is settled (see below), namespace your anchor
names by prefixing them with the provider or file: docker-init instead of
init, mylib-version instead of version.
Anchors in v2? An open question
Anchors predate typed contexts: they were the escape hatch when templating
followed cookiecutter's JSON-shaped model, where multiline strings were
painful. Today's repolish has YAML config and pydantic context models —
neither has that limitation — and tests/integration/test_anchor_vs_context.py
proves anchors ≡ a Jinja context variable plus a config context override.
Even the in-template default has an equivalent: a field default on the
context model.
If you want the fix today, use Jinja2 context instead of anchors. The mapping is direct:
- default → field default on the context model
- provider-computed →
create_context() - project override →
overrides.context_merge
providers:
mylib:
overrides:
context_merge:
install_extras: '\tpip install -e ".[minimal]"'
Context fields are naturally scoped to their provider too, so the global collision problem above never arises.
If anchors nonetheless survive into v2, per-provider scoping (the provider that owns the template wins its own names) is the fix — a breaking change, since it requires tracking anchor provenance rather than one merged map. The final call is deferred until repolish dogfoods its own providers.