@decocms/blocks-cli 8.0.0 → 8.1.0-next.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 +5 -3
- package/scripts/analyze-traces.mjs +1 -0
- package/scripts/cdn-rules.test.ts +105 -0
- package/scripts/cdn-rules.ts +192 -0
- package/scripts/deco-migrate-cli.ts +6 -7
- package/scripts/fast-deploy-kv.test.ts +172 -0
- package/scripts/generate-blocks.test.ts +11 -0
- package/scripts/generate-blocks.ts +0 -10
- package/scripts/generate-invoke.test.ts +2 -2
- package/scripts/generate-invoke.ts +9 -21
- package/scripts/generate-loaders.test.ts +2 -2
- package/scripts/generate-loaders.ts +0 -10
- package/scripts/generate-schema-loader-aliases.test.ts +186 -0
- package/scripts/generate-schema-workspaces.test.ts +180 -0
- package/scripts/generate-schema.test.ts +68 -29
- package/scripts/generate-schema.ts +101 -89
- package/scripts/generate-sections.test.ts +104 -35
- package/scripts/generate-sections.ts +57 -11
- package/scripts/generate-workspaces.test.ts +179 -0
- package/scripts/generate.test.ts +1 -2
- package/scripts/generate.ts +43 -45
- package/scripts/lib/blocks-dedupe.test.ts +1 -1
- package/scripts/lib/cf-kv-rest.ts +49 -1
- package/scripts/lib/installedPackages.ts +42 -0
- package/scripts/lib/invokeSource.ts +18 -0
- package/scripts/lib/kv-snapshot.ts +44 -0
- package/scripts/lib/read-decofile.ts +13 -2
- package/scripts/lib/upgrade-6-to-7.ts +1 -1
- package/scripts/lib/wrangler-config.test.ts +49 -0
- package/scripts/lib/wrangler-config.ts +31 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
- package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
- package/scripts/migrate/config.ts +2 -2
- package/scripts/migrate/delete-sets.ts +75 -0
- package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
- package/scripts/migrate/icon-casing.test.ts +63 -0
- package/scripts/migrate/phase-analyze.test.ts +66 -4
- package/scripts/migrate/phase-analyze.ts +79 -60
- package/scripts/migrate/phase-cleanup.test.ts +3 -1
- package/scripts/migrate/phase-cleanup.ts +1 -1
- package/scripts/migrate/phase-compile.test.ts +3 -1
- package/scripts/migrate/phase-report.test.ts +2 -0
- package/scripts/migrate/phase-report.ts +3 -4
- package/scripts/migrate/phase-scaffold.ts +150 -45
- package/scripts/migrate/phase-transform.ts +107 -2
- package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
- package/scripts/migrate/phase-verify.test.ts +3 -1
- package/scripts/migrate/phase-verify.ts +87 -33
- package/scripts/migrate/post-cleanup/rules.ts +29 -11
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
- package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
- package/scripts/migrate/source-layout.test.ts +43 -0
- package/scripts/migrate/source-layout.ts +32 -5
- package/scripts/migrate/templates/app-css.test.ts +24 -0
- package/scripts/migrate/templates/app-css.ts +27 -0
- package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
- package/scripts/migrate/templates/ci-yml.ts +182 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
- package/scripts/migrate/templates/hooks.test.ts +3 -1
- package/scripts/migrate/templates/lib-utils.ts +3 -3
- package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
- package/scripts/migrate/templates/package-json.ts +10 -0
- package/scripts/migrate/templates/parity-yml.ts +136 -0
- package/scripts/migrate/templates/perf-yml.ts +449 -0
- package/scripts/migrate/templates/playwright-yml.ts +127 -0
- package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
- package/scripts/migrate/templates/routes.test.ts +31 -0
- package/scripts/migrate/templates/routes.ts +19 -9
- package/scripts/migrate/templates/section-loaders.ts +3 -3
- package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
- package/scripts/migrate/templates/server-entry.ts +19 -14
- package/scripts/migrate/templates/setup.ts +11 -3
- package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
- package/scripts/migrate/templates/ui-components.ts +3 -0
- package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
- package/scripts/migrate/transforms/ctx-compat.ts +93 -1
- package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
- package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
- package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
- package/scripts/migrate/transforms/jsx.ts +10 -0
- package/scripts/migrate/transforms/picture.test.ts +56 -0
- package/scripts/migrate/transforms/picture.ts +95 -0
- package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
- package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
- package/scripts/migrate/transforms/timer-types.test.ts +30 -0
- package/scripts/migrate/transforms/timer-types.ts +48 -0
- package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
- package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
- package/scripts/migrate/types.ts +13 -1
- package/scripts/migrate-blocks-to-kv.ts +16 -2
- package/scripts/migrate-to-cf-observability.test.ts +5 -5
- package/scripts/migrate-to-cf-observability.ts +1 -1
- package/scripts/migrate.ts +244 -6
- package/scripts/reconcile.test.ts +162 -0
- package/scripts/reconcile.ts +303 -0
- package/scripts/sync-blocks-bot.test.ts +276 -0
- package/scripts/sync-blocks-bot.ts +567 -0
- package/scripts/sync-blocks-to-kv.ts +20 -2
- package/scripts/upgrade-6-to-7.ts +0 -0
- package/scripts/lib/legacyArtifact.ts +0 -20
|
@@ -27,16 +27,14 @@ import { fileURLToPath } from "node:url";
|
|
|
27
27
|
* --skip-apps Skip app schema generation
|
|
28
28
|
* --out Output file (default: ".deco/meta.gen.json")
|
|
29
29
|
* --platform Platform name (default: "cloudflare")
|
|
30
|
-
* --
|
|
31
|
-
* SELF-CONTAINED (bakes in Page, matchers, __SECTION_REF__,
|
|
32
|
-
* Resolvable). For consumers that read meta.gen.json straight
|
|
33
|
-
* from disk with no runtime (FS-based Studio / Eitri stack).
|
|
34
|
-
* --framework With --compose, the value written to the `framework` field
|
|
30
|
+
* --framework Value written to the composed meta's `framework` field
|
|
35
31
|
* (default: "tanstack-start").
|
|
36
32
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
33
|
+
* The output is ALWAYS run through composeMeta() before writing, so
|
|
34
|
+
* meta.gen.json is self-contained (bakes in Page, matchers, __SECTION_REF__,
|
|
35
|
+
* Resolvable). This matters for consumers that read the file straight from
|
|
36
|
+
* disk with no runtime (FS-based Studio / Eitri stack); runtime readers
|
|
37
|
+
* re-compose idempotently (see composeMeta's `framework` sentinel).
|
|
40
38
|
*/
|
|
41
39
|
import {
|
|
42
40
|
type Symbol as MorphSymbol,
|
|
@@ -47,7 +45,7 @@ import {
|
|
|
47
45
|
type Type,
|
|
48
46
|
} from "ts-morph";
|
|
49
47
|
import { isExcludedCodegenFile } from "./lib/codegenExclusions";
|
|
50
|
-
import {
|
|
48
|
+
import { resolvePackageDirectory } from "./lib/installedPackages";
|
|
51
49
|
|
|
52
50
|
// ---------------------------------------------------------------------------
|
|
53
51
|
// CLI arg parsing
|
|
@@ -56,12 +54,9 @@ import { warnLegacyArtifact } from "./lib/legacyArtifact";
|
|
|
56
54
|
// here) so that importing this module for its pure exports
|
|
57
55
|
// (definitionIdForPath, applyWidgetFormat, typeToJsonSchema — see
|
|
58
56
|
// generate-schema.test.ts) never reads argv or touches the filesystem.
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// the test actually wanted to exercise. generateMeta() (below) and the
|
|
63
|
-
// final write are themselves only reached inside `if (isMainModule())`, so
|
|
64
|
-
// these vars only need real values in that same case.
|
|
57
|
+
// generateMeta() (below) and the final write are themselves only reached
|
|
58
|
+
// inside `if (isMainModule())`, so these vars only need real values in that
|
|
59
|
+
// same case.
|
|
65
60
|
// ---------------------------------------------------------------------------
|
|
66
61
|
const argv = process.argv.slice(2);
|
|
67
62
|
function arg(name: string, fallback: string): string {
|
|
@@ -70,7 +65,6 @@ function arg(name: string, fallback: string): string {
|
|
|
70
65
|
}
|
|
71
66
|
|
|
72
67
|
const NEW_DEFAULT_OUT_REL = ".deco/meta.gen.json";
|
|
73
|
-
const OLD_DEFAULT_OUT_REL = "src/server/admin/meta.gen.json";
|
|
74
68
|
|
|
75
69
|
let SITE_NAMESPACE = "site";
|
|
76
70
|
let SITE_NAME = "storefront";
|
|
@@ -81,14 +75,12 @@ let APPS_REL = "src/apps";
|
|
|
81
75
|
let SKIP_APPS = false;
|
|
82
76
|
let OUT_REL = NEW_DEFAULT_OUT_REL;
|
|
83
77
|
let PLATFORM = "cloudflare";
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// (
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
// Value written to the composed meta's `framework` field (only used with
|
|
91
|
-
// --compose). Defaults to composeMeta's historical "tanstack-start".
|
|
78
|
+
// Value written to the composed meta's `framework` field. Defaults to
|
|
79
|
+
// composeMeta's historical "tanstack-start". composeMeta ALWAYS runs before
|
|
80
|
+
// writing (see the write block below), so the output file is SELF-CONTAINED —
|
|
81
|
+
// it carries the framework block types (Page, matchers, __SECTION_REF__,
|
|
82
|
+
// Resolvable). Required by consumers that read meta.gen.json straight from the
|
|
83
|
+
// filesystem with no runtime (e.g. the FS-based Studio / Eitri stack).
|
|
92
84
|
let FRAMEWORK = "tanstack-start";
|
|
93
85
|
|
|
94
86
|
if (isMainModule()) {
|
|
@@ -99,14 +91,9 @@ if (isMainModule()) {
|
|
|
99
91
|
LOADERS_REL = arg("loaders", LOADERS_REL);
|
|
100
92
|
APPS_REL = arg("apps", APPS_REL);
|
|
101
93
|
SKIP_APPS = argv.includes("--skip-apps");
|
|
102
|
-
const outFileExplicit = argv.includes("--out");
|
|
103
94
|
OUT_REL = arg("out", NEW_DEFAULT_OUT_REL);
|
|
104
95
|
PLATFORM = arg("platform", PLATFORM);
|
|
105
|
-
COMPOSE = argv.includes("--compose");
|
|
106
96
|
FRAMEWORK = arg("framework", FRAMEWORK);
|
|
107
|
-
if (!outFileExplicit && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_REL))) {
|
|
108
|
-
warnLegacyArtifact(OLD_DEFAULT_OUT_REL, NEW_DEFAULT_OUT_REL);
|
|
109
|
-
}
|
|
110
97
|
}
|
|
111
98
|
|
|
112
99
|
// ---------------------------------------------------------------------------
|
|
@@ -319,6 +306,33 @@ const SECTION_REF_DEF_KEY = "__SECTION_REF__";
|
|
|
319
306
|
// Well-known definition key for Resolvable (saved blocks picker)
|
|
320
307
|
const RESOLVABLE_KEY = "Resolvable";
|
|
321
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
|
+
|
|
322
336
|
// Only truly React-internal props that are never user-defined.
|
|
323
337
|
// Do NOT include "children", "type", "props", or "key" — those are commonly
|
|
324
338
|
// used as legitimate property names in data interfaces (e.g. SelectedFacet
|
|
@@ -356,6 +370,37 @@ interface GenerationContext {
|
|
|
356
370
|
outputTypeToLoaderKeys: Map<string, string[]>;
|
|
357
371
|
}
|
|
358
372
|
|
|
373
|
+
function namedLoaderType(type: Type | undefined): string | null {
|
|
374
|
+
if (!type) return null;
|
|
375
|
+
const name = type.getSymbol()?.getName();
|
|
376
|
+
if (
|
|
377
|
+
name &&
|
|
378
|
+
name !== "__type" &&
|
|
379
|
+
name !== "__object" &&
|
|
380
|
+
name !== "Array" &&
|
|
381
|
+
name !== "ReadonlyArray"
|
|
382
|
+
) {
|
|
383
|
+
return name;
|
|
384
|
+
}
|
|
385
|
+
// Mapped/object aliases have a synthetic symbol but retain their own name.
|
|
386
|
+
// Bare Omit<T, K>/Generic<T> must not share a bucket across instantiations.
|
|
387
|
+
const alias = type.getAliasSymbol();
|
|
388
|
+
return alias && type.getAliasTypeArguments().length === 0 ? alias.getName() : null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Same bounded name lookup for loader outputs and section props; no shape matching. */
|
|
392
|
+
function loaderTypeName(type: Type): string | null {
|
|
393
|
+
if (type.isUnion()) {
|
|
394
|
+
const nonNull = type.getUnionTypes().filter((t) => !t.isNull() && !t.isUndefined());
|
|
395
|
+
if (nonNull.length === 1) type = nonNull[0];
|
|
396
|
+
}
|
|
397
|
+
if (type.isArray()) {
|
|
398
|
+
const name = namedLoaderType(type.getArrayElementType());
|
|
399
|
+
return name ? `${name}[]` : null;
|
|
400
|
+
}
|
|
401
|
+
return namedLoaderType(type);
|
|
402
|
+
}
|
|
403
|
+
|
|
359
404
|
/**
|
|
360
405
|
* Extract the return type name of a loader's default export.
|
|
361
406
|
* Unwraps Promise<T> and T | null wrappers.
|
|
@@ -370,25 +415,7 @@ function extractLoaderOutputTypeName(sourceFile: SourceFile): string | null {
|
|
|
370
415
|
const args = ret.getTypeArguments();
|
|
371
416
|
if (args.length) ret = args[0];
|
|
372
417
|
}
|
|
373
|
-
|
|
374
|
-
const nonNull = ret.getUnionTypes().filter((t) => !t.isNull() && !t.isUndefined());
|
|
375
|
-
if (nonNull.length === 1) ret = nonNull[0];
|
|
376
|
-
}
|
|
377
|
-
// Unwrap array element type — Product[] → "Product[]" (keyed as array).
|
|
378
|
-
// When the element type has no resolvable name (primitives like `string[]`,
|
|
379
|
-
// or opaque types like `VNode[]`), there is NO meaningful output-type key.
|
|
380
|
-
// Returning the generic `Array` symbol here would bucket the loader under
|
|
381
|
-
// "Array", which then collides with EVERY array-typed section prop
|
|
382
|
-
// (`Collection[]`, `Tab[]`, …) and wrongly turns them into loader pickers.
|
|
383
|
-
if (ret.isArray()) {
|
|
384
|
-
const elType = ret.getArrayElementType();
|
|
385
|
-
const elName = elType?.getSymbol()?.getName() ?? elType?.getAliasSymbol()?.getName() ?? null;
|
|
386
|
-
return elName ? `${elName}[]` : null;
|
|
387
|
-
}
|
|
388
|
-
// Reject anonymous object returns (`{ … }` → symbol name "__type"): they are
|
|
389
|
-
// not a nameable output type and would over-match anonymous-object props.
|
|
390
|
-
const name = ret.getSymbol()?.getName() ?? ret.getAliasSymbol()?.getName() ?? null;
|
|
391
|
-
return name && name !== "__type" ? name : null;
|
|
418
|
+
return loaderTypeName(ret);
|
|
392
419
|
}
|
|
393
420
|
|
|
394
421
|
export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?: GenerationContext): any {
|
|
@@ -573,9 +600,16 @@ export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?:
|
|
|
573
600
|
if (tn) typeHint = tn.getText();
|
|
574
601
|
}
|
|
575
602
|
|
|
576
|
-
// Section type → section picker reference (resolved by composeMeta)
|
|
603
|
+
// Section type → section picker reference (resolved by composeMeta).
|
|
604
|
+
// Guard on the resolved shape: the name `Section` is not reserved, so a
|
|
605
|
+
// component may declare its own local `type Section = { label; items }`
|
|
606
|
+
// (e.g. a footer column list). Only the framework's opaque Section type
|
|
607
|
+
// becomes a picker; a concretely-shaped local type falls through to the
|
|
608
|
+
// normal inline-object handling below (`isOpaqueSectionType`).
|
|
577
609
|
const baseHint = typeHint.replace(/\s*\|\s*(null|undefined)/g, "").trim();
|
|
578
|
-
|
|
610
|
+
const isSectionName =
|
|
611
|
+
baseHint === "Section" || baseHint === "Section[]" || baseHint === "Section[] | null";
|
|
612
|
+
if (isSectionName && isOpaqueSectionType(propType)) {
|
|
579
613
|
const isArray = baseHint.includes("[]");
|
|
580
614
|
const sectionSchema: any = isArray
|
|
581
615
|
? {
|
|
@@ -599,32 +633,10 @@ export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?:
|
|
|
599
633
|
// Loader output type → block-ref: emit anyOf [Resolvable, ...matchingLoaders]
|
|
600
634
|
// baseHint strips "| null | undefined" so "ProductListingPage | null" → "ProductListingPage"
|
|
601
635
|
if (ctx?.outputTypeToLoaderKeys) {
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
// text (`baseHint`, e.g. "Collection[]") instead. The element-based
|
|
606
|
-
// `${elName}[]` lookup below handles arrays precisely.
|
|
607
|
-
const symName = typeSym?.getName();
|
|
608
|
-
const outputTypeName = symName && symName !== "Array" ? symName : baseHint;
|
|
609
|
-
let matchingLoaders =
|
|
610
|
-
ctx.outputTypeToLoaderKeys.get(outputTypeName) ??
|
|
636
|
+
const outputTypeName = loaderTypeName(propType);
|
|
637
|
+
const matchingLoaders =
|
|
638
|
+
(outputTypeName ? ctx.outputTypeToLoaderKeys.get(outputTypeName) : undefined) ??
|
|
611
639
|
(outputTypeName !== baseHint ? ctx.outputTypeToLoaderKeys.get(baseHint) : undefined);
|
|
612
|
-
// If no match yet and the prop type is an array, try element type name + "[]"
|
|
613
|
-
if (!matchingLoaders?.length) {
|
|
614
|
-
let arrayElementType = propType.isArray() ? propType.getArrayElementType() : null;
|
|
615
|
-
if (!arrayElementType && propType.isUnion()) {
|
|
616
|
-
const nonNull = propType.getUnionTypes().filter((t) => !t.isNull() && !t.isUndefined());
|
|
617
|
-
if (nonNull.length === 1 && nonNull[0].isArray()) {
|
|
618
|
-
arrayElementType = nonNull[0].getArrayElementType();
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
if (arrayElementType) {
|
|
622
|
-
const elName = arrayElementType.getSymbol()?.getName() ?? arrayElementType.getAliasSymbol()?.getName();
|
|
623
|
-
if (elName) {
|
|
624
|
-
matchingLoaders = ctx.outputTypeToLoaderKeys.get(`${elName}[]`);
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
640
|
if (matchingLoaders?.length) {
|
|
629
641
|
const blockRefSchema: any = {
|
|
630
642
|
anyOf: [
|
|
@@ -1051,8 +1063,8 @@ function generateMeta(): MetaResponse {
|
|
|
1051
1063
|
// ---------------------------------------------------------------------------
|
|
1052
1064
|
|
|
1053
1065
|
/** Absolute path to the installed @decocms/apps-<namespace> package, if present. */
|
|
1054
|
-
function getAppPkgDir(namespace: string): string {
|
|
1055
|
-
return
|
|
1066
|
+
function getAppPkgDir(namespace: string): string | null {
|
|
1067
|
+
return resolvePackageDirectory(root, `@decocms/apps-${namespace}`);
|
|
1056
1068
|
}
|
|
1057
1069
|
|
|
1058
1070
|
/** Detect installed app namespaces from src/apps/ bridge files. */
|
|
@@ -1082,6 +1094,7 @@ function generateMeta(): MetaResponse {
|
|
|
1082
1094
|
|
|
1083
1095
|
for (const namespace of installed) {
|
|
1084
1096
|
const pkgDir = getAppPkgDir(namespace);
|
|
1097
|
+
if (!pkgDir) continue;
|
|
1085
1098
|
const loadersDir = path.join(pkgDir, "src", "loaders");
|
|
1086
1099
|
if (!fs.existsSync(loadersDir)) continue;
|
|
1087
1100
|
|
|
@@ -1522,18 +1535,17 @@ function isMainModule(): boolean {
|
|
|
1522
1535
|
}
|
|
1523
1536
|
|
|
1524
1537
|
if (isMainModule()) {
|
|
1525
|
-
// Wrapped in an async IIFE so
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1538
|
+
// Wrapped in an async IIFE so the composeMeta dynamic import happens only
|
|
1539
|
+
// when this module is actually run (any test importing this module's pure
|
|
1540
|
+
// exports never reaches here, so it never pulls in the @decocms/blocks/cms
|
|
1541
|
+
// barrel). composeMeta always runs, making the written file self-contained.
|
|
1528
1542
|
void (async () => {
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
meta = composeMeta(meta, { framework: FRAMEWORK }) as MetaResponse;
|
|
1536
|
-
}
|
|
1543
|
+
const rawMeta = generateMeta();
|
|
1544
|
+
const { composeMeta } = await import("@decocms/blocks/cms");
|
|
1545
|
+
// composeMeta returns @decocms/blocks' MetaResponse (platform optional);
|
|
1546
|
+
// this file's local MetaResponse requires platform. It's always present
|
|
1547
|
+
// (composeMeta spreads siteMeta, which set it), so the cast is safe.
|
|
1548
|
+
const meta = composeMeta(rawMeta, { framework: FRAMEWORK }) as MetaResponse;
|
|
1537
1549
|
const outPath = path.resolve(process.cwd(), OUT_REL);
|
|
1538
1550
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
1539
1551
|
fs.writeFileSync(outPath, JSON.stringify(meta, null, 2));
|
|
@@ -1543,7 +1555,7 @@ if (isMainModule()) {
|
|
|
1543
1555
|
const ldrCount = Object.keys(meta.manifest.blocks.loaders || {}).length;
|
|
1544
1556
|
const appCount = Object.keys(meta.manifest.blocks.apps || {}).length;
|
|
1545
1557
|
console.log(
|
|
1546
|
-
`\nGenerated schema
|
|
1558
|
+
`\nGenerated schema (self-contained): ${defCount} definitions, ${secCount} sections, ${ldrCount} loaders, ${appCount} apps → ${path.relative(process.cwd(), outPath)}`,
|
|
1547
1559
|
);
|
|
1548
1560
|
})();
|
|
1549
1561
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Verifies the operationally important behavior: a co-located test/spec/
|
|
11
11
|
* stories/gen file sitting next to a real section must never be walked into
|
|
12
|
-
* sectionMeta (
|
|
12
|
+
* sectionMeta (a production-site incident this generator's `walkDir` reproduced:
|
|
13
13
|
* `sections.test.ts` became a bogus section in a site's generated output).
|
|
14
14
|
*/
|
|
15
15
|
import * as cp from "node:child_process";
|
|
@@ -72,6 +72,22 @@ describe("generate-sections walkDir exclusions", () => {
|
|
|
72
72
|
expect(generated).not.toContain("Hero.stories.tsx");
|
|
73
73
|
expect(generated).not.toContain("sections.gen.ts");
|
|
74
74
|
}, 30_000);
|
|
75
|
+
|
|
76
|
+
it("extracts `export const deferred = true` into sectionMeta", () => {
|
|
77
|
+
fs.writeFileSync(
|
|
78
|
+
path.join(sectionsDir, "HeavyPLP.tsx"),
|
|
79
|
+
"export const deferred = true;\nexport default function HeavyPLP() { return null; }\n",
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]);
|
|
83
|
+
expect(code).toBe(0);
|
|
84
|
+
|
|
85
|
+
const generated = fs.readFileSync(outFile, "utf-8");
|
|
86
|
+
// The per-section deferral flag must survive the regex scan → sectionMeta,
|
|
87
|
+
// or applySectionConventions can never register it as always-defer.
|
|
88
|
+
expect(generated).toMatch(/"site\/sections\/HeavyPLP\.tsx":\s*\{[^}]*deferred:\s*true/);
|
|
89
|
+
expect(generated).toContain("deferred?: boolean;");
|
|
90
|
+
}, 30_000);
|
|
75
91
|
});
|
|
76
92
|
|
|
77
93
|
describe("generate-sections default output path (.deco/)", () => {
|
|
@@ -99,39 +115,9 @@ describe("generate-sections default output path (.deco/)", () => {
|
|
|
99
115
|
const newDefault = path.join(tmpDir, ".deco", "sections.gen.ts");
|
|
100
116
|
expect(fs.existsSync(newDefault)).toBe(true);
|
|
101
117
|
expect(fs.readFileSync(newDefault, "utf-8")).toContain("site/sections/Hero.tsx");
|
|
102
|
-
// No legacy
|
|
118
|
+
// No legacy default path is written anymore, so no warning is expected.
|
|
103
119
|
expect(stderr).not.toContain("Generator default output moved");
|
|
104
120
|
}, 30_000);
|
|
105
|
-
|
|
106
|
-
it("warns once to stderr naming both paths when the OLD default file exists and no --out-file is passed, but still writes the NEW default", () => {
|
|
107
|
-
const oldDefaultDir = path.join(tmpDir, "src", "server", "cms");
|
|
108
|
-
fs.mkdirSync(oldDefaultDir, { recursive: true });
|
|
109
|
-
fs.writeFileSync(path.join(oldDefaultDir, "sections.gen.ts"), "// stale\n");
|
|
110
|
-
|
|
111
|
-
const { code, stderr } = runGenerator([], { cwd: tmpDir });
|
|
112
|
-
expect(code).toBe(0);
|
|
113
|
-
|
|
114
|
-
expect(stderr).toContain("src/server/cms/sections.gen.ts");
|
|
115
|
-
expect(stderr).toContain(".deco/sections.gen.ts");
|
|
116
|
-
expect(stderr).toContain("Move the file and update its importers");
|
|
117
|
-
|
|
118
|
-
const newDefault = path.join(tmpDir, ".deco", "sections.gen.ts");
|
|
119
|
-
expect(fs.existsSync(newDefault)).toBe(true);
|
|
120
|
-
expect(fs.readFileSync(newDefault, "utf-8")).toContain("site/sections/Hero.tsx");
|
|
121
|
-
}, 30_000);
|
|
122
|
-
|
|
123
|
-
it("does not warn when an explicit --out-file is passed, even if the OLD default file exists", () => {
|
|
124
|
-
const oldDefaultDir = path.join(tmpDir, "src", "server", "cms");
|
|
125
|
-
fs.mkdirSync(oldDefaultDir, { recursive: true });
|
|
126
|
-
fs.writeFileSync(path.join(oldDefaultDir, "sections.gen.ts"), "// stale\n");
|
|
127
|
-
|
|
128
|
-
const explicitOut = path.join(tmpDir, "custom", "sections.gen.ts");
|
|
129
|
-
const { code, stderr } = runGenerator(["--out-file", explicitOut], { cwd: tmpDir });
|
|
130
|
-
expect(code).toBe(0);
|
|
131
|
-
|
|
132
|
-
expect(stderr).not.toContain("Generator default output moved");
|
|
133
|
-
expect(fs.existsSync(explicitOut)).toBe(true);
|
|
134
|
-
}, 30_000);
|
|
135
121
|
});
|
|
136
122
|
|
|
137
123
|
describe("generate-sections --registry", () => {
|
|
@@ -287,8 +273,8 @@ describe("generate-sections neverDefer convention", () => {
|
|
|
287
273
|
// SectionMetaEntry interface the SAME file declares omitted the field —
|
|
288
274
|
// so every generated file containing a neverDefer section failed the
|
|
289
275
|
// site's typecheck (TS2353 excess property), and sites hand-patched the
|
|
290
|
-
// interface only to have the next regeneration wipe the patch (
|
|
291
|
-
// .deco/sections.gen.ts carried exactly that TODO). The emitted interface
|
|
276
|
+
// interface only to have the next regeneration wipe the patch (a production
|
|
277
|
+
// site's .deco/sections.gen.ts carried exactly that TODO). The emitted interface
|
|
292
278
|
// must match SectionMetaEntry in @decocms/blocks/cms.
|
|
293
279
|
let tmpDir: string;
|
|
294
280
|
let sectionsDir: string;
|
|
@@ -306,7 +292,7 @@ describe("generate-sections neverDefer convention", () => {
|
|
|
306
292
|
});
|
|
307
293
|
|
|
308
294
|
it("declares neverDefer on the emitted SectionMetaEntry interface and the generated file typechecks + imports", () => {
|
|
309
|
-
// Mirrors
|
|
295
|
+
// Mirrors a production site's src/sections/Product/SearchResult.tsx.
|
|
310
296
|
fs.writeFileSync(
|
|
311
297
|
path.join(sectionsDir, "SearchResult.tsx"),
|
|
312
298
|
"export const neverDefer = true;\nexport default function SearchResult() { return null; }\n",
|
|
@@ -350,6 +336,89 @@ describe("generate-sections neverDefer convention", () => {
|
|
|
350
336
|
}, 60_000);
|
|
351
337
|
});
|
|
352
338
|
|
|
339
|
+
describe("generate-sections renderJson convention", () => {
|
|
340
|
+
let tmpDir: string;
|
|
341
|
+
let sectionsDir: string;
|
|
342
|
+
let outFile: string;
|
|
343
|
+
|
|
344
|
+
beforeEach(() => {
|
|
345
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-renderjson-"));
|
|
346
|
+
sectionsDir = path.join(tmpDir, "sections");
|
|
347
|
+
outFile = path.join(tmpDir, "out", "sections.gen.ts");
|
|
348
|
+
fs.mkdirSync(sectionsDir, { recursive: true });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
afterEach(() => {
|
|
352
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("emits `renderJson: false` into sectionMeta and a `renderJsons` map for projection fns; the output typechecks + imports", () => {
|
|
356
|
+
// `= false` opt-out: web-only section dropped from ?renderJson.
|
|
357
|
+
fs.writeFileSync(
|
|
358
|
+
path.join(sectionsDir, "Theme.tsx"),
|
|
359
|
+
"export const renderJson = false;\nexport default function Theme() { return null; }\n",
|
|
360
|
+
);
|
|
361
|
+
// projection function: trims props for the mobile app.
|
|
362
|
+
fs.writeFileSync(
|
|
363
|
+
path.join(sectionsDir, "ProductDetails.tsx"),
|
|
364
|
+
[
|
|
365
|
+
"export const renderJson = (props: Record<string, unknown>) => {",
|
|
366
|
+
" const { storeConfig: _s, ...rest } = props;",
|
|
367
|
+
" return rest;",
|
|
368
|
+
"};",
|
|
369
|
+
"export default function ProductDetails() { return null; }",
|
|
370
|
+
"",
|
|
371
|
+
].join("\n"),
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]);
|
|
375
|
+
expect(code).toBe(0);
|
|
376
|
+
|
|
377
|
+
const generated = fs.readFileSync(outFile, "utf-8");
|
|
378
|
+
|
|
379
|
+
// The `= false` section carries the literal false flag; the fn section
|
|
380
|
+
// carries hasRenderJson (its actual fn lives in the renderJsons map).
|
|
381
|
+
expect(generated).toMatch(/"site\/sections\/Theme\.tsx":\s*\{[^}]*renderJson:\s*false/);
|
|
382
|
+
expect(generated).toMatch(/"site\/sections\/ProductDetails\.tsx":\s*\{[^}]*hasRenderJson:\s*true/);
|
|
383
|
+
// The emitted interface declares both (excess-property guard, cf. neverDefer).
|
|
384
|
+
expect(generated).toContain("renderJson?: false;");
|
|
385
|
+
expect(generated).toContain("hasRenderJson?: boolean;");
|
|
386
|
+
// The fn is imported and wired into renderJsons; the `= false` one is NOT.
|
|
387
|
+
expect(generated).toContain("import { renderJson as _rj0 }");
|
|
388
|
+
expect(generated).toMatch(/export const renderJsons: Record<string, any> = \{[\s\S]*ProductDetails\.tsx": _rj0/);
|
|
389
|
+
expect(generated).not.toMatch(/renderJsons: Record<string, any> = \{[\s\S]*Theme\.tsx/);
|
|
390
|
+
|
|
391
|
+
// Typecheck the output (the real failure mode for a bad emit is TS2353, not
|
|
392
|
+
// a runtime error) and confirm it imports with a working projection fn.
|
|
393
|
+
// --jsx: the generated file imports a projection fn from a `.tsx` section,
|
|
394
|
+
// so tsc needs jsx set (a real site's tsconfig always does; the standalone
|
|
395
|
+
// invocation must too, or it fails with TS6142).
|
|
396
|
+
const tscResult = cp.spawnSync(
|
|
397
|
+
"npx",
|
|
398
|
+
["tsc", "--noEmit", "--strict", "--skipLibCheck", "--jsx", "react-jsx", outFile],
|
|
399
|
+
{ encoding: "utf8" },
|
|
400
|
+
);
|
|
401
|
+
expect(tscResult.status, tscResult.stdout + tscResult.stderr).toBe(0);
|
|
402
|
+
|
|
403
|
+
const checkerFile = path.join(tmpDir, "check-import.mjs");
|
|
404
|
+
fs.writeFileSync(
|
|
405
|
+
checkerFile,
|
|
406
|
+
[
|
|
407
|
+
`const m = await import(${JSON.stringify(pathToFileURL(outFile).href)});`,
|
|
408
|
+
`if (m.sectionMeta?.["site/sections/Theme.tsx"]?.renderJson !== false) {`,
|
|
409
|
+
` throw new Error("Theme renderJson:false missing from sectionMeta");`,
|
|
410
|
+
`}`,
|
|
411
|
+
`const fn = m.renderJsons?.["site/sections/ProductDetails.tsx"];`,
|
|
412
|
+
`if (typeof fn !== "function") throw new Error("ProductDetails renderJson fn missing");`,
|
|
413
|
+
`const out = fn({ storeConfig: 1, page: 2 });`,
|
|
414
|
+
`if (out.storeConfig !== undefined || out.page !== 2) throw new Error("projection fn wrong");`,
|
|
415
|
+
].join("\n"),
|
|
416
|
+
);
|
|
417
|
+
const importResult = cp.spawnSync("npx", ["tsx", checkerFile], { encoding: "utf8" });
|
|
418
|
+
expect(importResult.status, importResult.stderr).toBe(0);
|
|
419
|
+
}, 60_000);
|
|
420
|
+
});
|
|
421
|
+
|
|
353
422
|
describe("generate-sections output hygiene (non-registry)", () => {
|
|
354
423
|
let tmpDir: string;
|
|
355
424
|
let sectionsDir: string;
|
|
@@ -28,15 +28,10 @@
|
|
|
28
28
|
* Built from every scanned section file, not just the ones
|
|
29
29
|
* carrying convention exports. Off by default so existing
|
|
30
30
|
* Vite sites regenerating sections.gen.ts in CI see zero diff.
|
|
31
|
-
*
|
|
32
|
-
* If no `--out-file` is passed and the OLD default (src/server/cms/sections.gen.ts)
|
|
33
|
-
* still exists on disk, a one-line legacy warning is printed to stderr and the
|
|
34
|
-
* NEW default is written anyway — see lib/legacyArtifact.ts.
|
|
35
31
|
*/
|
|
36
32
|
import fs from "node:fs";
|
|
37
33
|
import path from "node:path";
|
|
38
34
|
import { isExcludedCodegenFile } from "./lib/codegenExclusions";
|
|
39
|
-
import { warnLegacyArtifact } from "./lib/legacyArtifact";
|
|
40
35
|
|
|
41
36
|
const args = process.argv.slice(2);
|
|
42
37
|
function arg(name: string, fallback: string): string {
|
|
@@ -45,27 +40,33 @@ function arg(name: string, fallback: string): string {
|
|
|
45
40
|
}
|
|
46
41
|
|
|
47
42
|
const sectionsDir = path.resolve(process.cwd(), arg("sections-dir", "src/sections"));
|
|
48
|
-
const OUT_FILE_EXPLICIT = args.includes("--out-file");
|
|
49
43
|
const NEW_DEFAULT_OUT_FILE = ".deco/sections.gen.ts";
|
|
50
|
-
const OLD_DEFAULT_OUT_FILE = "src/server/cms/sections.gen.ts";
|
|
51
44
|
const outFile = path.resolve(process.cwd(), arg("out-file", NEW_DEFAULT_OUT_FILE));
|
|
52
|
-
if (!OUT_FILE_EXPLICIT && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_FILE))) {
|
|
53
|
-
warnLegacyArtifact(OLD_DEFAULT_OUT_FILE, NEW_DEFAULT_OUT_FILE);
|
|
54
|
-
}
|
|
55
45
|
const EMIT_REGISTRY = args.includes("--registry");
|
|
56
46
|
|
|
57
47
|
interface SectionMeta {
|
|
58
48
|
eager?: boolean;
|
|
59
49
|
neverDefer?: boolean;
|
|
50
|
+
deferred?: boolean;
|
|
60
51
|
cache?: string;
|
|
61
52
|
layout?: boolean;
|
|
62
53
|
sync?: boolean;
|
|
63
54
|
clientOnly?: boolean;
|
|
64
55
|
seo?: boolean;
|
|
65
56
|
hasLoadingFallback?: boolean;
|
|
57
|
+
renderJson?: false;
|
|
58
|
+
hasRenderJson?: boolean;
|
|
66
59
|
}
|
|
67
60
|
|
|
68
|
-
const EXPORT_CONST_RE = /export\s+const\s+(eager|neverDefer|cache|layout|sync|clientOnly|seo)\s*=\s*(.+?)(?:;|\n)/g;
|
|
61
|
+
const EXPORT_CONST_RE = /export\s+const\s+(eager|neverDefer|deferred|cache|layout|sync|clientOnly|seo)\s*=\s*(.+?)(?:;|\n)/g;
|
|
62
|
+
// renderJson (?renderJson mobile path). `= false` drops the section; a function
|
|
63
|
+
// (`export const renderJson = (props) => ...` / `export function renderJson`)
|
|
64
|
+
// projects its props. Kept separate from EXPORT_CONST_RE because the value is a
|
|
65
|
+
// literal `false` OR a function, not the `= true` booleans that regex handles.
|
|
66
|
+
const RENDER_JSON_FALSE_RE = /export\s+const\s+renderJson\s*=\s*false\b/;
|
|
67
|
+
const RENDER_JSON_FN_INLINE_RE =
|
|
68
|
+
/export\s+(?:function\s+renderJson\b|const\s+renderJson\s*=\s*(?!false\b)\S)/;
|
|
69
|
+
const RENDER_JSON_REEXPORT_RE = /export\s*\{[^}]*\brenderJson\b[^}]*\}/;
|
|
69
70
|
// Detects `export function LoadingFallback(...)`, `export const LoadingFallback = ...`, etc.
|
|
70
71
|
const LOADING_FALLBACK_INLINE_RE = /export\s+(?:function|const|let|var)\s+LoadingFallback\b/;
|
|
71
72
|
// Detects re-exports like:
|
|
@@ -105,6 +106,14 @@ function extractMeta(content: string): SectionMeta | null {
|
|
|
105
106
|
found = true;
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
if (RENDER_JSON_FALSE_RE.test(content)) {
|
|
110
|
+
meta.renderJson = false;
|
|
111
|
+
found = true;
|
|
112
|
+
} else if (RENDER_JSON_FN_INLINE_RE.test(content) || RENDER_JSON_REEXPORT_RE.test(content)) {
|
|
113
|
+
meta.hasRenderJson = true;
|
|
114
|
+
found = true;
|
|
115
|
+
}
|
|
116
|
+
|
|
108
117
|
return found ? meta : null;
|
|
109
118
|
}
|
|
110
119
|
|
|
@@ -177,6 +186,8 @@ const lines: string[] = [
|
|
|
177
186
|
"// export const clientOnly = true → skip SSR (client-only rendering)",
|
|
178
187
|
"// export const seo = true → SEO section (provides page head data)",
|
|
179
188
|
"// export function LoadingFallback → skeleton shown while section loads",
|
|
189
|
+
"// export const renderJson = false → drop section from ?renderJson (mobile)",
|
|
190
|
+
"// export const renderJson = (p)=> → project props for ?renderJson (mobile)",
|
|
180
191
|
"",
|
|
181
192
|
];
|
|
182
193
|
|
|
@@ -196,6 +207,16 @@ for (let i = 0; i < nonSyncFallbacks.length; i++) {
|
|
|
196
207
|
lines.push(`import { LoadingFallback as _fb${i} } from "${importPath}";`);
|
|
197
208
|
}
|
|
198
209
|
|
|
210
|
+
// renderJson projection-function imports — sections whose renderJson is a
|
|
211
|
+
// function (not `= false`) and aren't sync-imported.
|
|
212
|
+
const renderJsonEntries = entries.filter((e) => e.meta.hasRenderJson);
|
|
213
|
+
const nonSyncRenderJsons = renderJsonEntries.filter((e) => !e.meta.sync);
|
|
214
|
+
for (let i = 0; i < nonSyncRenderJsons.length; i++) {
|
|
215
|
+
const e = nonSyncRenderJsons[i];
|
|
216
|
+
const importPath = relativeImportPath(outFile, e.filePath);
|
|
217
|
+
lines.push(`import { renderJson as _rj${i} } from "${importPath}";`);
|
|
218
|
+
}
|
|
219
|
+
|
|
199
220
|
lines.push("");
|
|
200
221
|
|
|
201
222
|
// Metadata map
|
|
@@ -207,12 +228,15 @@ lines.push("");
|
|
|
207
228
|
lines.push("export interface SectionMetaEntry {");
|
|
208
229
|
lines.push(" eager?: boolean;");
|
|
209
230
|
lines.push(" neverDefer?: boolean;");
|
|
231
|
+
lines.push(" deferred?: boolean;");
|
|
210
232
|
lines.push(" cache?: string;");
|
|
211
233
|
lines.push(" layout?: boolean;");
|
|
212
234
|
lines.push(" sync?: boolean;");
|
|
213
235
|
lines.push(" clientOnly?: boolean;");
|
|
214
236
|
lines.push(" seo?: boolean;");
|
|
215
237
|
lines.push(" hasLoadingFallback?: boolean;");
|
|
238
|
+
lines.push(" renderJson?: false;");
|
|
239
|
+
lines.push(" hasRenderJson?: boolean;");
|
|
216
240
|
lines.push("}");
|
|
217
241
|
lines.push("");
|
|
218
242
|
lines.push("export const sectionMeta: Record<string, SectionMetaEntry> = {");
|
|
@@ -256,6 +280,28 @@ if (allFallbacks.length > 0) {
|
|
|
256
280
|
}
|
|
257
281
|
lines.push("");
|
|
258
282
|
|
|
283
|
+
// renderJson projection-functions map (?renderJson mobile path). `= false`
|
|
284
|
+
// sections are NOT here — they carry `renderJson: false` in sectionMeta, which
|
|
285
|
+
// applySectionConventions reads directly. Only projection functions need a live
|
|
286
|
+
// reference. Typed `any` (like syncComponents) to avoid importing the RenderJson
|
|
287
|
+
// type into the generated file.
|
|
288
|
+
if (renderJsonEntries.length > 0) {
|
|
289
|
+
lines.push("export const renderJsons: Record<string, any> = {");
|
|
290
|
+
for (const e of renderJsonEntries) {
|
|
291
|
+
if (e.meta.sync) {
|
|
292
|
+
const syncIdx = syncEntries.indexOf(e);
|
|
293
|
+
lines.push(` "${e.key}": _sync${syncIdx}.renderJson,`);
|
|
294
|
+
} else {
|
|
295
|
+
const rjIdx = nonSyncRenderJsons.indexOf(e);
|
|
296
|
+
lines.push(` "${e.key}": _rj${rjIdx},`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
lines.push("};");
|
|
300
|
+
} else {
|
|
301
|
+
lines.push("export const renderJsons: Record<string, any> = {};");
|
|
302
|
+
}
|
|
303
|
+
lines.push("");
|
|
304
|
+
|
|
259
305
|
// Lazy section-import registry — opt-in via --registry, built from every
|
|
260
306
|
// scanned section file (not just convention-carrying `entries`).
|
|
261
307
|
if (EMIT_REGISTRY) {
|