Expression Language Spec
API Reference: This Page · Engine · Types · Events · Security · Servers
Formal specification of the Alap expression language. For a tutorial introduction, see Expressions.
Design principle
The query lives inside the link itself. An Alap expression is a self-contained instruction that selects what to show. The element (<alap-link>, <alap-lightbox>, <alap-lens>) decides how to show it.
<!-- same query, different presentations -->
<alap-link query=".coffee + .nyc">menu</alap-link>
<alap-lightbox query=".coffee + .nyc">carousel</alap-lightbox>
<alap-lens query=".coffee + .nyc">details</alap-lens>Grammar
query = segment (',' segment)*
segment = term (operator term)*
operator = '+' | '|' | '-'
term = '(' segment ')' | atom
atom = MACRO | SEARCH | PROTOCOL | REFINER | TAG | ITEM_ID
TAG = '.' IDENTIFIER
MACRO = '@' IDENTIFIER?
SEARCH = '/' KEY '/' OPTIONS?
PROTOCOL = ':' NAME ':' ARGS ':'
REFINER = '*' NAME ':' ARGS '*'
ITEM_ID = IDENTIFIERIDENTIFIER is one or more characters excluding whitespace, operators (,, +, |, -), parentheses, and reserved prefixes (., @, /, :, *). Underscores are allowed; hyphens are not (the - character is the WITHOUT operator).
Evaluation rules
- Comma-separated segments are evaluated independently. Results are concatenated in order and deduplicated.
- Operators evaluate strictly left-to-right. There is no operator precedence.
A | B + Cmeans(A | B) + C. - Parentheses override left-to-right evaluation. Nest up to 32 levels deep.
- Deduplication preserves first-seen order. If an item appears in multiple segments or operations, only the first occurrence is kept.
- Unknown atoms (missing item IDs, missing tags, missing macros) resolve to an empty set. They do not produce errors.
Operator semantics
Every expression operates on sets of item IDs.
| Operator | Name | Semantics | Set operation |
|---|---|---|---|
+ | AND | Items in both operands | Intersection |
| | OR | Items in either operand | Union |
- | WITHOUT | Items in left but not in right | Difference |
Atom types
| Atom | Syntax | Resolves to |
|---|---|---|
| Item ID | brooklyn | Single item if it exists, else empty set |
| Tag | .bridge | All items with that tag |
| Macro | @favorites | Expand the named macro's linkItems expression |
| Bare macro | @ | Expand the macro whose name matches the trigger's DOM id |
| Search | /bridges/ | Items matching the search pattern defined in config |
| Protocol | :web:query: | Items generated by the protocol handler |
| Refiner | *sort:label* | Shapes the result set (sort, limit, shuffle, etc.) |
Worked examples
Given this config:
{
"macros": {
"nycbridges": { "linkItems": ".nyc + .bridge" },
"everything": { "linkItems": ".nyc | .sf" }
},
"allLinks": {
"brooklyn": { "tags": ["nyc", "bridge", "landmark"] },
"manhattan": { "tags": ["nyc", "bridge"] },
"highline": { "tags": ["nyc", "park", "landmark"] },
"centralpark": { "tags": ["nyc", "park"] },
"goldengate": { "tags": ["sf", "bridge", "landmark"] },
"dolores": { "tags": ["sf", "park"] },
"bluebottle": { "tags": ["coffee", "sf", "nyc"] },
"aqus": { "tags": ["coffee", "sf"] },
"bmwe36": { "tags": ["car", "germany"] },
"miata": { "tags": ["car", "japan"] },
"vwbug": { "tags": ["car", "germany"] }
}
}Direct item IDs
{ "query": "brooklyn", "result": ["brooklyn"] }
{ "query": "doesnotexist", "result": [] }Tag queries
{ "query": ".bridge", "result": ["brooklyn", "manhattan", "goldengate"] }
{ "query": ".car", "result": ["bmwe36", "miata", "vwbug"] }
{ "query": ".japan", "result": ["miata"] }
{ "query": ".doesnotexist", "result": [] }Intersection (+)
{ "query": ".nyc + .bridge", "result": ["brooklyn", "manhattan"] }
{ "query": ".sf + .bridge", "result": ["goldengate"] }
{ "query": ".nyc + .bridge + .landmark", "result": ["brooklyn"] }
{ "query": ".car + .coffee", "result": [] }Union (|)
{ "query": ".nyc | .sf", "result": ["brooklyn", "manhattan", "highline", "centralpark", "goldengate", "dolores", "bluebottle", "aqus"] }
{ "query": ".bridge | .landmark", "result": ["brooklyn", "manhattan", "goldengate", "highline", "towerbridge"] }Subtraction (-)
{ "query": ".nyc - .bridge", "result": ["highline", "centralpark", "bluebottle"] }
{ "query": ".nyc - .landmark", "result": ["manhattan", "centralpark", "bluebottle"] }
{ "query": ".car - .germany", "result": ["miata"] }
{ "query": ".car - .car", "result": [] }Commas (concatenation)
{ "query": "brooklyn, goldengate", "result": ["brooklyn", "goldengate"] }
{ "query": ".car, .coffee", "result": ["bmwe36", "miata", "vwbug", "bluebottle", "aqus"] }
{ "query": "bmwe36, bmwe36", "result": ["bmwe36"], "note": "deduplicated" }Macros (@)
{ "query": "@nycbridges", "result": ["brooklyn", "manhattan"] }
{ "query": "@everything", "result": ["brooklyn", "manhattan", "highline", "centralpark", "goldengate", "dolores", "bluebottle", "aqus"] }
{ "query": "@everything + .bridge", "result": ["brooklyn", "manhattan", "goldengate"] }
{ "query": "@nonexistent", "result": [] }Parentheses (grouping)
{ "query": "(.nyc + .bridge) | (.sf + .bridge)", "result": ["brooklyn", "manhattan", "goldengate"] }
{ "query": ".nyc | (.sf - .coffee)", "result": ["brooklyn", "manhattan", "highline", "centralpark", "bluebottle", "goldengate", "dolores"] }
{ "query": "(.nyc | .sf) + .bridge", "result": ["brooklyn", "manhattan", "goldengate"] }
{ "query": ".bridge - (.nyc | .london)", "result": ["goldengate"] }Left-to-right evaluation (no precedence)
These demonstrate why parentheses matter:
{
"query": ".nyc | .sf + .bridge",
"evaluation": "(.nyc | .sf) + .bridge",
"result": ["brooklyn", "manhattan", "goldengate"],
"note": "OR first, then AND — not .nyc | (.sf + .bridge)"
}
{
"query": ".nyc + .bridge | .coffee",
"evaluation": "(.nyc + .bridge) | .coffee",
"result": ["brooklyn", "manhattan", "bluebottle", "aqus"],
"note": "AND first, then OR"
}
{
"query": ".nyc | .sf - .landmark",
"evaluation": "(.nyc | .sf) - .landmark",
"result": ["manhattan", "centralpark", "dolores", "bluebottle", "aqus"],
"note": "OR first, then WITHOUT"
}Mixed operand types
{ "query": "bmwe36 + .car", "result": ["bmwe36"], "note": "item ID AND tag" }
{ "query": "bmwe36 + .coffee", "result": [], "note": "item lacks tag" }
{ "query": ".car - bmwe36", "result": ["miata", "vwbug"], "note": "tag WITHOUT item" }
{ "query": "aqus | .coffee", "result": ["aqus", "bluebottle"], "note": "item OR tag" }
{ "query": ".sf + .bridge, miata", "result": ["goldengate", "miata"], "note": "expression, then item" }Edge cases
{ "query": "", "result": [], "note": "empty string" }
{ "query": " ", "result": [], "note": "whitespace only" }
{ "query": ".x +", "result": "matches .x", "note": "dangling operator ignored" }
{ "query": "!@#$%^&", "result": [], "note": "invalid characters" }Reserved characters
| Character | Role | Notes |
|---|---|---|
. | Tag prefix | .coffee |
@ | Macro prefix | @favorites |
, | Segment separator | a, b |
+ | AND operator | .a + .b |
| | OR operator | .a | .b |
- | WITHOUT operator | .a - .b |
( ) | Grouping | (.a + .b) | .c |
/ | Search pattern delimiter | /bridges/ |
: | Protocol delimiter | :web:query: |
* | Refiner delimiter | *sort:label* |
_ | Allowed in identifiers | golden_gate |
Hyphens (-) are the WITHOUT operator and cannot appear in item IDs or tag names. Use underscores instead.
Three-phase query model
An expression can combine all three phases:
- Selection — tags, IDs, macros, and operators select the initial set
- Generation — protocols (
:web:,:atproto:,:json:) add dynamically fetched items - Refinement — refiners (
*sort:label*,*limit:5*,*shuffle*) shape the final output
.coffee + .nyc, :json:recommendations:, *sort:label*, *limit:10*Selection and generation produce sets of items. Refinement transforms the combined set into the final ordered result that the renderer displays.