Documentation Index Fetch the complete documentation index at: https://docs.penbox.io/llms.txt
Use this file to discover all available pages before exploring further.
penscript provides a full set of operators for working with collections — iterating, transforming, filtering, querying, and building arrays. These are used to generate dynamic form fields, validate uploaded documents, compute aggregates, and build conditional structures based on variable-length data.
Context Variables
When iterating over an array (with :map, :every, :some, :find, or :filter), penscript makes the following context variables available inside the loop body:
Variable Description {@item}The current item value {@index}Current index (0, 1, 2…) {@position}Current position (1, 2, 3…) {@first}true if the current item is the first{@last}true if the current item is the last
Access nested properties of the current item with dot notation: {@item.name}, {@item.role}, {@item.amount}.
Normalizing to Arrays: :array
Wraps a value in an array if it isn’t one already. Useful for ensuring consistent array input when a value might be a single item or an array.
{ ":array" : 1 }
// Result: [1]
{ ":array" : "{data.tags}" }
// Scope: { "data": { "tags": [1, 2, 3] } }
// Result: [1, 2, 3]
{ ":array" : "{data.tags}" }
// Scope: { "data": { "tags": "urgent" } }
// Result: ["urgent"]
Generating Arrays with :fill
Combine :array with :fill to generate arrays of a specific length. Each element is created from the :fill template, with context variables available.
{
":array" : 3 ,
":fill" : { "id" : "item_{@index}" }
}
// Result: [{ "id": "item_0" }, { "id": "item_1" }, { "id": "item_2" }]
Mapping: :map
Transforms every item in an array. The transformation expression has access to {@item}, {@index}, {@position}, {@first}, and {@last}.
Using :to syntax:
{
":map" : [ "a" , "b" , "c" ],
":to" : "Value: {@item} (pos: {@position})"
}
// Result: ["Value: a (pos: 1)", "Value: b (pos: 2)", "Value: c (pos: 3)"]
Using array syntax — the second element is the transformation expression:
{
":map" : [
"{data.numbers}" ,
{ ":sum" : [ "@item" , "@item" ] }
]
}
// Scope: { "data": { "numbers": [1, 2, 3] } }
// Result: [2, 4, 6]
Complex mapping with local variables using :with:
This is the most powerful pattern — generating dynamic structures from data. The :with operator creates local variables for each iteration, keeping the template readable.
{
":map" : [
{ ":range-array" : [ 0 , "{data.count}" ] },
{
":with" : [
{ "pos" : { ":sum" : [ "{@index}" , 1 ] } },
[
{ "type" : "paragraph" , "title" : "Person {pos}" },
{
"key" : "name_{@index}" ,
"type" : "text" ,
"title" : "Person {pos} name"
}
]
]
}
]
}
// Scope: { "data": { "count": 2 } }
// Result:
// [
// { "type": "paragraph", "title": "Person 1" },
// { "key": "name_0", "type": "text", "title": "Person 1 name" },
// { "type": "paragraph", "title": "Person 2" },
// { "key": "name_1", "type": "text", "title": "Person 2 name" }
// ]
Filtering: :filter
Removes items from an array based on a condition. Works by mapping items to themselves (kept) or false (removed), then stripping the falsy values.
{
":filter" : {
":map" : [
"{data.numbers}" ,
{
":if" : [
{ ":cmp" : "@item" , ":gt" : 2 },
"@item" ,
false
]
}
]
}
}
// Scope: { "data": { "numbers": [1, 2, 3, 4, 5] } }
// Result: [3, 4, 5]
Filtering with inline expressions:
{
":filter" : {
":map" : [
"{data.emails}" ,
{
":if" : [
"{{ @item.endsWith('@example.com') }}" ,
"@item" ,
false
]
}
]
}
}
// Scope: { "data": { "emails": ["a@example.com", "b@other.com", "c@example.com"] } }
// Result: ["a@example.com", "c@example.com"]
Finding Items: :find
Returns the first item in an array that matches a condition. If no match is found, returns undefined.
{
":find" : [ "{data.users}" , { ":eq" : [ "@item.role" , "admin" ] }]
}
// Scope: {
// "data": {
// "users": [
// { "id": 1, "role": "user" },
// { "id": 2, "role": "admin" },
// { "id": 3, "role": "admin" }
// ]
// }
// }
// Result: { "id": 2, "role": "admin" } (first match only)
Membership & Intersections
:includes
Tests if an array contains a specific value.
{ ":includes" : [[ 1 , 2 , 3 ], 2 ] }
// Result: true
{ ":includes" : [[ 1 , 2 , 3 ], 4 ] }
// Result: false
Works with :if for access control patterns:
{
":if" : { ":includes" : [ "{data.roles}" , "admin" ] },
":then" : "Full access" ,
":else" : "Limited access"
}
// Scope: { "data": { "roles": ["user", "admin"] } }
// Result: "Full access"
:intersects
Tests if two arrays share any common elements.
{ ":intersects" : [ "{data.user_tags}" , [ "vip" , "premium" ]] }
// Scope: { "data": { "user_tags": ["new", "vip"] } }
// Result: true
// Scope: { "data": { "user_tags": ["basic"] } }
// Result: false
Counting: :count
Returns the number of items in an array.
{ ":count" : "{data.items}" }
// Scope: { "data": { "items": [1, 2, 3] } }
// Result: 3
Conditional Counting with :where
Count only items that satisfy a condition:
{
":count" : "{data.items}" ,
":where" : { ":cmp" : "@item" , ":gt" : 1 }
}
// Scope: { "data": { "items": [1, 2, 3, 4] } }
// Result: 3 (counts 2, 3, 4)
Excluding Items with :unless
Combine :where and :unless for fine-grained filtering:
{
":count" : "{data.items}" ,
":where" : { ":cmp" : "@item" , ":gt" : 1 },
":unless" : { ":eq" : [ "@item" , 3 ] }
}
// Scope: { "data": { "items": [1, 2, 3, 4] } }
// Result: 2 (counts 2 and 4 — excludes 3)
Range Arrays: :range-array
Generates an array of sequential integers. The start is inclusive, the end is exclusive.
{ ":range-array" : [ 0 , 3 ] }
// Result: [0, 1, 2]
{ ":range-array" : [ 2 , 6 ] }
// Result: [2, 3, 4, 5]
Commonly used with :map to generate a dynamic number of form fields or UI elements based on a user-provided count.
Flattening: :flatten
Deeply flattens nested arrays into a single flat array.
{ ":flatten" : [[ 1 , [ 2 , [ 3 ]]]] }
// Result: [1, 2, 3]
{
":flatten" : [ "{data.items}" , [ null , 4 , [[ 5 ], [[[[ 6 ]]]]]], false ]
}
// Scope: { "data": { "items": [1, 2, 3] } }
// Result: [1, 2, 3, null, 4, 5, 6, false]
Useful after a :map that produces arrays of arrays — for example, when each mapped item generates multiple form fields and you need them in a single flat list.
Sequence Check: :increasing
Returns true if all values in the array are in strictly increasing order.
{ ":increasing" : "{data.daily_balances}" }
// Scope: { "data": { "daily_balances": [100, 150, 200] } }
// Result: true
// Scope: { "data": { "daily_balances": [100, 90, 120] } }
// Result: false
Useful for validating that sequential inputs are in the expected order (e.g., dates, amounts, installment schedules).
Next Steps
Logic & comparisons :every and :some for array conditions
Numbers & calculations Aggregate calculations on arrays
Variables & scope Context variables in loops
How penscript works Dynamic form generation with :map