@opencraw/mcp 0.1.45 → 0.1.48
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.
- package/README.md +28 -1
- package/dist/diff-tool/diff-tool.handler.d.ts +42 -0
- package/dist/diff-tool/index.d.ts +3 -0
- package/dist/docs-tool/docs-folder.repository.d.ts +24 -0
- package/dist/docs-tool/docs-root.client.d.ts +8 -0
- package/dist/docs-tool/docs-tool.handler.d.ts +21 -0
- package/dist/docs-tool/docs-topics.algorithm.d.ts +39 -0
- package/dist/docs-tool/index.d.ts +3 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.esm.js +517 -1
- package/dist/list-tool/index.d.ts +3 -0
- package/dist/list-tool/list-recipes-tool.handler.d.ts +32 -0
- package/dist/main.d.ts +7 -0
- package/dist/probe-tool/index.d.ts +2 -0
- package/dist/probe-tool/probe-tool.handler.d.ts +27 -0
- package/dist/recipe-source/index.d.ts +3 -0
- package/dist/recipe-source/recipe-source.mapper.d.ts +21 -0
- package/dist/run-tool/index.d.ts +3 -0
- package/dist/run-tool/run-tool.handler.d.ts +51 -0
- package/dist/server/create-server.use-case.d.ts +11 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/src/docs-tool/docs-folder.repository.d.ts +24 -0
- package/dist/src/docs-tool/docs-root.client.d.ts +8 -0
- package/dist/src/docs-tool/docs-tool.handler.d.ts +21 -0
- package/dist/src/docs-tool/docs-topics.algorithm.d.ts +39 -0
- package/dist/src/docs-tool/index.d.ts +3 -0
- package/dist/src/server/create-server.use-case.d.ts +1 -1
- package/dist/src/studio-tool/index.d.ts +2 -0
- package/dist/src/studio-tool/studio-command.client.d.ts +11 -0
- package/dist/src/studio-tool/studio-instance.repository.d.ts +22 -0
- package/dist/src/studio-tool/studio-tools.handler.d.ts +68 -0
- package/dist/studio-tool/index.d.ts +2 -0
- package/dist/studio-tool/studio-command.client.d.ts +11 -0
- package/dist/studio-tool/studio-instance.repository.d.ts +22 -0
- package/dist/studio-tool/studio-tools.handler.d.ts +68 -0
- package/dist/validate-tool/index.d.ts +3 -0
- package/dist/validate-tool/validate-tool.handler.d.ts +31 -0
- package/docs/how-it-works/01-recipes.md +698 -0
- package/docs/how-it-works/02-page-steps.md +672 -0
- package/docs/how-it-works/03-data-steps.md +813 -0
- package/docs/how-it-works/04-flow-steps.md +738 -0
- package/docs/how-it-works/05-templates-and-scope.md +428 -0
- package/docs/how-it-works/06-documents.md +1413 -0
- package/docs/how-it-works/07-mapping-and-transforms.md +699 -0
- package/docs/how-it-works/08-policies.md +405 -0
- package/docs/how-it-works/09-running.md +804 -0
- package/docs/how-it-works/README.md +225 -0
- package/docs/recipes/README.md +22 -0
- package/docs/recipes/access.md +440 -0
- package/docs/recipes/authoring.md +1503 -0
- package/docs/recipes/azure-durable.md +180 -0
- package/docs/recipes/captcha.md +204 -0
- package/docs/recipes/movies-example.md +12 -0
- package/docs/recipes/programmatic-authoring.md +101 -0
- package/docs/recipes/quick-guide.md +157 -0
- package/docs/recipes/vehicles-example.md +9 -0
- package/docs/recipes/worker-mode.md +221 -0
- package/docs/studio/manual.md +314 -0
- package/docs/studio/pdf-agreements.md +150 -0
- 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).
|