@decocms/start 2.27.0 → 2.28.1
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/.agents/skills/deco-to-tanstack-migration/references/css-styling.md +82 -0
- package/.agents/skills/deco-to-tanstack-migration/references/gotchas.md +32 -1
- package/.agents/skills/deco-to-tanstack-migration/templates/setup-ts.md +33 -6
- package/MIGRATION_TOOLING_PLAN.md +115 -0
- package/package.json +1 -1
- package/scripts/generate-blocks.ts +47 -32
- package/scripts/lib/blocks-dedupe.test.ts +179 -0
- package/scripts/lib/blocks-dedupe.ts +128 -0
- package/scripts/migrate/templates/section-loaders.ts +36 -24
- package/src/cms/index.ts +7 -1
- package/src/cms/sectionMixins.test.ts +117 -0
- package/src/cms/sectionMixins.ts +55 -0
|
@@ -154,3 +154,85 @@ c = Color("#EE4F31")
|
|
|
154
154
|
l, c_val, h = c.convert("oklch").coords()
|
|
155
155
|
print(f"{l*100:.2f}% {c_val:.2f} {h:.0f}deg") # 64.42% 0.20 33deg
|
|
156
156
|
```
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
## 48. Custom Color Palette + fontFamily Dropped on Migration
|
|
160
|
+
|
|
161
|
+
**Severity**: HIGH — entire pages render unstyled and Vite throws "unknown utility class" hot-overlay errors
|
|
162
|
+
|
|
163
|
+
The migrator's scaffold writes a minimal `app.css` with `@theme` containing only the gray scale + a couple of colors. Sites that defined custom palettes in `tailwind.config.ts` `theme.extend.colors` (e.g. an `als: { gray: {...}, blue: {...} }` namespace, or seasonal/brand maps) lose ALL of those tokens on migration. Same for `theme.extend.fontFamily`.
|
|
164
|
+
|
|
165
|
+
**Symptom**:
|
|
166
|
+
- Vite HMR overlay: `Cannot apply unknown utility class 'font-bebas-neue'` / `'bg-als-blue-500'`
|
|
167
|
+
- Or for CSS files using the v3 `theme()` helper: `Could not resolve value for theme function: theme(colors.als.gray.50)`
|
|
168
|
+
- Page DOM renders correctly but visually unstyled — no colors, default fonts.
|
|
169
|
+
|
|
170
|
+
**Detection** (run before booting dev for a fresh migration):
|
|
171
|
+
```bash
|
|
172
|
+
# Find all <prefix>-{custom-name}-* tailwind classes used in the codebase
|
|
173
|
+
grep -rEo '\b(bg|text|border|fill|stroke|ring|outline|divide|placeholder|caret|accent|shadow|from|to|via)-[a-z]+-[a-z-]+(-[0-9]+)?\b' src/ \
|
|
174
|
+
| awk -F: '{print $2}' | sort -u
|
|
175
|
+
|
|
176
|
+
# Find theme() calls in CSS that need v4 vars
|
|
177
|
+
grep -rE 'theme\(colors\.|theme\(fontFamily\.' src/styles/
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Cross-reference against the original `tailwind.config.ts` `theme.extend.colors` / `theme.extend.fontFamily` keys.
|
|
181
|
+
|
|
182
|
+
**Fix** — port the missing tokens into `@theme`:
|
|
183
|
+
|
|
184
|
+
```css
|
|
185
|
+
/* src/styles/app.css */
|
|
186
|
+
@theme {
|
|
187
|
+
--color-*: initial;
|
|
188
|
+
/* gray scale + std colors ... */
|
|
189
|
+
|
|
190
|
+
/* Custom brand palette (ported from tailwind.config.ts) */
|
|
191
|
+
--color-als-gray-50: #E4E4E4;
|
|
192
|
+
--color-als-gray-100: #BBBBBB;
|
|
193
|
+
/* ...etc */
|
|
194
|
+
--color-als-blue-500: #1C4DA1;
|
|
195
|
+
|
|
196
|
+
/* Custom fonts (ported from tailwind.config.ts) */
|
|
197
|
+
--font-bebas-neue: "Bebas Neue", sans-serif;
|
|
198
|
+
--font-bebas-neue-pro: "bebas-neue-pro", sans-serif;
|
|
199
|
+
--font-suisse-intl: "SuisseIntl", sans-serif;
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Tailwind v4 auto-generates `bg-als-blue-500`, `font-bebas-neue` etc. from these vars.
|
|
204
|
+
|
|
205
|
+
**For raw `theme()` calls in CSS files** — Tailwind v4's `theme()` resolver accepts the dot path but only for tokens registered under `@theme`. Easier and more idiomatic: rewrite as `var(--color-...)`:
|
|
206
|
+
|
|
207
|
+
```css
|
|
208
|
+
/* v3 → v4 */
|
|
209
|
+
background-color: theme(colors.als.gray.50); /* old */
|
|
210
|
+
background-color: var(--color-als-gray-50); /* new */
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
## 49. `@layer components` Custom Classes Can't Be `@apply`d in v4
|
|
215
|
+
|
|
216
|
+
**Severity**: MEDIUM — Vite overlay error `Cannot apply unknown utility class 'container-pdp'`
|
|
217
|
+
|
|
218
|
+
Tailwind v4 only allows `@apply` to reference *utility classes* (built-ins or those declared with `@utility`). Custom classes declared inside `@layer components { .my-class { ... } }` are not utilities and can't be `@apply`d from elsewhere.
|
|
219
|
+
|
|
220
|
+
**Symptom**:
|
|
221
|
+
```css
|
|
222
|
+
@layer components {
|
|
223
|
+
.container-pdp { @apply max-w-[1920px] md:!ml-[83px]; }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* Later: */
|
|
227
|
+
.product-details ~ .pdt-div { @apply container-pdp w-full; }
|
|
228
|
+
/* ❌ "Cannot apply unknown utility class 'container-pdp'" */
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**Fix** — promote the helper to a `@utility` directive:
|
|
232
|
+
```css
|
|
233
|
+
@utility container-pdp {
|
|
234
|
+
@apply max-w-[1920px] md:!ml-[83px] lg:!ml-[167px] ml-0;
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Then `@apply container-pdp` works, and so do variants (`hover:container-pdp`).
|
|
@@ -9,5 +9,36 @@ This file is an index. Each topic has its own focused file.
|
|
|
9
9
|
| [jsx-migration.md](jsx-migration.md) | Preact→React JSX differences | #4–6, #11, #20–22, #41 |
|
|
10
10
|
| [vtex-commerce.md](vtex-commerce.md) | VTEX loaders, cart, facets, price specs | #1, #7–8, #32, #34–36, #39 |
|
|
11
11
|
| [worker-cloudflare.md](worker-cloudflare.md) | Worker entry, build, Cloudflare, npm | #9–10, #12–14, #19, #24–28, #30, #44–45 |
|
|
12
|
-
| [css-styling.md](css-styling.md) | Tailwind v4, oklch, DaisyUI | #15, #17, #31, #37, #40, #42–43 |
|
|
12
|
+
| [css-styling.md](css-styling.md) | Tailwind v4, oklch, DaisyUI, custom palettes | #15, #17, #31, #37, #40, #42–43, #48–49 |
|
|
13
13
|
| [admin-cms.md](admin-cms.md) | Admin routes, schema, device context | #16, #18, #23, #26, #29 |
|
|
14
|
+
| [vtex-commerce.md](vtex-commerce.md) | Section loader composition (`withSectionLoader`) | #50 |
|
|
15
|
+
|
|
16
|
+
## #50 Quick Reference — Section Loader Composition
|
|
17
|
+
|
|
18
|
+
When wiring `registerSectionLoaders`, mixins (`withDevice`, `withMobile`,
|
|
19
|
+
`withSearchParam`) MUST be composed with the section's own `loader`
|
|
20
|
+
export — never replace it. The framework calls the registered entry as
|
|
21
|
+
THE section loader; if you register only mixins, the section's
|
|
22
|
+
`loader.ts` work silently never runs and the section renders empty
|
|
23
|
+
(or worse, downstream components crash on the missing data).
|
|
24
|
+
|
|
25
|
+
**Use `withSectionLoader` from `@decocms/start/cms` (≥ 2.28):**
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { compose, withDevice, withSearchParam, withSectionLoader } from "@decocms/start/cms";
|
|
29
|
+
|
|
30
|
+
registerSectionLoaders({
|
|
31
|
+
"site/sections/Header/Header.tsx": compose(
|
|
32
|
+
withDevice(),
|
|
33
|
+
withSearchParam(),
|
|
34
|
+
withSectionLoader(() => import("~/sections/Header/Header")),
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`withSectionLoader` MUST be last — it sees the mixin-enriched props and
|
|
40
|
+
returns the merged result. The `@decocms/start@2.28+` migrator emits
|
|
41
|
+
this layout automatically; sites migrated with older versions need a
|
|
42
|
+
manual rewire (16 sections in als-tanstack — symptom was empty pages
|
|
43
|
+
and `Cannot read properties of undefined` cascades). Full pattern in
|
|
44
|
+
[vtex-commerce.md](vtex-commerce.md).
|
|
@@ -13,6 +13,7 @@ import { setBlocks } from "@decocms/start/cms/loader";
|
|
|
13
13
|
import { setMetaData, setInvokeLoaders, setRenderShell } from "@decocms/start/admin";
|
|
14
14
|
import { registerSections, registerSectionsSync, setResolvedComponent } from "@decocms/start/cms/registry";
|
|
15
15
|
import { registerSectionLoaders, registerLayoutSections } from "@decocms/start/cms/sectionLoaders";
|
|
16
|
+
import { compose, withDevice, withMobile, withSearchParam, withSectionLoader } from "@decocms/start/cms";
|
|
16
17
|
import { registerCommerceLoaders, setAsyncRenderingConfig, onBeforeResolve } from "@decocms/start/cms/resolve";
|
|
17
18
|
import { createCachedLoader } from "@decocms/start/sdk/cachedLoader";
|
|
18
19
|
import appCss from "./styles/app.css?url";
|
|
@@ -77,13 +78,39 @@ registerLayoutSections([
|
|
|
77
78
|
// 4. SECTION LOADERS
|
|
78
79
|
// ==========================================================================
|
|
79
80
|
|
|
80
|
-
// Section loaders enrich CMS props with server-side data
|
|
81
|
-
//
|
|
81
|
+
// Section loaders enrich CMS props with server-side data.
|
|
82
|
+
//
|
|
83
|
+
// Composition pattern — mixins inject helpers (device, isMobile,
|
|
84
|
+
// currentSearchParam) BEFORE the section's own loader, then the
|
|
85
|
+
// section's loader has the final say:
|
|
86
|
+
//
|
|
87
|
+
// compose(
|
|
88
|
+
// withMobile(),
|
|
89
|
+
// withSearchParam(),
|
|
90
|
+
// withSectionLoader(() => import("~/sections/Foo")), // must be last
|
|
91
|
+
// )
|
|
92
|
+
//
|
|
93
|
+
// withSectionLoader is a no-op if the module has no `loader` export and
|
|
94
|
+
// catches loader exceptions (legacy `(props, req, ctx)` signatures throw
|
|
95
|
+
// because we no longer pass ctx) — so a single broken loader never takes
|
|
96
|
+
// the page down. Errors are logged via `[withSectionLoader] section
|
|
97
|
+
// loader threw`.
|
|
98
|
+
//
|
|
99
|
+
// Anti-pattern (silently drops the section's loader):
|
|
100
|
+
//
|
|
101
|
+
// "site/sections/Product/SearchContainer.tsx": withSearchParam(),
|
|
102
|
+
// // ❌ SearchContainer's own loader (which sets `props.url`, runs
|
|
103
|
+
// // VTEX queries, etc.) is REPLACED by the mixin — page renders
|
|
104
|
+
// // with stale props.
|
|
82
105
|
registerSectionLoaders({
|
|
83
|
-
"site/sections/Product/ProductShelf.tsx": (
|
|
84
|
-
import("./components/product/ProductShelf")
|
|
85
|
-
|
|
86
|
-
|
|
106
|
+
"site/sections/Product/ProductShelf.tsx": withSectionLoader(
|
|
107
|
+
() => import("./components/product/ProductShelf"),
|
|
108
|
+
),
|
|
109
|
+
"site/sections/Product/SearchResult.tsx": compose(
|
|
110
|
+
withMobile(),
|
|
111
|
+
withSearchParam(),
|
|
112
|
+
withSectionLoader(() => import("./components/search/SearchResult")),
|
|
113
|
+
),
|
|
87
114
|
// ... add for each section that has `export const loader`
|
|
88
115
|
});
|
|
89
116
|
|
|
@@ -1589,3 +1589,118 @@ Tested via Playwright (cursor-ide-browser MCP) on `https://baggagio-tanstack.dec
|
|
|
1589
1589
|
- **Preview deploys via `wrangler versions upload --preview-alias` are cheap, fast (90 s), and PR-scoped.** Used `https://pr-N-baggagio-tanstack.deco-cx.workers.dev` to validate cumulative state BEFORE merging. Should be the default validation step for any consolidation PR.
|
|
1590
1590
|
- **The canonical Picture component's per-source `<link rel="preload" as="image" media="...">` injection is a real LCP win** — but it only triggers when `<Picture preload={true}>` is set on the call site. Baggagio's `BannerCarousel.tsx` already passes `preload={lcp}` from the CMS config; the local Picture.tsx wrapper just didn't honor it. Migration to canonical IS a perf upgrade, not just a code-cleanup.
|
|
1591
1591
|
- **Canary-driven validation matters even when the changes are mechanical.** I had high confidence the cumulative state would work (typecheck + build clean), but the live test is what surfaced the "PR-B5 actually emits preload links now" finding. Without the canary loop the perf delta would have been invisible.
|
|
1592
|
+
|
|
1593
|
+
### Wave 17 (2026-05-02 — als clean migration: end-to-end first time, with discoveries) — ✅ **SHIPPED**
|
|
1594
|
+
|
|
1595
|
+
User opted to wipe `als-tanstack` and re-import `als-storefront` from
|
|
1596
|
+
scratch, then run our migration tooling end-to-end on a real, htmx-heavy
|
|
1597
|
+
site for the first time. Goal stop-point: dev server boots + homepage
|
|
1598
|
+
SSR returns 200. Stretch: "the right way, not the fast way" — port the
|
|
1599
|
+
real things, then backport the learnings.
|
|
1600
|
+
|
|
1601
|
+
#### What landed on als-tanstack `main` (force-pushed, fresh history)
|
|
1602
|
+
|
|
1603
|
+
| Commit | What |
|
|
1604
|
+
|---|---|
|
|
1605
|
+
| `f1b6a11` | Import `als-storefront` baseline at origin/main `096686ab` |
|
|
1606
|
+
| `3af54d4` | Run `@decocms/start migrate.ts` (analyze/scaffold/transform/cleanup) |
|
|
1607
|
+
| `69727a0` | `npm install` + run codegens (blocks/sections/loaders/schema/routes) |
|
|
1608
|
+
| `85d5317` | Worker boots — homepage SSR returns HTTP 200 |
|
|
1609
|
+
| `2123516` | Port casaevideo CI/CD; bump deco-start `^2.27` + apps `^1.10`; rename worker |
|
|
1610
|
+
| `c6f9dcb` | Defensive guards + restored site-local utils (`format`, `formatPhoneNumber`, `formatStatusName`) |
|
|
1611
|
+
| `e6a1fd8` | Rewire 16 section loaders + restore Tailwind v4 theme tokens (`als` palette + custom fonts) |
|
|
1612
|
+
|
|
1613
|
+
End state: `npm run dev` boots, homepage renders 2592 DOM nodes (full
|
|
1614
|
+
shell, navigation, content, footer), no Invalid URL / undefined.invoke
|
|
1615
|
+
crashes. CSP and `clogger` warnings are non-fatal residue from the
|
|
1616
|
+
pre-migration site (catalog as known follow-up).
|
|
1617
|
+
|
|
1618
|
+
#### Framework changes back-ported to deco-start (this PR)
|
|
1619
|
+
|
|
1620
|
+
The als run surfaced THREE migrator regressions that previous sites
|
|
1621
|
+
(casaevideo / lebiscuit / baggagio) didn't trip because their section
|
|
1622
|
+
authors happened to wire `loader` exports differently. Three real fixes:
|
|
1623
|
+
|
|
1624
|
+
| Fix | File | Why |
|
|
1625
|
+
|---|---|---|
|
|
1626
|
+
| **`withSectionLoader` helper** | `src/cms/sectionMixins.ts` | Lets `compose(withDevice(), withSearchParam(), withSectionLoader(() => import("~/sections/Foo")))` chain mixins WITH the section's own `loader` export. Previously the migrator's template chose mixins XOR own-loader and silently dropped the section's loader when both were present. |
|
|
1627
|
+
| **Migrator template fix** | `scripts/migrate/templates/section-loaders.ts` | Always emit `withSectionLoader(...)` last in the chain when `meta.hasLoader === true`, alongside any `withDevice / withMobile / withSearchParam` mixins. Eliminates the silent-drop bug for future migrations. |
|
|
1628
|
+
| **`gotcha #50` + `setup-ts.md` template + `css-styling.md` #48–#49** | `.agents/skills/deco-to-tanstack-migration/` | Documents both the section-loader composition pattern AND the Tailwind v4 custom-palette / `@layer components → @utility` migration pitfalls discovered during als CSS restoration. |
|
|
1629
|
+
|
|
1630
|
+
`withSectionLoader` is defensive by design — if the module has no
|
|
1631
|
+
`loader`, returns props unchanged; if the loader throws (e.g. legacy
|
|
1632
|
+
`(props, req, ctx)` signature with `ctx === undefined`), logs once via
|
|
1633
|
+
`[withSectionLoader] section loader threw` and returns the original
|
|
1634
|
+
props. One broken section never takes the page down.
|
|
1635
|
+
|
|
1636
|
+
#### Wave 17 — discoveries (added to gotcha catalog)
|
|
1637
|
+
|
|
1638
|
+
- **The migrator template was XOR-ing mixins vs section loaders.** This
|
|
1639
|
+
is a class of bug, not just one section. Across als 16 sections were
|
|
1640
|
+
affected. Detection on a fresh migration is hard because everything
|
|
1641
|
+
builds and SSR renders — sections just silently drop their data.
|
|
1642
|
+
Symptoms: empty product carousels, `Cannot read properties of
|
|
1643
|
+
undefined (reading 'X')` cascades from downstream components that
|
|
1644
|
+
expected the loader's data. Now detected at template-generation time
|
|
1645
|
+
by always composing both.
|
|
1646
|
+
- **Tailwind v4 `@theme` token loss.** The migrator's scaffold writes a
|
|
1647
|
+
minimal `app.css` with grays + base colors only. Sites with custom
|
|
1648
|
+
brand palettes in `tailwind.config.ts theme.extend.colors` (als had
|
|
1649
|
+
`als: { gray, blue, red, ... }` namespace) lose ALL of those tokens.
|
|
1650
|
+
Symptom: Vite HMR overlay `Cannot apply unknown utility class
|
|
1651
|
+
'font-bebas-neue' / 'bg-als-blue-500'`, page DOM correct but visually
|
|
1652
|
+
unstyled. Plus a v4-specific second hop: `theme(colors.als.gray.50)`
|
|
1653
|
+
in `.css` files no longer resolves — must rewrite as
|
|
1654
|
+
`var(--color-als-gray-50)`. Plus `@layer components` custom classes
|
|
1655
|
+
(`.container-pdp`) can't be `@apply`d in v4 — must promote to
|
|
1656
|
+
`@utility`. All three documented in [css-styling.md #48–49](.agents/skills/deco-to-tanstack-migration/references/css-styling.md).
|
|
1657
|
+
- **Site-local format utilities should NOT be hoisted to the apps SDK.**
|
|
1658
|
+
The migrator's overly-aggressive import-rewriting routed
|
|
1659
|
+
`formatPhoneNumber` / `formatStatusName` / `capitalize` to
|
|
1660
|
+
`@decocms/apps/commerce/sdk/formatPrice` (which doesn't export
|
|
1661
|
+
them). Only true commerce primitives (price formatting, currency,
|
|
1662
|
+
installments) belong in the apps SDK. Site-specific text formatting
|
|
1663
|
+
stays site-local in `src/sdk/`. Restored als-local versions and
|
|
1664
|
+
fixed import paths.
|
|
1665
|
+
- **`HttpError` was the third common shim.** Already promoted `cn`,
|
|
1666
|
+
`cookie`, `encoding`, `STATUS_CODE`, `UserAgent` to `@decocms/start/sdk/`
|
|
1667
|
+
in Wave 15. `HttpError` joined them in [deco-start#138](https://github.com/decocms/deco-start/pull/138).
|
|
1668
|
+
als-tanstack ships with a temporary local shim until the next apps
|
|
1669
|
+
release picks up the framework export — TODO is checked into
|
|
1670
|
+
`src/lib/http-utils.ts`.
|
|
1671
|
+
- **CI/CD porting from casaevideo to a new TanStack site is a 1-minute
|
|
1672
|
+
copy.** `deploy.yml`, `preview.yml`, `regen-blocks.yml`, plus
|
|
1673
|
+
`wrangler.jsonc` worker `name` rename, plus `account_id` paste from
|
|
1674
|
+
another site. No template needed yet — three sites is too few. Will
|
|
1675
|
+
template if a fourth migration needs it.
|
|
1676
|
+
|
|
1677
|
+
#### Counter-evidence the user-rule asks for
|
|
1678
|
+
|
|
1679
|
+
Going "the right way" added ~3 hours over going "the fast way" (the
|
|
1680
|
+
fast way was: defer the 16 section-loader rewiring, accept empty
|
|
1681
|
+
shelves, ship the boot SSR-200 commit and stop). The fast way would
|
|
1682
|
+
have hidden two of the three discoveries above, because:
|
|
1683
|
+
|
|
1684
|
+
1. The migrator template fix only became obvious AFTER manually
|
|
1685
|
+
rewiring 16 sections by hand and noticing the pattern. If we had
|
|
1686
|
+
stopped at boot, the next site to migrate would have hit the same
|
|
1687
|
+
silent-drop bug.
|
|
1688
|
+
2. The Tailwind v4 token-loss issue was only visible after the page
|
|
1689
|
+
rendered enough DOM to see "this should be branded." Boot
|
|
1690
|
+
verification (HTTP 200) would have passed without it.
|
|
1691
|
+
|
|
1692
|
+
So: 3 hours of "right way" produced one framework helper, one migrator
|
|
1693
|
+
fix, three documented gotchas, and a working canary site. The next
|
|
1694
|
+
htmx-heavy migration starts with these problems already solved. Net
|
|
1695
|
+
positive.
|
|
1696
|
+
|
|
1697
|
+
#### What this PR does NOT do (deliberately)
|
|
1698
|
+
|
|
1699
|
+
- Migrate als's htmx surface to React (deferred per Wave 14 plan; the
|
|
1700
|
+
codemod handled the mechanical 47% — the rest is per-component
|
|
1701
|
+
product work and depends on des-system decisions for things like
|
|
1702
|
+
filter sidebars + minicart drawer animation)
|
|
1703
|
+
- Validate als visually against production (visual-parity is a Phase 5+
|
|
1704
|
+
task; we're at Phase 4 dev-boots)
|
|
1705
|
+
- Ship `HttpError` consumption in als or apps — als has a local shim,
|
|
1706
|
+
apps will pick up the framework export on next release
|
package/package.json
CHANGED
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import fs from "node:fs";
|
|
20
20
|
import path from "node:path";
|
|
21
|
+
import {
|
|
22
|
+
blockHasPath,
|
|
23
|
+
type Candidate,
|
|
24
|
+
decodeBlockNameWithPasses,
|
|
25
|
+
mergeCandidates,
|
|
26
|
+
} from "./lib/blocks-dedupe";
|
|
21
27
|
|
|
22
28
|
const args = process.argv.slice(2);
|
|
23
29
|
function arg(name: string, fallback: string): string {
|
|
@@ -29,20 +35,6 @@ const blocksDir = path.resolve(process.cwd(), arg("blocks-dir", ".deco/blocks"))
|
|
|
29
35
|
const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/blocks.gen.ts"));
|
|
30
36
|
const jsonFile = outFile.replace(/\.ts$/, ".json");
|
|
31
37
|
|
|
32
|
-
function decodeBlockName(filename: string): string {
|
|
33
|
-
let name = filename.replace(/\.json$/, "");
|
|
34
|
-
while (name.includes("%")) {
|
|
35
|
-
try {
|
|
36
|
-
const next = decodeURIComponent(name);
|
|
37
|
-
if (next === name) break;
|
|
38
|
-
name = next;
|
|
39
|
-
} catch {
|
|
40
|
-
break; // literal % in the decoded name — nothing left to decode
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return name;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
38
|
const TS_STUB = [
|
|
47
39
|
"// Auto-generated — thin wrapper around blocks.gen.json.",
|
|
48
40
|
"// The Vite plugin replaces this at load time with JSON.parse(...).",
|
|
@@ -62,30 +54,53 @@ if (!fs.existsSync(blocksDir)) {
|
|
|
62
54
|
|
|
63
55
|
const files = fs.readdirSync(blocksDir).filter((f) => f.endsWith(".json"));
|
|
64
56
|
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
57
|
+
// Read each file into a Candidate, then let the dedupe lib pick the winner
|
|
58
|
+
// per decoded key and report any collisions. See `lib/blocks-dedupe.ts` for
|
|
59
|
+
// the priority order and the rationale behind it (TL;DR: never use file size,
|
|
60
|
+
// don't trust mtime alone in CI clones).
|
|
61
|
+
const candidatesWithKeys: Array<{ candidate: Candidate; key: string }> = [];
|
|
68
62
|
for (const file of files) {
|
|
69
|
-
const name =
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
63
|
+
const { name, passes } = decodeBlockNameWithPasses(file);
|
|
64
|
+
const fp = path.join(blocksDir, file);
|
|
65
|
+
let parsed: unknown;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(fs.readFileSync(fp, "utf-8"));
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.warn(`Failed to parse ${file}:`, e);
|
|
76
70
|
continue;
|
|
77
71
|
}
|
|
78
|
-
|
|
72
|
+
candidatesWithKeys.push({
|
|
73
|
+
key: name,
|
|
74
|
+
candidate: {
|
|
75
|
+
file,
|
|
76
|
+
passes,
|
|
77
|
+
mtimeMs: fs.statSync(fp).mtimeMs,
|
|
78
|
+
hasPath: blockHasPath(parsed),
|
|
79
|
+
parsed,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
const { winners, collisions } = mergeCandidates(candidatesWithKeys);
|
|
85
|
+
|
|
86
|
+
if (collisions.length > 0) {
|
|
87
|
+
console.warn(
|
|
88
|
+
`Detected ${collisions.length} filename collision(s) in ${path.relative(process.cwd(), blocksDir)}:`,
|
|
89
|
+
);
|
|
90
|
+
for (const c of collisions) {
|
|
91
|
+
const losers = c.files.filter((f) => f !== c.winner);
|
|
92
|
+
console.warn(` - ${c.key}`);
|
|
93
|
+
console.warn(` winner: ${c.winner}`);
|
|
94
|
+
for (const l of losers) console.warn(` ignore: ${l}`);
|
|
88
95
|
}
|
|
96
|
+
console.warn(" Cause: multiple writers (manual sync vs deco-sync-bot) producing");
|
|
97
|
+
console.warn(" different filename encodings for the same logical key. Delete the");
|
|
98
|
+
console.warn(" stale file(s) listed under 'ignore' to silence this warning.");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const blocks: Record<string, unknown> = {};
|
|
102
|
+
for (const [name, c] of Object.entries(winners)) {
|
|
103
|
+
blocks[name] = c.parsed;
|
|
89
104
|
}
|
|
90
105
|
|
|
91
106
|
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
blockHasPath,
|
|
4
|
+
type Candidate,
|
|
5
|
+
decodeBlockName,
|
|
6
|
+
decodeBlockNameWithPasses,
|
|
7
|
+
mergeCandidates,
|
|
8
|
+
pickWinner,
|
|
9
|
+
} from "./blocks-dedupe";
|
|
10
|
+
|
|
11
|
+
const cand = (overrides: Partial<Candidate> & { file: string }): Candidate => ({
|
|
12
|
+
passes: 0,
|
|
13
|
+
mtimeMs: 0,
|
|
14
|
+
hasPath: true,
|
|
15
|
+
parsed: { path: "/" },
|
|
16
|
+
...overrides,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("decodeBlockNameWithPasses", () => {
|
|
20
|
+
it("returns the literal key with a 0 pass count when filename has no encoding", () => {
|
|
21
|
+
expect(decodeBlockNameWithPasses("Header.json")).toEqual({ name: "Header", passes: 0 });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("decodes a single layer of URL encoding", () => {
|
|
25
|
+
expect(decodeBlockNameWithPasses("pages-Home%20-%20LB-618509.json")).toEqual({
|
|
26
|
+
name: "pages-Home - LB-618509",
|
|
27
|
+
passes: 1,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("decodes the bot's double-encoded scheme through to the literal key", () => {
|
|
32
|
+
// Bot encodes the raw prod URL-encoded key once: `encodeURIComponent("pages-Home%20-%20LB-618509")`.
|
|
33
|
+
expect(decodeBlockNameWithPasses("pages-Home%2520-%2520LB-618509.json")).toEqual({
|
|
34
|
+
name: "pages-Home - LB-618509",
|
|
35
|
+
passes: 2,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("stops decoding when a literal % survives a round", () => {
|
|
40
|
+
// `%` alone isn't valid encoding — the loop catches the throw and stops.
|
|
41
|
+
expect(decodeBlockNameWithPasses("weird%percent.json")).toEqual({
|
|
42
|
+
name: "weird%percent",
|
|
43
|
+
passes: 0,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("decodeBlockName", () => {
|
|
49
|
+
it("matches decodeBlockNameWithPasses on the name", () => {
|
|
50
|
+
expect(decodeBlockName("pages-Home%2520-%2520LB-618509.json")).toBe("pages-Home - LB-618509");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("blockHasPath", () => {
|
|
55
|
+
it("returns true for live page blocks", () => {
|
|
56
|
+
expect(blockHasPath({ path: "/", sections: [] })).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns false when path is null (zombie entry)", () => {
|
|
60
|
+
expect(blockHasPath({ path: null, sections: [] })).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns false when path is missing", () => {
|
|
64
|
+
expect(blockHasPath({ sections: [] })).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns false for empty path strings", () => {
|
|
68
|
+
expect(blockHasPath({ path: "" })).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns false for non-objects", () => {
|
|
72
|
+
expect(blockHasPath(null)).toBe(false);
|
|
73
|
+
expect(blockHasPath("/")).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("pickWinner", () => {
|
|
78
|
+
it("prefers a candidate with a real path over a zombie", () => {
|
|
79
|
+
const live = cand({ file: "live.json", hasPath: true });
|
|
80
|
+
const zombie = cand({ file: "zombie.json", hasPath: false, parsed: { path: null } });
|
|
81
|
+
expect(pickWinner(live, zombie)).toBe(live);
|
|
82
|
+
expect(pickWinner(zombie, live)).toBe(live);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("prefers higher decode-pass count when path-status matches", () => {
|
|
86
|
+
// The lebiscuit reproduction case: a stale single-encoded leftover with a
|
|
87
|
+
// newer mtime and larger size loses to the bot's double-encoded fresh file.
|
|
88
|
+
const stale = cand({
|
|
89
|
+
file: "pages-Home%20-%20LB-618509.json",
|
|
90
|
+
passes: 1,
|
|
91
|
+
mtimeMs: 2_000_000,
|
|
92
|
+
});
|
|
93
|
+
const fresh = cand({
|
|
94
|
+
file: "pages-Home%2520-%2520LB-618509.json",
|
|
95
|
+
passes: 2,
|
|
96
|
+
mtimeMs: 1_000_000,
|
|
97
|
+
});
|
|
98
|
+
expect(pickWinner(stale, fresh)).toBe(fresh);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("falls through to mtime when pass count ties", () => {
|
|
102
|
+
const older = cand({ file: "a.json", passes: 1, mtimeMs: 1 });
|
|
103
|
+
const newer = cand({ file: "b.json", passes: 1, mtimeMs: 2 });
|
|
104
|
+
expect(pickWinner(older, newer)).toBe(newer);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("falls through to lex filename when everything else ties", () => {
|
|
108
|
+
const a = cand({ file: "a.json", passes: 0, mtimeMs: 5 });
|
|
109
|
+
const b = cand({ file: "b.json", passes: 0, mtimeMs: 5 });
|
|
110
|
+
expect(pickWinner(a, b)).toBe(a);
|
|
111
|
+
expect(pickWinner(b, a)).toBe(a);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("mergeCandidates", () => {
|
|
116
|
+
it("returns each candidate unchanged when there are no collisions", () => {
|
|
117
|
+
const a = cand({ file: "Header.json" });
|
|
118
|
+
const b = cand({ file: "Footer.json" });
|
|
119
|
+
const result = mergeCandidates([
|
|
120
|
+
{ candidate: a, key: "Header" },
|
|
121
|
+
{ candidate: b, key: "Footer" },
|
|
122
|
+
]);
|
|
123
|
+
expect(result.collisions).toEqual([]);
|
|
124
|
+
expect(result.winners).toEqual({ Header: a, Footer: b });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("records a collision and picks the winner", () => {
|
|
128
|
+
const stale = cand({ file: "pages-Home%20-%20LB-618509.json", passes: 1, mtimeMs: 5 });
|
|
129
|
+
const fresh = cand({ file: "pages-Home%2520-%2520LB-618509.json", passes: 2, mtimeMs: 1 });
|
|
130
|
+
const result = mergeCandidates([
|
|
131
|
+
{ candidate: stale, key: "pages-Home - LB-618509" },
|
|
132
|
+
{ candidate: fresh, key: "pages-Home - LB-618509" },
|
|
133
|
+
]);
|
|
134
|
+
expect(result.winners["pages-Home - LB-618509"]).toBe(fresh);
|
|
135
|
+
expect(result.collisions).toEqual([
|
|
136
|
+
{
|
|
137
|
+
key: "pages-Home - LB-618509",
|
|
138
|
+
files: ["pages-Home%20-%20LB-618509.json", "pages-Home%2520-%2520LB-618509.json"],
|
|
139
|
+
winner: "pages-Home%2520-%2520LB-618509.json",
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("collapses three-way collisions into one record without dropping the winner", () => {
|
|
145
|
+
const a = cand({ file: "a.json", passes: 0, mtimeMs: 1 });
|
|
146
|
+
const b = cand({ file: "b.json", passes: 1, mtimeMs: 2 });
|
|
147
|
+
const c = cand({ file: "c.json", passes: 2, mtimeMs: 3 });
|
|
148
|
+
const result = mergeCandidates([
|
|
149
|
+
{ candidate: a, key: "k" },
|
|
150
|
+
{ candidate: b, key: "k" },
|
|
151
|
+
{ candidate: c, key: "k" },
|
|
152
|
+
]);
|
|
153
|
+
expect(result.winners.k).toBe(c);
|
|
154
|
+
expect(result.collisions).toHaveLength(1);
|
|
155
|
+
expect(result.collisions[0].winner).toBe("c.json");
|
|
156
|
+
// a, b, and c should all be tracked (a and b as ignored, c as winner).
|
|
157
|
+
expect(new Set(result.collisions[0].files)).toEqual(new Set(["a.json", "b.json", "c.json"]));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("prefers the live page over a zombie even when zombie has more passes", () => {
|
|
161
|
+
const livePlain = cand({
|
|
162
|
+
file: "Home.json",
|
|
163
|
+
passes: 0,
|
|
164
|
+
hasPath: true,
|
|
165
|
+
parsed: { path: "/" },
|
|
166
|
+
});
|
|
167
|
+
const zombieEncoded = cand({
|
|
168
|
+
file: "Home%2520.json",
|
|
169
|
+
passes: 2,
|
|
170
|
+
hasPath: false,
|
|
171
|
+
parsed: { path: null },
|
|
172
|
+
});
|
|
173
|
+
const result = mergeCandidates([
|
|
174
|
+
{ candidate: zombieEncoded, key: "Home" },
|
|
175
|
+
{ candidate: livePlain, key: "Home" },
|
|
176
|
+
]);
|
|
177
|
+
expect(result.winners.Home).toBe(livePlain);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers used by `generate-blocks.ts` to choose between multiple files
|
|
3
|
+
* that decode to the same logical CMS block key.
|
|
4
|
+
*
|
|
5
|
+
* Background: the live decofile snapshot lives under `.deco/blocks/`, with
|
|
6
|
+
* one file per block. The filename is `encodeURIComponent(<rawProdKey>) +
|
|
7
|
+
* ".json"`. The Deco admin sometimes serves URL-encoded keys (e.g.
|
|
8
|
+
* `pages-Home%20-%20LB-618509`), so a single block can land on disk with
|
|
9
|
+
* different filenames depending on which writer produced it:
|
|
10
|
+
*
|
|
11
|
+
* - The `deco-sync-bot` (CI) encodes the raw prod key as-is, producing
|
|
12
|
+
* `pages-Home%2520-%2520LB-618509.json` (two decode passes back to the
|
|
13
|
+
* literal key).
|
|
14
|
+
* - The legacy manual `sync-decofile.ts` decoded keys to literal first,
|
|
15
|
+
* so it wrote `pages-Home%20-%20LB-618509.json` (one decode pass).
|
|
16
|
+
*
|
|
17
|
+
* Both files decode to the same logical key, so the block generator must
|
|
18
|
+
* pick one. Picking by file size is wrong (a shrunk live page gives a
|
|
19
|
+
* smaller JSON than the stale older snapshot, so size silently prefers
|
|
20
|
+
* stale); picking by mtime alone is wrong (fresh `git clone` writes all
|
|
21
|
+
* files with the clone time and erases temporal ordering).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface Candidate {
|
|
25
|
+
file: string;
|
|
26
|
+
passes: number;
|
|
27
|
+
mtimeMs: number;
|
|
28
|
+
hasPath: boolean;
|
|
29
|
+
parsed: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Repeatedly URL-decode the basename of `filename` until no `%` sequence
|
|
34
|
+
* remains. Returns the fully-decoded canonical key plus the number of
|
|
35
|
+
* decode rounds it took. Higher pass count = the writer encoded a key
|
|
36
|
+
* that itself contained `%XX` sequences = bot scheme. See module-level
|
|
37
|
+
* comment for why this matters.
|
|
38
|
+
*/
|
|
39
|
+
export function decodeBlockNameWithPasses(filename: string): {
|
|
40
|
+
name: string;
|
|
41
|
+
passes: number;
|
|
42
|
+
} {
|
|
43
|
+
let name = filename.replace(/\.json$/, "");
|
|
44
|
+
let passes = 0;
|
|
45
|
+
while (name.includes("%")) {
|
|
46
|
+
try {
|
|
47
|
+
const next = decodeURIComponent(name);
|
|
48
|
+
if (next === name) break;
|
|
49
|
+
name = next;
|
|
50
|
+
passes++;
|
|
51
|
+
} catch {
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { name, passes };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function decodeBlockName(filename: string): string {
|
|
59
|
+
return decodeBlockNameWithPasses(filename).name;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Tie-break two candidates that decode to the same key. Priority:
|
|
64
|
+
* 1. Block has a non-null `path` — beats zombie/orphan entries.
|
|
65
|
+
* 2. More decode passes — bot's "encode raw prod key"
|
|
66
|
+
* scheme wins over legacy
|
|
67
|
+
* "decode-then-encode" leftovers
|
|
68
|
+
* when prod uses URL-encoded keys
|
|
69
|
+
* (the only case that collides).
|
|
70
|
+
* 3. Newer mtime — last-write-wins for same scheme.
|
|
71
|
+
* 4. Lexicographic filename — deterministic last resort.
|
|
72
|
+
*/
|
|
73
|
+
export function pickWinner(a: Candidate, b: Candidate): Candidate {
|
|
74
|
+
if (a.hasPath !== b.hasPath) return a.hasPath ? a : b;
|
|
75
|
+
if (a.passes !== b.passes) return a.passes > b.passes ? a : b;
|
|
76
|
+
if (a.mtimeMs !== b.mtimeMs) return a.mtimeMs > b.mtimeMs ? a : b;
|
|
77
|
+
return a.file < b.file ? a : b;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True iff a parsed block JSON looks like a live page (non-empty `.path`). */
|
|
81
|
+
export function blockHasPath(parsed: unknown): boolean {
|
|
82
|
+
return (
|
|
83
|
+
typeof parsed === "object" &&
|
|
84
|
+
parsed !== null &&
|
|
85
|
+
"path" in parsed &&
|
|
86
|
+
typeof (parsed as { path?: unknown }).path === "string" &&
|
|
87
|
+
(parsed as { path: string }).path.length > 0
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CollisionRecord {
|
|
92
|
+
key: string;
|
|
93
|
+
files: string[];
|
|
94
|
+
winner: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface MergeResult {
|
|
98
|
+
winners: Record<string, Candidate>;
|
|
99
|
+
collisions: CollisionRecord[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reduce a list of candidates into one winner per decoded key, recording
|
|
104
|
+
* every collision so the caller can surface it as a build warning.
|
|
105
|
+
*/
|
|
106
|
+
export function mergeCandidates(
|
|
107
|
+
candidates: Array<{ candidate: Candidate; key: string }>,
|
|
108
|
+
): MergeResult {
|
|
109
|
+
const winners: Record<string, Candidate> = {};
|
|
110
|
+
// Track every file that decoded to a given key so three-way (and beyond)
|
|
111
|
+
// collisions don't lose the eventual winner from the file list.
|
|
112
|
+
const filesByKey: Record<string, string[]> = {};
|
|
113
|
+
for (const { candidate, key } of candidates) {
|
|
114
|
+
if (!filesByKey[key]) filesByKey[key] = [];
|
|
115
|
+
const list = filesByKey[key];
|
|
116
|
+
if (!list.includes(candidate.file)) list.push(candidate.file);
|
|
117
|
+
|
|
118
|
+
const existing = winners[key];
|
|
119
|
+
winners[key] = existing ? pickWinner(existing, candidate) : candidate;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const collisions: CollisionRecord[] = [];
|
|
123
|
+
for (const [key, files] of Object.entries(filesByKey)) {
|
|
124
|
+
if (files.length < 2) continue;
|
|
125
|
+
collisions.push({ key, files, winner: winners[key].file });
|
|
126
|
+
}
|
|
127
|
+
return { winners, collisions };
|
|
128
|
+
}
|
|
@@ -69,6 +69,7 @@ export function generateSectionLoaders(ctx: MigrationContext): string {
|
|
|
69
69
|
lines.push(` withDevice,`);
|
|
70
70
|
lines.push(` withMobile,`);
|
|
71
71
|
lines.push(` withSearchParam,`);
|
|
72
|
+
lines.push(` withSectionLoader,`);
|
|
72
73
|
lines.push(` compose,`);
|
|
73
74
|
lines.push(`} from "@decocms/start/cms";`);
|
|
74
75
|
|
|
@@ -117,20 +118,36 @@ export function generateSectionLoaders(ctx: MigrationContext): string {
|
|
|
117
118
|
for (const meta of ctx.sectionMetas) {
|
|
118
119
|
if (!meta.isHeader || !meta.hasLoader) continue;
|
|
119
120
|
const sectionKey = `site/${meta.path}`;
|
|
120
|
-
|
|
121
|
+
const importPath = `~/` + meta.path.replace(/\.tsx?$/, "");
|
|
122
|
+
entries.push(` // Header: device + search param + section's own loader`);
|
|
121
123
|
entries.push(` "${sectionKey}": async (props, req) => ({`);
|
|
122
|
-
entries.push(` ...(await compose(
|
|
124
|
+
entries.push(` ...(await compose(`);
|
|
125
|
+
entries.push(` withDevice(),`);
|
|
126
|
+
entries.push(` withSearchParam(),`);
|
|
127
|
+
entries.push(` withSectionLoader(() => import("${importPath}")),`);
|
|
128
|
+
entries.push(` )(props, req)),`);
|
|
123
129
|
entries.push(` userName: "",`);
|
|
124
130
|
entries.push(` }),`);
|
|
125
131
|
}
|
|
126
132
|
|
|
127
|
-
// ---------- Device/mobile
|
|
133
|
+
// ---------- Device/mobile/url + own-loader composition ----------
|
|
134
|
+
//
|
|
135
|
+
// Rule of thumb: if a section exports its own `loader`, ALWAYS run it.
|
|
136
|
+
// Mixins (withDevice/withMobile/withSearchParam) are composed BEFORE the
|
|
137
|
+
// section loader so they can inject device/search-param props the section
|
|
138
|
+
// loader may read; the section loader has the final say over what is
|
|
139
|
+
// returned.
|
|
140
|
+
//
|
|
141
|
+
// The previous template chose mixins XOR own-loader and silently dropped
|
|
142
|
+
// the section's loader when both were present — see als-tanstack
|
|
143
|
+
// SearchContainerV2 SSR regression.
|
|
128
144
|
for (const meta of ctx.sectionMetas) {
|
|
129
145
|
if (meta.isHeader || meta.isAccountSection || meta.isStatusOnly) continue;
|
|
130
146
|
// Skip sections with no loader AND no device needs
|
|
131
147
|
if (!meta.hasLoader && !meta.loaderUsesDevice) continue;
|
|
132
148
|
const sectionKey = `site/${meta.path}`;
|
|
133
149
|
const basename = meta.path.split("/").pop()?.replace(/\.\w+$/, "") || "";
|
|
150
|
+
const importPath = `~/` + meta.path.replace(/\.tsx?$/, "");
|
|
134
151
|
|
|
135
152
|
// Skip sections handled specially below
|
|
136
153
|
const specialSections = [
|
|
@@ -141,27 +158,22 @@ export function generateSectionLoaders(ctx: MigrationContext): string {
|
|
|
141
158
|
];
|
|
142
159
|
if (specialSections.includes(basename)) continue;
|
|
143
160
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
entries.push(` "${sectionKey}":
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
// We invoke with 2 args; any ctx-dependent code path inside the loader will throw
|
|
161
|
-
// at runtime and must be refactored — the migration phase-transform flags these.
|
|
162
|
-
entries.push(` if (typeof mod.loader === "function") return (mod.loader as any)(props, req);`);
|
|
163
|
-
entries.push(` return props;`);
|
|
164
|
-
entries.push(` },`);
|
|
161
|
+
const mixins: string[] = [];
|
|
162
|
+
if (meta.loaderUsesDevice) {
|
|
163
|
+
mixins.push(meta.usesMobileBoolean ? "withMobile()" : "withDevice()");
|
|
164
|
+
}
|
|
165
|
+
if (meta.loaderUsesUrl) mixins.push("withSearchParam()");
|
|
166
|
+
if (meta.hasLoader) {
|
|
167
|
+
mixins.push(`withSectionLoader(() => import("${importPath}"))`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (mixins.length === 0) continue;
|
|
171
|
+
if (mixins.length === 1) {
|
|
172
|
+
entries.push(` "${sectionKey}": ${mixins[0]},`);
|
|
173
|
+
} else {
|
|
174
|
+
entries.push(` "${sectionKey}": compose(`);
|
|
175
|
+
for (const m of mixins) entries.push(` ${m},`);
|
|
176
|
+
entries.push(` ),`);
|
|
165
177
|
}
|
|
166
178
|
}
|
|
167
179
|
|
package/src/cms/index.ts
CHANGED
|
@@ -75,6 +75,12 @@ export {
|
|
|
75
75
|
runSectionLoaders,
|
|
76
76
|
runSingleSectionLoader,
|
|
77
77
|
} from "./sectionLoaders";
|
|
78
|
-
export {
|
|
78
|
+
export {
|
|
79
|
+
compose,
|
|
80
|
+
withDevice,
|
|
81
|
+
withMobile,
|
|
82
|
+
withSearchParam,
|
|
83
|
+
withSectionLoader,
|
|
84
|
+
} from "./sectionMixins";
|
|
79
85
|
export type { ApplySectionConventionsInput, SectionMetaEntry } from "./applySectionConventions";
|
|
80
86
|
export { applySectionConventions } from "./applySectionConventions";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section mixins — focused on `withSectionLoader` since the device/mobile
|
|
3
|
+
* mixins are exercised by sectionLoaders integration tests already.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { compose, withSectionLoader, withSearchParam } from "./sectionMixins";
|
|
7
|
+
|
|
8
|
+
const makeReq = (url = "https://store.example/foo?q=hello") =>
|
|
9
|
+
new Request(url, { headers: { "user-agent": "vitest" } });
|
|
10
|
+
|
|
11
|
+
describe("withSectionLoader", () => {
|
|
12
|
+
it("invokes the section's exported loader and returns its result", async () => {
|
|
13
|
+
const loader = vi.fn(async (props: Record<string, unknown>) => ({
|
|
14
|
+
...props,
|
|
15
|
+
url: "https://store.example/foo?q=hello",
|
|
16
|
+
enriched: true,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const mixin = withSectionLoader(async () => ({ loader }));
|
|
20
|
+
const result = await mixin({ original: "value" }, makeReq());
|
|
21
|
+
|
|
22
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
23
|
+
expect(result).toEqual({
|
|
24
|
+
original: "value",
|
|
25
|
+
url: "https://store.example/foo?q=hello",
|
|
26
|
+
enriched: true,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns props unchanged when module has no loader export", async () => {
|
|
31
|
+
const mixin = withSectionLoader(async () => ({ default: () => null }));
|
|
32
|
+
const props = { foo: "bar" };
|
|
33
|
+
const result = await mixin(props, makeReq());
|
|
34
|
+
expect(result).toBe(props);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns props unchanged when module is undefined / loader is not a function", async () => {
|
|
38
|
+
const mixin = withSectionLoader(async () => undefined);
|
|
39
|
+
const props = { foo: "bar" };
|
|
40
|
+
expect(await mixin(props, makeReq())).toBe(props);
|
|
41
|
+
|
|
42
|
+
const mixinWithBadLoader = withSectionLoader(async () => ({
|
|
43
|
+
loader: "not-a-function",
|
|
44
|
+
}));
|
|
45
|
+
expect(await mixinWithBadLoader(props, makeReq())).toBe(props);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("swallows loader errors and returns the original props", async () => {
|
|
49
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
50
|
+
const mixin = withSectionLoader(async () => ({
|
|
51
|
+
loader: async () => {
|
|
52
|
+
throw new Error("boom");
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
const props = { foo: "bar" };
|
|
56
|
+
const result = await mixin(props, makeReq());
|
|
57
|
+
expect(result).toBe(props);
|
|
58
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
59
|
+
"[withSectionLoader] section loader threw:",
|
|
60
|
+
expect.any(Error),
|
|
61
|
+
);
|
|
62
|
+
errorSpy.mockRestore();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("falls back to original props when loader returns undefined", async () => {
|
|
66
|
+
const mixin = withSectionLoader(async () => ({
|
|
67
|
+
loader: async () => undefined,
|
|
68
|
+
}));
|
|
69
|
+
const props = { foo: "bar" };
|
|
70
|
+
expect(await mixin(props, makeReq())).toBe(props);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("composes alongside mixins: mixins run first, then the section loader sees the enriched props", async () => {
|
|
74
|
+
const seen: Array<Record<string, unknown>> = [];
|
|
75
|
+
const sectionLoader = vi.fn(
|
|
76
|
+
async (props: Record<string, unknown>) => {
|
|
77
|
+
seen.push({ ...props });
|
|
78
|
+
return { ...props, sectionLoaderRan: true };
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const composed = compose(
|
|
83
|
+
withSearchParam(),
|
|
84
|
+
withSectionLoader(async () => ({ loader: sectionLoader })),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const result = await composed({ original: 1 }, makeReq());
|
|
88
|
+
|
|
89
|
+
expect(seen).toHaveLength(1);
|
|
90
|
+
// The section loader sees the enriched props (currentSearchParam injected by withSearchParam)
|
|
91
|
+
expect(seen[0]).toMatchObject({
|
|
92
|
+
original: 1,
|
|
93
|
+
currentSearchParam: "hello",
|
|
94
|
+
});
|
|
95
|
+
expect(result).toMatchObject({
|
|
96
|
+
original: 1,
|
|
97
|
+
currentSearchParam: "hello",
|
|
98
|
+
sectionLoaderRan: true,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("loads the module lazily — modImport is only called on first invocation", async () => {
|
|
103
|
+
const modImport = vi.fn(async () => ({
|
|
104
|
+
loader: async (p: Record<string, unknown>) => ({ ...p, ran: true }),
|
|
105
|
+
}));
|
|
106
|
+
const mixin = withSectionLoader(modImport);
|
|
107
|
+
expect(modImport).not.toHaveBeenCalled();
|
|
108
|
+
|
|
109
|
+
await mixin({ a: 1 }, makeReq());
|
|
110
|
+
expect(modImport).toHaveBeenCalledTimes(1);
|
|
111
|
+
|
|
112
|
+
// A second invocation calls the import again — caching is the
|
|
113
|
+
// dynamic import's responsibility (which the runtime memoises).
|
|
114
|
+
await mixin({ a: 2 }, makeReq());
|
|
115
|
+
expect(modImport).toHaveBeenCalledTimes(2);
|
|
116
|
+
});
|
|
117
|
+
});
|
package/src/cms/sectionMixins.ts
CHANGED
|
@@ -74,3 +74,58 @@ export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn {
|
|
|
74
74
|
return result;
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Wraps a section module's exported `loader` so it can be composed alongside
|
|
80
|
+
* mixins like {@link withDevice}, {@link withMobile}, {@link withSearchParam}.
|
|
81
|
+
*
|
|
82
|
+
* The `modImport` argument is a lazy factory (typically `() => import("~/sections/...")`)
|
|
83
|
+
* — the module is loaded on first call. If the module does not export a
|
|
84
|
+
* `loader`, the original `props` are returned unchanged (no-op).
|
|
85
|
+
*
|
|
86
|
+
* Why this exists: the migrator and many sites lifted from Fresh declare a
|
|
87
|
+
* `loader` export on the section file (often re-exported from the inner
|
|
88
|
+
* component). That loader sets things like `url: req.url`, runs platform
|
|
89
|
+
* invocations, or calls the section's domain logic. If the section is wired
|
|
90
|
+
* in `registerSectionLoaders` with mixin-only (e.g. `withSearchParam()`),
|
|
91
|
+
* the section's own loader is silently *replaced* — its work never runs and
|
|
92
|
+
* its returned props are dropped.
|
|
93
|
+
*
|
|
94
|
+
* Compose this helper FIRST in the chain so mixin-injected props
|
|
95
|
+
* (`device`, `currentSearchParam`, …) are available to the section's loader,
|
|
96
|
+
* then the section's loader has the final word over what is returned.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```ts
|
|
100
|
+
* import {
|
|
101
|
+
* compose,
|
|
102
|
+
* withMobile,
|
|
103
|
+
* withSearchParam,
|
|
104
|
+
* withSectionLoader,
|
|
105
|
+
* } from "@decocms/start/cms";
|
|
106
|
+
*
|
|
107
|
+
* registerSectionLoaders({
|
|
108
|
+
* "site/sections/Product/SearchContainerV2.tsx": compose(
|
|
109
|
+
* withMobile(),
|
|
110
|
+
* withSearchParam(),
|
|
111
|
+
* withSectionLoader(() => import("~/sections/Product/SearchContainerV2")),
|
|
112
|
+
* ),
|
|
113
|
+
* });
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
export function withSectionLoader(
|
|
117
|
+
modImport: () => Promise<unknown>,
|
|
118
|
+
): SectionLoaderFn {
|
|
119
|
+
return async (props, req) => {
|
|
120
|
+
const mod = (await modImport()) as { loader?: unknown } | undefined;
|
|
121
|
+
const loader = mod?.loader;
|
|
122
|
+
if (typeof loader !== "function") return props;
|
|
123
|
+
try {
|
|
124
|
+
const result = await (loader as SectionLoaderFn)(props, req);
|
|
125
|
+
return result ?? props;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error("[withSectionLoader] section loader threw:", error);
|
|
128
|
+
return props;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|