Skip to content

Commit 74e532e

Browse files
authored
Merge pull request #162 from BarredEwe/docs/templates-stencil-authoring
Document authoring of custom Stencil templates
2 parents f8fb53c + da8d179 commit 74e532e

4 files changed

Lines changed: 281 additions & 2 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ playbook_configuration:
4444
| `target` | Target name used for snapshot generation. Default: _FirstTarget_ |
4545
| `test_target_path` | Path to unit test directory. Snapshots will be written to its `__Snapshots__` folder. Default: target name folder |
4646
| `test_file_path` | Output file path for generated tests. Default: DerivedData or resolved via plugin |
47-
| `template_file_path` | Custom template path relative to target. Optional. Defaults:‣ _PreviewTests.stencil_ for test plugin‣ _PreviewModels.stencil_ for playbook plugin |
47+
| `template_file_path` | Custom template path relative to target. Optional. Defaults:‣ _PreviewTests.stencil_ for test plugin‣ _PreviewModels.stencil_ for playbook plugin. See [Templates documentation](Templates.md) for available context and filters. |
4848
| `simulator_device` | Device identifier used to run tests (e.g. `iPhone15,2`). Optional |
4949
| `required_os` | Minimal iOS version required for preview rendering. Optional |
5050
| `snapshot_devices` | List of logical snapshot "targets" (used as trait collections). Each will snapshot separately. Optional |
File renamed without changes.

Documentation/Templates.md

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
# ✍️ Stencil Templates
2+
3+
Prefire generates both snapshot tests and Playbook models by rendering a [Stencil](https://stencil.fuller.li/) template with a context built from your project. The default templates cover the most common case, but every team has its own conventions — you can fully replace either template with your own `.stencil` file.
4+
5+
This page describes the template engine, the variables available inside templates, the filters you can use, and how to wire a custom template into your project.
6+
7+
---
8+
9+
## 1. Configuration
10+
11+
Point Prefire at a custom template through `template_file_path` in `.prefire.yml`. The path is resolved relative to your target — `test_target_path` for the tests plugin, `target` for the playbook plugin. If the key is omitted, Prefire uses its built-in template.
12+
13+
```yaml
14+
# .prefire.yml
15+
16+
test_configuration:
17+
target: MyApp
18+
test_target_path: ${PROJECT_DIR}/MyAppTests
19+
test_file_path: MyAppTests/PreviewTests.generated.swift
20+
template_file_path: MyAppTests/CustomPreviewTests.stencil
21+
22+
playbook_configuration:
23+
target: ${PROJECT_DIR}/MyApp
24+
template_file_path: MyApp/CustomPreviewModels.stencil
25+
```
26+
27+
Notes:
28+
29+
- The path is relative to the resolved `test_target_path` / `target` directory, **not** to the location of `.prefire.yml`.
30+
- Both `test_configuration.template_file_path` and `playbook_configuration.template_file_path` are independent — you can override one and keep the default for the other.
31+
- The same `template_file_path` can be reused by multiple targets if the relative path resolves correctly from each.
32+
33+
---
34+
35+
## 2. Template engine
36+
37+
Prefire uses the Stencil templating language with a few extra filters and Swift-language extensions shipped by [Sourcery](https://github.com/SourceryProtocol/Sourcery). If you've written a SwiftGen or Sourcery template before, the syntax will feel familiar.
38+
39+
- [Stencil syntax](https://stencil.fuller.li/) — `{% ... %}` for logic, `{{ ... }}` for output, `{# ... #}` for comments.
40+
- [Sourcery template extensions](https://github.com/SourceryProtocol/Sourcery/blob/master/Documentation/Templates.md) — additional filters and helpers for Swift code generation.
41+
42+
The context rendered into your template has three top-level objects: `argument`, `types`, and (implicitly) the `previewsMacrosDict` array attached to `argument`.
43+
44+
---
45+
46+
## 3. Context reference
47+
48+
### 3.1 `argument.*` — configuration values
49+
50+
These keys are produced by `GenerateTestsCommand` and `GeneratePlaybookCommand` and are always available inside templates:
51+
52+
| Key | Type | Plugin | Description |
53+
| ---------------------------------------- | ---------------------- | ------ | -------------------------------------------------------------------------------------------------------- |
54+
| `argument.imports` | `[String]` (or absent) | both | Extra `import` lines from `imports:` in the config. |
55+
| `argument.testableImports` | `[String]` (or absent) | both | Extra `@testable import` lines from `testable_imports:` in the config. |
56+
| `argument.mainTarget` | `String` (or absent) | tests | The `target:` value; emitted as `@testable import {{ argument.mainTarget }}`. |
57+
| `argument.file` | `String` (or absent) | tests | Resolved output path for the generated test file; used as the `file:` argument of `assertSnapshot`. |
58+
| `argument.simulatorDevice` | `String` (or absent) | tests | Device model identifier (e.g. `iPhone15,2`) from `simulator_device:`. |
59+
| `argument.simulatorOSVersion` | `String` (or absent) | tests | Major iOS version from `required_os:`. |
60+
| `argument.snapshotDevices` | `String` (or absent) | tests | Snapshot device names joined by `\|`. Iterate by applying `\|split:"\|"`. See §4. |
61+
| `argument.drawHierarchyInKeyWindowDefaultEnabled` | `String` `"true"`/`"false"` (or absent) | tests | Value of `draw_hierarchy_in_key_window_default_enabled:` as a string. |
62+
| `argument.previewsMacrosDict` | `[[String: Any]]` | both | Array of `#Preview` macro models. See §3.3. |
63+
64+
`NSNull` values (i.e. when a config key is missing) should be guarded with `{% if argument.foo %}` — Stencil treats both `nil` and `NSNull` as falsy.
65+
66+
### 3.2 `types.*` — `PreviewProvider`-based previews
67+
68+
The `types` collection comes from Sourcery's `Types` wrapper around the parsed Swift AST. The default templates iterate over it to generate one `func test_*()` per `PreviewProvider`/`PrefireProvider` type:
69+
70+
```stencil
71+
{% for type in types.types where type.implements.PrefireProvider or type.based.PrefireProvider or type|annotated:"PrefireProvider" %}
72+
func test_{{ type.name|lowerFirstLetter|replace:"_Previews", "" }}() {
73+
for preview in {{ type.name }}._allPreviews {
74+
// ...
75+
}
76+
}
77+
{% endfor %}
78+
```
79+
80+
Commonly used fields on a `type`:
81+
82+
| Field | Description |
83+
| ----------- | ---------------------------------------------------------------- |
84+
| `name` | Fully-qualified type name. |
85+
| `implements.X` | `true` if the type conforms to protocol `X` (e.g. `PrefireProvider`). |
86+
| `based.X` | `true` if the type inherits from a class (or protocol) named `X`. |
87+
| `localName` | Short (unqualified) name of the type. |
88+
89+
For the full Sourcery `Type` API, see the [Sourcery type reference](https://github.com/SourceryProtocol/Sourcery/blob/master/Documentation/Templates.md#types).
90+
91+
### 3.3 `macroModel.*` — `#Preview` macro models
92+
93+
When the source file contains `#Preview { ... }` blocks, each one is parsed into a `RawPreviewModel` and exposed as a dictionary inside `argument.previewsMacrosDict`. The default templates iterate over the array and emit one test per element:
94+
95+
```stencil
96+
{% for macroModel in argument.previewsMacrosDict %}
97+
func test_{{ macroModel.componentTestName }}_Preview() {
98+
// ...
99+
{{ macroModel.body|indent:12 }}
100+
// ...
101+
}
102+
{% endfor %}
103+
```
104+
105+
Each element of `previewsMacrosDict` has the following keys (see `RawPreviewModel.makeStencilDict()`):
106+
107+
| Key | Type | Description |
108+
| --------------------- | --------- | ----------------------------------------------------------------------------------------------------------------- |
109+
| `displayName` | `String` | Human-readable name of the preview, derived from the first string literal in the macro (e.g. `"Default" → Default`). |
110+
| `componentTestName` | `String` | Sanitized identifier (non-alphanumerics stripped). Safe to embed in Swift identifiers. |
111+
| `isScreen` | `Bool` | `true` if the preview contains the `.device` trait, `false` otherwise. |
112+
| `body` | `String` | The Swift body of the preview, as a raw string. Use `\|indent:N` to align it inside a generated closure. |
113+
| `properties` | `String?` | Captured `@Previewable` property declarations (joined with newlines), or `nil` if there are none. |
114+
| `traits` | `[String]`| Raw trait tokens, e.g. `["device"]`, `[".myTrait(\"x\")"]`. |
115+
116+
---
117+
118+
## 4. Filters reference
119+
120+
Only filters that the default templates actually use are listed below. For the complete set, see the [Stencil built-in filters](https://stencil.fuller.li/) and the [Sourcery template extensions](https://github.com/SourceryProtocol/Sourcery/blob/master/Documentation/Templates.md#filters).
121+
122+
| Filter | Origin | Purpose | Example |
123+
| --------------------- | -------- | ----------------------------------------------------------------------- | -------------------------------------------------------------- |
124+
| `lowerFirstLetter` | Stencil | Lowercase the first character (for test function names). | `{{ type.name\|lowerFirstLetter }}` → `myView` from `MyView` |
125+
| `replace:OLD,NEW` | Stencil | Replace literal substring `OLD` with `NEW`. | `{{ type.name\|replace:"_Previews", "" }}` |
126+
| `split:SEP` | Sourcery | Split a string by `SEP` and emit a Swift array literal. | `{{ argument.snapshotDevices\|split:"\|" }}` → `["iPhone 14"]` |
127+
| `indent:N` | Stencil | Indent every line of the input by `N` spaces. | `{{ macroModel.body\|indent:12 }}` |
128+
| `default:VALUE` | Stencil | Use `VALUE` when the variable is missing. | `{{ argument.simulatorDevice\|default:nil }}` |
129+
| `annotated:NAME` | Sourcery | Inside a `{% for type in types.types %}`, filter types annotated with `NAME`. | `{% for type in types.types where type\|annotated:"PrefireProvider" %}` |
130+
| `forloop.last` | Stencil | `true` on the last iteration of a `{% for %}` loop. Useful for separators. | `{%- if not forloop.last %}\n\n{% endif %}` |
131+
132+
---
133+
134+
## 5. Default templates
135+
136+
The built-in templates are the most useful starting point — copy them, change what you need, and reference the new file from `.prefire.yml`.
137+
138+
- **Tests template** — `EmbeddedTemplates.previewTests` in [`PrefireExecutable/Sources/PrefireCore/Templates/PreviewTestsTemplate.swift`](../PrefireExecutable/Sources/PrefireCore/Templates/PreviewTestsTemplate.swift)
139+
- **Playbook template** — `EmbeddedTemplates.previewModels` in [`PrefireExecutable/Sources/PrefireCore/Templates/PreviewModelsTemplates.swift`](../PrefireExecutable/Sources/PrefireCore/Templates/PreviewModelsTemplates.swift)
140+
141+
Key blocks worth studying:
142+
143+
- **Class-name placeholder** — the tests template references `{PREVIEW_FILE_NAME}` so the same file works for both `use_grouped_snapshots: true` and `use_grouped_snapshots: false` modes:
144+
```stencil
145+
@MainActor class {PREVIEW_FILE_NAME}Tests: XCTestCase, Sendable {
146+
```
147+
When `use_grouped_snapshots: false`, Prefire replaces `{PREVIEW_FILE_NAME}` in **both** the output path and the template body with the source-file name before rendering. The same placeholder is also valid in `test_file_path`.
148+
- **`snapshotDevices` array** — the config stores a pipe-joined string; the template splits it back into a Swift array:
149+
```stencil
150+
private let snapshotDevices: [String]{% if argument.snapshotDevices %} = {{ argument.snapshotDevices|split:"|" }}{% else %} = []{% endif %}
151+
```
152+
- **Per-device iteration** — guarded by an empty-check, so a single test runs once on the default device and once per `snapshot_devices` entry:
153+
```stencil
154+
{% if argument.file %}
155+
private var file: StaticString { .init(stringLiteral: "{{ argument.file }}") }
156+
{% endif %}
157+
```
158+
- **`@Previewable` properties** — captured as raw Swift source and embedded verbatim into a generated `PreviewWrapper` view:
159+
```stencil
160+
{% if macroModel.properties %}
161+
struct PreviewWrapper{{ macroModel.componentTestName }}: SwiftUI.View {
162+
{{ macroModel.properties }}
163+
var body: some View {
164+
{{ macroModel.body|indent:12 }}
165+
}
166+
}
167+
{% endif %}
168+
```
169+
- **Macro/preview-provider coexistence** — the `{% if argument.previewsMacrosDict %}` guard is required; an empty array still produces a `for` block, but the guard skips it cleanly when there are no `#Preview` macros in the file.
170+
171+
---
172+
173+
## 6. Worked example
174+
175+
Suppose you don't use AccessibilitySnapshot and you want a leaner tests file. Save the following as `MyAppTests/MinimalPreviewTests.stencil`:
176+
177+
```stencil
178+
// swiftlint:disable all
179+
// swiftformat:disable all
180+
181+
import XCTest
182+
import SwiftUI
183+
import Prefire
184+
{% for import in argument.imports %}
185+
import {{ import }}
186+
{% endfor %}
187+
{% if argument.mainTarget %}
188+
@testable import {{ argument.mainTarget }}
189+
{% endif %}
190+
{% for import in argument.testableImports %}
191+
@testable import {{ import }}
192+
{% endfor %}
193+
import SnapshotTesting
194+
195+
@MainActor class {PREVIEW_FILE_NAME}Tests: XCTestCase {
196+
private let deviceConfig: DeviceConfig = ViewImageConfig.iPhoneX.deviceConfig
197+
198+
{% if argument.previewsMacrosDict %}
199+
{% for macroModel in argument.previewsMacrosDict %}
200+
func test_{{ macroModel.componentTestName }}_Preview() {
201+
let prefireSnapshot = PrefireSnapshot(
202+
{
203+
{{ macroModel.body|indent:12 }}
204+
},
205+
name: "{{ macroModel.displayName }}",
206+
isScreen: {% if macroModel.isScreen == 1 %}true{% else %}false{% endif %},
207+
device: deviceConfig
208+
)
209+
210+
if let failure = assertSnapshot(of: prefireSnapshot.loadViewWithPreferences().0, as: .image) {
211+
XCTFail(failure)
212+
}
213+
}
214+
{% endfor %}
215+
{% endif %}
216+
}
217+
```
218+
219+
Wire it up in `.prefire.yml`:
220+
221+
```yaml
222+
test_configuration:
223+
target: MyApp
224+
test_target_path: ${PROJECT_DIR}/MyAppTests
225+
test_file_path: MyAppTests/PreviewTests.generated.swift
226+
template_file_path: MyAppTests/MinimalPreviewTests.stencil
227+
imports:
228+
- UIKit
229+
```
230+
231+
For a preview like:
232+
233+
```swift
234+
#Preview("Default") {
235+
Button("Submit") { }
236+
}
237+
```
238+
239+
Prefire will produce (skeleton):
240+
241+
```swift
242+
import XCTest
243+
import SwiftUI
244+
import Prefire
245+
import UIKit
246+
@testable import MyApp
247+
import SnapshotTesting
248+
249+
@MainActor class PreviewTests: XCTestCase {
250+
private let deviceConfig: DeviceConfig = ViewImageConfig.iPhoneX.deviceConfig
251+
252+
func test_Default_Preview() {
253+
let prefireSnapshot = PrefireSnapshot(
254+
{
255+
Button("Submit") { }
256+
},
257+
name: "Default",
258+
isScreen: false,
259+
device: deviceConfig
260+
)
261+
262+
if let failure = assertSnapshot(of: prefireSnapshot.loadViewWithPreferences().0, as: .image) {
263+
XCTFail(failure)
264+
}
265+
}
266+
}
267+
```
268+
269+
---
270+
271+
## 7. Caveats and tips
272+
273+
- **Required imports** are not auto-added. For tests you almost always need `XCTest`, `SwiftUI`, `Prefire`, and `SnapshotTesting`; for playbook you need `SwiftUI` and `Prefire`. Add them at the top of your template literally.
274+
- **`{PREVIEW_FILE_NAME}` is a real placeholder.** It is replaced in the template string *and* in `test_file_path` *only* when `use_grouped_snapshots: false`. The class declared in your template must be named `{PREVIEW_FILE_NAME}Tests`, otherwise the generated file won't compile.
275+
- **`snapshotDevices` is a pipe-joined string**, not an array. Use `|split:"|"`. Forgetting the filter produces `snapshotDevices = iPhone 14|iPad` in the generated file.
276+
- **Guard `previewsMacrosDict`.** An empty array will still produce a (broken) `for` block. Always wrap the macro loop in `{% if argument.previewsMacrosDict %}`.
277+
- **Cache invalidation.** `PrefireCacheManager` keys the cache on source content. When you change a template, delete `~/.prefire-cache/` to force a full re-render.
278+
- **`isScreen` is a `Bool` but Stencil may serialize it as `1`/`0`.** Compare with `{% if macroModel.isScreen == 1 %}` rather than `{% if macroModel.isScreen %}`, as in the default template.
279+
- **Stuck on a syntax error?** Run `prefire tests` once with the default template and look at the generated file — it's the fastest way to see which context keys actually have values for your project.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
- 🧩 **UIKit Support** — support for `UIView` and `UIViewController`
2828
- ⚙️ **SPM + Xcode Plugins** — works in CLI, Xcode build phases, or CI
2929
- 🧠 **Fast Caching** — fingerprint-based AST and body caching avoids redundant work
30-
- ✍️ **Stencil Templates** — customize output with your own templates
30+
- ✍️ **Stencil Templates** — customize output with [your own templates](Documentation/Templates.md)
3131

3232
### Why Prefire?
3333

0 commit comments

Comments
 (0)