@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.
- package/.github/workflows/verify.yml +23 -10
- package/AGENTS.md +7 -3
- package/README.md +123 -23
- package/dist/cli.mjs +84 -39
- package/dist/schema.d.mts +8 -6
- package/dist/schema.mjs +6 -4
- package/docs/build-spec.md +105 -7
- package/docs/js-modules-plan.md +278 -0
- package/example/blocks/built--components--card.liquid +34 -0
- package/example/snippets/built--components--card.liquid +1 -1
- package/example/src/components/card/block/card.schema.ts +14 -0
- package/example/src/components/card/card.liquid +1 -1
- package/lib/build/build.test.ts +207 -0
- package/lib/build/build.ts +92 -18
- package/lib/resolver.test.ts +91 -4
- package/lib/resolver.ts +121 -36
- package/lib/schema.ts +8 -10
- package/package.json +1 -1
- package/docs/library-conversion-plan.md +0 -419
package/docs/build-spec.md
CHANGED
|
@@ -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' %}
|
|
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
|
-
|
|
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
|
-
-
|
|
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 %}
|
|
@@ -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
|
+
});
|