@decocms/blocks-cli 7.37.5 → 7.38.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.38.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
|
|
6
6
|
"repository": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"lint:unused": "knip"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@decocms/blocks": "7.
|
|
34
|
+
"@decocms/blocks": "7.38.0",
|
|
35
35
|
"ts-morph": "^27.0.0",
|
|
36
36
|
"tsx": "^4.22.5"
|
|
37
37
|
},
|
|
@@ -191,6 +191,68 @@ describe("typeToJsonSchema with intersection types", () => {
|
|
|
191
191
|
}, 30_000);
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
+
describe("typeToJsonSchema Section-typed props", () => {
|
|
195
|
+
// The framework's `Section` is opaque (`export type Section = any`), so a
|
|
196
|
+
// Section-typed prop is a "pick any section" reference emitted as a
|
|
197
|
+
// __SECTION_REF__ picker. This must keep working.
|
|
198
|
+
it("emits a __SECTION_REF__ picker for the framework's opaque Section[]", () => {
|
|
199
|
+
const project = new Project({
|
|
200
|
+
useInMemoryFileSystem: true,
|
|
201
|
+
compilerOptions: { skipLibCheck: true },
|
|
202
|
+
});
|
|
203
|
+
const sf = project.createSourceFile(
|
|
204
|
+
"props.ts",
|
|
205
|
+
`
|
|
206
|
+
type Section = any;
|
|
207
|
+
export interface Props {
|
|
208
|
+
children?: Section[] | null;
|
|
209
|
+
}
|
|
210
|
+
`,
|
|
211
|
+
);
|
|
212
|
+
const schema = typeToJsonSchema(sf.getInterfaceOrThrow("Props").getType());
|
|
213
|
+
|
|
214
|
+
expect(schema.properties.children).toMatchObject({
|
|
215
|
+
type: "array",
|
|
216
|
+
items: { $ref: "#/definitions/__SECTION_REF__" },
|
|
217
|
+
});
|
|
218
|
+
}, 30_000);
|
|
219
|
+
|
|
220
|
+
// Regression (casaevideo footer): a component that declares its own local
|
|
221
|
+
// `type Section = { label; items }` used to collide with the magic name and
|
|
222
|
+
// collapse into a non-editable __SECTION_REF__ picker — the footer columns
|
|
223
|
+
// showed only a drag handle, no expand arrow, no form. A concretely-shaped
|
|
224
|
+
// local type is user data and must render as an inline editable object.
|
|
225
|
+
it("keeps a user-defined local `type Section` as an inline editable object", () => {
|
|
226
|
+
const project = new Project({
|
|
227
|
+
useInMemoryFileSystem: true,
|
|
228
|
+
compilerOptions: { skipLibCheck: true },
|
|
229
|
+
});
|
|
230
|
+
const sf = project.createSourceFile(
|
|
231
|
+
"props.ts",
|
|
232
|
+
`
|
|
233
|
+
interface Item { label: string; href: string; }
|
|
234
|
+
type Section = { label: string; items: Item[] };
|
|
235
|
+
export interface Props {
|
|
236
|
+
sections?: Section[];
|
|
237
|
+
}
|
|
238
|
+
`,
|
|
239
|
+
);
|
|
240
|
+
const schema = typeToJsonSchema(sf.getInterfaceOrThrow("Props").getType());
|
|
241
|
+
|
|
242
|
+
const sections = schema.properties.sections;
|
|
243
|
+
// Editable array of objects — NOT a section-picker reference.
|
|
244
|
+
expect(sections.type).toBe("array");
|
|
245
|
+
expect(JSON.stringify(sections)).not.toContain("__SECTION_REF__");
|
|
246
|
+
|
|
247
|
+
const item = sections.items;
|
|
248
|
+
expect(item.type).toBe("object");
|
|
249
|
+
expect(item.properties.label).toMatchObject({ type: "string" });
|
|
250
|
+
expect(item.properties.items.type).toBe("array");
|
|
251
|
+
expect(item.properties.items.items.type).toBe("object");
|
|
252
|
+
expect(item.properties.items.items.properties.href).toMatchObject({ type: "string" });
|
|
253
|
+
}, 30_000);
|
|
254
|
+
});
|
|
255
|
+
|
|
194
256
|
describe("typeToJsonSchema loader block-ref matching", () => {
|
|
195
257
|
// Regression: a loader that returns an array whose element type has no
|
|
196
258
|
// resolvable name (`VNode[]`, `string[]`, …) used to be bucketed under the
|
|
@@ -306,6 +306,33 @@ const SECTION_REF_DEF_KEY = "__SECTION_REF__";
|
|
|
306
306
|
// Well-known definition key for Resolvable (saved blocks picker)
|
|
307
307
|
const RESOLVABLE_KEY = "Resolvable";
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Whether a prop annotated `Section` / `Section[]` is the framework's opaque
|
|
311
|
+
* Section type (a "pick any section" reference) rather than a user-defined type
|
|
312
|
+
* that merely happens to be named `Section`.
|
|
313
|
+
*
|
|
314
|
+
* The framework's `Section` is opaque — `export type Section = any` in the
|
|
315
|
+
* scaffolded `~/types/deco.ts` — so it resolves to `any` (or `unknown`). A
|
|
316
|
+
* component that declares its own local `type Section = { label; items }`
|
|
317
|
+
* (e.g. a footer column list) resolves to a concrete object with properties;
|
|
318
|
+
* that is user data and must render as an inline editable object, NOT a picker.
|
|
319
|
+
*
|
|
320
|
+
* Discriminates by the resolved shape after stripping `| null | undefined` and
|
|
321
|
+
* the array wrapper: only the opaque type is a section reference.
|
|
322
|
+
*/
|
|
323
|
+
function isOpaqueSectionType(propType: Type): boolean {
|
|
324
|
+
let type = propType;
|
|
325
|
+
if (type.isUnion()) {
|
|
326
|
+
const nonNull = type.getUnionTypes().filter((u) => !u.isNull() && !u.isUndefined());
|
|
327
|
+
if (nonNull.length === 1) type = nonNull[0];
|
|
328
|
+
}
|
|
329
|
+
if (type.isArray()) {
|
|
330
|
+
const el = type.getArrayElementType();
|
|
331
|
+
if (el) type = el;
|
|
332
|
+
}
|
|
333
|
+
return type.isAny() || type.isUnknown();
|
|
334
|
+
}
|
|
335
|
+
|
|
309
336
|
// Only truly React-internal props that are never user-defined.
|
|
310
337
|
// Do NOT include "children", "type", "props", or "key" — those are commonly
|
|
311
338
|
// used as legitimate property names in data interfaces (e.g. SelectedFacet
|
|
@@ -560,9 +587,16 @@ export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?:
|
|
|
560
587
|
if (tn) typeHint = tn.getText();
|
|
561
588
|
}
|
|
562
589
|
|
|
563
|
-
// Section type → section picker reference (resolved by composeMeta)
|
|
590
|
+
// Section type → section picker reference (resolved by composeMeta).
|
|
591
|
+
// Guard on the resolved shape: the name `Section` is not reserved, so a
|
|
592
|
+
// component may declare its own local `type Section = { label; items }`
|
|
593
|
+
// (e.g. a footer column list). Only the framework's opaque Section type
|
|
594
|
+
// becomes a picker; a concretely-shaped local type falls through to the
|
|
595
|
+
// normal inline-object handling below (`isOpaqueSectionType`).
|
|
564
596
|
const baseHint = typeHint.replace(/\s*\|\s*(null|undefined)/g, "").trim();
|
|
565
|
-
|
|
597
|
+
const isSectionName =
|
|
598
|
+
baseHint === "Section" || baseHint === "Section[]" || baseHint === "Section[] | null";
|
|
599
|
+
if (isSectionName && isOpaqueSectionType(propType)) {
|
|
566
600
|
const isArray = baseHint.includes("[]");
|
|
567
601
|
const sectionSchema: any = isArray
|
|
568
602
|
? {
|
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
* .github/workflows/perf.yml — GitHub Actions workflow
|
|
4
4
|
* tools/perf/changed-paths.sh — maps edited sections → CMS page paths
|
|
5
5
|
* tools/perf/compare.mjs — compares two LHCI result dirs, emits markdown
|
|
6
|
-
* lighthouserc.json — LHCI collect settings (3 runs, mobile UA)
|
|
6
|
+
* lighthouserc.json — LHCI collect settings (3 runs, mobile UA, 4 categories)
|
|
7
7
|
*
|
|
8
8
|
* Design rationale:
|
|
9
9
|
* - CF Workers Builds generates a per-PR preview URL (workers.dev). Both the
|
|
10
10
|
* PR and main previews are workers.dev — no edge cache — so the delta is
|
|
11
11
|
* purely code, not cache state.
|
|
12
|
-
* - Gate signal = CLS + TBT
|
|
13
|
-
*
|
|
12
|
+
* - Gate signal = CLS + TBT + a11y/best-practices/SEO scores. CLS/TBT are
|
|
13
|
+
* layout stability / JS work; the three category scores are static-analysis
|
|
14
|
+
* audits. All cache-insensitive, so a regression is real code, not cold SSR.
|
|
15
|
+
* LCP and performance score are informational only (noisy on cold SSR).
|
|
14
16
|
* - Advisory: `continue-on-error: true`. Remove it once the site stabilises.
|
|
15
17
|
* - LHCI 0.15 does not write manifest.json; compare.mjs reads lhr-*.json
|
|
16
18
|
* directly and selects the median run by performance score.
|
|
@@ -40,7 +42,7 @@ function generatePerfYml(workerName: string): string {
|
|
|
40
42
|
# preview URL do output — sem token CF nem build local.
|
|
41
43
|
#
|
|
42
44
|
# Baseline: URL fixa da preview do main (https://${workerName}.deco-cx.workers.dev).
|
|
43
|
-
# Gate: CLS + TBT (estrutura/JS, insensíveis a cache). LCP/score: informativos.
|
|
45
|
+
# Gate: CLS + TBT + A11y/BP/SEO (estrutura/JS + auditorias estáticas, insensíveis a cache). LCP/score: informativos.
|
|
44
46
|
|
|
45
47
|
on:
|
|
46
48
|
pull_request:
|
|
@@ -297,8 +299,10 @@ const COMPARE_MJS = `#!/usr/bin/env node
|
|
|
297
299
|
// cache, same runner/region, same mobile UA — so the delta is the PR's code
|
|
298
300
|
// effect, not cache warm/cold.
|
|
299
301
|
//
|
|
300
|
-
// Gate signal = CLS + TBT
|
|
301
|
-
//
|
|
302
|
+
// Gate signal = CLS + TBT + a11y/best-practices/SEO scores. CLS/TBT are page
|
|
303
|
+
// structure / JS work; the three category scores are static-analysis audits.
|
|
304
|
+
// All are cache-insensitive, so a regression is real code, not cold SSR.
|
|
305
|
+
// LCP / performance score are shown but informative only (noisy on cold workers.dev SSR).
|
|
302
306
|
//
|
|
303
307
|
// LHCI 0.15 does not write manifest.json; we read lhr-*.json directly.
|
|
304
308
|
//
|
|
@@ -312,7 +316,12 @@ const TBT_ABS = 50; // ms — deltas below this are noise
|
|
|
312
316
|
const TBT_REL = 0.15; // 15% relative change
|
|
313
317
|
const CLS_ABS = 0.02; // absolute CLS delta that matters
|
|
314
318
|
const CLS_FLOOR = 0.1; // only flag CLS regression when PR is above "good"
|
|
319
|
+
const SCORE_DROP = 3; // pts — a11y/bp/seo score drop that gates (tolerates 1–2pt audit jitter)
|
|
320
|
+
const SCORES = ["a11y", "bp", "seo"]; // gated category scores (0–100)
|
|
315
321
|
|
|
322
|
+
// base/pr = { tbt, cls, a11y, bp, seo }. Called from selftest with only
|
|
323
|
+
// { tbt, cls }: the SCORES deltas become NaN and NaN comparisons are false,
|
|
324
|
+
// so score gating is simply inert there.
|
|
316
325
|
function verdict(base, pr) {
|
|
317
326
|
const tbtUp = pr.tbt - base.tbt;
|
|
318
327
|
const tbtBad = tbtUp > TBT_ABS && tbtUp > base.tbt * TBT_REL;
|
|
@@ -320,8 +329,10 @@ function verdict(base, pr) {
|
|
|
320
329
|
const clsUp = pr.cls - base.cls;
|
|
321
330
|
const clsBad = clsUp > CLS_ABS && pr.cls > CLS_FLOOR;
|
|
322
331
|
const clsGood = -clsUp > CLS_ABS && base.cls > CLS_FLOOR;
|
|
323
|
-
|
|
324
|
-
|
|
332
|
+
const scoreBad = SCORES.some((k) => pr[k] - base[k] <= -SCORE_DROP);
|
|
333
|
+
const scoreGood = !scoreBad && SCORES.some((k) => pr[k] - base[k] >= SCORE_DROP);
|
|
334
|
+
if (tbtBad || clsBad || scoreBad) return { icon: "🔴", gate: true };
|
|
335
|
+
if (tbtGood || clsGood || scoreGood) return { icon: "🟢", gate: false };
|
|
325
336
|
return { icon: "⚪", gate: false };
|
|
326
337
|
}
|
|
327
338
|
|
|
@@ -340,7 +351,10 @@ function loadDir(dir) {
|
|
|
340
351
|
const lhr = runs[Math.floor(runs.length / 2)];
|
|
341
352
|
const a = lhr.audits;
|
|
342
353
|
byPath[new URL(url).pathname] = {
|
|
343
|
-
score: Math.round((lhr.categories.performance
|
|
354
|
+
score: Math.round((lhr.categories.performance?.score ?? 0) * 100),
|
|
355
|
+
a11y: Math.round((lhr.categories.accessibility?.score ?? 0) * 100),
|
|
356
|
+
bp: Math.round((lhr.categories["best-practices"]?.score ?? 0) * 100),
|
|
357
|
+
seo: Math.round((lhr.categories.seo?.score ?? 0) * 100),
|
|
344
358
|
lcp: a["largest-contentful-paint"].numericValue,
|
|
345
359
|
cls: a["cumulative-layout-shift"].numericValue,
|
|
346
360
|
tbt: a["total-blocking-time"].numericValue,
|
|
@@ -351,11 +365,14 @@ function loadDir(dir) {
|
|
|
351
365
|
|
|
352
366
|
const ms = (v) => \`\${Math.round(v)}ms\`;
|
|
353
367
|
const cls = (v) => v.toFixed(3);
|
|
368
|
+
const pts = (v) => \`\${Math.round(v)}\`;
|
|
354
369
|
function delta(base, pr, fmt) {
|
|
355
370
|
const d = pr - base;
|
|
356
371
|
const arrow = d < 0 ? "↓" : d > 0 ? "↑" : "";
|
|
357
372
|
return \`\${d > 0 ? "+" : ""}\${fmt(d)} \${arrow}\`.trim();
|
|
358
373
|
}
|
|
374
|
+
// base→PR (Δ) for a 0–100 category score.
|
|
375
|
+
const scoreCell = (b, p) => \`\${b}→\${p} (\${delta(b, p, pts)})\`;
|
|
359
376
|
|
|
360
377
|
function render(base, pr) {
|
|
361
378
|
const paths = [...new Set([...Object.keys(base), ...Object.keys(pr)])].sort();
|
|
@@ -365,29 +382,29 @@ function render(base, pr) {
|
|
|
365
382
|
"",
|
|
366
383
|
"Ambas as previews rodam em \`*.workers.dev\` (sem edge cache), mesmo runner e UA mobile — o delta é efeito do código, não de cache quente/frio.",
|
|
367
384
|
"",
|
|
368
|
-
"**Gate:** CLS + TBT (estrutura
|
|
385
|
+
"**Gate:** CLS + TBT + A11y/BP/SEO (estrutura/JS + auditorias estáticas). LCP e score de performance são informativos (ruído esperado em SSR frio).",
|
|
369
386
|
"",
|
|
370
|
-
"| Página | | CLS (base→PR) | TBT (base→PR) | LCP | Score |",
|
|
371
|
-
"
|
|
387
|
+
"| Página | | CLS (base→PR) | TBT (base→PR) | A11y | BP | SEO | LCP | Score |",
|
|
388
|
+
"|---|:--:|---|---|---|---|---|---|---|",
|
|
372
389
|
];
|
|
373
390
|
for (const p of paths) {
|
|
374
391
|
const b = base[p];
|
|
375
392
|
const r = pr[p];
|
|
376
393
|
if (!b || !r) {
|
|
377
|
-
lines.push(\`| \\\`\${p}\\\` | ⚠️ | \${b ? "faltou PR" : "faltou base"} | | | |\`);
|
|
394
|
+
lines.push(\`| \\\`\${p}\\\` | ⚠️ | \${b ? "faltou PR" : "faltou base"} | | | | | | |\`);
|
|
378
395
|
continue;
|
|
379
396
|
}
|
|
380
397
|
const v = verdict(b, r);
|
|
381
398
|
anyGate = anyGate || v.gate;
|
|
382
399
|
lines.push(
|
|
383
|
-
\`| \\\`\${p}\\\` | \${v.icon} | \${cls(b.cls)}→\${cls(r.cls)} (\${delta(b.cls, r.cls, cls)}) | \${ms(b.tbt)}→\${ms(r.tbt)} (\${delta(b.tbt, r.tbt, ms)}) | \${ms(b.lcp)}→\${ms(r.lcp)} | \${b.score}→\${r.score} |\`,
|
|
400
|
+
\`| \\\`\${p}\\\` | \${v.icon} | \${cls(b.cls)}→\${cls(r.cls)} (\${delta(b.cls, r.cls, cls)}) | \${ms(b.tbt)}→\${ms(r.tbt)} (\${delta(b.tbt, r.tbt, ms)}) | \${scoreCell(b.a11y, r.a11y)} | \${scoreCell(b.bp, r.bp)} | \${scoreCell(b.seo, r.seo)} | \${ms(b.lcp)}→\${ms(r.lcp)} | \${b.score}→\${r.score} |\`,
|
|
384
401
|
);
|
|
385
402
|
}
|
|
386
403
|
lines.push("");
|
|
387
404
|
lines.push(
|
|
388
405
|
anyGate
|
|
389
|
-
? "🔴 **Regressão
|
|
390
|
-
: "🟢 Sem regressão
|
|
406
|
+
? "🔴 **Regressão detectada em CLS/TBT/A11y/BP/SEO** (advisory — não trava o merge)."
|
|
407
|
+
: "🟢 Sem regressão nas métricas gateadas (CLS/TBT/A11y/BP/SEO).",
|
|
391
408
|
);
|
|
392
409
|
return { md: lines.join("\\n"), anyGate };
|
|
393
410
|
}
|
|
@@ -400,6 +417,13 @@ function selftest() {
|
|
|
400
417
|
ok(!verdict({ tbt: 100, cls: 0.02 }, { tbt: 100, cls: 0.08 }).gate, "cls under 0.1 floor no gate");
|
|
401
418
|
ok(verdict({ tbt: 300, cls: 0.01 }, { tbt: 100, cls: 0.01 }).icon === "🟢", "tbt drop is green");
|
|
402
419
|
ok(verdict({ tbt: 100, cls: 0.01 }, { tbt: 105, cls: 0.01 }).icon === "⚪", "tiny change is neutral");
|
|
420
|
+
// Category-score gating. S() supplies a neutral CLS/TBT baseline plus perfect scores.
|
|
421
|
+
const S = (o) => ({ tbt: 100, cls: 0.01, a11y: 100, bp: 100, seo: 100, ...o });
|
|
422
|
+
ok(verdict(S({}), S({ seo: 96 })).gate, "seo 100→96 (-4) should gate");
|
|
423
|
+
ok(verdict(S({ a11y: 90 }), S({ a11y: 87 })).gate, "a11y -3 should gate");
|
|
424
|
+
ok(!verdict(S({}), S({ bp: 98 })).gate, "bp -2 is jitter, no gate");
|
|
425
|
+
ok(verdict(S({ a11y: 90 }), S({ a11y: 95 })).icon === "🟢", "a11y +5 is green");
|
|
426
|
+
ok(verdict(S({}), S({ a11y: 99, seo: 97 })).icon === "🔴", "seo -3 gates even with a11y -1");
|
|
403
427
|
console.log("compare.mjs selftest: OK");
|
|
404
428
|
}
|
|
405
429
|
|
|
@@ -411,12 +435,12 @@ process.stdout.write(render(loadDir(baseDir), loadDir(prDir)).md + "\\n");
|
|
|
411
435
|
`;
|
|
412
436
|
|
|
413
437
|
const LIGHTHOUSERC_JSON = `{
|
|
414
|
-
"//": "Lighthouse CI config for the per-PR perf workflow (.github/workflows/perf.yml). Mobile UA matches worker-entry.ts buildSegment (MOBILE_RE) so warmup and measurement hit the same device cache key.",
|
|
438
|
+
"//": "Lighthouse CI config for the per-PR perf workflow (.github/workflows/perf.yml). Mobile UA matches worker-entry.ts buildSegment (MOBILE_RE) so warmup and measurement hit the same device cache key. accessibility/best-practices/seo são análise estática determinística (não sofrem ruído de SSR frio como LCP/score), então compare.mjs os gateia por regressão de score.",
|
|
415
439
|
"ci": {
|
|
416
440
|
"collect": {
|
|
417
441
|
"numberOfRuns": 3,
|
|
418
442
|
"settings": {
|
|
419
|
-
"onlyCategories": ["performance"],
|
|
443
|
+
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
|
|
420
444
|
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
|
421
445
|
}
|
|
422
446
|
}
|