Skip to content

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.

html
<!-- 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   = IDENTIFIER

IDENTIFIER 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 ​

  1. Comma-separated segments are evaluated independently. Results are concatenated in order and deduplicated.
  2. Operators evaluate strictly left-to-right. There is no operator precedence. A | B + C means (A | B) + C.
  3. Parentheses override left-to-right evaluation. Nest up to 32 levels deep.
  4. Deduplication preserves first-seen order. If an item appears in multiple segments or operations, only the first occurrence is kept.
  5. 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.

OperatorNameSemanticsSet operation
+ANDItems in both operandsIntersection
|ORItems in either operandUnion
-WITHOUTItems in left but not in rightDifference

Atom types ​

AtomSyntaxResolves to
Item IDbrooklynSingle item if it exists, else empty set
Tag.bridgeAll items with that tag
Macro@favoritesExpand 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:

json
{
  "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 ​

json
{ "query": "brooklyn", "result": ["brooklyn"] }
{ "query": "doesnotexist", "result": [] }

Tag queries ​

json
{ "query": ".bridge", "result": ["brooklyn", "manhattan", "goldengate"] }
{ "query": ".car", "result": ["bmwe36", "miata", "vwbug"] }
{ "query": ".japan", "result": ["miata"] }
{ "query": ".doesnotexist", "result": [] }

Intersection (+) ​

json
{ "query": ".nyc + .bridge", "result": ["brooklyn", "manhattan"] }
{ "query": ".sf + .bridge", "result": ["goldengate"] }
{ "query": ".nyc + .bridge + .landmark", "result": ["brooklyn"] }
{ "query": ".car + .coffee", "result": [] }

Union (|) ​

json
{ "query": ".nyc | .sf", "result": ["brooklyn", "manhattan", "highline", "centralpark", "goldengate", "dolores", "bluebottle", "aqus"] }
{ "query": ".bridge | .landmark", "result": ["brooklyn", "manhattan", "goldengate", "highline", "towerbridge"] }

Subtraction (-) ​

json
{ "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) ​

json
{ "query": "brooklyn, goldengate", "result": ["brooklyn", "goldengate"] }
{ "query": ".car, .coffee", "result": ["bmwe36", "miata", "vwbug", "bluebottle", "aqus"] }
{ "query": "bmwe36, bmwe36", "result": ["bmwe36"], "note": "deduplicated" }

Macros (@) ​

json
{ "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) ​

json
{ "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:

json
{
  "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 ​

json
{ "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 ​

json
{ "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 ​

CharacterRoleNotes
.Tag prefix.coffee
@Macro prefix@favorites
,Segment separatora, 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 identifiersgolden_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:

  1. Selection — tags, IDs, macros, and operators select the initial set
  2. Generation — protocols (:web:, :atproto:, :json:) add dynamically fetched items
  3. 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.