@lowdefy/build 5.1.0 → 5.2.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.
Files changed (53) hide show
  1. package/dist/build/buildApi/buildRoutine/countStepTypes.js +3 -0
  2. package/dist/build/buildApi/buildRoutine/setStepId.js +3 -2
  3. package/dist/build/buildApi/buildRoutine/validateStep.js +19 -0
  4. package/dist/build/buildApi/validateEndpoint.js +10 -0
  5. package/dist/build/buildApi/validateStepReferences.js +4 -4
  6. package/dist/build/buildAuth/buildApiAuth.js +2 -1
  7. package/dist/build/buildAuth/buildPageAuth.js +2 -1
  8. package/dist/build/buildAuth/getApiRoles.js +12 -6
  9. package/dist/build/buildAuth/getPageRoles.js +12 -6
  10. package/dist/build/buildAuth/getProtectedApi.js +3 -2
  11. package/dist/build/buildAuth/getProtectedPages.js +3 -2
  12. package/dist/build/buildAuth/matchPattern.js +22 -0
  13. package/dist/build/buildConnections.js +42 -4
  14. package/dist/build/buildJs/jsMapParser.js +25 -12
  15. package/dist/build/buildJs/writeJs.js +2 -2
  16. package/dist/build/buildMenu.js +41 -0
  17. package/dist/build/buildModuleDefs.js +97 -0
  18. package/dist/build/buildModules.js +96 -0
  19. package/dist/build/buildPages/buildBlock/buildBlock.js +2 -2
  20. package/dist/build/buildPages/buildBlock/buildEvents.js +16 -1
  21. package/dist/build/buildPages/buildBlock/buildSubBlocks.js +2 -1
  22. package/dist/build/buildPages/buildBlock/validateBlock.js +3 -3
  23. package/dist/build/buildPages/buildPage.js +1 -0
  24. package/dist/build/buildPages/validateCallApiRefs.js +31 -0
  25. package/dist/build/buildRefs/getModuleRefContent.js +81 -0
  26. package/dist/build/buildRefs/makeRefDefinition.js +6 -0
  27. package/dist/build/buildRefs/walker.js +424 -44
  28. package/dist/build/fetchGitHubModule.js +94 -0
  29. package/dist/build/fetchModules.js +60 -0
  30. package/dist/build/full/buildPages.js +10 -1
  31. package/dist/build/full/writePages.js +1 -1
  32. package/dist/build/jit/buildPageJit.js +34 -4
  33. package/dist/build/jit/collectSkeletonSourceFiles.js +8 -0
  34. package/dist/build/jit/createPageRegistry.js +10 -1
  35. package/dist/build/jit/shallowBuild.js +22 -11
  36. package/dist/build/jit/writePageJit.js +2 -2
  37. package/dist/build/jit/writeSourcelessPages.js +1 -1
  38. package/dist/build/parseModuleSource.js +48 -0
  39. package/dist/build/registerModules.js +242 -0
  40. package/dist/build/resolveDepTarget.js +43 -0
  41. package/dist/build/resolveModuleDependencies.js +60 -0
  42. package/dist/build/resolveModuleOperators.js +27 -0
  43. package/dist/build/testSchema.js +22 -11
  44. package/dist/build/writePluginImports/writeGlobalsCss.js +1 -1
  45. package/dist/createContext.js +4 -0
  46. package/dist/defaultPackages.js +51 -0
  47. package/dist/defaultTypesMap.js +399 -357
  48. package/dist/index.js +16 -1
  49. package/dist/indexDev.js +3 -1
  50. package/dist/lowdefySchema.js +58 -0
  51. package/dist/scripts/generateDefaultTypes.js +1 -35
  52. package/package.json +46 -42
  53. package/dist/build/jit/stripPageContent.js +0 -29
@@ -12,13 +12,17 @@
12
12
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
- */ import { get, type } from '@lowdefy/helpers';
15
+ */ import path from 'path';
16
+ import { get, serializer, type } from '@lowdefy/helpers';
16
17
  import { ConfigError } from '@lowdefy/errors';
17
18
  import { evaluateOperators } from '@lowdefy/operators';
18
19
  import makeRefDefinition from './makeRefDefinition.js';
