What's new¶
Inner DSL — list & map elements are readable by index¶
A [<expr>] index may now appear in a read position, mirroring the
write target that already existed. Inside both inner-DSL value position
and ${…} templates:
if context.readsince[loc] == 0 # map read by captured key
set context.buf[context.laststore[loc]] `… eliminated`
end
write `${context.buf[i]}` # list read by loop index
${context.grid[i][j]} # nested
${context.rows[i].name} # index then field
${context.buf[(sub n 1)]} # computed index expression
${context.buf[-1]} # negative → last
Map parents key on the index's string form; list parents key on an
integer (negative from the end) — read and write semantics are
identical, so a value written by set context.buf[i] … reads back by the
same index. Missing keys / out-of-range indices are nil (falsy) in
value position and render empty in a template, so if context.seen[k]
guards without a for-scan. This retires the
for k,v in … / if k == name linear-lookup idiom: the new
samples/value-index-read/
rewrites the dead-store eliminator's nested scans as two direct indexed
reads and produces byte-identical output. Design notes:
value-position indexing.
Ten new samples show the idiom across map-by-key, list-by-index,
computed and negative indices — all in the playground under the
Indexed reads category:
word-frequency
(map increment),
memo-fib
(computed index),
stack-top
(negative index),
enum-lookup
(list by index),
leaderboard
(positional reads),
color-palette
(map by name),
symbol-types,
register-allocator,
grid-game
and
emoji-react.
See the 🔢 Indexed reads showcase
for the full gallery.
Inner DSL — list elements are writable by index¶
set context.buf[i] value now overwrites a list element in place
(previously only map keys accepted a […] write target; a list parent
errored with "target parent is not a map"). append/prepend against an
indexed target reach the nested list at that element, and negative
indices count from the end (-1 = last).
This unlocks retroactive rewrites of buffered output — the pattern a
code generator uses when a decision can't be made until later. Buffer the
instruction stream in a context list, keep side-tables as you go, and
then edit a past slot once the future is known: null out a dead store,
back-patch a jump offset, or drop a redundant reload. The new
samples/list-index-assign/
is a working dead-store eliminator built entirely in-library this
way, and the cross-arch assembler notes
use it to show that "looking back at already-emitted code" is
bookkeeping, not whole-program reasoning.
Helpers — integer div / mod / align¶
Three new numeric template helpers join add/sub/mul:
div a b— integer quotienta / b(returns0whenb == 0).mod a b— remaindera % b(returns0whenb == 0).align n a— roundnup to the next multiple ofa((n + a - 1) / a * a).
align is the op a code generator needs for ABI-correct layout: struct
field offsets, struct total size, and 16-byte stack-frame rounding all
reduce to it. Previously the only arithmetic helpers were
add/sub/mul/percent, so alignment rounding (which needs division
or bitwise AND) couldn't be expressed in-library — align closes that
gap. See the function cookbook and
the cross-arch assembler design notes,
where this unblocks Problems 6 (struct layout) and 7 (stack frames).
Docs — how Capy parses & extracts content¶
A new end-to-end walkthrough,
How Capy parses & extracts content, explains the
four-stage pipeline (lex → match → capture → render) and showcases it
with 40+ runnable examples, building from a one-line literal grammar up
to matched-pair tag parsing, nonterminal repetition with independent
sep/join, custom types, and group-type inline syntax. Every output
shown is produced by the engine, and the trickier behaviors (greedy
tail, the auto-name-prepend rule, when_followed_by indent,
capture-bound block_close_seq) are grounded in CI-checked samples.
Docs — library keyword cookbook¶
A companion to the function cookbook: the
Library keyword cookbook documents every keyword
you write inside a .capy library — function, arg literal,
arg capture (with sep/join/default), all six block_* body
openers, write/template, the file-level directives (extension,
context, comments, command, import, file, …), the built-in
capture types, and type definitions. It also answers "how do I know a
function can have a body?" — it declares exactly one block_* directive.
The canonical if … end example is verified by
samples/library-keywords/.
Docs — built-in function cookbook¶
Every built-in template helper (the functions you call inside ${ … })
now has a single reference page:
Built-in function cookbook. All 29 helpers —
indent, pascalCase/camelCase/snakeCase, dasherize, unquote,
decoded, escapeHtml, toQuoted, asString, unescape,
trimSuffix/trimPrefix, join/split/nonEmpty, toJSON/
toJSONIndent/toPyLit, add/sub/mul/div/mod/align,
percent, stars, lower/upper — with a worked example each. The examples are mirrored by
the CI-checked samples/builtin-functions/
sample, also runnable in the playground.
Round 6 — matched-pair HTML: sequence closers & named nonterminals¶
Two composing grammar primitives that together make well-formed, matched-pair markup (HTML/XML) authorable from a single library function.
block_close_seq — multi-token sequence closers. A block can now
close on an exact run of tokens rather than a single keyword or
delimiter. The closer is declared as a list of segments — each a quoted
literal or a bare capture-name ref bound by the opener:
function element
arg literal "<"
arg capture name ident
arg capture attrs attribute*
arg literal ">"
block_close_seq "</" name ">"
write `<${name}${attrs}>${body}</${name}>`
end
The ref makes the closer depend on the opener, so one element function
covers every paired tag: <div> closes only on </div>, <p> only on
</p>, and mismatched nesting (<div><p></div>) is a hard parse error.
Inside the body, layout is insignificant — structure comes from the tags.
Function-as-type captures (named nonterminals). A capture's type may
name another library function. The capture matches that function's shape
and renders its template. Add * / + to repeat, and sep "X" for a
separator literal:
arg capture attrs attribute* # zero or more attributes
arg capture items cell+ sep "," # one or more, comma-separated input
arg capture params param* sep "," join ", " # comma input, ", " output
sep is the input separator (consumed between repetitions while
parsing); join is the output separator (inserted between the rendered
sub-results). They're independent — sep "," join ", " parses
comma-separated input and renders a comma-space-separated list. A
pure-capture nonterminal (one with no leading keyword, like param NAME
TYPE) must be declared bare, otherwise Capy auto-prepends the function
name as a required keyword.
Both primitives are additive — no existing library changes behaviour.
Four runnable demos, all in the playground under ✨ Features:
| Demo | Shows |
|---|---|
html-xml-parser |
One generic element parses any <tag> — capture-bound </NAME> closer + attribute* |
bbcode-parser |
Same primitive, bracket delimiters: [b]…[/b] with literal close-seqs |
markdown-from-tags |
The same tag markup rewritten to Markdown — target-agnostic |
signature-parser |
Param lists via param* sep "," join ", " |
Tests: functype_test.go,
multitoken_closer_test.go.
Round 5 — tail preserves quoted slots¶
A follow-up to the tail capture (Round 3, §6). Previously tail
rebuilt its value from de-quoted tokens, so a spaced, quoted argument
lost its slot boundary: exec git commit -m "fix the bug" collapsed to
commit -m fix the bug, indistinguishable from four separate tokens.
Now quoted tokens are re-emitted with their quotes, and inter-token spacing is computed from each token's true source width (which counts the quotes). So the boundary survives:
A quote-aware split on the value now recovers "fix the bug" as one
argument. Bare flags/paths/globs are unchanged, and no existing library
breaks (only quoted tokens inside a tail change, and none of the
samples relied on the old quote-stripping). This lets a single tail
function replace a hand-written word-ladder for shell-style argv.
Tests: missing_features_test.go (TestTailPreservesQuotedSlots).
Round 4 — context-sensitive grammar & multi-section blocks¶
The last two open items from the automation-DSL audit — both grammar features rather than tokenizer fixes. Additive and opt-in:
| Feature | What it gives you |
|---|---|
when_followed_by indent / when_not_followed_by indent |
Context-sensitive keyword reuse. A function matches only when an indented block does (or doesn't) follow, so a flat os "linux" allowlist entry and an os "…" … end conditional block can share the one os keyword and disambiguate purely by position — no rename needed. |
block_sections <S>... closer <NAME> |
Multi-section blocks — try … rescue … finally … end. The main body and each interior section render independently and land in the template as ${body}, ${rescue}, ${finally}. Omitted sections render empty; order and subset are free. |
function try
arg literal "try"
block_sections rescue finally closer end
write `try { ${body} } rescue { ${rescue} } finally { ${finally} }`
end
Tests: context_blocks_test.go.
Sample: samples/error-handling/.
Round 3 — automation-DSL parser hardening¶
A third wave, driven by building a shell-/automation-style DSL on Capy
(command runners, exec surfaces, config languages with member access).
These close concrete parser and tokenizer gaps. All additive, all opt-in:
| Feature | What it gives you |
|---|---|
| Block backtracking | When a function matches a block opener's header but no body follows, the parser now backtracks and tries the next candidate — so a flat function and a block function can safely share a leading keyword (e.g. os "linux" as a flat entry vs os "…" … end as a conditional block). Previously it committed to the block and errored. |
| Deterministic candidate ordering | The function-match order is now total (priority → literal-start → literal-length → name). Functions that tie on all other keys no longer inherit Go's randomized map order — eliminating run-to-run "parses 50% of the time" heisenbugs on keyword collisions. |
word capture type |
A shell-style bare word: --oneline, -f, k8s/deploy.yaml, name=^web$, restart-api capture as ONE value despite the lexer splitting on -///=/.. |
dotted_ident capture type |
match err.kind works bare — captures the dotted path as one string instead of needing the "${err.kind}" workaround. |
${asString x} helper |
Emits exactly one valid JSON string, quoting iff the capture isn't already a string. exec echo foo and exec echo "foo" both interpolate correctly — no more ${toJSON} double-quoting or bare-ident invalid JSON. |
Source-level metaprogramming (define … end) |
A script can declare its own DSL functions inline — same grammar as a library function — then use them in the same file. The library stays minimal; power users extend the vocabulary without forking it. Embedded callers get the same behaviour through capy.Library.Run / RunMulti. |
Tests: missing_features_test.go.
Metaprogramming in one breath. The source below ships with a library
that defines only print — heading, todo, and quote are declared
inline with define … end and then used immediately:
define heading
arg literal "heading"
arg capture text string
write `# ${unquote text}
`
end
heading "Capy metaprogramming"
todo yes "Ship metaprogramming feature"
Full worked example: samples/metaprogramming/
· pattern docs: metaprogramming.md
· runnable in the playground under 🧬 Metaprogramming.
Round 2 — editor integration & authoring ergonomics¶
A second wave, driven by building a real product on Capy (a 46-component library + a live editor with highlight / autocomplete / hover-docs / scroll-sync). All additive, all opt-in:
| Feature | What it gives you |
|---|---|
${line} / ${col} render locals |
Source↔output mapping. Stamp data-capy-line="${line}" and the host editor does querySelector('[data-capy-line="N"]') for scroll-sync / inline errors — no source mutation. |
Library.Introspect() + capyIntrospect() / pagesIntrospect() |
Declared functions, args, capture types, optional/default flags, and description doc strings — so an editor derives its autocomplete / hover / highlight metadata instead of hand-maintaining a parallel catalogue. |
${decoded x} handles embedded quotes |
<div class="x">\nmore round-trips — quotes preserved, newlines real. No host-side decoder needed. |
Column-0 lines inside template … end |
A flush-left ${indent 2 body} no longer truncates the function body. |
| Verbatim raw-byte fidelity | block_verbatim preserves blank lines and #-comment lines exactly — unblocks a real markdown … end block. |
| Backtick code spans in captures | markdown `inline \`code\` here` keeps the span. |
| Optional args with defaults | arg capture variant string default "primary" — collapses button / button_link / submit families into one function. |
Samples: line-mapping,
backtick-codespan,
optional-args,
plus extended string-decoded,
template-sugar,
verbatim-pre.
Round 1 — prose-heavy DSL substrate¶
This release closes a six-stage roadmap focused on making Capy a viable substrate for prose-heavy DSLs (markdown-like authoring tools, blog/notes engines, technical docs, AI-emitted documents). Every feature here is opt-in — existing libraries keep working unchanged; nothing is deprecated.
The lineup, ordered by impact-per-line-of-engine-change:
| Stage | Feature | Solves |
|---|---|---|
| 1 | UTF-8 in bare prose | Em-dashes, accents, CJK, emoji no longer crash the lexer |
| 2 | block_verbatim directive |
Code blocks, embedded HTML, anywhere a body is data not grammar |
| 3 | ${decoded x} helper |
Quotes-in-prose round-trip — "He said \"hi\"" → He said "hi" |
| 4 | ${escapeHtml x} helper |
XSS surface closed for HTML-emitting libraries |
| 5 | Multi-line backticks in scripts | Heredoc-style prose, multi-line paragraphs |
| 6 | group_open / group_close types |
First-class inline syntax — [label](url), **bold**, ~~strike~~ |
| 7 | template … end write-literal sugar |
Multi-line HTML/text templates without backtick bookkeeping |
Plus a few smaller correctness fixes (source-absolute column tracking,
# and \ accepted as punctuation tokens, the renamed
${escapeHtml x} helper) that all the above build on.
A flagship demo: math equation plots¶
Every primitive composes in samples/math-plots/.
The library takes a one-line DSL —
title "A few familiar shapes"
plot "sin(x)"
domain -6.28 6.28
color "#4ef"
end
plot "exp(-x*x)"
domain -3 3
color "#bf4"
samples 400
end
— and produces a self-contained HTML page with canvas plots and a small inline plotter. Live preview (rendered by the actual library committed to this repo):
The library uses every new primitive in concert:
template … endsugar for the multi-line HTML literals (the page template AND the per-plot<figure>block);${escapeHtml (decoded expr)}to render the expression safely in the figure caption —sin(x*<thing>)won't inject markup;${decoded expr}to recover the user-intended expression for JS evaluation;- context accumulation (
append context.plots …) tracks every plot for an end-of-page summary; - UTF-8 in prose for the page title and captions (works with Greek letters, em-dashes, accented descriptions out of the box);
- A
command "run"block compiles the script, writes the HTML next to it, and opens the default browser —capy math-plots run page.plotsis the entire workflow.
If you have the repo:
Copy-paste snippets for each new primitive¶
Want to see each feature in isolation? The snippets below all
compile against today's capy and produce the output shown.
template … end sugar¶
function card
arg literal "card"
arg capture title string
block_closer end
template
<div class="card">
<h3>${escapeHtml (decoded title)}</h3>
<div>${indent 2 body}</div>
</div>
end
end
function p
arg literal "p"
arg capture text string
template
<p>${escapeHtml (decoded text)}</p>
end
end
function end
end
Input:
Output:
<div class="card">
<h3>Hello</h3>
<div> <p>First line of body.</p>
<p>Second line, with <markup> & quotes.</p>
</div>
</div>
block_verbatim — raw code blocks¶
function pre
arg capture lang ident
block_verbatim end
template
<pre><code class="language-${lang}">${escapeHtml body}</code></pre>
end
end
function end
end
Input:
Output:
<pre><code class="language-go">func main() {
fmt.Println("hello & world")
}
</code></pre>
Group types — [label](url), **bold**¶
type Bracketed
group_open "["
group_close "]"
end
type Parens
group_open "("
group_close ")"
end
type Bold
group_open "**"
group_close "**"
end
function link
arg literal "link"
arg capture text Bracketed
arg capture url Parens
template
<a href="${escapeHtml url}">${escapeHtml text}</a>
end
end
function bold
arg literal "bold"
arg capture text Bold
template
<strong>${escapeHtml text}</strong>
end
end
Input:
Output:
Multi-line user-script backticks + ${decoded …}¶
function p
arg literal "p"
arg capture text string
template
<p>${escapeHtml (decoded text)}</p>
end
end
Input — a single p call spans three source lines:
Output:
UTF-8 in bare prose¶
Input:
Each line — yes, with em-dashes — becomes a paragraph.
Café au lait with naïve crème brûlée
北京 上海 东京
🎉 emoji works too 🚀
Output:
<p>Each line — yes, with em-dashes — becomes a paragraph.</p>
<p>Café au lait with naïve crème brûlée</p>
<p>北京 上海 东京</p>
<p>🎉 emoji works too 🚀</p>
Source↔output mapping with ${line} / ${col}¶
Every statement knows its 1-indexed source position. Stamp it onto
emitted elements and a host editor can scroll-sync the preview to the
cursor, or underline the exact line that failed — with a single
querySelector, no source mutation, working inside every region.
function p
arg literal "p"
arg capture text string
template
<p data-capy-line="${line}">${escapeHtml (decoded text)}</p>
end
end
Input:
Output — each <p> carries the line it came from:
<p data-capy-line="1">First paragraph.</p>
<p data-capy-line="2">Second paragraph.</p>
<p data-capy-line="3">Third paragraph.</p>
The editor then does querySelector('[data-capy-line="2"]'). A user
capture named line/col still wins — these are render locals, same
precedence as body / depth / top_level.
Optional args with defaults — one function, many call shapes¶
A trailing capture can declare a default, so the call site may omit
it. One button collapses what used to be button / button_link /
submit:
function button
arg literal "button"
arg capture label string
arg capture variant string default "primary"
arg capture kind string default "button"
template
<button type="${decoded kind}" class="btn-${decoded variant}">${escapeHtml (decoded label)}</button>
end
end
Input:
Output:
<button type="button" class="btn-primary">Save</button>
<button type="button" class="btn-danger">Delete</button>
<button type="submit" class="btn-primary">Submit</button>
A required capture after an optional one is a load-time error (optional args must be trailing).
The library describes itself — Introspect()¶
Point a tool at a library and it reports every function's shape — including which args are optional and their defaults — so an editor builds autocomplete / hover-docs / highlighting from one source of truth:
for _, fn := range lib.Introspect() {
fmt.Println(fn.Name, "-", fn.Description)
for _, a := range fn.Args {
if a.Kind == "capture" {
fmt.Printf(" %s: %s (optional=%v default=%q)\n",
a.Name, a.Type, a.Optional, a.Default)
}
}
}
The identical JSON shape is available in the browser via
capyIntrospect(librarySrc) / pagesIntrospect(). Full struct
reference and a runnable example: Introspection in
embedding.md.
1. UTF-8 in bare prose¶
Before: the lexer walked the source byte-by-byte, so any
non-ASCII character (em-dash, accented Latin, CJK, emoji) crashed
with unexpected character. To get prose with Unicode through, you
had to wrap every line in "…" — which then needed escape
unwrapping at render time. The Pages preprocessor was doing exactly
this.
Now: the lexer decodes UTF-8 properly. Any rune that's a Letter
(or any non-ASCII rune at all) is accepted as part of an identifier
token. A bare + tail catch-all function happily reassembles
arbitrary prose:
Source — straight from samples/utf8-prose/script.capy:
Each line — yes, with em-dashes — becomes a paragraph.
Café au lait with naïve crème brûlée
北京 上海 东京
🎉 emoji works too 🚀
Output:
<p>Each line — yes, with em-dashes — becomes a paragraph.</p>
<p>Café au lait with naïve crème brûlée</p>
<p>北京 上海 东京</p>
<p>🎉 emoji works too 🚀</p>
ASCII-only sources tokenise identically to before; this is a strict superset.
2. block_verbatim — raw bodies, no nested parsing¶
Before: every block mode (block_closer, block_open … close …,
block_dedent) re-parsed the body as nested Capy statements. A code
block like pre go { func main() { fmt.Println("hi") } } would
fail because func main() { doesn't match any library function.
Workaround: every line had to be wrapped in raw "…" calls.
Now: declare block_verbatim NAME and the body is captured as
raw source bytes until the named closer keyword appears. The
captured text reaches your template as ${body}.
function pre
arg capture lang ident
block_verbatim end
write `<pre><code class="language-${lang}">${escapeHtml body}</code></pre>
`
end
function end
end
Source — from samples/verbatim-pre/script.capy:
Output (indent preserved, HTML-escaped for safety):
<pre><code class="language-go">func main() {
fmt.Println("hello & welcome <world>")
}
</code></pre>
block_verbatim is the fourth block mode; the loader validates
that exactly one of the four is set per function:
| Directive | Body delimited by | Body parsed? |
|---|---|---|
block_closer NAME |
Indented body, ends at NAME keyword |
Yes |
block_open "X" close "Y" |
Delimiter pair | Yes |
block_dedent |
First DEDENT (no closer keyword) | Yes |
block_verbatim NAME |
Indented body, ends at NAME keyword |
No — raw source bytes |
3. ${decoded x} — full escape round-trip in one helper¶
Before: capturing a string like "He said \"hi\"" left two
levels of escaping in the captured text. ${x} showed
"He said \\\"hi\\\"", ${unquote x} stripped one level to
He said \\\"hi\\\", and ${unescape x} only ran one Unquote
pass — He said \"hi\". No composition recovered the user-intended
form. Quotes inside prose simply didn't work.
Now: the decoded helper does the full round-trip:
Source:
Output:
decoded strips outer quotes (if present), then resolves Go-style
escape sequences (\", \n, \t, \\, plus \xNN and \uNNNN).
If the source has doubled escapes (which happens when Capy stored
them as byte-preserved literals), a second decode pass catches them.
Compose with escapeHtml for safe-by-construction HTML:
${escapeHtml (decoded text)}.
This is additive: existing libraries that want the source-text
quoted form (YAML output, TypeScript string literals, Markdown
frontmatter) keep using ${x} directly. decoded is opt-in.
4. ${escapeHtml x} — close the XSS surface¶
Before: Capy shipped helpers for case conversion (upper,
lower, pascalCase), indentation (indent N), JSON (toJSON,
toJSONIndent), and quoting (unquote, toQuoted, unescape) —
but no HTML escape. Any ${user_value} in an HTML-emitting
library was an XSS hole.
Now: the escapeHtml helper replaces the five characters every
HTML emitter has to neutralise — & < > " ' — with their entity
references:
Source:
Output:
The verbose name is deliberate: ${html x} would read as "convert
this to HTML" instead of "escape this FOR HTML". ${escapeHtml x}
makes the intent obvious at the call site.
Composes with decoded for the common pattern "decode user-string
escapes, then HTML-escape the result":
5. Multi-line backticks in user scripts¶
Before: the docs said backtick strings span lines — and they did,
inside library write blocks (which run a separate body parser).
Inside user scripts the tokenizer treated backticks the same as
double quotes, closing them at end-of-line:
→ line 1: unterminated string. Workaround: type each line as a
separate p "…" call.
Now: the user-script lexer merges any line opening an unclosed
backtick with subsequent lines (using \n escapes for the
intervening newlines) until the closing backtick. Combine with
${decoded text} to recover the real newlines at render time:
Source — from samples/multiline-strings/script.capy:
Output:
The docs' multi-line-backtick promise now holds in user scripts too.
6. group_open / group_close — inline syntax as a type¶
Before: Capy could parse statement-shaped DSLs cleanly, but it
had no way to express Markdown-style inline syntax. A line like
link [Al the Alien](https://alien.com/1) had no path through the
parser — [ and ] weren't delimiters that any function could
own.
Now: types can declare group_open / group_close directives.
A capture of that type consumes the open delimiter, walks tokens
(with balanced nesting and multi-line support) until the matching
close delimiter, and returns the joined source text:
type Bracketed
group_open "["
group_close "]"
end
type Parens
group_open "("
group_close ")"
end
type Bold
group_open "**"
group_close "**"
end
function link
arg literal "link"
arg capture text Bracketed
arg capture url Parens
write `<a href="${escapeHtml url}">${escapeHtml text}</a>
`
end
function bold
arg literal "bold"
arg capture text Bold
write `<strong>${escapeHtml text}</strong>
`
end
Source — from samples/inline-markdown/script.capy:
link [Al the Alien](https://alien.com/1)
link [Click & view](https://example.com/<safe>)
link [nested [inner] brackets](https://example.com/path)
bold **important text**
Output:
<a href="https://alien.com/1">Al the Alien</a>
<a href="https://example.com/<safe>">Click & view</a>
<a href="https://example.com/path">nested [inner] brackets</a>
<strong>important text</strong>
Key properties:
- Balanced nesting —
[nested [inner] brackets]captures the whole text including the inner brackets, because the parser tracks depth. - Multi-char delimiters work —
**is a single token thanks to the lexer's punct-greedy rule. Same for~~,$$, etc. - Multi-line groups — a group can span newlines; the captured text contains the real newlines.
- Mutually exclusive with constraint fields — a type is either
a group type OR a constraint type (
base/pattern/options), never both.
Limitations called out in types.md:
- The backtick (
`) collides with Capy's string-literal lexing; it can't currently be used as a group delimiter. Pick another delimiter (e.g.~~code~~). - A prose run that embeds inline calls (
This is **important** text.) needs a separate prose-scanner that's not part of the group-types primitive. For now, write inline calls on their own lines.
7. template … end — sugar for multi-line write ... ¶
Before: long HTML / text templates carried three pieces of
ceremony per write — the opening backtick after write, the
trailing newline before the closer, the closing backtick on its own
line at arbitrary indent. Editors couldn't syntax-highlight the body
as HTML because of the wrapping backtick. Auto-indent fought the
closing backtick:
function card
arg capture title string
block_closer end
write `<div class="card">
<h3>${escapeHtml (decoded title)}</h3>
<div>${indent 2 body}</div>
</div>
`
end
Now: the same function, no backtick bookkeeping:
function card
arg capture title string
block_closer end
template
<div class="card">
<h3>${escapeHtml (decoded title)}</h3>
<div>${indent 2 body}</div>
</div>
end
end
template … end is pure sugar — the lib parser rewrites it into
the synthesised backtick write before anything downstream sees the
function body. Identical AST, identical render path, identical
output bytes. ${…} interpolation is active (the one thing that
distinguishes this from block_verbatim, which is interpolation-OFF).
Properties:
- Where it works — anywhere a
writeis valid: function bodies,file_template, andfile "X" … endblocks. - Composes with state mutations —
template … endis one statement among many in the function body. Pair it withappend context.x …/set/if/forexactly the same way you would pair awrite. - Auto-dedented — body lines retain their relative indentation but the common leading whitespace is stripped, so the captured text starts flush-left.
- Nested templates at the same indent are balanced via depth
tracking, so a
template … endinside anothertemplate … endworks. - Backticks and backslashes inside the body are escaped
automatically — paste HTML containing
`without escaping manually.
Adds one new keyword (template) at statement position. Any
existing library function literally named template would conflict
at call site — we grepped the in-tree samples and found none.
See samples/template-sugar/
for a complete worked example with card, p, file_template, and
state accumulation in context.cards.
Bonus: #!/usr/bin/env -S capy --lib … shebang scripts¶
Capy strips a leading #! line before lexing, so any .capy /
.recipe / .greet / .<your-ext> file with a shebang is
directly executable on Linux and macOS:
chmod +x hello.greet
./hello.greet
# → Hello from greet, world!
# Hello from greet, from a shebang script!
Works with the system capy binary or a standalone capy build
output. Four shebang forms with trade-offs (env -S, plain env,
absolute path, built-binary path) are documented in the
compile cookbook §12.
Built standalone binaries automatically suppress the
"library not on CAPY_LIBS" trust warning since the library is
embedded in the binary — chmod +x script.greet and ship it.
Smaller correctness fixes that ship with the above¶
Source-absolute column tracking¶
The lexer previously used two different column conventions: lines
outside brackets were tokenised after the indent was stripped (so
fmt on a func main() line reported col 1), but lines inside
brackets kept their raw source column. block_verbatim
reconstruction needed consistent columns, so the lexer now passes a
startCol argument to tokenizeLine equal to 1 + stripped_count.
Tokens carry source-absolute columns regardless of bracket state —
better error messages too.
# and \ accepted as punct tokens¶
These weren't in punctChars before, so a Python comment inside a
block_verbatim body or a LaTeX command (\href{x}{y}) would error
with unexpected character. Now they're tokens — neutral by
default; libraries that want them to mean something (e.g.
comments line "#") opt in.
${html x} renamed to ${escapeHtml x}¶
The helper shipped briefly as ${html x} but the name was
ambiguous ("make this HTML"? "treat this as HTML"?). Renamed to
${escapeHtml x} before reaching anyone outside this repo.
Where to go from here¶
| You want to… | Read |
|---|---|
| Build a Markdown-like authoring DSL | Group types in types.md |
| Ship a code-block library | Block functions in library-authoring.md |
| Safely emit HTML | escapeHtml in templates.md |
| Handle quoted user strings cleanly | decoded in templates.md |
| Build editor tooling from the library | Introspection in embedding.md |
| Map output back to source lines | ${line} / ${col} in inner-dsl.md |
| See the full grammar | language-reference.md |
| Drop into the LLM-facing brief | CAPY_FOR_LLMS.md |
Every new feature has a regression sample under samples/: