@augeo/smelt 1.2.2 → 1.3.0

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.
@@ -117,10 +117,10 @@ aliases (modeled on Next.js / TypeScript path conventions):
117
117
 
118
118
  <!-- prettier-ignore -->
119
119
  ```liquid
120
- {# inside src/sections/hero/hero.liquid: #}
120
+ {% # inside src/sections/hero/hero.liquid: %}
121
121
 
122
- {% render './components/foo' %} {# nested child of hero #}
123
- {% render '@/components/button' %} {# top-level shared component #}
122
+ {% render './components/foo' %} {% # nested child of hero %}
123
+ {% render '@/components/button' %} {% # top-level shared component %}
124
124
  ```
125
125
 
126
126
  Both rewrite to namespaced output names:
@@ -133,7 +133,8 @@ Both rewrite to namespaced output names:
133
133
  Hand-written snippets remain referenceable by their bare name as usual:
134
134
 
135
135
  ```liquid
136
- {% render 'icon-cart' %} {# unchanged — resolves to snippets/icon-cart.liquid #}
136
+ {% render 'icon-cart' %}
137
+ {% # unchanged — resolves to snippets/icon-cart.liquid %}
137
138
  ```
138
139
 
139
140
  ### TS / CSS — colocated and implicit
@@ -249,6 +250,97 @@ The parent section or block's schema auto-merges these private children into its
249
250
  prepended, any blocks the author explicitly lists (e.g. globally-shared block
250
251
  types) appended after. No helper call required.
251
252
 
253
+ ### Block faces — nested `block/`
254
+
255
+ A `src/components/*` component is a snippet — reusable through
256
+ `{% render '@/components/...' %}`, but invisible to the theme editor. A **block
257
+ face** exposes that same component as a theme block a merchant can add, without
258
+ duplicating it. It's declared structurally: a singular `block/` directory nested
259
+ inside the component.
260
+
261
+ ```
262
+ src/components/image/
263
+ ├── image.liquid # the reusable snippet
264
+ ├── image.css
265
+ └── block/
266
+ └── image.schema.ts # the block face's schema
267
+ ```
268
+
269
+ Compiles to **both**:
270
+
271
+ ```
272
+ snippets/built--components--image.liquid # the snippet, unchanged
273
+ blocks/built--components--image.liquid # the block face
274
+ ```
275
+
276
+ `block/` is a **zero-segment marker**. Unlike the plural `blocks/` type marker
277
+ (which contributes a `blocks` segment and holds private children), singular
278
+ `block/` contributes nothing to the naming segments — it only retargets the
279
+ component's type to `blocks` and reroots file lookup inside `block/`. That's why
280
+ the face is named `built--components--image` (after the parent), not
281
+ `…--image--block`, and why it lands in `blocks/` rather than `snippets/`. The
282
+ face's anchor files are named after the **parent** component (`image.liquid`,
283
+ `image.schema.ts`), so the source basename still equals the output token, as
284
+ everywhere else.
285
+
286
+ > Note the singular/plural distinction: `block/` (one face) vs `blocks/` (many
287
+ > private children). They can nest — see the `tabs` example below.
288
+
289
+ The face's schema is authored exactly like any block schema — a sibling
290
+ `image.schema.ts` exporting `schema` (via `defineSchemaBlock`). It needs no new
291
+ concepts; the `block/` directory is the only signal.
292
+
293
+ **Mechanical vs. override.** Two ways to author the face body:
294
+
295
+ - **Mechanical (schema only).** With just `block/<name>.schema.ts` and no
296
+ `block/<name>.liquid`, the engine **synthesizes** the wrapper: it renders the
297
+ underlying component, mapping each schema setting to a render arg of the same
298
+ name (`id == arg`) and passing `shopify_attributes` through. This covers the
299
+ common case where every setting maps 1:1 to a prop.
300
+
301
+ ```liquid
302
+ {%- comment -%} GENERATED … {%- endcomment -%}
303
+ {% render 'built--components--image',
304
+ src: block.settings.src,
305
+ shopify_attributes: block.shopify_attributes
306
+ %}
307
+ {% schema %}…{% endschema %}
308
+ ```
309
+
310
+ Because the mechanism is `id == arg`, **name the component's props to match
311
+ the setting ids.** There is no mapping table.
312
+
313
+ - **Override (`block/<name>.liquid`).** When the wrapper isn't mechanical —
314
+ derived props, conditional logic, or passing `children: block.blocks` — drop a
315
+ `block/<name>.liquid` and the engine uses it as the body verbatim (rewriting
316
+ its `@/...` renders, stamping the banner, injecting the schema). The face is
317
+ then just a normal block component living in `block/`, so it may also carry
318
+ its own `block/<name>.ts` / `block/<name>.css`.
319
+
320
+ **Private children of a face.** Because private children belong to the block —
321
+ not the snippet — they nest under the face via the plural `blocks/` marker:
322
+
323
+ ```
324
+ src/components/tabs/
325
+ ├── tabs.liquid
326
+ └── block/
327
+ ├── tabs.liquid # override — renders children: block.blocks
328
+ ├── tabs.schema.ts
329
+ └── blocks/
330
+ └── tab/
331
+ ├── tab.liquid
332
+ └── tab.schema.ts
333
+ ```
334
+
335
+ The singular `block/` adds no segment and the plural `blocks/` adds `blocks`, so
336
+ the child resolves to `_built--components--tabs--blocks--tab` and auto-merges
337
+ into the face's `blocks: [...]` — the existing private-block machinery,
338
+ unchanged.
339
+
340
+ A mechanical face requires the underlying snippet to exist (it renders it); if
341
+ the snippet is missing the render won't resolve. A thing that is purely a block
342
+ with no reusable-snippet angle should just live in `src/blocks/`.
343
+
252
344
  ## Build Pipeline