19
20
  import getRefContent from './getRefContent.js';
21
+ import getModuleRefContent from './getModuleRefContent.js';
20
22
  import runTransformer from './runTransformer.js';
21
23
  import getKey from './getKey.js';
24
+ import { scopeMenuItemIds } from '../resolveModuleOperators.js';
25
+ import resolveDepTarget from '../resolveDepTarget.js';
22
26
  import setNonEnumerableProperty from '../../utils/setNonEnumerableProperty.js';
23
27
  import collectExceptions from '../../utils/collectExceptions.js';
24
28
  let WalkContext = class WalkContext {
@@ -28,6 +32,10 @@ let WalkContext = class WalkContext {
28
32
  refId: this.refId,
29
33
  sourceRefId: this.sourceRefId,
30
34
  vars: this.vars,
35
+ moduleDependencies: this.moduleDependencies,
36
+ moduleEntry: this.moduleEntry,
37
+ moduleRoot: this.moduleRoot,
38
+ packageRoot: this.packageRoot,
31
39
  path: this.path ? `${this.path}.${segment}` : segment,
32
40
  currentFile: this.currentFile,
33
41
  refChain: this.refChain,
@@ -37,16 +45,25 @@ let WalkContext = class WalkContext {
37
45
  shouldStop: this.shouldStop
38
46
  });
39
47
  }
40
- forRef({ refId, vars, filePath }) {
48
+ forRef({ refId, vars, filePath, moduleRoot, packageRoot, moduleDependencies, moduleEntry, extraRefChainKeys }) {
41
49
  const newChain = new Set(this.refChain);
42
50
  if (filePath) {
43
51
  newChain.add(filePath);
44
52
  }
53
+ if (extraRefChainKeys) {
54
+ for (const key of extraRefChainKeys){
55
+ newChain.add(key);
56
+ }
57
+ }
45
58
  return new WalkContext({
46
59
  buildContext: this.buildContext,
47
60
  refId,
48
61
  sourceRefId: this.refId,
49
62
  vars: vars ?? {},
63
+ moduleDependencies: moduleDependencies ?? this.moduleDependencies,
64
+ moduleEntry: moduleEntry ?? this.moduleEntry,
65
+ moduleRoot: moduleRoot ?? this.moduleRoot,
66
+ packageRoot: packageRoot ?? this.packageRoot,
50
67
  path: this.path,
51
68
  currentFile: filePath ?? this.currentFile,
52
69
  refChain: newChain,
@@ -65,11 +82,15 @@ let WalkContext = class WalkContext {
65
82
  get unresolvedRefVars() {
66
83
  return this.buildContext.unresolvedRefVars;
67
84
  }
68
- constructor({ buildContext, refId, sourceRefId, vars, path, currentFile, refChain, operators, env, dynamicIdentifiers, shouldStop }){
85
+ constructor({ buildContext, refId, sourceRefId, vars, moduleDependencies, moduleEntry, moduleRoot, packageRoot, path, currentFile, refChain, operators, env, dynamicIdentifiers, shouldStop }){
69
86
  this.buildContext = buildContext;
70
87
  this.refId = refId;
71
88
  this.sourceRefId = sourceRefId;
72
89
  this.vars = vars;
90
+ this.moduleDependencies = moduleDependencies;
91
+ this.moduleEntry = moduleEntry ?? null;
92
+ this.moduleRoot = moduleRoot;
93
+ this.packageRoot = packageRoot;
73
94
  this.path = path;
74
95
  this.currentFile = currentFile;
75
96
  this.refChain = refChain;
@@ -108,7 +129,7 @@ function tagRefDeep(node, refId) {
108
129
  }
109
130
  }
110
131
  }
111
- // Deep clone preserving ~r, ~l, ~k non-enumerable markers.
132
+ // Deep clone preserving non-enumerable build markers (~r, ~l, ~k, ~arr, ~deferredFrom).
112
133
  // Used before resolving ref def path/vars to prevent mutation of stored originals.
113
134
  function cloneForResolve(value) {
114
135
  if (!type.isObject(value) && !type.isArray(value)) return value;
@@ -118,6 +139,7 @@ function cloneForResolve(value) {
118
139
  if (value['~l'] !== undefined) setNonEnumerableProperty(clone, '~l', value['~l']);
119
140
  if (value['~k'] !== undefined) setNonEnumerableProperty(clone, '~k', value['~k']);
120
141
  if (value['~arr'] !== undefined) setNonEnumerableProperty(clone, '~arr', value['~arr']);
142
+ if (value['~deferredFrom'] !== undefined) setNonEnumerableProperty(clone, '~deferredFrom', value['~deferredFrom']);
121
143
  return clone;
122
144
  }
123
145
  const clone = {};
@@ -127,6 +149,7 @@ function cloneForResolve(value) {
127
149
  if (value['~r'] !== undefined) setNonEnumerableProperty(clone, '~r', value['~r']);
128
150
  if (value['~l'] !== undefined) setNonEnumerableProperty(clone, '~l', value['~l']);
129
151
  if (value['~k'] !== undefined) setNonEnumerableProperty(clone, '~k', value['~k']);
152
+ if (value['~deferredFrom'] !== undefined) setNonEnumerableProperty(clone, '~deferredFrom', value['~deferredFrom']);
130
153
  return clone;
131
154
  }
132
155
  // Deep clone a var value, preserving markers and setting ~r provenance.
@@ -204,7 +227,258 @@ function resolveVar(node, ctx) {
204
227
  filePath: ctx.currentFile
205
228
  });
206
229
  }
207
- // Resolve a _ref node (12-step ref handling)
230
+ // Resolve a _module.var node via lazy resolution against the module entry.
231
+ async function resolveModuleVar(node, ctx) {
232
+ const key = node['_module.var'];
233
+ if (!type.isString(key)) {
234
+ throw new ConfigError('_module.var operator takes a string argument.', {
235
+ filePath: ctx.currentFile
236
+ });
237
+ }
238
+ const value = await resolveEffectiveVar(key, ctx.moduleEntry, ctx);
239
+ return cloneVarValue(value, ctx.sourceRefId);
240
+ }
241
+ // Navigate the var definitions tree by dot-path key, following `properties` nesting.
242
+ function getVarDef(varDefs, key) {
243
+ const parts = key.split('.');
244
+ let current = varDefs;
245
+ for(let i = 0; i < parts.length; i++){
246
+ const part = parts[i];
247
+ if (!current?.[part]) return undefined;
248
+ if (current[part].properties && i < parts.length - 1) {
249
+ current = current[part].properties;
250
+ } else {
251
+ return current[part];
252
+ }
253
+ }
254
+ return undefined;
255
+ }
256
+ // Resolve a raw var default through the walker using a fresh WalkContext rooted
257
+ // at the module manifest. The fresh context prevents false circular-ref detection
258
+ // from the consumer's refChain and ensures _ref paths resolve relative to the
259
+ // module root.
260
+ async function resolveVarDefault(rawDefault, moduleEntry, ctx) {
261
+ const moduleYamlPath = path.join(moduleEntry.moduleRoot, 'module.lowdefy.yaml');
262
+ const defaultCtx = new WalkContext({
263
+ buildContext: ctx.buildContext,
264
+ refId: moduleEntry.refDef.id,
265
+ sourceRefId: null,
266
+ vars: {},
267
+ moduleDependencies: moduleEntry.moduleDependencies,
268
+ moduleEntry,
269
+ moduleRoot: moduleEntry.moduleRoot,
270
+ packageRoot: moduleEntry.packageRoot,
271
+ path: '',
272
+ currentFile: moduleYamlPath,
273
+ refChain: new Set(moduleEntry.refDef.path ? [
274
+ moduleEntry.refDef.path
275
+ ] : []),
276
+ operators: ctx.operators,
277
+ env: ctx.env,
278
+ dynamicIdentifiers: ctx.dynamicIdentifiers
279
+ });
280
+ return await resolve(rawDefault, defaultCtx);
281
+ }
282
+ // Build a merged object for namespace vars (vars with `properties`). Each
283
+ // declared property resolves through resolveEffectiveVar — consumer values
284
+ // take precedence per-leaf; missing leaves fall back to defaults.
285
+ async function resolveNamespaceVar(prefix, varDef, moduleEntry, ctx) {
286
+ const result = {};
287
+ for (const propName of Object.keys(varDef.properties)){
288
+ const fullKey = `${prefix}.${propName}`;
289
+ result[propName] = await resolveEffectiveVar(fullKey, moduleEntry, ctx);
290
+ }
291
+ return result;
292
+ }
293
+ // Core lazy var resolution with caching on the module entry.
294
+ async function resolveEffectiveVar(key, moduleEntry, ctx) {
295
+ if (Object.hasOwn(moduleEntry.resolvedVarCache, key)) {
296
+ return moduleEntry.resolvedVarCache[key];
297
+ }
298
+ const consumerValue = get(moduleEntry.consumerVars, key, {
299
+ default: undefined
300
+ });
301
+ const varDef = getVarDef(moduleEntry.varDefs, key);
302
+ let result;
303
+ if (varDef?.properties) {
304
+ result = await resolveNamespaceVar(key, varDef, moduleEntry, ctx);
305
+ } else if (!type.isNone(consumerValue)) {
306
+ result = consumerValue;
307
+ } else if (varDef && !type.isUndefined(varDef.default)) {
308
+ result = await resolveVarDefault(varDef.default, moduleEntry, ctx);
309
+ } else {
310
+ result = null;
311
+ }
312
+ moduleEntry.resolvedVarCache[key] = result;
313
+ return result;
314
+ }
315
+ // Detect _module.*Id operators
316
+ const MODULE_ID_OPERATOR_KEYS = [
317
+ '_module.pageId',
318
+ '_module.connectionId',
319
+ '_module.endpointId',
320
+ '_module.id'
321
+ ];
322
+ function isModuleIdOperator(node) {
323
+ return MODULE_ID_OPERATOR_KEYS.some((key)=>!type.isUndefined(node[key]));
324
+ }
325
+ // Resolve _module.pageId
326
+ function resolveModulePageId(arg, moduleEntry, context, configKey) {
327
+ if (type.isString(arg)) {
328
+ if (!moduleEntry) {
329
+ throw new ConfigError('_module.pageId string form is ambiguous at the app level — no module to scope against. Use { id, module } to specify the target module.', {
330
+ configKey
331
+ });
332
+ }
333
+ if (!(moduleEntry.exports?.pages ?? []).some((p)=>p.id === arg)) {
334
+ throw new ConfigError(`Module "${moduleEntry.id}" does not export page "${arg}".`, {
335
+ configKey
336
+ });
337
+ }
338
+ return `${moduleEntry.id}/${arg}`;
339
+ }
340
+ if (type.isObject(arg) && type.isString(arg.id) && type.isString(arg.module)) {
341
+ const targetEntry = resolveDepTarget({
342
+ moduleEntry,
343
+ depName: arg.module,
344
+ context,
345
+ configKey,
346
+ usage: `_module.pageId { id: "${arg.id}", module: "${arg.module}" }`
347
+ });
348
+ if (!(targetEntry.exports?.pages ?? []).some((p)=>p.id === arg.id)) {
349
+ const caller = moduleEntry ? `Module "${moduleEntry.id}"` : 'App config';
350
+ throw new ConfigError(`${caller} references page "${arg.id}" ` + `from "${arg.module}" (entry "${targetEntry.id}"), ` + `but that module does not export page "${arg.id}".`, {
351
+ configKey
352
+ });
353
+ }
354
+ return `${targetEntry.id}/${arg.id}`;
355
+ }
356
+ throw new ConfigError('_module.pageId requires a string or object { id, module }.', {
357
+ configKey
358
+ });
359
+ }
360
+ // Resolve _module.connectionId
361
+ function resolveModuleConnectionId(arg, moduleEntry, context, configKey) {
362
+ if (type.isString(arg)) {
363
+ if (!moduleEntry) {
364
+ throw new ConfigError('_module.connectionId string form is ambiguous at the app level — no module to scope against. Use { id, module } to specify the target module.', {
365
+ configKey
366
+ });
367
+ }
368
+ if (!(moduleEntry.exports?.connections ?? []).some((c)=>c.id === arg)) {
369
+ throw new ConfigError(`Module "${moduleEntry.id}" does not export connection "${arg}".`, {
370
+ configKey
371
+ });
372
+ }
373
+ const remapping = moduleEntry.connections ?? {};
374
+ if (remapping[arg]) {
375
+ return remapping[arg];
376
+ }
377
+ return `${moduleEntry.id}/${arg}`;
378
+ }
379
+ if (type.isObject(arg) && type.isString(arg.id) && type.isString(arg.module)) {
380
+ const targetEntry = resolveDepTarget({
381
+ moduleEntry,
382
+ depName: arg.module,
383
+ context,
384
+ configKey,
385
+ usage: `_module.connectionId { id: "${arg.id}", module: "${arg.module}" }`
386
+ });
387
+ if (!(targetEntry.exports?.connections ?? []).some((c)=>c.id === arg.id)) {
388
+ const caller = moduleEntry ? `Module "${moduleEntry.id}"` : 'App config';
389
+ throw new ConfigError(`${caller} references connection "${arg.id}" ` + `from "${arg.module}" (entry "${targetEntry.id}"), ` + `but that module does not export connection "${arg.id}".`, {
390
+ configKey
391
+ });
392
+ }
393
+ const targetRemapping = targetEntry.connections ?? {};
394
+ if (targetRemapping[arg.id]) {
395
+ return targetRemapping[arg.id];
396
+ }
397
+ return `${targetEntry.id}/${arg.id}`;
398
+ }
399
+ throw new ConfigError('_module.connectionId requires a string or object { id, module }.', {
400
+ configKey
401
+ });
402
+ }
403
+ // Resolve _module.endpointId
404
+ function resolveModuleEndpointId(arg, moduleEntry, context, configKey) {
405
+ if (type.isString(arg)) {
406
+ if (!moduleEntry) {
407
+ throw new ConfigError('_module.endpointId string form is ambiguous at the app level — no module to scope against. Use { id, module } to specify the target module.', {
408
+ configKey
409
+ });
410
+ }
411
+ if (!(moduleEntry.exports?.api ?? []).some((e)=>e.id === arg)) {
412
+ throw new ConfigError(`Module "${moduleEntry.id}" does not export endpoint "${arg}".`, {
413
+ configKey
414
+ });
415
+ }
416
+ return `${moduleEntry.id}/${arg}`;
417
+ }
418
+ if (type.isObject(arg) && type.isString(arg.id) && type.isString(arg.module)) {
419
+ const targetEntry = resolveDepTarget({
420
+ moduleEntry,
421
+ depName: arg.module,
422
+ context,
423
+ configKey,
424
+ usage: `_module.endpointId { id: "${arg.id}", module: "${arg.module}" }`
425
+ });
426
+ if (!(targetEntry.exports?.api ?? []).some((e)=>e.id === arg.id)) {
427
+ const caller = moduleEntry ? `Module "${moduleEntry.id}"` : 'App config';
428
+ throw new ConfigError(`${caller} references endpoint "${arg.id}" ` + `from "${arg.module}" (entry "${targetEntry.id}"), ` + `but that module does not export endpoint "${arg.id}".`, {
429
+ configKey
430
+ });
431
+ }
432
+ return `${targetEntry.id}/${arg.id}`;
433
+ }
434
+ throw new ConfigError('_module.endpointId requires a string or object { id, module }.', {
435
+ configKey
436
+ });
437
+ }
438
+ // Resolve _module.id
439
+ function resolveModuleId(arg, moduleEntry, context, configKey) {
440
+ if (!type.isObject(arg)) {
441
+ if (!moduleEntry) {
442
+ throw new ConfigError('_module.id is ambiguous at the app level — no module to scope against. Use { module } to specify the target module.', {
443
+ configKey
444
+ });
445
+ }
446
+ return moduleEntry.id;
447
+ }
448
+ if (type.isString(arg.module)) {
449
+ const targetEntry = resolveDepTarget({
450
+ moduleEntry,
451
+ depName: arg.module,
452
+ context,
453
+ configKey,
454
+ usage: `_module.id { module: "${arg.module}" }`
455
+ });
456
+ return targetEntry.id;
457
+ }
458
+ throw new ConfigError('_module.id requires a truthy value or object { module }.', {
459
+ configKey
460
+ });
461
+ }
462
+ // Dispatch _module.*Id operators
463
+ function resolveModuleIdOperator(node, ctx) {
464
+ const { moduleEntry } = ctx;
465
+ const context = ctx.buildContext;
466
+ const configKey = node['~k'];
467
+ if (!type.isUndefined(node['_module.pageId'])) {
468
+ return resolveModulePageId(node['_module.pageId'], moduleEntry, context, configKey);
469
+ }
470
+ if (!type.isUndefined(node['_module.connectionId'])) {
471
+ return resolveModuleConnectionId(node['_module.connectionId'], moduleEntry, context, configKey);
472
+ }
473
+ if (!type.isUndefined(node['_module.endpointId'])) {
474
+ return resolveModuleEndpointId(node['_module.endpointId'], moduleEntry, context, configKey);
475
+ }
476
+ if (!type.isUndefined(node['_module.id'])) {
477
+ return resolveModuleId(node['_module.id'], moduleEntry, context, configKey);
478
+ }
479
+ return node;
480
+ }
481
+ // Resolve a _ref node (16-step ref handling)
208
482
  async function resolveRef(node, ctx) {
209
483
  // 1. Create ref definition
210
484
  const lineNumber = node['~l'];
@@ -228,12 +502,22 @@ async function resolveRef(node, ctx) {
228
502
  if (type.isObject(refDef.key)) {
229
503
  refDef.key = await resolve(cloneForResolve(refDef.key), ctx);
230
504
  }
231
- // 4. Update refMap with resolved path; store original for resolver refs
505
+ // 4. Module path resolution: resolve relative paths from the module root
506
+ if (ctx.moduleRoot && type.isString(refDef.path) && !path.isAbsolute(refDef.path)) {
507
+ refDef.path = path.resolve(ctx.moduleRoot, refDef.path);
508
+ }
509
+ // 5. Update refMap with resolved path; store original for resolver refs
232
510
  ctx.refMap[refDef.id].path = refDef.path;
233
511
  if (!refDef.path) {
234
512
  ctx.refMap[refDef.id].original = refDef.original;
235
513
  }
236
- // 5. Circular detection
514
+ // 6. Path escape constraint: module refs cannot escape the package root
515
+ if (ctx.packageRoot && refDef.path) {
516
+ if (!refDef.path.startsWith(ctx.packageRoot + '/') && refDef.path !== ctx.packageRoot) {
517
+ throw new ConfigError(`Module ref path "${refDef.path}" escapes the package root.`);
518
+ }
519
+ }
520
+ // 7. Circular detection
237
521
  if (refDef.path && ctx.refChain.has(refDef.path)) {
238
522
  const chainDisplay = [
239
523
  ...ctx.refChain,
@@ -244,38 +528,116 @@ async function resolveRef(node, ctx) {
244
528
  lineNumber: ctx.currentFile ? lineNumber : null
245
529
  });
246
530
  }
247
- // Steps 6-12: File operations that can fail independently per ref.
531
+ // Steps 8-16: File operations that can fail independently per ref.
248
532
  // Errors are collected so the walker can continue processing sibling refs,
249
533
  // allowing multiple errors to be reported at once.
250
534
  try {
251
- // 6. Load content
252
- let content = await getRefContent({
253
- context: ctx.buildContext,
254
- refDef,
255
- referencedFrom: ctx.currentFile
256
- });
257
- // 7. Create child context for the ref file
258
- const childCtx = ctx.forRef({
259
- refId: refDef.id,
260
- vars: refDef.vars,
261
- filePath: refDef.path
262
- });
263
- // 8. Walk the content
535
+ // 8. Load content
536
+ let content;
537
+ let resolvedEntryId = null;
538
+ if (refDef.module) {
539
+ const result = await getModuleRefContent({
540
+ context: ctx.buildContext,
541
+ refDef,
542
+ referencedFrom: ctx.currentFile,
543
+ walkCtx: ctx,
544
+ configKey: node['~k']
545
+ });
546
+ content = cloneForResolve(result.content);
547
+ resolvedEntryId = result.entryId;
548
+ } else {
549
+ content = await getRefContent({
550
+ context: ctx.buildContext,
551
+ refDef,
552
+ referencedFrom: ctx.currentFile
553
+ });
554
+ }
555
+ // 9. Circular detection for cross-module component/menu refs.
556
+ // File-based cycle detection (step 7) misses these because each module
557
+ // has a different file path. Use a synthetic key with the resolved
558
+ // concrete entry ID: "module:<entryId>/<type>:<name>".
559
+ if (resolvedEntryId && (refDef.component || refDef.menu)) {
560
+ const exportType = refDef.component ? 'component' : 'menu';
561
+ const exportName = refDef.component ?? refDef.menu;
562
+ const cycleKey = `module:${resolvedEntryId}/${exportType}:${exportName}`;
563
+ if (ctx.refChain.has(cycleKey)) {
564
+ const chainDisplay = [
565
+ ...ctx.refChain,
566
+ cycleKey
567
+ ].join('\n -> ');
568
+ throw new ConfigError(`Circular module reference detected. Module "${resolvedEntryId}" ${exportType} "${exportName}" ` + `references itself through:\n -> ${chainDisplay}`, {
569
+ filePath: ctx.currentFile
570
+ });
571
+ }
572
+ }
573
+ // 10. Create child context for the ref
574
+ let childCtx;
575
+ if (refDef.module && (refDef.component || refDef.menu)) {
576
+ const moduleEntry = ctx.buildContext.modules[resolvedEntryId];
577
+ const deferredFrom = content['~deferredFrom'];
578
+ const exportType = refDef.component ? 'component' : 'menu';
579
+ const exportName = refDef.component ?? refDef.menu;
580
+ const cycleKey = `module:${resolvedEntryId}/${exportType}:${exportName}`;
581
+ childCtx = ctx.forRef({
582
+ refId: refDef.id,
583
+ vars: refDef.vars,
584
+ filePath: deferredFrom ?? path.join(moduleEntry.moduleRoot, 'module.lowdefy.yaml'),
585
+ moduleRoot: moduleEntry.moduleRoot,
586
+ packageRoot: moduleEntry.packageRoot,
587
+ moduleDependencies: moduleEntry.moduleDependencies,
588
+ moduleEntry,
589
+ extraRefChainKeys: [
590
+ cycleKey
591
+ ]
592
+ });
593
+ // Clone so each consumer gets an independent copy — getModuleRefContent
594
+ // returns a shared reference, and resolve() mutates in place.
595
+ // deferredFrom was read above before the clone (serializer.copy strips
596
+ // non-enumerable properties).
597
+ content = serializer.copy(content);
598
+ // When component/menu content is a file _ref, the inner ref would create
599
+ // a fresh var scope and lose the consumer's vars. Inject them into the clone.
600
+ if ((refDef.component || refDef.menu) && type.isObject(content) && content._ref) {
601
+ if (type.isObject(content._ref)) {
602
+ content._ref.vars = {
603
+ ...content._ref.vars ?? {},
604
+ ...refDef.vars
605
+ };
606
+ } else if (type.isString(content._ref) && Object.keys(refDef.vars).length > 0) {
607
+ content._ref = {
608
+ path: content._ref,
609
+ vars: refDef.vars
610
+ };
611
+ }
612
+ }
613
+ } else {
614
+ childCtx = ctx.forRef({
615
+ refId: refDef.id,
616
+ vars: refDef.vars,
617
+ filePath: refDef.path
618
+ });
619
+ }
620
+ // 11. Walk the content
264
621
  content = await resolve(content, childCtx);
265
- // 9. Run transformer
622
+ // 12. Scope menu item IDs (module menu refs only)
623
+ if (refDef.module && refDef.menu) {
624
+ const moduleEntry = ctx.buildContext.modules[resolvedEntryId];
625
+ scopeMenuItemIds(content, moduleEntry.id);
626
+ }
627
+ // 13. Run transformer
266
628
  content = await runTransformer({
267
629
  context: ctx.buildContext,
268
630
  input: content,
269
631
  refDef
270
632
  });
271
- // 10. Extract key
633
+ // 14. Extract key
272
634
  content = getKey({
273
635
  input: content,
274
636
  refDef
275
637
  });
276
- // 11. Tag all nodes with ~r for provenance
638
+ // 15. Tag all nodes with ~r for provenance
277
639
  tagRefDeep(content, refDef.id);
278
- // 12. Propagate ~ignoreBuildChecks
640
+ // 16. Propagate ~ignoreBuildChecks
279
641
  if (refDef.ignoreBuildChecks !== undefined) {
280
642
  if (type.isObject(content)) {
281
643
  content['~ignoreBuildChecks'] = refDef.ignoreBuildChecks;
@@ -300,44 +662,62 @@ async function resolveRef(node, ctx) {
300
662
  async function resolve(node, ctx) {
301
663
  // 1. Primitives pass through
302
664
  if (!type.isObject(node) && !type.isArray(node)) return node;
303
- // 2. Object with _ref
665
+ // 2. _ref top-down (only operator that needs it)
304
666
  if (type.isObject(node) && !type.isUndefined(node._ref)) {
305
667
  return resolveRef(node, ctx);
306
668
  }
307
- // 4. Object with _var resolve, then re-walk the result so any
308
- // _ref or _build.* operators inside the default value get processed.
309
- if (type.isObject(node) && !type.isUndefined(node._var)) {
310
- try {
311
- const varResult = resolveVar(node, ctx);
312
- return await resolve(varResult, ctx);
313
- } catch (error) {
314
- if (error instanceof ConfigError) {
315
- ctx.collectError(error);
316
- return null;
317
- }
318
- throw error;
319
- }
320
- }
321
- // 5. Array — walk children in parallel
669
+ // 3. Array — walk children in parallel
322
670
  if (type.isArray(node)) {
323
671
  await Promise.all(node.map(async (item, i)=>{
324
672
  node[i] = await resolve(item, ctx.child(String(i)));
325
673
  }));
326
674
  return node;
327
675
  }
328
- // 6. Object — walk children in parallel
676
+ // 4. Object — walk children in parallel (with shouldStop)
329
677
  const keys = Object.keys(node);
330
678
  await Promise.all(keys.map(async (key)=>{
331
679
  if (ctx.shouldStop) {
332
680
  const childPath = ctx.path ? `${ctx.path}.${key}` : key;
333
- if (ctx.shouldStop(childPath, ctx.refId)) {
681
+ const stopMode = ctx.shouldStop(childPath, ctx.refId);
682
+ if (stopMode === 'delete' || stopMode === true) {
334
683
  delete node[key];
335
684
  return;
336
685
  }
686
+ if (stopMode === 'preserve') {
687
+ if (type.isObject(node[key]) || type.isArray(node[key])) {
688
+ setNonEnumerableProperty(node[key], '~deferredFrom', ctx.currentFile);
689
+ }
690
+ return;
691
+ }
337
692
  }
338
693
  node[key] = await resolve(node[key], ctx.child(key));
339
694
  }));
340
- // Check if this is a _build.* operator
695
+ // 5. _var substitution (children already resolved)
696
+ if (!type.isUndefined(node._var)) {
697
+ try {
698
+ const varResult = resolveVar(node, ctx);
699
+ return await resolve(varResult, ctx);
700
+ } catch (error) {
701
+ if (error instanceof ConfigError) {
702
+ ctx.collectError(error);
703
+ return null;
704
+ }
705
+ throw error;
706
+ }
707
+ }
708
+ // 6. _module.var — module variable substitution
709
+ if (!type.isUndefined(node['_module.var'])) {
710
+ if (!ctx.moduleEntry) {
711
+ if (ctx.moduleRoot) return node;
712
+ throw new ConfigError('_module.var cannot be used at the app level.');
713
+ }
714
+ return resolve(await resolveModuleVar(node, ctx), ctx);
715
+ }
716
+ // 7. _module.*Id — resolve to scoped ID string
717
+ if (isModuleIdOperator(node)) {
718
+ return resolveModuleIdOperator(node, ctx);
719
+ }
720
+ // 8. _build.* operator
341
721
  if (isBuildOperator(node)) {
342
722
  const result = evaluateBuildOperator(node, ctx);
343
723
  tagRefDeep(result, ctx.refId);