@opencraw/mcp 0.1.45 → 0.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +27 -1
  2. package/dist/diff-tool/diff-tool.handler.d.ts +42 -0
  3. package/dist/diff-tool/index.d.ts +3 -0
  4. package/dist/docs-tool/docs-folder.repository.d.ts +24 -0
  5. package/dist/docs-tool/docs-root.client.d.ts +8 -0
  6. package/dist/docs-tool/docs-tool.handler.d.ts +21 -0
  7. package/dist/docs-tool/docs-topics.algorithm.d.ts +39 -0
  8. package/dist/docs-tool/index.d.ts +3 -0
  9. package/dist/index.d.ts +12 -1
  10. package/dist/index.esm.js +430 -1
  11. package/dist/list-tool/index.d.ts +3 -0
  12. package/dist/list-tool/list-recipes-tool.handler.d.ts +32 -0
  13. package/dist/main.d.ts +7 -0
  14. package/dist/probe-tool/index.d.ts +2 -0
  15. package/dist/probe-tool/probe-tool.handler.d.ts +27 -0
  16. package/dist/recipe-source/index.d.ts +3 -0
  17. package/dist/recipe-source/recipe-source.mapper.d.ts +21 -0
  18. package/dist/run-tool/index.d.ts +3 -0
  19. package/dist/run-tool/run-tool.handler.d.ts +51 -0
  20. package/dist/server/create-server.use-case.d.ts +11 -0
  21. package/dist/server/index.d.ts +2 -0
  22. package/dist/src/docs-tool/docs-folder.repository.d.ts +24 -0
  23. package/dist/src/docs-tool/docs-root.client.d.ts +8 -0
  24. package/dist/src/docs-tool/docs-tool.handler.d.ts +21 -0
  25. package/dist/src/docs-tool/docs-topics.algorithm.d.ts +39 -0
  26. package/dist/src/docs-tool/index.d.ts +3 -0
  27. package/dist/src/server/create-server.use-case.d.ts +1 -1
  28. package/dist/src/studio-tool/index.d.ts +2 -0
  29. package/dist/src/studio-tool/studio-command.client.d.ts +11 -0
  30. package/dist/src/studio-tool/studio-instance.repository.d.ts +22 -0
  31. package/dist/src/studio-tool/studio-tools.handler.d.ts +47 -0
  32. package/dist/studio-tool/index.d.ts +2 -0
  33. package/dist/studio-tool/studio-command.client.d.ts +11 -0
  34. package/dist/studio-tool/studio-instance.repository.d.ts +22 -0
  35. package/dist/studio-tool/studio-tools.handler.d.ts +47 -0
  36. package/dist/validate-tool/index.d.ts +3 -0
  37. package/dist/validate-tool/validate-tool.handler.d.ts +31 -0
  38. package/docs/how-it-works/01-recipes.md +698 -0
  39. package/docs/how-it-works/02-page-steps.md +672 -0
  40. package/docs/how-it-works/03-data-steps.md +813 -0
  41. package/docs/how-it-works/04-flow-steps.md +738 -0
  42. package/docs/how-it-works/05-templates-and-scope.md +428 -0
  43. package/docs/how-it-works/06-documents.md +1413 -0
  44. package/docs/how-it-works/07-mapping-and-transforms.md +699 -0
  45. package/docs/how-it-works/08-policies.md +405 -0
  46. package/docs/how-it-works/09-running.md +804 -0
  47. package/docs/how-it-works/README.md +225 -0
  48. package/docs/recipes/README.md +22 -0
  49. package/docs/recipes/access.md +440 -0
  50. package/docs/recipes/authoring.md +1503 -0
  51. package/docs/recipes/azure-durable.md +180 -0
  52. package/docs/recipes/captcha.md +204 -0
  53. package/docs/recipes/movies-example.md +12 -0
  54. package/docs/recipes/programmatic-authoring.md +101 -0
  55. package/docs/recipes/quick-guide.md +157 -0
  56. package/docs/recipes/vehicles-example.md +9 -0
  57. package/docs/recipes/worker-mode.md +221 -0
  58. package/docs/studio/manual.md +312 -0
  59. package/docs/studio/pdf-agreements.md +150 -0
  60. package/package.json +29 -4