253
345
 
254
346
  The engine lives in `lib/build/build.ts` and is invoked by `lib/cli.ts` (a citty
@@ -266,8 +358,11 @@ rebuilds the bin and `npm run test:all` runs the full integration chain against
266
358
  `sections/`, `snippets/`, and `blocks/`. Output is regenerated from scratch
267
359
  on every build, so removed source components disappear cleanly. Hand-written
268
360
  files without the prefix are untouched.
269
- 3. **For each component** (anything with a `.liquid`):
270
- - Prepend a `{%- comment -%}` banner naming the source file, for tracing.
361
+ 3. **For each component** (anything with a `.liquid`, plus mechanical block
362
+ faces anchored on a schema alone see
363
+ [Block faces](#block-faces--nested-block)):
364
+ - Prepend a `{%- comment -%}` banner naming the source file (the `.liquid`,
365
+ or for a mechanical face its `.schema.ts`), for tracing.
271
366
  - Bundle `<name>.ts` (esbuild, IIFE, utilities inlined) → string.
272
367
  - Read `<name>.css` as-is (no transform today — see TODO).
273
368
  - Execute `<name>.schema.ts` — esbuild → `data:` URL → dynamic `import()`,
@@ -277,7 +372,10 @@ rebuilds the bin and `npm run test:all` runs the full integration chain against
277
372
  - Auto-merge any immediate private `blocks/` children into the loaded
278
373
  schema's `blocks: [...]` array (see
279
374
  [Private blocks](#private-blocks--nested-blocks)).
280
- - Transform `<name>.liquid`:
375
+ - Produce the body — either the source `<name>.liquid`, or, for a mechanical
376
+ block face (no `.liquid`), a synthesized wrapper that renders the
377
+ underlying component with each schema setting mapped to a render arg and
378
+ `shopify_attributes` passed through. Then:
281
379
  - **Error** if the source `.liquid` contains an inline `{% schema %}` block
282
380
  — schemas must live in `<name>.schema.ts`.
283
381
  - Rewrite `{% render '@/...' %}` and `{% render './...' %}` to namespaced
@@ -0,0 +1,278 @@
1
+ ---
2
+ status: draft
3
+ ---
4
+
5
+ # JavaScript Module Delivery
6
+
7
+ Plan for how a component's JavaScript reaches the browser as **shared ES
8
+ modules** instead of per-component inlined bundles, so a dependency used by N
9
+ components ships once. Companion to [`build-spec.md`](./build-spec.md) (which
10
+ describes today's `{% javascript %}` inlining) and a direct answer to its
11
+ standing TODO, _"Utility deduplication across component bundles."_
12
+
13
+ This doc is deliberately scoped to the **JS delivery strategy** — modules,
14
+ import maps, deduplication, and load placement. It is **not** about web
15
+ components, which are an optional authoring style that rides on top of
16
+ everything here and stays out of scope (see [Out of Scope](#out-of-scope)). The
17
+ two are orthogonal: every byte-level win below comes from modules + import maps,
18
+ not from custom elements.
19
+
20
+ ## The problem this solves
21
+
22
+ Today `bundleJs` (`lib/build/build.ts`) runs each component's `<name>.ts`
23
+ through esbuild with `bundle: true`, format `iife`, and inlines the result into
24
+ a `{% javascript %}` block. esbuild does not distinguish a `src/utilities/`
25
+ import from a `node_modules` import — it inlines **both** into that component's
26
+ IIFE. So:
27
+
28
+ - Two components importing `debounce` from `lodash-es` each ship their own copy.
29
+ - The same is true for any shared `src/utilities/` helper.
30
+
31
+ Crucially, **Shopify does not rescue us here.** Its `{% javascript %}`
32
+ deduplication is keyed on the _file_, not the _content_: `{% javascript %}`
33
+ blocks are concatenated into one bundle per file type (`scripts.js` /
34
+ `block-scripts.js` / `snippet-scripts.js`), and _"bundled assets are only
35
+ injected once for each section, block or snippet file, not for each instance of
36
+ that file."_ That dedupes **instances of one component**, but two **different**
37
+ built files with byte-identical content produce two copies in the concatenated
38
+ bundle. The duplication is real and grows linearly with no platform relief.
39
+
40
+ ## Goal
41
+
42
+ Let a component author write the most boring thing possible —
43
+
44
+ ```ts
45
+ import { debounce } from "lodash-es";
46
+ import { formatMoney } from "@/utilities/money";
47
+ ```
48
+
49
+ — and have the build deliver `lodash-es` and `money` as **shared module assets
50
+ imported by URL**, so the browser fetches and caches each exactly once across
51
+ every component that uses it. The colocated authoring model
52
+ (`button/{button.liquid,button.ts,button.css}`), the layer/resolver system, and
53
+ the `@/` alias all stay exactly as they are. Only the _packaging and delivery_
54
+ of the compiled JS changes.
55
+
56
+ ## The shape
57
+
58
+ ### 1. Each component's JS compiles to a module asset
59
+
60
+ `<name>.ts` is bundled as an **ES module** written to `assets/`, named with the
61
+ same `built--<segments>` scheme used everywhere else:
62
+
63
+ ```
64
+ src/components/button/button.ts
65
+ → assets/built--components--button.js
66
+ ```
67
+
68
+ The component's `.liquid` no longer carries an inlined `{% javascript %}` block;
69
+ its behavior lives in the module asset and is loaded as
70
+ `<script type="module" src="{{ 'built--components--button.js' | asset_url }}">`
71
+ (see [Load placement](#3-load-placement--scoped-vs-global)).
72
+
73
+ ### 2. Shared code is imported, not inlined — and an import map dedupes it
74
+
75
+ Shared `src/utilities/*` and external `node_modules` packages are marked
76
+ `external` to esbuild (not inlined). They become real `import` statements
77
+ resolved at runtime through a generated **import map**. Because the browser
78
+ caches modules by resolved URL, a utility imported by ten components is fetched,
79
+ parsed, and evaluated **once**.
80
+
81
+ The import map is the runtime twin of Smelt's build-time `@/` alias — the same
82
+ move Shopify's flagship Horizon theme makes with its `@theme/` alias. The build
83
+ emits a snippet, e.g. `snippets/built--scripts.liquid`:
84
+
85
+ ```liquid
86
+ <script type="importmap">
87
+ {
88
+ "imports": {
89
+ "@/utilities/money": "{{ 'built--utilities--money.js' | asset_url }}",
90
+ "lodash-es": "{{ 'built--vendor--lodash-es.js' | asset_url }}"
91
+ }
92
+ }
93
+ </script>
94
+ ```
95
+
96
+ The consumer renders that snippet once. Thanks to Shopify's
97
+ [resilient import maps](https://shopify.engineering/resilient-import-maps) work,
98
+ **multiple import maps merge and are loading-order independent**, so Smelt's
99
+ generated map coexists with a consumer's (or Horizon's) without owning a single
100
+ canonical `<head>` slot — and Shopify auto-injects the `es-module-shims`
101
+ polyfill for browsers lacking native support. This is what keeps the approach
102
+ **additive**: we emit a `built--` snippet, we never edit the consumer's
103
+ `layout/`.
104
+
105
+ ### 3. Load placement — scoped vs global
106
+
107
+ Import maps resolve specifiers to URLs; they do **not** decide _which_ module
108
+ script tags a given page includes. That is a separate, important choice:
109
+
110
+ - **Global registry** (Horizon's default): one snippet lists every component's
111
+ `<script type="module">`, included on every page. Simple, fully robust against
112
+ the merchant composing any component into any section at runtime — but it
113
+ **downloads, parses, and evaluates the JS for unused components on every
114
+ page.** (A web-component definition's `connectedCallback` won't _run_ without
115
+ its tag present, but the module is still shipped, parsed, and registered. WC
116
+ saves execution, not loading.)
117
+ - **Section-scoped loading** (recommended — Smelt's compiler edge): because the
118
+ build rewrites every `{% render %}`, it knows which components a section pulls
119
+ in. Emit a component's `<script type="module">` **with the section/snippet
120
+ that renders it**, so the module loads only on pages where that section is
121
+ present. Page weight tracks components _actually rendered_, not the total
122
+ catalog.
123
+
124
+ The safe granularity for scoping is **"section/block presence," not "page"** —
125
+ runtime composition (theme editor adding blocks) means the build can prove "this
126
+ section renders a button" but not "this rendered page has no button." Scoping to
127
+ the rendering unit stays correct under dynamic composition.
128
+
129
+ These are not mutually exclusive — a global registry for a small core plus
130
+ section-scoped loading for the rest mirrors what Horizon actually ships.
131
+
132
+ ### 4. Tree-shaking comes from the build, not the import map
133
+
134
+ An import map only maps specifier → URL; it neither bundles nor shakes. Real
135
+ dead-code elimination and shared-chunk extraction come from running esbuild with
136
+ `format: "esm"` and `splitting: true` over the component graph, so the **union**
137
+ of used exports lands in shared chunks and unused code is dropped. Shipping
138
+ whole-package vendor modules is the acceptable first cut; splitting is the
139
+ optimization when measured weight justifies it.
140
+
141
+ ### 5. External npm packages use the same path
142
+
143
+ There is no separate mechanism for third-party modules. A `node_modules` package
144
+ is externalized, emitted (or referenced) as a module asset, and mapped in the
145
+ import map — exactly like a `src/utilities/` helper. A package can even map
146
+ straight to a vendor CDN URL (Horizon maps `@shopify/events` to a
147
+ `cdn.shopify.com` URL this way) when self-hosting isn't wanted.
148
+
149
+ ## How this interacts with `{% javascript %}`
150
+
151
+ `{% javascript %}` content is a classic IIFE; an import map only governs
152
+ **module** resolution. Static `import` is illegal in that context, so the two do
153
+ not mix at the statement level. **Mix at the component boundary instead:**
154
+
155
+ - A component with **no shared dependency** can stay an inlined
156
+ `{% javascript %}` block (today's model — simple, no asset proliferation).
157
+ - A component that **imports a shared utility or external package** graduates to
158
+ a module asset.
159
+
160
+ The switch is the _presence of a shared import_. Do not try to make a single
161
+ component half-classic / half-module — that forces transforming static imports
162
+ into awaited dynamic `import()` and silently makes init async. Keep each
163
+ component wholly one or the other.
164
+
165
+ ## Design Decisions
166
+
167
+ ### Modules + import map over a shared global or per-utility snippet guard
168
+
169
+ The build-spec TODO lists three candidates: a single
170
+ `assets/built--utilities.js` on a global, ES modules via an import map, and
171
+ per-utility snippet guards. This plan selects **ES modules via an import map**.
172
+
173
+ - **Why:** the browser dedupes by URL natively (execute-once-per-URL), import
174
+ order is resolved by the module graph rather than by a fragile load-order
175
+ convention, scoping is clean, and it is the idiomatic modern-Shopify path
176
+ (Horizon is built on it). The global-bundle option pollutes a namespace and
177
+ couples load order; the snippet-guard option leans on a `window.__smelt`
178
+ namespace plus a `DOMContentLoaded` discipline.
179
+ - **Cost:** component init becomes async-_loaded_ (gated on the import graph),
180
+ and the approach depends on Shopify's recent resilient-import-maps platform
181
+ work. Ordering, by contrast, becomes a module-system _guarantee_ rather than a
182
+ discipline — a net improvement on the axis we care about.
183
+
184
+ ### Delivery is build-internal; the authoring contract is not
185
+
186
+ Moving from inlined IIFE to module asset is a change to **how the build packages
187
+ the same source** — the author's `.ts` is unchanged for plain "enhance-the-DOM"
188
+ code. That makes this migration cheap, reversible, and deferrable. The
189
+ expensive, distributed change is anything that touches the **public authoring
190
+ contract** (e.g. mandating web components). Keep delivery swappable and the
191
+ authoring contract deliberate.
192
+
193
+ - **Why:** preserves the option to adopt — or never adopt — web components
194
+ without a rewrite.
195
+ - **Cost:** none today; it is a constraint on how we _describe_ the contract.
196
+
197
+ ### Keep component JS idempotent DOM enhancement
198
+
199
+ Author component JS as side-effect-light, presence-checking, idempotent DOM
200
+ enhancement (find my elements, enhance them) rather than "run once at load,
201
+ mutate globals."
202
+
203
+ - **Why:** that single habit slots cleanly into a section-scoped plain-module
204
+ world _and_ a web-component (`connectedCallback`) world later, with no
205
+ rewrite. It is also what survives theme-editor section re-renders.
206
+ - **Cost:** a mild authoring discipline, already implied by "no Liquid inside
207
+ `{% javascript %}`" (component JS is already Liquid-free and
208
+ attribute-driven).
209
+
210
+ ## Out of Scope
211
+
212
+ - **Web components.** Custom elements are an optional authoring style layered on
213
+ top of this. They buy ergonomics (auto-hydrate by tag presence, editor-safe
214
+ re-init) — **not** the byte/cache/dedup wins, which are all delivered here.
215
+ They are a separate decision with its own (public, breaking) contract cost.
216
+ - **CSS deduplication.** `{% stylesheet %}` has the same per-file model; whether
217
+ shared CSS wants the same module-style treatment is its own discussion.
218
+ - **Lazy / dynamic `import()` on interaction or visibility.** A real further win
219
+ for avoiding unused-component load, but a separate strategy from static module
220
+ delivery. Note and defer.
221
+ - **`props.ts` runtime validation** and other open `build-spec.md` questions.
222
+
223
+ ## Implementation Outline
224
+
225
+ Concrete-but-not-final list of where changes land:
226
+
227
+ 1. **`lib/resolver.ts`** — utilities (and externalized vendor packages) need to
228
+ be addressable as emittable module assets, not only as inlined imports.
229
+ Likely a utility slot that produces output, plus a way to enumerate the
230
+ external packages referenced across the component graph.
231
+ 2. **`lib/build/build.ts`**
232
+ - **`bundleJs`** — switch the component path from `format: "iife"` inlined
233
+ string to `format: "esm"` written to `assets/built--<segments>.js`, with
234
+ `external` covering `@/utilities/*` and bare `node_modules` specifiers.
235
+ Consider one `splitting: true` invocation over all module entry points for
236
+ cross-component tree-shaking + shared chunks.
237
+ - **`buildComponent`** — stop appending `{% javascript %}` for module
238
+ components; instead record the component's module asset + import-map entry,
239
+ and (for section-scoped loading) emit its `<script type="module">` into the
240
+ rendering unit.
241
+ - **import-map generation** — new step producing
242
+ `snippets/built--scripts.liquid` from the resolved utility + vendor set.
243
+ - **`cleanBuiltOutputs`** — extend the `/^_?built--/` sweep to `assets/` for
244
+ emitted `.js` (overlaps with [`assets-plan.md`](./assets-plan.md)).
245
+ 3. **`docs/build-spec.md`** — note that component JS may be delivered as a
246
+ module asset; cross-reference this plan from the dedup TODO.
247
+ 4. **`example/`** — two components importing the same `lodash-es` function plus
248
+ a shared `src/utilities/*` helper; verify the dep lands once in `assets/`,
249
+ appears once in the import map, theme-check passes, and assay browser tests
250
+ resolve the module assets.
251
+ 5. **Tests** — `lib/build/build.test.ts`: module-asset emission, import-map
252
+ contents, externalized-vs-inlined import handling, and the
253
+ `{% javascript %}`-vs-module component-boundary switch. Testing harness note:
254
+ assay/liquidjs must resolve module assets via `assetsPath` / the import map —
255
+ see [`TESTING.md`](./TESTING.md).
256
+
257
+ ## Open Questions
258
+
259
+ - **De-risk the externalization first.** The keystone is esbuild's handling of
260
+ `external` packages in module output and how the import-map entry resolves
261
+ them at runtime. Worth a throwaway prototype against `example/` before
262
+ committing — confirm a shared dep rewrites cleanly and lands once.
263
+ - **Whole-package vs union tree-shaking for vendors.** Ship the whole package
264
+ entry first, or compute the union of used exports across components from the
265
+ start? Whole-entry is simpler; union preserves shaking. Lean simple, measure,
266
+ optimize.
267
+ - **Default load placement.** Global registry (simplest, robust) vs
268
+ section-scoped (lighter pages, needs compiler wiring) vs a hybrid. Likely
269
+ hybrid, but the default and the authoring surface for opting in need a
270
+ decision.
271
+ - **Multiple versions of one package.** If two deps pin different versions of
272
+ the same package, one import-map specifier collides. Assume one version per
273
+ name in v1 and **fail loudly** if violated rather than silently serving the
274
+ wrong one (consistent with the no-silent-overwrite stance).
275
+ - **Uniform vs mixed output.** Is it acceptable for some components to emit
276
+ inlined `{% javascript %}` and others module assets, based on whether they
277
+ import shared code? More powerful, but a consumer debugging built output sees
278
+ two shapes. Uniformity has its own value.
@@ -0,0 +1,34 @@
1
+ {%- comment -%}
2
+ GENERATED FROM consumer/src/components/card/block/card.schema.ts — do not edit this file directly.
3
+ Edit the source and run `npm run build`.
4
+ {%- endcomment -%}
5
+
6
+ {% render 'built--components--card',
7
+ title: block.settings.title,
8
+ body: block.settings.body,
9
+ shopify_attributes: block.shopify_attributes
10
+ %}
11
+
12
+ {% schema %}
13
+ {
14
+ "name": "Card",
15
+ "settings": [
16
+ {
17
+ "type": "text",
18
+ "id": "title",
19
+ "label": "Title",
20
+ "default": "Untitled"
21
+ },
22
+ {
23
+ "type": "textarea",
24
+ "id": "body",
25
+ "label": "Body"
26
+ }
27
+ ],
28
+ "presets": [
29
+ {
30
+ "name": "Card"
31
+ }
32
+ ]
33
+ }
34
+ {% endschema %}
@@ -8,7 +8,7 @@
8
8
  assign body = body | default: ''
9
9
  -%}
10
10
 
11
- <article class="tr-card">
11
+ <article class="tr-card" {{ shopify_attributes }}>
12
12
  <h3 class="tr-card__title">{{ title }}</h3>
13
13
  <p class="tr-card__body">{{ body }}</p>
14
14
  </article>
@@ -0,0 +1,14 @@
1
+ import { defineSchemaBlock } from "@augeo/smelt/schema";
2
+
3
+ // A mechanical block face: no `card.liquid` override here, so the build
4
+ // synthesizes the wrapper render from these settings (id → render arg) and
5
+ // passes `shopify_attributes` through. The card snippet stays reusable via
6
+ // `{% render '@/components/card' %}`; this just also exposes it in the editor.
7
+ export const schema = defineSchemaBlock({
8
+ name: "Card",
9
+ settings: [
10
+ { type: "text", id: "title", label: "Title", default: "Untitled" },
11
+ { type: "textarea", id: "body", label: "Body" },
12
+ ],
13
+ presets: [{ name: "Card" }],
14
+ });
@@ -3,7 +3,7 @@
3
3
  assign body = body | default: ''
4
4
  -%}
5
5
 
6
- <article class="tr-card">
6
+ <article class="tr-card" {{ shopify_attributes }}>
7
7
  <h3 class="tr-card__title">{{ title }}</h3>
8
8
  <p class="tr-card__body">{{ body }}</p>
9
9
  </article>