fancy-template 0.2.0__tar.gz

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.
@@ -0,0 +1,13 @@
1
+ node_modules/
2
+ dist/
3
+ vendor/
4
+ .phpunit.cache/
5
+ composer.lock
6
+
7
+ # Python build artifacts
8
+ __pycache__/
9
+ *.py[cod]
10
+ *.egg-info/
11
+ .pytest_cache/
12
+ .venv/
13
+ scripts/.sabotage.log
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Particle Academy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.5
2
+ Name: fancy-template
3
+ Version: 0.2.0
4
+ Summary: Document templates for the Fancy document writers: a writer document with {{ tokens }} and JSON directives, plus a declaration of the data it needs. inspect() at save time, validate_data() before render, render() to plain writer JSON. No code execution, no includes, bounded everywhere. One spec, three runtimes, held to a shared conformance table.
5
+ Project-URL: Homepage, https://github.com/Particle-Academy/fancy-template
6
+ Project-URL: Issues, https://github.com/Particle-Academy/fancy-template/issues
7
+ Project-URL: Source, https://github.com/Particle-Academy/fancy-template
8
+ Author: Particle Academy
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agentic,certificate,cross-language,document,docx,particle-academy,pdf,pptx,template,xlsx
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Text Processing :: Markup
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: fancy-expr<2.0,>=0.2.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # fancy-template (Python)
26
+
27
+ Document templates for the Fancy document writers: a writer document
28
+ (fine-print, last-word, dark-slide, holy-sheet JSON) with `{{ tokens }}` in some
29
+ string values and JSON directives on some array items, plus a declaration of the
30
+ data it needs. **One specification, three implementations** (Python, TypeScript,
31
+ PHP), all asserted against the same `template/*` table in `fancy-conformance`.
32
+
33
+ ```python
34
+ from fancy_template import inspect, validate_data, render
35
+
36
+ inspect(template)["issues"] # at SAVE time, no data
37
+ validate_data(template, data) # before rendering
38
+ doc = render(template, data) # plain writer JSON, or raises TemplateError
39
+
40
+ import holy_sheet
41
+
42
+ holy_sheet.to_bytes(doc) # the writer validates it again
43
+ ```
44
+
45
+ ## What it guarantees
46
+
47
+ - **Missing data is loud.** An absent value raises `TemplateError` with a stable
48
+ `code`, a JSON Pointer `path` into the template and the `variable` path --
49
+ never the data value itself.
50
+ - **No code execution, no includes.** Tokens are paths plus a fixed filter list;
51
+ `$if` is a sandboxed fancy-expr expression; data is reduced to plain JSON
52
+ before anything reads it, and no method of a host object is ever called.
53
+ - **Profiles are allowlists.** Only listed fields take tokens. holy-sheet cells
54
+ write every string result as `{"value": s}`, so user data can never become a
55
+ formula; dark-slide escapes values for the element's markdown or html.
56
+ - **Bounded everywhere**, with a stable error code per bound.
57
+
58
+ ## Where Python differs from its own instincts
59
+
60
+ - `True == 1` is true in Python; here a boolean is never a number, and
61
+ `{"fancyTemplate": True}` is refused.
62
+ - `str.isalpha`, `str.isdigit` and `re`'s `\d` are Unicode-aware; names, dates
63
+ and digits here are ASCII only, as in the other legs.
64
+ - The recursion limit sits below the 4096 levels the other legs accept as JSON,
65
+ so every walk of unknown depth is iterative.
66
+
67
+ The specification is `SPEC.md` in the repository root.
@@ -0,0 +1,43 @@
1
+ # fancy-template (Python)
2
+
3
+ Document templates for the Fancy document writers: a writer document
4
+ (fine-print, last-word, dark-slide, holy-sheet JSON) with `{{ tokens }}` in some
5
+ string values and JSON directives on some array items, plus a declaration of the
6
+ data it needs. **One specification, three implementations** (Python, TypeScript,
7
+ PHP), all asserted against the same `template/*` table in `fancy-conformance`.
8
+
9
+ ```python
10
+ from fancy_template import inspect, validate_data, render
11
+
12
+ inspect(template)["issues"] # at SAVE time, no data
13
+ validate_data(template, data) # before rendering
14
+ doc = render(template, data) # plain writer JSON, or raises TemplateError
15
+
16
+ import holy_sheet
17
+
18
+ holy_sheet.to_bytes(doc) # the writer validates it again
19
+ ```
20
+
21
+ ## What it guarantees
22
+
23
+ - **Missing data is loud.** An absent value raises `TemplateError` with a stable
24
+ `code`, a JSON Pointer `path` into the template and the `variable` path --
25
+ never the data value itself.
26
+ - **No code execution, no includes.** Tokens are paths plus a fixed filter list;
27
+ `$if` is a sandboxed fancy-expr expression; data is reduced to plain JSON
28
+ before anything reads it, and no method of a host object is ever called.
29
+ - **Profiles are allowlists.** Only listed fields take tokens. holy-sheet cells
30
+ write every string result as `{"value": s}`, so user data can never become a
31
+ formula; dark-slide escapes values for the element's markdown or html.
32
+ - **Bounded everywhere**, with a stable error code per bound.
33
+
34
+ ## Where Python differs from its own instincts
35
+
36
+ - `True == 1` is true in Python; here a boolean is never a number, and
37
+ `{"fancyTemplate": True}` is refused.
38
+ - `str.isalpha`, `str.isdigit` and `re`'s `\d` are Unicode-aware; names, dates
39
+ and digits here are ASCII only, as in the other legs.
40
+ - The recursion limit sits below the 4096 levels the other legs accept as JSON,
41
+ so every walk of unknown depth is iterative.
42
+
43
+ The specification is `SPEC.md` in the repository root.
@@ -0,0 +1,418 @@
1
+ # fancy-template -- specification
2
+
3
+ **Normative.** One specification, three implementations (PHP first, then
4
+ TypeScript and Python), held together by the `template/*` suites in
5
+ `fancy-conformance`. Where an implementation and this file disagree, the
6
+ implementation is wrong. Where this file and a conformance row disagree, one of
7
+ them is a bug and it gets fixed in both places in one change.
8
+
9
+ Design and the owner's decisions: envelope `.ai/plans/document-writer-templates.md`
10
+ (accepted 2026-10-27).
11
+
12
+ ## 1. What this is
13
+
14
+ A **document template** is a writer document (fine-print, last-word, dark-slide,
15
+ holy-sheet JSON) with `{{ tokens }}` in some string values and JSON directives on
16
+ some array items, plus a declaration of the data it needs. `render(template,
17
+ data)` returns a plain writer document, which the host then passes through that
18
+ writer's own `validate` and `write`. The writers do not depend on this package.
19
+
20
+ It is not a programming language. No code execution, no host-object access, no
21
+ includes, no calls. Templates and data both arrive from people and agents over the
22
+ wire.
23
+
24
+ ## 2. Envelope
25
+
26
+ ```json
27
+ { "fancyTemplate": 1,
28
+ "writer": "fine-print",
29
+ "description": "optional, free text",
30
+ "variables": { "recipientName": { "type": "string", "required": true, "maxLength": 200 } },
31
+ "document": { "...": "a writer document" } }
32
+ ```
33
+
34
+ | key | rule | issue code |
35
+ |---|---|---|
36
+ | `fancyTemplate` | exactly the integer `1` (an integral value, as section 3 defines integer: `1.0` is the same number; `true` and `"1"` are not) | `TEMPLATE_VERSION` |
37
+ | `writer` | a profile this implementation knows (section 9) | `TEMPLATE_WRITER` |
38
+ | `variables` | optional object; absent means `{}` | `TEMPLATE_SHAPE` |
39
+ | `document` | required object | `TEMPLATE_SHAPE` |
40
+ | anything else except `description` (a string) | not allowed | `TEMPLATE_SHAPE` |
41
+
42
+ The envelope itself must be an object (`TEMPLATE_SHAPE` at path `""`).
43
+
44
+ ## 3. Variable declarations
45
+
46
+ `variables` maps a **name** (`^[A-Za-z_][A-Za-z0-9_]*$`) to a **declaration**.
47
+ A declaration is an object with these keys and no others:
48
+
49
+ | key | applies to | meaning |
50
+ |---|---|---|
51
+ | `type` | all, required | `string` `number` `integer` `boolean` `date` `image` `array` `object` |
52
+ | `required` | all | boolean, default `false` |
53
+ | `default` | all | used when the value is absent; must itself be valid |
54
+ | `example` | all | used by `preview()`; must itself be valid |
55
+ | `description` | all | string |
56
+ | `enum` | string, number, integer | non-empty list of allowed values of that type |
57
+ | `maxLength` | string, date, image | non-negative integer, counted in **Unicode code points** (JSON Schema's unit) |
58
+ | `maxItems` | array | non-negative integer |
59
+ | `items` | array, required | the declaration of every element (its `required` is ignored) |
60
+ | `properties` | object, required | map of name to declaration, same rules recursively |
61
+
62
+ Any other shape is `DECL_INVALID` at the offending key's path (or at the
63
+ declaration when `type` is missing or unknown). A bad name is `DECL_INVALID` at
64
+ `/variables/<name>`.
65
+
66
+ Types:
67
+
68
+ - `string`: a JSON string.
69
+ - `number`: a finite JSON number. `integer`: a number with an integral value
70
+ (`3.0` is an integer, as in JSON Schema).
71
+ - `boolean`: `true` / `false`.
72
+ - `date`: a string, `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM[:SS[.fraction]][Z|+HH:MM|-HH:MM]`,
73
+ with a real calendar date (leap years honoured) and a real time.
74
+ - `image`: a string `data:image/png;base64,<b64>` or `data:image/jpeg;base64,<b64>`
75
+ whose base64 decodes strictly and whose decoded bytes start with the format's
76
+ magic number (PNG `89 50 4E 47 0D 0A 1A 0A`, JPEG `FF D8 FF`).
77
+ - `array`, `object`: JSON containers.
78
+
79
+ `null` is not a value of any type.
80
+
81
+ **Not JSON Schema, on purpose.** `required: true` on the declaration and the
82
+ `date`/`image` types are the plan's approved vocabulary and read better to the
83
+ people writing templates. They map mechanically to JSON Schema (`required`
84
+ hoisted to an array; `date` and `image` become `string` with a `format`), so a
85
+ form can still be generated from them; that mapping is not part of phase 1.
86
+
87
+ ## 4. Data and `validateData`
88
+
89
+ `validateData(template, data) -> errors` where each error is `{ path, code }`,
90
+ `path` a JSON Pointer into `data`. Errors are listed in declaration order,
91
+ depth-first. An empty list means valid. Codes:
92
+
93
+ | code | when |
94
+ |---|---|
95
+ | `DATA_TYPE` | wrong type, including `null`; also when `data` itself is not an object (path `""`) |
96
+ | `DATA_REQUIRED` | a required name is absent and has no `default` |
97
+ | `DATA_MAX_LENGTH` | longer than `maxLength` code points |
98
+ | `DATA_MAX_ITEMS` | more than `maxItems` elements |
99
+ | `DATA_ENUM` | not one of `enum` (compared by type and value) |
100
+ | `DATA_FORMAT` | a `date` or `image` string that is not one |
101
+ | `DATA_TOO_DEEP` | data nested deeper than `limits.depth` (section 8) |
102
+
103
+ **Undeclared keys are ignored and unreachable**: `inspect` refuses any token or
104
+ directive that names one, so extra data a host passes along can never appear in a
105
+ document. Absent optional names are fine at this stage; a token that reads one
106
+ fails at render unless it has a fallback (section 6).
107
+
108
+ Before validating, data is reduced to plain JSON values. A host object that is
109
+ not plain JSON data is converted the way the host language's JSON encoder would
110
+ convert it, or refused (`DATA_TYPE`). No host method is called during render.
111
+
112
+ Never plain JSON, in every language, so the three legs refuse the same input
113
+ (`DATA_TYPE` at `""` for data, `TEMPLATE_SHAPE` at `""` for a template): a
114
+ non-finite number, a string that is not well-formed Unicode (a lone surrogate),
115
+ a cycle, and nesting deeper than 4096 levels (PHP's JSON encoder limit). These
116
+ are refused WHOLE, before validation, rather than reported at a path, because
117
+ JavaScript's encoder would otherwise rewrite `Infinity` to `null` and move the
118
+ error.
119
+
120
+ **Defaults** apply at every level: an absent name with a `default` takes the
121
+ default (recursively for object properties) before anything reads it.
122
+
123
+ ## 5. Tokens
124
+
125
+ Tokens appear only inside string VALUES of `document`, and only in fields the
126
+ writer's profile allows (section 9).
127
+
128
+ ```
129
+ text := ( char | "{{" token "}}" )*
130
+ token := ws ( string | path ) ( ws "|" ws filter )* ws
131
+ path := head ( "." segment )*
132
+ head := name | "$index" | "$first" | "$last"
133
+ segment := name | digits
134
+ name := [A-Za-z_][A-Za-z0-9_]*
135
+ digits := [0-9]+
136
+ filter := fname ( ws ":" ws arg )?
137
+ fname := [a-z]+
138
+ arg := string | bare
139
+ bare := [A-Za-z0-9_.+-]+
140
+ string := '"' ( any char except '"' and '\' | '\"' | '\\' )* '"'
141
+ ws := ( " " | TAB )*
142
+ ```
143
+
144
+ - `{{` always opens a token. There is no other escape: a literal `{{` is written
145
+ `{{ "{{" }}`. A `}}` outside a token, or a single `{` or `}`, is literal text.
146
+ - Inside a string literal `}}` does not close the token.
147
+ - Scanning is a single left-to-right pass with no backtracking.
148
+ - Failures, in scan order. The scan for the closing `}}` (skipping string
149
+ literals) fails with `TOKEN_TOO_LONG` as soon as it has passed
150
+ `limits.tokenLength` UTF-8 bytes after the `{{` without finding one, and with
151
+ `TOKEN_UNCLOSED` if the text ends first. A token is therefore never scanned
152
+ past its limit. Then the inner text is parsed: `TOKEN_EMPTY` if it is only
153
+ whitespace, `TOKEN_SYNTAX` for anything else that does not match, including a
154
+ newline or an unknown `$` head.
155
+
156
+ `tokenize(text)` returns the parts in order: `{ "text": s }` for literal text
157
+ (adjacent text merged, empty text omitted), `{ "path": [segments], "filters": [...] }`
158
+ or `{ "literal": s, "filters": [...] }` for a token. A filter is `{ "name": n }` or
159
+ `{ "name": n, "arg": s }`; an arg is always a string, whether written bare or quoted.
160
+ `""` tokenizes to `[]`.
161
+
162
+ A string that is **exactly one token** (nothing before `{{` or after `}}`, not even
163
+ whitespace) is a **whole token**. Whether that keeps the value's type depends on
164
+ the field's encoding (section 9); in a text field it is stringified like any
165
+ other token.
166
+
167
+ ## 6. Resolution
168
+
169
+ The **scope** is: loop bindings of the enclosing `$each` items (innermost first:
170
+ its `$as` name, then `$index`, `$first`, `$last`), then the declared variables.
171
+ `inspect` refuses a token whose head is neither (`TOKEN_UNDECLARED`) and a
172
+ `$index`/`$first`/`$last` outside any `$each` (`TOKEN_UNDECLARED`).
173
+
174
+ `inspect` also walks the rest of the path through the DECLARATIONS: a name must be
175
+ a declared property of an `object`, digits must step into an `array`, and nothing
176
+ steps into a scalar; otherwise `TOKEN_UNDECLARED`. A loop binding's declaration is
177
+ its array's `items`.
178
+
179
+ At render the rest of the path walks the value: a name selects an object key, digits select
180
+ an array element. Anything that does not resolve -- an absent key, an index out of
181
+ range, a step into a scalar -- is **absent**. Absent is never an empty string.
182
+
183
+ | resolved value | text field result |
184
+ |---|---|
185
+ | string | itself |
186
+ | number | shortest round-trip decimal, as JavaScript's `Number.prototype.toString` writes it (`3`, `0.1`, `1e+21`) |
187
+ | boolean | `true` / `false` |
188
+ | array, object | `TEMPLATE_NOT_SCALAR` |
189
+ | absent | `TEMPLATE_MISSING`, unless a `default` filter supplies a value |
190
+
191
+ Every render error carries `code` and `path`, where `path` is a JSON Pointer into
192
+ the TEMPLATE (`/document/blocks/3/runs/0/text`), never an expanded copy and never
193
+ a data value. Errors name variables by path (`recipientName`, `item.name`), never
194
+ by value.
195
+
196
+ ## 7. Filters
197
+
198
+ Applied left to right. A filter receives the current value, which may be absent.
199
+ Every filter except `default` given an absent value fails with `TEMPLATE_MISSING`.
200
+ Unknown filter names are `FILTER_UNKNOWN` and a bad or missing argument is
201
+ `FILTER_ARG`, both reported by `inspect`. A value of the wrong kind at render is
202
+ `FILTER_INPUT`.
203
+
204
+ | filter | argument | input | output |
205
+ |---|---|---|---|
206
+ | `default` | required | anything | the input unchanged if present, else the argument (a string) |
207
+ | `upper` / `lower` | none | scalar (stringified first) | ASCII letters mapped; every other character unchanged |
208
+ | `number` | required, integer 0-10 | number | fixed decimals, comma thousands groups |
209
+ | `money` | required, ISO 4217 code from the table | integer minor units | symbol or code, comma groups, the currency's minor digits |
210
+ | `date` | required, a pattern | a `date` string | formatted |
211
+ | `join` | optional, default `", "` | array of scalars | elements stringified and joined |
212
+
213
+ **`number`**: the input's shortest round-trip decimal representation is rounded to
214
+ the given number of decimals, half away from zero, in decimal arithmetic (never by
215
+ binary floating point: `2.675 | number:2` is `2.68`). Thousands are grouped with
216
+ `,`. Negative values take a leading `-`; a value that rounds to zero is written
217
+ without a sign.
218
+
219
+ **`money`**: the input must be an integer (minor units: `123450 | money:USD` is
220
+ `$1,234.50`). The exponent comes from the table; the amount is never converted
221
+ through a float. Prefix is the symbol (`USD $`, `EUR €`, `GBP £`, `JPY ¥`,
222
+ `INR ₹`, `KRW ₩`) or the code and a space (`CHF 1,234.50`); a negative amount is
223
+ `-$1,234.50`. Exponents: 0 for `JPY KRW CLP VND ISK`, 3 for `KWD BHD JOD OMR TND`,
224
+ 2 for `USD EUR GBP CAD AUD NZD CHF CNY INR BRL MXN SEK NOK DKK PLN SGD HKD ZAR`.
225
+ Any other code is `FILTER_ARG`.
226
+
227
+ **`date`**: input is a `date` string (section 3); a time-of-day defaults to
228
+ `00:00:00`, an offset is ignored (the wall-clock time written is the one given).
229
+ Pattern letters, each run matched in full:
230
+
231
+ | run | output |
232
+ |---|---|
233
+ | `yyyy` / `yy` | year, 4 digits / last 2 |
234
+ | `MMMM` `MMM` `MM` `M` | January / Jan / 01 / 1 |
235
+ | `dd` `d` | day, 2 digits / no padding |
236
+ | `EEEE` `EEE` | Monday / Mon |
237
+ | `HH` `H` | hour 00-23 / 0-23 |
238
+ | `hh` `h` | hour 01-12 / 1-12 |
239
+ | `mm` `ss` | minute, second, 2 digits |
240
+ | `a` | `AM` / `PM` |
241
+
242
+ Text in single quotes is literal (`''` is one quote). Any other ASCII letter, or a
243
+ run length not listed, is `FILTER_ARG`. Every other character is literal. English
244
+ names only (owner decision 4).
245
+
246
+ ## 8. Directives
247
+
248
+ On an object that is an ELEMENT OF AN ARRAY anywhere in `document`:
249
+
250
+ | key | value |
251
+ |---|---|
252
+ | `$each` | a path (section 5 grammar, no filters) to an array |
253
+ | `$as` | a name for the element; default `item`; only with `$each` |
254
+ | `$if` | a fancy-expr expression, kept or dropped by fancy-expr truthiness |
255
+ | `$value` | any JSON value; makes the element a **splice** (below); only with `$each` |
256
+
257
+ Directive keys are removed from the output. `$each` repeats the object once per
258
+ element, spliced into the parent array in order, with bindings `<$as>`, `$index`
259
+ (0-based), `$first`, `$last`. When both are present, `$each` runs first and `$if`
260
+ is evaluated once per element. `$if` sees every declared variable (after
261
+ defaults) and the loop bindings -- including the element's own `$each` binding,
262
+ **whatever order the two keys are written in** (JSON key order is not something
263
+ an author should have to get right); an absent path is `null` (fancy-expr's
264
+ rule), so a condition on an optional variable is not an error.
265
+
266
+ ### The `$value` splice
267
+
268
+ A directive sits on an object, so an element that is itself an array (a
269
+ holy-sheet row), a string (a list of strings) or any other value cannot carry
270
+ one. The **splice** repeats any value:
271
+
272
+ ```json
273
+ { "$each": "lines", "$as": "line", "$value": ["{{ line.desc }}", "{{ line.qty }}"] }
274
+ ```
275
+
276
+ An object element of an array that has a `$value` key is a splice. It is
277
+ replaced, in place, by one rendered copy of its `$value` per element of `$each`,
278
+ in order; an empty array produces nothing. Everything else is `$each`'s rules:
279
+ `$as`, the bindings, `$if` evaluated once per element (in any key order), an
280
+ absent array at render is `TEMPLATE_MISSING` at `$each`, and the same bounds
281
+ (below).
282
+
283
+ - **`$value` is the element.** Its content renders exactly as if it had been
284
+ written at the splice's position in the array: the profile matches it by the
285
+ ELEMENT's path (`sheets.*.rows.*` for a row, so its strings are `cell`s and a
286
+ string result is still `{ "value": s }`), and `$value` is not a path segment.
287
+ Error paths point into the template: `/document/sheets/0/rows/1/$value/0`.
288
+ - **Any JSON value**: an array, an object, a string, a number, a boolean or
289
+ `null`. `null`, `0` and `false` are values to copy, not "no value".
290
+ - **It may contain directives, under the same nesting bound.** Arrays inside
291
+ `$value` take directive elements and splices like any other array, and each
292
+ counts toward `limits.directiveDepth` on its path, the splice itself
293
+ included. The TOP of `$value` is not an array element, so a `$`-key on it is
294
+ `DIRECTIVE_MISPLACED` -- a condition belongs on the splice.
295
+ - **A splice holds only directives.** `$value` without `$each` is
296
+ `DIRECTIVE_INVALID` at `$value` (as `$as` without `$each` is), and its content
297
+ is not inspected. A key not starting with `$` beside `$value` is
298
+ `DIRECTIVE_SPLICE_KEY` at that key, and its value is not inspected: there is
299
+ no object left to put it on.
300
+ - **Bounds**: each element counts one toward `limits.iterations`, shared with
301
+ every `$each`; each rendered copy's values count toward `limits.outputNodes`
302
+ and `outputBytes`; the splice object itself is not output and counts nothing.
303
+
304
+ `inspect` order for a splice: its `$` keys in written order, then its other
305
+ keys in written order, then the content of `$value` (when `$each` is present).
306
+
307
+ Static issues (`inspect`):
308
+
309
+ | code | when |
310
+ |---|---|
311
+ | `DIRECTIVE_MISPLACED` | a `$`-key on an object that is not an array element, or on `document` itself, or on the top of a `$value` |
312
+ | `DIRECTIVE_UNKNOWN` | any other `$`-key |
313
+ | `DIRECTIVE_INVALID` | `$as` or `$value` without `$each`; a non-string value; a bad path; an `$as` that is not a name or shadows a variable or an enclosing `$as` |
314
+ | `DIRECTIVE_SPLICE_KEY` | a key not starting with `$` on a splice (an object with `$value`) |
315
+ | `DIRECTIVE_EXPR` | `$if` that fancy-expr refuses (any fancy-expr code) |
316
+ | `DIRECTIVE_UNDECLARED` | `$each` head or a `$if` reference that is not in scope |
317
+ | `DIRECTIVE_NOT_ARRAY` | `$each` path whose declaration is not `array` |
318
+ | `DIRECTIVE_TOO_DEEP` | more than `limits.directiveDepth` directive objects nested |
319
+
320
+ At render, a `$each` path that is absent is `TEMPLATE_MISSING` and one that is not
321
+ an array is `DIRECTIVE_NOT_ARRAY`.
322
+
323
+ ## 9. Profiles
324
+
325
+ A profile is data (`profiles/<writer>.json`): which string fields may hold tokens
326
+ and how a value is ENCODED there. Field patterns are dot-separated over the path
327
+ of the string inside `document`: a literal key, `*` for exactly one key or
328
+ index, `**` for zero or more. **Anything not listed refuses tokens**
329
+ (`TOKEN_FORBIDDEN_FIELD`), and a token inside an object KEY is `TOKEN_IN_KEY`.
330
+
331
+ | encoding | result |
332
+ |---|---|
333
+ | `text` | the string, every token stringified |
334
+ | `band` | as `text`, but a VALUE containing `{page}` or `{pages}` is `VALUE_PAGE_FIELD` |
335
+ | `markup` | as `text`, each value escaped for the element's `format`: `markdown` backslash-escapes every ASCII punctuation character (CommonMark), `html` escapes `& < > " '` as entities, `plain` or absent leaves it |
336
+ | `markdown` | as `markup` with format fixed to markdown |
337
+ | `cell` | a whole token keeps a number, boolean or date as-is; every other result is written `{ "value": s }`, so user data can never become a formula. "Date" means: the token's path is DECLARED `date` and the result (after filters) is a valid `date` string (section 3) -- a date-shaped value of a `string` variable is wrapped, and `{{ d \| date:"yyyy" }}` is text. A valid date cannot begin with `=`. A literal (token-free) cell is the author's and is left as written |
338
+ | `image` | must be a whole token, no filters, naming an `image` variable; the value's type must be in the profile's `mime` list (`VALUE_IMAGE_TYPE`). A literal (token-free) value must be a valid image data URL of an allowed type (`TEMPLATE_IMAGE_INVALID`) |
339
+
340
+ Wrong use of an image field is `TOKEN_IMAGE_FIELD`.
341
+
342
+ **`{page}` is refused rather than escaped** because neither fine-print nor
343
+ last-word has an escape for it: both replace the literal text in a band. A refusal
344
+ is loud; any lookalike substitution would print a different character. When a
345
+ writer gains an escape, the band encoding uses it.
346
+
347
+ **The markdown escape is CommonMark's, and the profile does not force `plain`.**
348
+ dark-slide's PPTX writer (`MarkdownInline`, in all three of its languages) does
349
+ not honour backslash escapes and writes `html` as plain text. Measured in phase 6
350
+ by writing the hostile corpus deck through it: an escaped value prints its
351
+ backslashes, AND the escape fails to neutralise markup -- `\*bold\*` still opens
352
+ italics and `` \`c\` `` still opens a code span, because the tokenizer reads the
353
+ `*` and the backtick as markers regardless of the backslash before them.
354
+
355
+ Forcing `format: plain` on a substituted element is not the answer: `format` is
356
+ per ELEMENT, not per run, so it would print the template author's own `**`, `#`
357
+ and `-` literally on every slide that uses a token, and it contradicts
358
+ template/profiles 0101 and 0102. The escape is the correct encoding for every
359
+ CommonMark consumer, including fancy-slides; the fix belongs in dark-slide
360
+ (`MarkdownInline`: a backslash before ASCII punctuation yields that character,
361
+ consumed before marker detection). Until then a dark-slide template that must
362
+ show user text verbatim in a `.pptx` uses an element without `format`.
363
+
364
+ **dark-slide speaker notes are `text`, not `markdown`** (owner ruling,
365
+ 2026-10-27). The PPTX writer writes notes as plain text in all three of its
366
+ languages, and fancy-slides edits them in a plain textarea, so a markdown
367
+ escape would print its backslashes in both. A value reaches the notes exactly
368
+ as given (template/profiles 0104).
369
+
370
+ Profiles: `fine-print`, `last-word`, `dark-slide` and `holy-sheet`, implemented
371
+ in all three legs (phase 6).
372
+
373
+ ## 10. Bounds
374
+
375
+ Host-overridable through `options.limits`; each failure has a stable code.
376
+
377
+ | limit | default | measured as | code |
378
+ |---|---|---|---|
379
+ | `templateBytes` | 262144 | UTF-8 bytes of every string in the envelope, keys included | `TEMPLATE_TOO_LARGE` |
380
+ | `tokens` | 10000 | tokens in the template | `TEMPLATE_TOO_MANY_TOKENS` |
381
+ | `tokenLength` | 512 | UTF-8 bytes between `{{` and `}}`; also the length of a `$if` or `$each` value | `TOKEN_TOO_LONG` |
382
+ | `directiveDepth` | 8 | directive objects nested inside directive objects | `DIRECTIVE_TOO_DEEP` |
383
+ | `iterations` | 10000 | `$each` elements expanded in one render, all loops and splices together | `RENDER_TOO_MANY_ITERATIONS` |
384
+ | `outputNodes` | 50000 | values in the output (each object, array and scalar is one) | `RENDER_TOO_MANY_NODES` |
385
+ | `outputBytes` | 8388608 | UTF-8 bytes of every string value in the output | `RENDER_TOO_LARGE` |
386
+ | `depth` | 64 | nesting of the template and of the data | `TEMPLATE_TOO_DEEP` / `DATA_TOO_DEEP` |
387
+ | per value | declarations | `maxLength`, `maxItems` | `DATA_MAX_LENGTH` / `DATA_MAX_ITEMS` |
388
+
389
+ `depth` counts containers: the envelope (or the data object) is level 1 and each
390
+ nested object or array one more; a container at a level above the limit fails.
391
+ `directiveDepth` counts directive-bearing objects on one path from `document`
392
+ down, the object itself included. Iterations are counted as they happen, so a
393
+ nested `$each` counts every inner element of every outer one.
394
+
395
+ `depth`, then `templateBytes`, then `tokens` are checked first and stop
396
+ `inspect`: a template over one of them is reported with that one issue. fancy-expr's own limits
397
+ (8192 bytes, depth 64) apply inside `$if` as well.
398
+
399
+ ## 11. API
400
+
401
+ Identical in every language (names adapted to the language's case convention:
402
+ PHP `Template::validateData`, TypeScript `validateData`, Python `validate_data`):
403
+
404
+ - `inspect(template, options?) -> { ok, issues: [{ code, path, variable? }] }` -- no
405
+ data. Issues in document order: envelope keys, then variables (in written
406
+ order, a declaration's own keys before its nested declarations), then
407
+ `document` depth-first. Within an object its `$` keys come first, in written
408
+ order, then its other entries in written order (a splice then walks its
409
+ `$value`, section 8). Within a string, tokens in
410
+ order; within a token, its path before its filters.
411
+ - `validateData(template, data, options?) -> errors` (section 4).
412
+ - `render(template, data, options?) -> document`, or throws `TemplateError { code, path, variable? }`.
413
+ Order: `inspect` (first issue), then `validateData` (first error), then render.
414
+ - `preview(template, options?) -> document` -- `render` with each variable's
415
+ `example` (else `default`).
416
+ - `tokenize(text, options?) -> parts` (section 5).
417
+
418
+ Output key order is the template's, minus directive keys.
@@ -0,0 +1,16 @@
1
+ {
2
+ "writer": "dark-slide",
3
+ "schema": "dark-slide deck",
4
+ "notes": "Implemented in all three legs (phase 6). src and background.image are image fields so that a LITERAL local path is refused: dark-slide's ImageResolver reads local paths.",
5
+ "fields": [
6
+ { "path": "title", "encode": "text" },
7
+ { "path": "slides.*.elements.*.content", "encode": "markup" },
8
+ { "path": "slides.*.notes", "encode": "text" },
9
+ { "path": "slides.*.elements.*.alt", "encode": "text" },
10
+ { "path": "slides.*.elements.*.items.*", "encode": "text" },
11
+ { "path": "slides.*.elements.*.columns.*.label", "encode": "text" },
12
+ { "path": "slides.*.elements.*.rows.**", "encode": "text" },
13
+ { "path": "slides.*.elements.*.src", "encode": "image", "mime": ["image/png", "image/jpeg"] },
14
+ { "path": "slides.*.background.image", "encode": "image", "mime": ["image/png", "image/jpeg"] }
15
+ ]
16
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "writer": "fine-print",
3
+ "schema": "fine-print 0.3",
4
+ "notes": "An ALLOWLIST: a string field not matched here refuses tokens. link, color, font, fonts, page geometry and every style field are deliberately absent. Band slots refuse a value containing {page}/{pages}: fine-print replaces them with str_replace and has no escape.",
5
+ "fields": [
6
+ { "path": "title", "encode": "text" },
7
+ { "path": "**.runs.*.text", "encode": "text" },
8
+ { "path": "**.blocks.*.text", "encode": "text" },
9
+ { "path": "**.blocks.*.alt", "encode": "text" },
10
+ { "path": "**.blocks.*.src", "encode": "image", "mime": ["image/png", "image/jpeg"] },
11
+ { "path": "page.header.left", "encode": "band" },
12
+ { "path": "page.header.center", "encode": "band" },
13
+ { "path": "page.header.right", "encode": "band" },
14
+ { "path": "page.footer.left", "encode": "band" },
15
+ { "path": "page.footer.center", "encode": "band" },
16
+ { "path": "page.footer.right", "encode": "band" }
17
+ ]
18
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "writer": "holy-sheet",
3
+ "schema": "holy-sheet workbook",
4
+ "notes": "Implemented in all three legs (phase 6). Every string result in a cell is written {value: s}, so user data can never become a formula. formula is deliberately absent.",
5
+ "fields": [
6
+ { "path": "sheets.*.name", "encode": "text" },
7
+ { "path": "sheets.*.rows.*.*", "encode": "cell" },
8
+ { "path": "sheets.*.cells.*", "encode": "cell" },
9
+ { "path": "sheets.*.cells.*.value", "encode": "text" }
10
+ ]
11
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "writer": "last-word",
3
+ "schema": "last-word 0.x (Doc model shared with fine-print)",
4
+ "notes": "An ALLOWLIST: a string field not matched here refuses tokens. link, chart option, color, font, fonts, page geometry and every style field are deliberately absent. Band slots refuse a value containing {page}/{pages}: last-word writes them as PAGE and NUMPAGES fields with str_replace and has no escape.",
5
+ "fields": [
6
+ { "path": "title", "encode": "text" },
7
+ { "path": "**.runs.*.text", "encode": "text" },
8
+ { "path": "**.blocks.*.text", "encode": "text" },
9
+ { "path": "**.blocks.*.alt", "encode": "text" },
10
+ { "path": "**.blocks.*.src", "encode": "image", "mime": ["image/png", "image/jpeg"] },
11
+ { "path": "page.header.left", "encode": "band" },
12
+ { "path": "page.header.center", "encode": "band" },
13
+ { "path": "page.header.right", "encode": "band" },
14
+ { "path": "page.footer.left", "encode": "band" },
15
+ { "path": "page.footer.center", "encode": "band" },
16
+ { "path": "page.footer.right", "encode": "band" }
17
+ ]
18
+ }