@@ -0,0 +1,428 @@
1
+ <p align="center">
2
+ <img src="../assets/opencraw-logo.svg" alt="OpenCraw" width="360">
3
+ </p>
4
+
5
+ # Templates and scope
6
+
7
+ [← How OpenCraw works](README.md) · Previous: [5. Flow steps](04-flow-steps.md) · Next: [7. Documents, under the hood](06-documents.md)
8
+
9
+ Every step that has an `id` stores its result under that name, and every later step reads it by name: in a
10
+ `{{ }}` template, in `from`, in `over`. Where those names live is the **scope**, and which names a step can see
11
+ depends on where the step sits in the recipe. This page follows the scope through a real run (loops, pages,
12
+ shadowing, what an emit takes with it), then the `{{ }}` language that reads it, with the values it produced,
13
+ and the three traps that catch most people.
14
+
15
+ [authoring.md §3.4–3.5](../recipes/authoring.md#34-templates) is the reference.
16
+
17
+ ## What a step can see: the scope chain
18
+
19
+ A scope is a set of names with values, plus a link to its parent. Reading a name looks in the scope, then its
20
+ parent, then its parent's parent, up to the root. Writing a name (a step's `id`) always writes in the scope the
21
+ step runs in. A scope can also hold **page state**: the current URL, the page number, and the document an
22
+ `extract` reads when it has no `from`.
23
+
24
+ Each start point gets a fresh **root scope** holding `vars`, `start` (`{ url }`) and the page state
25
+ `{ url: start.url, number: 1 }`. Two steps open child scopes:
26
+
27
+ - `forEach` opens a new child scope for **each iteration**, and binds the item under its `as` name there;
28
+ - `paginate` opens a new child scope for **each page**, with the page number set there.
29
+
30
+ When the iteration or the page ends, its scope is dropped, with everything bound in it. `if` doesn't open a
31
+ scope: both branches run in the scope the `if` is in, so an id bound in a branch is still there after it.
32
+
33
+ ### A real chain
34
+
35
+ The scene [`scope-chain`](recipes/scope-chain/books-tag.input.json) reads the quotes tagged "books" on
36
+ [quotes.toscrape.com](https://quotes.toscrape.com) (two pages) in a browser, and emits one record per quote,
37
+ then one more at the very end, outside every loop:
38
+
39
+ ```json
40
+ "vars": { "tag": "books" },
41
+ "start": [{ "url": "https://quotes.toscrape.com/" }],
42
+ "steps": [
43
+ { "type": "goto", "url": "{{start.url}}tag/{{vars.tag}}/" },
44
+ { "type": "set", "id": "label", "value": "outside the loop" },
45
+ { "type": "set", "id": "authors", "value": [] },
46
+ { "type": "paginate", "next": { "selector": "li.next a" }, "maxPages": 3, "steps": [
47
+ { "type": "extract", "id": "quotes", "selector": "div.quote", "kind": "css", "take": "html", "many": true },
48
+ { "type": "forEach", "over": "quotes", "as": "quote", "emit": true, "steps": [
49
+ { "type": "extract", "id": "text", "from": "quote", "selector": ".text", "kind": "css" },
50
+ { "type": "extract", "id": "author", "from": "quote", "selector": ".author", "kind": "css" },
51
+ { "type": "set", "id": "where", "value": "{{author}}, page {{page.number}}" },
52
+ { "type": "collect", "into": "authors", "value": "{{author}}" }
53
+ ]}
54
+ ]},
55
+ { "type": "emit" }
56
+ ]
57
+ ```
58
+
59
+ While the first quote's iteration runs, the chain has three scopes:
60
+
61
+ ```mermaid
62
+ flowchart BT
63
+ I["iteration scope (forEach)<br/>quote = the quote's HTML<br/>text, author, where"] --> P["page scope (paginate)<br/>quotes<br/>page number 1"]
64
+ P --> R["root scope (start point)<br/>vars, start, label, authors<br/>page url and number"]
65
+ ```
66
+
67
+ The emit hands the mapping a **snapshot** of the chain: every name visible from the iteration, the nearest
68
+ binding of each, plus `page` as `{ url, number }`. For the first quote:
69
+
70
+ <!-- capture:scope-chain scope ids=text,author,where,quote,label,authors,page,start,vars record=0 -->
71
+ ```json
72
+ {
73
+ "text": "“The person, be it gentleman or lady, who has not pleasure in a good novel, must be intolerably stupid.”",
74
+ "author": "Jane Austen",
75
+ "where": "Jane Austen, page 1",
76
+ "quote": "\n <span class=\"text\" itemprop=\"text\">“The person, be it gentleman or lady, who has not pleasure in a good novel, must be intolerably stupid.”</span>\n …",
77
+ "label": "outside the loop",
78
+ "authors": [
79
+ "Jane Austen"
80
+ ],
81
+ "page": {
82
+ "url": "https://quotes.toscrape.com/tag/books/",
83
+ "number": 1
84
+ },
85
+ "start": {
86
+ "url": "https://quotes.toscrape.com/"
87
+ },
88
+ "vars": {
89
+ "tag": "books"
90
+ }
91
+ }
92
+ ```
93
+ <!-- /capture -->
94
+
95
+ `text`, `author` and `where` come from the iteration scope, `label` and `authors` from the root, two levels up.
96
+ `quote` is the loop's item, the HTML fragment. `page.url` is the tag page the `goto` opened: `start.url` stays
97
+ the URL the start point gave.
98
+
99
+ The first quote on page 2, the run's 11th record:
100
+
101
+ <!-- capture:scope-chain scope ids=where,authors,page record=10 -->
102
+ ```json
103
+ {
104
+ "where": "George R.R. Martin, page 2",
105
+ "authors": [
106
+ "Jane Austen",
107
+ "Mark Twain",
108
+ "Jorge Luis Borges",
109
+ "… 8 more"
110
+ ],
111
+ "page": {
112
+ "url": "https://quotes.toscrape.com/tag/books/page/2/",
113
+ "number": 2
114
+ }
115
+ }
116
+ ```
117
+ <!-- /capture -->
118
+
119
+ Page 1's iterations and page 1's scope are gone by now: nothing extracted there is visible on page 2. The
120
+ exception is `authors`. `collect` doesn't write in the scope it runs in: it appends to the list wherever that list
121
+ is bound, here in the root, which lives as long as the start point. That's how a value survives its loop: `set`
122
+ an empty list before the loop, `collect` into it inside.
123
+
124
+ And the last record, emitted by the top-level `emit` after `paginate` finished:
125
+
126
+ <!-- capture:scope-chain scope ids=text,author,where,quote,label,authors,page record=11 -->
127
+ ```json
128
+ {
129
+ "label": "outside the loop",
130
+ "authors": [
131
+ "Jane Austen",
132
+ "Mark Twain",
133
+ "Jorge Luis Borges",
134
+ "… 8 more"
135
+ ],
136
+ "page": {
137
+ "url": "https://quotes.toscrape.com/tag/books/page/2/",
138
+ "number": 2
139
+ }
140
+ }
141
+ ```
142
+ <!-- /capture -->
143
+
144
+ `text`, `author` and `where` aren't in it at all: they died with their iterations. The mapping reads them as
145
+ missing, so the record has `null` for them, and so is `quote`, the loop's name, bound in each iteration only. And
146
+ `page` is page 2: when
147
+ `paginate` moves to the next page it records the new URL and number on the scope it runs in (here the root), so
148
+ after the loop the root's page state is the last page, and steps after a `paginate` run against it.
149
+
150
+ ### Shadowing
151
+
152
+ A child binding with the same name as a parent's would hide the parent's value for the child's lifetime. Binding
153
+ refuses it for every name a recipe chooses: a step `id` and a `forEach`'s `as` can't reuse a name already bound on
154
+ the same path, nor `page`, `start` or `vars`. Renaming the loop's `where` to `label` in the recipe above gives, from
155
+ `loadRecipeSet` in a small Node script:
156
+
157
+ ```text
158
+ RecipeBindingError: recipes do not bind
159
+ books-tag steps.3.steps.1.steps.2: id "label" is already bound on this path
160
+ ```
161
+
162
+ and `"as": "label"` on the loop gives the same error at `steps.3.steps.1.as`. So a `forEach` over tags inside a
163
+ `forEach` over products can't both use `as: "item"`: the inner one would hide the product, and the record would see
164
+ only the tag. Name them `product` and `tag`. Loops that aren't nested (two loops one after the other, or one in
165
+ each branch of an `if`) can reuse a name, since neither is on the other's path.
166
+
167
+ Two things still shadow, by design:
168
+
169
+ - a pagination cursor (`next: { jsonpath, as }`), bound in each next page's scope;
170
+ - page state: a `goto` or a `request` inside a loop records its URL (and document) in the iteration's scope, so
171
+ the iteration sees the new page while the listing's page state, one level up, stays as it was for the next
172
+ page's click.
173
+
174
+ ### What `emit` takes
175
+
176
+ - The snapshot is taken **at the emit**, synchronously. What the steps do afterwards doesn't change it.
177
+ - `collect` doesn't change a list in place: it binds a new, longer list. So each record keeps the list as it was
178
+ at its own emit. The `seen` field counts `authors` in each record:
179
+
180
+ <!-- capture:scope-chain records n=3 -->
181
+ ```json
182
+ {"text":"“The person, be it gentleman or lady, who has not pleasure in a good novel, must be intolerably stupid.”","author":"Jane Austen","where":"Jane Austen, page 1","label":"outside the loop","page":1,"seen":1}
183
+ {"text":"“Good friends, good books, and a sleepy conscience: this is the ideal life.”","author":"Mark Twain","where":"Mark Twain, page 1","label":"outside the loop","page":1,"seen":2}
184
+ {"text":"“I have always imagined that Paradise will be a kind of library.”","author":"Jorge Luis Borges","where":"Jorge Luis Borges, page 1","label":"outside the loop","page":1,"seen":3}
185
+ ```
186
+ <!-- /capture -->
187
+
188
+ - `page` in the snapshot is `{ url, number }` only. The current document isn't in it; bind what you need with an
189
+ `extract` first.
190
+ - `forEach` with `emit: true` snapshots each iteration's scope after its body ran. An `emit` step snapshots the
191
+ scope it's in.
192
+
193
+ ### `page`, `start`, `vars`, `item`
194
+
195
+ | Name | What it holds | Where it comes from |
196
+ |---|---|---|
197
+ | `page` | `{ url, number }` of the nearest page state up the chain | the start point, then every navigation. In web mode, `page.url` is the browser's real URL after every step (after redirects and clicks too). In api mode, the final URL of the nearest `request`. `number` starts at 1 and only `paginate` moves it. |
198
+ | `start` | `{ url }` of the start point | fixed for the start point's whole run |
199
+ | `vars` | the recipe's `vars`, overridden by the matrix or worker item's, overridden by the start point's own | fixed for the start point's whole run |
200
+ | `item` | nothing special | the usual `as` name of a `forEach`. In a mapping `each` rule, `from` paths are read inside each list item instead. |
201
+
202
+ `page`, `start` and `vars` can't be used as step ids. They're the names every recipe can read without binding
203
+ them.
204
+
205
+ ## The `{{ }}` language
206
+
207
+ A template is a string with `{{ }}` placeholders. What's between the braces is either a **path** (`item.author.name`)
208
+ or an **expression** (`len(item.tags) > 3`). Nothing in it runs code: there's no method call, no assignment, and a
209
+ path only reads a value's own data.
210
+
211
+ The scene [`scope-templates`](recipes/scope-templates/template-probe.input.json) reads page 1 of the quotes JSON
212
+ API and, for the first quote, `set`s one id per template. Each `set` renders its value in the scope, so each
213
+ result below is a real rendered value from that snapshot. The inputs, for reference: `vars` is
214
+ `{ "offset": "1", "tag": "thinking" }`, and the first quote is
215
+
216
+ ```json
217
+ { "author": { "goodreads_link": "/author/show/9810.Albert_Einstein", "name": "Albert Einstein", "slug": "Albert-Einstein" },
218
+ "tags": ["change", "deep-thoughts", "thinking", "world"],
219
+ "text": "“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”" }
220
+ ```
221
+
222
+ ### Where templates are read
223
+
224
+ Not every string in a recipe is a template, and not every template gives the same kind of value:
225
+
226
+ | Kind | Where | Result |
227
+ |---|---|---|
228
+ | Typed | `set` and `collect` values, a `request` `body`, `hook` and `evaluate` `args` (every string inside, however nested); `when`, `if.test`, `paginate.until`; a `target` | a lone placeholder gives its value with its type; anything else gives text |
229
+ | Text | URLs (`goto`, `request`, `next.url`), `fill` values, headers, query values, `extract.selector`, `forEach.selector`, `screenshot.path`, `evaluate.script`, `saveTo` | always text |
230
+ | Not a template | `from`, `over`, `each`, `into` (they're paths or ids); the `selector` of `click`, `fill`, `press`, `select`, `wait` and `goto.ready` | the loader refuses a `{{` in those selectors: use `target` |
231
+
232
+ In a mapping, the `template` transform renders against the record's snapshot ([part 8](07-mapping-and-transforms.md)).
233
+
234
+ ### Paths
235
+
236
+ A path is dot-separated names, with `[n]` for list positions: `item.author.name`, `item.tags[0]`. Its first name
237
+ is looked up up the scope chain; the rest walks into the value. A segment that isn't there gives *missing*, never
238
+ an error. Only a value's own data is read: `constructor`, `__proto__` and `toString` are missing, and so is
239
+ `.length` of a text (a list's `length` works; for text use `len()`).
240
+
241
+ ### A lone placeholder keeps its type
242
+
243
+ When the whole string is one placeholder, the result is the value itself: a list stays a list, a number a number.
244
+ As soon as there's any other text around it, every placeholder is turned into text: missing as `""`, lists and
245
+ objects as JSON.
246
+
247
+ ```json
248
+ { "type": "set", "id": "quotes", "value": "{{response.quotes}}" },
249
+ { "type": "set", "id": "count", "value": "{{ len(quotes) }}" },
250
+ { "type": "set", "id": "countText", "value": "{{ len(quotes) }} quotes on page {{page.number}}" },
251
+ { "type": "set", "id": "tags", "value": "{{item.tags}}" },
252
+ { "type": "set", "id": "tagsInText", "value": "tags: {{item.tags}}" },
253
+ { "type": "set", "id": "card", "value": { "who": "{{item.author.name}}", "tagCount": "{{ len(item.tags) }}", "first": "{{ item.tags[0] }}" } }
254
+ ```
255
+
256
+ <!-- capture:scope-templates scope ids=count,countText,tags,tagsInText,card -->
257
+ ```json
258
+ {
259
+ "count": 10,
260
+ "countText": "10 quotes on page 1",
261
+ "tags": [
262
+ "change",
263
+ "deep-thoughts",
264
+ "thinking",
265
+ "world"
266
+ ],
267
+ "tagsInText": "tags: [\"change\",\"deep-thoughts\",\"thinking\",\"world\"]",
268
+ "card": {
269
+ "who": "Albert Einstein",
270
+ "tagCount": 4,
271
+ "first": "change"
272
+ }
273
+ }
274
+ ```
275
+ <!-- /capture -->
276
+
277
+ `quotes` (left out above for length) holds the API's list of ten quotes as data, which is why the next step can
278
+ loop over it: `over` takes an id, not a path, so `{{response.quotes}}` is bound to an id first. `count` is the number `10`; `countText` is text.
279
+ Inside `card`, every string was rendered on its own, so `tagCount` is the number `4`, not `"4"`.
280
+
281
+ ### Expressions and functions
282
+
283
+ A placeholder that isn't a plain path is parsed as an expression: literals (`12`, `'text'`, `true`, `null`),
284
+ `+ - * / %`, comparisons, `&& || !`, `??`, `test ? a : b`, and a closed set of functions (`upper`, `lower`, `trim`,
285
+ `len`, `default`, `round`, `number`, `join`, `first`, `last`, `replace`, `contains`, `split`, `urlEncode`, `entries`,
286
+ `keys`, `values`). An unknown function is an error when the step runs, listing the known ones.
287
+
288
+ ```json
289
+ { "type": "set", "id": "tagLine", "value": "{{ join(item.tags, ', ') }}" },
290
+ { "type": "set", "id": "firstTag", "value": "{{ first(item.tags) }}" },
291
+ { "type": "set", "id": "hasTag", "value": "{{ contains(item.tags, vars.tag) }}" },
292
+ { "type": "set", "id": "words", "value": "{{ len(split(item.text, ' ')) }}" },
293
+ { "type": "set", "id": "size", "value": "{{ len(item.text) > 100 ? 'long' : 'short' }}" },
294
+ { "type": "set", "id": "shout", "value": "{{ upper(item.author.name) }}" },
295
+ { "type": "set", "id": "search", "value": "https://quotes.toscrape.com/search.aspx?author={{ urlEncode(item.author.name) }}" }
296
+ ```
297
+
298
+ <!-- capture:scope-templates scope ids=tagLine,firstTag,hasTag,words,size,shout,search -->
299
+ ```json
300
+ {
301
+ "tagLine": "change, deep-thoughts, thinking, world",
302
+ "firstTag": "change",
303
+ "hasTag": true,
304
+ "words": 21,
305
+ "size": "long",
306
+ "shout": "ALBERT EINSTEIN",
307
+ "search": "https://quotes.toscrape.com/search.aspx?author=Albert%20Einstein"
308
+ }
309
+ ```
310
+ <!-- /capture -->
311
+
312
+ How the operators treat values:
313
+
314
+ - `&&` and `||` return one of their operands, not a boolean, and test it with recipe truthiness (below).
315
+ `a ?? b` gives `b` only when `a` is missing or `null`.
316
+ - `==` and `!=` are loose: `3 == '3'` is true, and so is `null == ` a missing value. Objects compare by content.
317
+ - `- * / %` read numeric text as numbers. A value that isn't a number gives *missing*, and so does dividing by 0.
318
+ - `<` and friends compare as numbers when both sides are numbers, else as text.
319
+ - `split` trims every piece and drops blank ones, so `split('', ',')` is `[]`.
320
+ - Strings have no escapes: a string ends at the next quote of the same kind.
321
+ - Placeholders can't contain `{` or `}`. Expressions are capped at 512 tokens and 64 levels of nesting.
322
+
323
+ **Truthiness**, for `when`, `if.test`, `paginate.until`, `!`, `&&`, `||` and `? :`: `false`, `0`, `null`, missing,
324
+ an empty list, and the texts `""`, `"false"`, `"0"`, `"null"` and `"undefined"` (trimmed, any case) are false.
325
+ Everything else is true, including `{}` and `" no "`.
326
+
327
+ ### Missing values
328
+
329
+ A path that isn't there is *missing*, and a missing value is never an error. Alone, it's `undefined`; in text,
330
+ it's `""`:
331
+
332
+ ```json
333
+ { "type": "set", "id": "bornText", "value": "born: {{item.author.born}}" },
334
+ { "type": "set", "id": "bornOr", "value": "{{ item.author.born ?? 'unknown' }}" },
335
+ { "type": "set", "id": "bornDefault", "value": "{{ default(item.author.born, 'unknown') }}" }
336
+ ```
337
+
338
+ <!-- capture:scope-templates scope ids=bornText,bornOr,bornDefault -->
339
+ ```json
340
+ {
341
+ "bornText": "born: ",
342
+ "bornOr": "unknown",
343
+ "bornDefault": "unknown"
344
+ }
345
+ ```
346
+ <!-- /capture -->
347
+
348
+ The API has no `born` field, so `bornText` ends in nothing. `??` falls back on missing or `null`; `default()` also
349
+ falls back on `""`. Choose `default()` for text a page might leave empty.
350
+
351
+ That silence has consequences outside `set`:
352
+
353
+ - a URL template that renders to `""` resolves to the current page: `goto "{{link}}"` with no `link` loads the
354
+ current page again instead of failing. In `paginate`, a `next.url` that renders to `""` ends the pagination, which is
355
+ deliberate;
356
+ - `if` and `when` treat missing as false;
357
+ - a mapping reads a missing value as missing, and the field's missing-value policy decides
358
+ ([part 9](08-policies.md)).
359
+
360
+ The one exception is the access config ([part 10](09-running.md)): a proxy setting that reads an `env.NAME`
361
+ that isn't set fails with an `AccessConfigError` naming the variable.
362
+
363
+ ## The traps
364
+
365
+ Three results in the snapshot were written to go wrong. The steps:
366
+
367
+ ```json
368
+ { "type": "set", "id": "pathTrap", "value": "[{{page.number-1}}]" },
369
+ { "type": "set", "id": "minus", "value": "{{ page.number - 1 }}" },
370
+ { "type": "set", "id": "plusTrap", "value": "{{ vars.offset + 2 }}" },
371
+ { "type": "set", "id": "plus", "value": "{{ number(vars.offset) + 2 }}" },
372
+ { "type": "set", "id": "times", "value": "{{ vars.offset * 2 }}" },
373
+ { "type": "set", "id": "trueTrap", "value": "[{{true}}]" },
374
+ { "type": "set", "id": "trueExpr", "value": "[{{ true ? 'yes' : 'no' }}]" },
375
+ { "type": "if", "test": "{{true}}",
376
+ "steps": [{ "type": "set", "id": "branch", "value": "then" }],
377
+ "else": [{ "type": "set", "id": "branch", "value": "else" }] }
378
+ ```
379
+
380
+ <!-- capture:scope-templates scope ids=pathTrap,minus,plusTrap,plus,times,trueTrap,trueExpr,branch -->
381
+ ```json
382
+ {
383
+ "pathTrap": "[]",
384
+ "minus": 0,
385
+ "plusTrap": "12",
386
+ "plus": 3,
387
+ "times": 2,
388
+ "trueTrap": "[]",
389
+ "trueExpr": "[yes]",
390
+ "branch": "else"
391
+ }
392
+ ```
393
+ <!-- /capture -->
394
+
395
+ **`{{a-b}}` is a path.** Before parsing, the engine checks whether the placeholder is made only of path
396
+ characters: letters, digits, `_`, `$`, `@`, `.`, `[`, `]` and `-`. If it is, it's looked up as a path, never
397
+ evaluated. That's what lets `item.display-name` and `ld.@type` work, but it makes `page.number-1` a lookup of a
398
+ name `number-1` inside `page`, which is missing. With spaces, `page.number - 1` has characters a path can't have,
399
+ so it's an expression, and gives `0`.
400
+
401
+ **`"1" + 2` is `"12"`.** `+` adds only when both sides are numbers. Otherwise it joins them as text. `vars.offset`
402
+ is the text `"1"`, so `vars.offset + 2` is `"12"`. Any value read from a page is text too, until a transform or a
403
+ field type converts it. `number()`
404
+ converts first. The other operators convert numeric text themselves, so `vars.offset * 2` is `2`.
405
+
406
+ **`{{true}}` alone is a lookup.** `true` is made of path characters too, so `{{true}}` (and `{{null}}`, `{{42}}`)
407
+ looks up an id named `true`, which doesn't exist. In text it renders as `""`; as an `if` test it's missing, so
408
+ false, and the `else` branch runs:
409
+
410
+ <!-- capture:scope-templates trace grep=⑂ -->
411
+ ```text
412
+ ⑂ steps.4.steps.21 else
413
+ ```
414
+ <!-- /capture -->
415
+
416
+ Inside a larger expression, `true` is the literal: `{{ true ? 'yes' : 'no' }}` gives `yes`. A `when` or a `test`
417
+ with no placeholder at all is constant text, read by truthiness: `"when": "false"` is false and `"when": "true"`
418
+ is true.
419
+
420
+ Two more that follow from the rules above:
421
+
422
+ - `evaluate.script` is a text template, so a `{{` inside the JavaScript is read as a placeholder. Pass values
423
+ through `args` instead.
424
+ - A lone placeholder can give a list where text was expected. `"value": "{{tags}}"` in a `fill` is fine (text
425
+ templates stringify it to JSON), but a `set` of it keeps the list, and a `string` field then refuses it at
426
+ coercion ([part 2](01-recipes.md#what-coercion-refuses)).
427
+
428
+ Next: [7. Documents, under the hood](06-documents.md).