ngx-blocks-studio 0.0.15 → 0.1.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.
@@ -3,7 +3,6 @@ import { inject, signal, computed, Injectable, isSignal, isWritableSignal, linke
3
3
  import { Router } from '@angular/router';
4
4
  import { HttpClient } from '@angular/common/http';
5
5
  import { firstValueFrom } from 'rxjs';
6
- import { z } from 'zod';
7
6
 
8
7
  /**
9
8
  * Single source of truth for metadata across component, service, and guard registries.
@@ -721,14 +720,17 @@ class RouteLoader {
721
720
  http = inject(HttpClient);
722
721
  componentRegistry = ComponentRegistry.getInstance();
723
722
  guardRegistry = GuardRegistry.getInstance();
724
- _routeConfigFile = signal(null, ...(ngDevMode ? [{ debugName: "_routeConfigFile" }] : []));
725
- _configPath = signal('', ...(ngDevMode ? [{ debugName: "_configPath" }] : []));
723
+ _routeConfigFile = signal(null, /* @ts-ignore */
724
+ ...(ngDevMode ? [{ debugName: "_routeConfigFile" }] : /* istanbul ignore next */ []));
725
+ _configPath = signal('', /* @ts-ignore */
726
+ ...(ngDevMode ? [{ debugName: "_configPath" }] : /* istanbul ignore next */ []));
726
727
  /** Currently loaded route config file, or null if not yet loaded. */
727
728
  routeConfigFile = this._routeConfigFile.asReadonly();
728
729
  /** Path from which the config was loaded. */
729
730
  configPath = this._configPath.asReadonly();
730
731
  /** Route config array from the loaded file. */
731
- routeConfig = computed(() => this._routeConfigFile()?.routes ?? [], ...(ngDevMode ? [{ debugName: "routeConfig" }] : []));
732
+ routeConfig = computed(() => this._routeConfigFile()?.routes ?? [], /* @ts-ignore */
733
+ ...(ngDevMode ? [{ debugName: "routeConfig" }] : /* istanbul ignore next */ []));
732
734
  /**
733
735
  * Load routes from a config object. Updates signals and resets the router.
734
736
  */
@@ -849,81 +851,269 @@ class RouteLoader {
849
851
  }
850
852
  return guard;
851
853
  }
852
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: RouteLoader, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
853
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: RouteLoader, providedIn: 'root' });
854
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: RouteLoader, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
855
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: RouteLoader, providedIn: 'root' });
854
856
  }
855
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: RouteLoader, decorators: [{
857
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: RouteLoader, decorators: [{
856
858
  type: Injectable,
857
859
  args: [{
858
860
  providedIn: 'root',
859
861
  }]
860
862
  }] });
861
863
 
862
- async function resolveBlockDefinitionValue(raw) {
863
- if (isBlockDefinitionLoader(raw)) {
864
- return raw();
865
- }
866
- return raw;
867
- }
868
864
  /**
869
- * Service entry: root-scoped (string id or { id, alias? } with no scope) or self-scoped ({ id, scope: "self", alias? }).
870
- * When scope is omitted or undefined, the service is resolved from the root injector.
865
+ * Registry that chains lookups to a parent registry (lexical scoping).
866
+ * Registration/unregistration always happens on the local registry only.
871
867
  */
872
- const ServiceEntrySchema = z.union([
873
- z.string().min(1),
874
- z.object({
875
- id: z.string().min(1),
876
- scope: z.literal('self').optional(),
877
- alias: z.string().min(1).optional(),
878
- }),
879
- ]);
868
+ class ChainedBlockRegistry {
869
+ local;
870
+ parent;
871
+ constructor(local, parent) {
872
+ this.local = local;
873
+ this.parent = parent;
874
+ }
875
+ register(id, handle) {
876
+ this.local.register(id, handle);
877
+ }
878
+ unregister(id) {
879
+ this.local.unregister(id);
880
+ }
881
+ get(id) {
882
+ return this.local.get(id) ?? this.parent?.get(id);
883
+ }
884
+ has(id) {
885
+ return this.local.has(id) || (this.parent?.has(id) ?? false);
886
+ }
887
+ hasLocal(id) {
888
+ return this.local.hasLocal(id);
889
+ }
890
+ }
880
891
  /**
881
- * Output handler: empty (use directive-provided handler) or reference-based (call method on ref).
892
+ * Default in-memory BlockRegistry.
882
893
  */
883
- const OutputReferenceSchema = z.object({
884
- type: z.literal('reference'),
885
- reference: z.string().min(1),
886
- method: z.string().min(1),
887
- params: z.union([z.array(z.unknown()), z.record(z.string(), z.unknown())]).optional(),
888
- then: z
889
- .array(z.object({
890
- reference: z.string().min(1),
891
- method: z.string().min(1),
892
- params: z.union([z.array(z.unknown()), z.record(z.string(), z.unknown())]).optional(),
893
- }))
894
- .optional(),
895
- onSuccess: z
896
- .object({
897
- reference: z.string().min(1),
898
- method: z.string().min(1),
899
- params: z.union([z.array(z.unknown()), z.record(z.string(), z.unknown())]).optional(),
900
- })
901
- .optional(),
902
- onError: z
903
- .object({
904
- reference: z.string().min(1),
905
- method: z.string().min(1),
906
- params: z.union([z.array(z.unknown()), z.record(z.string(), z.unknown())]).optional(),
907
- })
908
- .optional(),
909
- });
910
- const OutputValueSchema = z.union([z.record(z.string(), z.unknown()), OutputReferenceSchema]);
894
+ class BlockRegistryImpl {
895
+ map = new Map();
896
+ register(id, handle) {
897
+ if (this.map.has(id)) {
898
+ throw new Error(`Block id "${id}" is already registered. Each id may only occur once per tree.`);
899
+ }
900
+ this.map.set(id, handle);
901
+ }
902
+ unregister(id) {
903
+ this.map.delete(id);
904
+ }
905
+ get(id) {
906
+ return this.map.get(id);
907
+ }
908
+ has(id) {
909
+ return this.map.has(id);
910
+ }
911
+ hasLocal(id) {
912
+ return this.map.has(id);
913
+ }
914
+ }
915
+
911
916
  /**
912
- * Block description: JSON-serializable descriptor for dynamic block loading.
913
- * Refs in inputs: "model.info.title" (current block) or "BlockID:model.info.title" (block id + service/model + path).
917
+ * Holds named block registries (e.g. `root` for a route outlet, scoped keys for list items/columns).
918
+ * This enables explicit cross-scope ref resolution like `ScopeKey/BlockId:model.title`.
914
919
  */
915
- const BlockDescriptionSchema = z.object({
916
- component: z.string().min(1),
917
- id: z.string().min(1).optional(),
918
- services: z
919
- .union([ServiceEntrySchema, z.array(ServiceEntrySchema)])
920
- .optional()
921
- .default([]),
922
- /** Registered directive ids to apply to the host of the dynamically created component. */
923
- directives: z.union([z.string().min(1), z.array(z.string().min(1))]).optional().default([]),
924
- inputs: z.record(z.string(), z.unknown()).optional(),
925
- outputs: z.record(z.string(), OutputValueSchema).optional(),
926
- });
920
+ class BlockRegistryService {
921
+ registry = new Map([
922
+ ['root', { registry: new BlockRegistryImpl(), parentScopeKey: null }],
923
+ ]);
924
+ setScopedRegistry(key, reg, parentScopeKey = 'root') {
925
+ if (key === 'root') {
926
+ throw new Error('Cannot set scoped registry under reserved key "root".');
927
+ }
928
+ this.registry.set(key, { registry: reg, parentScopeKey });
929
+ }
930
+ removeScopedRegistry(key) {
931
+ if (key === 'root')
932
+ return;
933
+ this.registry.delete(key);
934
+ }
935
+ getRegistryForScope(scopeKey) {
936
+ return this.registry.get(scopeKey)?.registry ?? null;
937
+ }
938
+ getParentScopeKey(scopeKey) {
939
+ return this.registry.get(scopeKey)?.parentScopeKey ?? null;
940
+ }
941
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: BlockRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
942
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: BlockRegistryService, providedIn: 'root' });
943
+ }
944
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: BlockRegistryService, decorators: [{
945
+ type: Injectable,
946
+ args: [{ providedIn: 'root' }]
947
+ }] });
948
+
949
+ function isNonEmptyString(v) {
950
+ return typeof v === 'string' && v.length > 0;
951
+ }
952
+ function validateParams(v, path) {
953
+ if (v === undefined)
954
+ return null;
955
+ if (Array.isArray(v))
956
+ return null;
957
+ if (typeof v === 'object' && v !== null && !Array.isArray(v))
958
+ return null;
959
+ return `${path}: params must be array or record`;
960
+ }
961
+ function validateServiceEntry(v, path) {
962
+ if (isNonEmptyString(v))
963
+ return null;
964
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
965
+ return `${path}: invalid service entry`;
966
+ const o = v;
967
+ if (!isNonEmptyString(o['id']))
968
+ return `${path}: service object requires non-empty id`;
969
+ if (o['scope'] !== undefined && o['scope'] !== 'self')
970
+ return `${path}: invalid scope`;
971
+ if (o['alias'] !== undefined && !isNonEmptyString(o['alias']))
972
+ return `${path}: invalid alias`;
973
+ return null;
974
+ }
975
+ function validateThenChainStep(v, path) {
976
+ if (isNonEmptyString(v))
977
+ return null;
978
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
979
+ return `${path}: invalid then step`;
980
+ const o = v;
981
+ if (!isNonEmptyString(o['ref']))
982
+ return `${path}: then step object requires ref`;
983
+ const p = validateParams(o['params'], `${path}.params`);
984
+ if (p)
985
+ return p;
986
+ if (o['then'] !== undefined) {
987
+ if (!Array.isArray(o['then']))
988
+ return `${path}.then must be an array`;
989
+ for (let i = 0; i < o['then'].length; i++) {
990
+ const e = validateThenChainStep(o['then'][i], `${path}.then[${i}]`);
991
+ if (e)
992
+ return e;
993
+ }
994
+ }
995
+ return null;
996
+ }
997
+ function validateOnError(v, path) {
998
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
999
+ return `${path}: invalid onError`;
1000
+ const o = v;
1001
+ if (!isNonEmptyString(o['ref']))
1002
+ return `${path}: onError requires ref`;
1003
+ return validateParams(o['params'], `${path}.params`);
1004
+ }
1005
+ function validateOutputCallObject(v, path) {
1006
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
1007
+ return `${path}: invalid output object`;
1008
+ const o = v;
1009
+ if (!isNonEmptyString(o['ref']))
1010
+ return `${path}: output object requires ref`;
1011
+ const p = validateParams(o['params'], `${path}.params`);
1012
+ if (p)
1013
+ return p;
1014
+ if (o['then'] !== undefined) {
1015
+ if (!Array.isArray(o['then']))
1016
+ return `${path}.then must be an array`;
1017
+ for (let i = 0; i < o['then'].length; i++) {
1018
+ const e = validateThenChainStep(o['then'][i], `${path}.then[${i}]`);
1019
+ if (e)
1020
+ return e;
1021
+ }
1022
+ }
1023
+ if (o['onError'] !== undefined) {
1024
+ const e = validateOnError(o['onError'], `${path}.onError`);
1025
+ if (e)
1026
+ return e;
1027
+ }
1028
+ return null;
1029
+ }
1030
+ function validateOutputValue(v, path) {
1031
+ if (isNonEmptyString(v))
1032
+ return null;
1033
+ return validateOutputCallObject(v, path);
1034
+ }
1035
+ function validateBlockDescriptionShape(data) {
1036
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
1037
+ return 'Expected an object';
1038
+ }
1039
+ const o = data;
1040
+ if (!isNonEmptyString(o['component']))
1041
+ return 'component is required and must be non-empty';
1042
+ if (o['id'] !== undefined && !isNonEmptyString(o['id']))
1043
+ return 'id must be a non-empty string';
1044
+ if (o['services'] !== undefined && o['services'] !== null) {
1045
+ const s = o['services'];
1046
+ const entries = Array.isArray(s) ? s : [s];
1047
+ for (let i = 0; i < entries.length; i++) {
1048
+ const e = validateServiceEntry(entries[i], `services[${i}]`);
1049
+ if (e)
1050
+ return e;
1051
+ }
1052
+ }
1053
+ if (o['directives'] !== undefined && o['directives'] !== null) {
1054
+ const d = o['directives'];
1055
+ if (isNonEmptyString(d)) {
1056
+ /* ok */
1057
+ }
1058
+ else if (Array.isArray(d)) {
1059
+ for (let i = 0; i < d.length; i++) {
1060
+ if (!isNonEmptyString(d[i]))
1061
+ return `directives[${i}] must be a non-empty string`;
1062
+ }
1063
+ }
1064
+ else {
1065
+ return 'directives must be a string or array of strings';
1066
+ }
1067
+ }
1068
+ if (o['inputs'] !== undefined && o['inputs'] !== null) {
1069
+ if (typeof o['inputs'] !== 'object' || Array.isArray(o['inputs']))
1070
+ return 'inputs must be an object';
1071
+ }
1072
+ if (o['outputs'] !== undefined && o['outputs'] !== null) {
1073
+ if (typeof o['outputs'] !== 'object' || Array.isArray(o['outputs']))
1074
+ return 'outputs must be an object';
1075
+ const outs = o['outputs'];
1076
+ for (const key of Object.keys(outs)) {
1077
+ const e = validateOutputValue(outs[key], `outputs.${key}`);
1078
+ if (e)
1079
+ return e;
1080
+ }
1081
+ }
1082
+ return null;
1083
+ }
1084
+ function safeParseBlockDescription(data) {
1085
+ const message = validateBlockDescriptionShape(data);
1086
+ if (message)
1087
+ return { success: false, error: { message } };
1088
+ const o = data;
1089
+ const servicesRaw = o['services'];
1090
+ const services = servicesRaw == null
1091
+ ? []
1092
+ : Array.isArray(servicesRaw)
1093
+ ? servicesRaw
1094
+ : [servicesRaw];
1095
+ const directivesRaw = o['directives'];
1096
+ const directives = directivesRaw == null ? [] : directivesRaw;
1097
+ const result = {
1098
+ component: o['component'],
1099
+ services,
1100
+ directives,
1101
+ };
1102
+ if (o['id'] !== undefined)
1103
+ result.id = o['id'];
1104
+ if (o['inputs'] !== undefined)
1105
+ result.inputs = o['inputs'];
1106
+ if (o['outputs'] !== undefined)
1107
+ result.outputs = o['outputs'];
1108
+ return { success: true, data: result };
1109
+ }
1110
+
1111
+ async function resolveBlockDefinitionValue(raw) {
1112
+ if (isBlockDefinitionLoader(raw)) {
1113
+ return raw();
1114
+ }
1115
+ return raw;
1116
+ }
927
1117
  /** Normalize services to array. */
928
1118
  function normalizeServices(services) {
929
1119
  if (services == null)
@@ -936,15 +1126,16 @@ function normalizeDirectives(directives) {
936
1126
  return [];
937
1127
  return Array.isArray(directives) ? directives : [directives];
938
1128
  }
939
- function safeParseBlockDescription(data) {
940
- return BlockDescriptionSchema.safeParse(data);
941
- }
942
- function isOutputReference(value) {
1129
+ function isOutputCallObject(value) {
943
1130
  return (typeof value === 'object' &&
944
1131
  value !== null &&
945
- value.type === 'reference' &&
946
- typeof value.reference === 'string' &&
947
- typeof value.method === 'string');
1132
+ !Array.isArray(value) &&
1133
+ typeof value.ref === 'string' &&
1134
+ value.ref.length > 0);
1135
+ }
1136
+ /** @deprecated Use isOutputCallObject */
1137
+ function isOutputReference(value) {
1138
+ return isOutputCallObject(value);
948
1139
  }
949
1140
  function isBlockReference(value) {
950
1141
  if (typeof value !== 'object' || value === null || 'component' in value)
@@ -1010,28 +1201,6 @@ async function resolveBlockReference(ref, blockDefinitions) {
1010
1201
  return deepMergeBlockDefinition(base, overrides);
1011
1202
  }
1012
1203
 
1013
- /**
1014
- * Default in-memory BlockRegistry.
1015
- */
1016
- class BlockRegistryImpl {
1017
- map = new Map();
1018
- register(id, handle) {
1019
- if (this.map.has(id)) {
1020
- throw new Error(`Block id "${id}" is already registered. Each id may only occur once per tree.`);
1021
- }
1022
- this.map.set(id, handle);
1023
- }
1024
- unregister(id) {
1025
- this.map.delete(id);
1026
- }
1027
- get(id) {
1028
- return this.map.get(id);
1029
- }
1030
- has(id) {
1031
- return this.map.has(id);
1032
- }
1033
- }
1034
-
1035
1204
  /**
1036
1205
  * Parse ref paths and detect {{ref}} (read-only) and [(ref)] (two-way) in strings.
1037
1206
  */
@@ -1051,10 +1220,17 @@ function parseRefPathUncached(refPath) {
1051
1220
  if (colonIndex !== -1) {
1052
1221
  const prefix = trimmed.slice(0, colonIndex).trim();
1053
1222
  const rest = trimmed.slice(colonIndex + 1).trim();
1223
+ // Prefix may be "BlockID" or "ScopeKey/BlockID" (scope key may contain '/').
1054
1224
  // BlockID must not contain a dot (so "BlockID:model.info.title" is valid; "a:b:c" → prefix "a", rest "b:c")
1055
1225
  if (prefix.length > 0 && !prefix.includes('.')) {
1226
+ const lastSlash = prefix.lastIndexOf('/');
1227
+ const scopeKey = lastSlash !== -1 ? prefix.slice(0, lastSlash) : undefined;
1228
+ const blockId = lastSlash !== -1 ? prefix.slice(lastSlash + 1) : prefix;
1229
+ if (!blockId) {
1230
+ throw new Error(`Invalid block id: ${prefix}`);
1231
+ }
1056
1232
  const pathParts = rest.split('.').filter(Boolean);
1057
- return { blockId: prefix, serviceOrModel: pathParts[0], pathParts: pathParts.slice(1) };
1233
+ return { scopeKey, blockId, serviceOrModel: pathParts[0], pathParts: pathParts.slice(1) };
1058
1234
  }
1059
1235
  else {
1060
1236
  throw new Error(`Invalid block id: ${prefix}`);
@@ -1115,9 +1291,18 @@ function classifyTwoWayString(value) {
1115
1291
  if (typeof value !== 'string')
1116
1292
  return 'none';
1117
1293
  const s = value.trim();
1118
- if (!s.includes('[(') && !s.includes(')]'))
1294
+ if (TWOWAY_REF_RE.test(s))
1295
+ return 'two-way';
1296
+ const hasOpen = s.includes('[(');
1297
+ const hasClose = s.includes(')]');
1298
+ if (!hasOpen && !hasClose)
1119
1299
  return 'none';
1120
- return TWOWAY_REF_RE.test(s) ? 'two-way' : 'invalid-mix';
1300
+ // Only treat as invalid mix when the string looks like an attempted two-way ref
1301
+ // wrapper, not when it contains ")]" from normal indexing/calls inside templates.
1302
+ if ((hasOpen && hasClose) || s.startsWith('[(') || s.endsWith(')]')) {
1303
+ return 'invalid-mix';
1304
+ }
1305
+ return 'none';
1121
1306
  }
1122
1307
  function parseTwoWayRef(value) {
1123
1308
  const m = value.trim().match(TWOWAY_REF_RE);
@@ -1132,6 +1317,16 @@ function escapeRe(s) {
1132
1317
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1133
1318
  }
1134
1319
 
1320
+ const WARN_CACHE_MAX = 200;
1321
+ const warnCache = new Set();
1322
+ function warnOnce(msg) {
1323
+ if (warnCache.has(msg))
1324
+ return;
1325
+ if (warnCache.size >= WARN_CACHE_MAX)
1326
+ warnCache.clear();
1327
+ warnCache.add(msg);
1328
+ console.warn(msg);
1329
+ }
1135
1330
  /**
1136
1331
  * Resolve a ref path to the target object and path.
1137
1332
  * Path format: "model.info.title" (current block) or "BlockID:model.info.title" (named block).
@@ -1140,30 +1335,77 @@ function escapeRe(s) {
1140
1335
  */
1141
1336
  function resolveRefPath(refPath, ctx) {
1142
1337
  const parsed = parseRefPath(refPath);
1143
- const { blockId, serviceOrModel, pathParts } = parsed;
1338
+ const { scopeKey, blockId, serviceOrModel, pathParts } = parsed;
1144
1339
  if (!serviceOrModel) {
1145
1340
  return null;
1146
1341
  }
1342
+ const registry = scopeKey != null
1343
+ ? (ctx.getRegistryForScope?.(scopeKey) ?? null)
1344
+ : ctx.registry;
1345
+ if (scopeKey != null && registry == null) {
1346
+ warnOnce(`Ref "${refPath}" could not resolve scope "${scopeKey}".`);
1347
+ return null;
1348
+ }
1147
1349
  let instance;
1148
1350
  if (blockId != null) {
1149
- const handle = ctx.registry.get(blockId);
1150
- if (!handle)
1151
- return null;
1152
- instance = handle.instance;
1351
+ // Allow self-references during load before the current block is registered.
1352
+ // Important: only shortcut when there is no explicit scope. Scoped refs must resolve through that scope's registry.
1353
+ if (scopeKey == null &&
1354
+ ctx.currentBlockId != null &&
1355
+ blockId === ctx.currentBlockId &&
1356
+ ctx.currentInstance != null) {
1357
+ instance = ctx.currentInstance;
1358
+ }
1359
+ else {
1360
+ const handle = (registry ?? ctx.registry).get(blockId);
1361
+ if (!handle) {
1362
+ warnOnce(`Ref "${refPath}" could not resolve block id "${blockId}".`);
1363
+ return null;
1364
+ }
1365
+ instance = handle.instance;
1366
+ }
1153
1367
  }
1154
1368
  else {
1155
1369
  instance = ctx.currentInstance ?? undefined;
1156
1370
  }
1157
1371
  if (instance == null)
1158
1372
  return null;
1159
- const service = instance[serviceOrModel];
1160
- if (!service)
1373
+ // Special-case: `model` refers to the instance model signal (not a service).
1374
+ if (serviceOrModel === 'model') {
1375
+ const base = instance.model;
1376
+ if (!base) {
1377
+ warnOnce(`Ref "${refPath}" could not resolve model (no instance model).`);
1378
+ return null;
1379
+ }
1380
+ if (pathParts.length === 0) {
1381
+ return { target: base, path: [] };
1382
+ }
1383
+ let current = base;
1384
+ for (let i = 0; i < pathParts.length - 1; i++) {
1385
+ if (current != null && isSignal(current)) {
1386
+ current = current();
1387
+ }
1388
+ if (current == null || typeof current !== 'object')
1389
+ return null;
1390
+ current = current[pathParts[i]];
1391
+ }
1392
+ return { target: current, path: pathParts.slice(-1) };
1393
+ }
1394
+ const service = instance.services?.[serviceOrModel];
1395
+ // If the named service/model is not present on the instance, fall back to `model.<serviceOrModel>...`
1396
+ // (e.g. `Kanban:title` -> `Kanban.model.title`) but only for named-block refs.
1397
+ const shouldModelFallback = blockId != null && service == null && instance.model != null;
1398
+ const base = shouldModelFallback ? instance.model : service;
1399
+ const effectiveParts = shouldModelFallback ? [serviceOrModel, ...pathParts] : pathParts;
1400
+ if (!base) {
1401
+ warnOnce(`Ref "${refPath}" could not resolve service/model "${serviceOrModel}".`);
1161
1402
  return null;
1162
- if (pathParts.length === 0) {
1163
- return { target: service, path: [] };
1164
1403
  }
1165
- let current = service;
1166
- for (let i = 0; i < pathParts.length - 1; i++) {
1404
+ if (effectiveParts.length === 0) {
1405
+ return { target: base, path: [] };
1406
+ }
1407
+ let current = base;
1408
+ for (let i = 0; i < effectiveParts.length - 1; i++) {
1167
1409
  // Unwrap signals at each segment so model.item.a works even when
1168
1410
  // model or item are signals.
1169
1411
  if (current != null && isSignal(current)) {
@@ -1171,11 +1413,11 @@ function resolveRefPath(refPath, ctx) {
1171
1413
  }
1172
1414
  if (current == null || typeof current !== 'object')
1173
1415
  return null;
1174
- current = current[pathParts[i]];
1416
+ current = current[effectiveParts[i]];
1175
1417
  }
1176
1418
  // Do not unwrap the final segment here; leave it to getRefValue / setRefValue so they
1177
1419
  // can consistently handle signals and functions at the leaf.
1178
- return { target: current, path: pathParts.slice(-1) };
1420
+ return { target: current, path: effectiveParts.slice(-1) };
1179
1421
  }
1180
1422
  /**
1181
1423
  * Get a value from an object by dot-notation path (e.g. "name.firstName" -> obj.name?.firstName).
@@ -1234,13 +1476,23 @@ function getRefSignal(refPath, ctx) {
1234
1476
  const resolved = resolveRefPath(refPath, ctx);
1235
1477
  if (resolved == null)
1236
1478
  return undefined;
1237
- const val = resolved.path.length === 0
1238
- ? resolved.target
1239
- : resolved.target?.[resolved.path[0]];
1240
- if (val != null && isSignal(val)) {
1241
- return val;
1479
+ let value;
1480
+ if (resolved.path.length === 0) {
1481
+ value = resolved.target;
1482
+ }
1483
+ else {
1484
+ if (isSignal(resolved.target)) {
1485
+ const signalValue = resolved.target();
1486
+ value =
1487
+ signalValue != null && typeof signalValue === 'object'
1488
+ ? signalValue[resolved.path[0]]
1489
+ : undefined;
1490
+ }
1491
+ else {
1492
+ value = resolved.target?.[resolved.path[0]];
1493
+ }
1242
1494
  }
1243
- return undefined;
1495
+ return value != null && isSignal(value) ? value : undefined;
1244
1496
  }
1245
1497
  /**
1246
1498
  * Set value at ref path (write). No-op if target is not writable.
@@ -1278,6 +1530,284 @@ function buildComputedForTemplate(template, refPaths, ctx) {
1278
1530
  });
1279
1531
  }
1280
1532
 
1533
+ /** Match ${refPath} at start of string (no g flag so match() returns groups). */
1534
+ const REF_AT_START_RE = /^\$\{([^}]*)\}/;
1535
+ const IF_PREFIX_RE = /^@if\s*\(/;
1536
+ const FOR_PREFIX_RE = /^@for\s*\(/;
1537
+ /** `s[openIdx] === '('` → index of matching `)`, or -1. */
1538
+ function findMatchingParen(s, openIdx) {
1539
+ let depth = 0;
1540
+ for (let k = openIdx; k < s.length; k++) {
1541
+ const c = s[k];
1542
+ if (c === '(')
1543
+ depth++;
1544
+ else if (c === ')') {
1545
+ depth--;
1546
+ if (depth === 0)
1547
+ return k;
1548
+ }
1549
+ }
1550
+ return -1;
1551
+ }
1552
+ function findMatchingBrace(s, start) {
1553
+ let depth = 1;
1554
+ for (let i = start; i < s.length; i++) {
1555
+ if (s[i] === '{')
1556
+ depth++;
1557
+ else if (s[i] === '}') {
1558
+ depth--;
1559
+ if (depth === 0)
1560
+ return i;
1561
+ }
1562
+ }
1563
+ return -1;
1564
+ }
1565
+ /**
1566
+ * Parses a flow template string into an AST.
1567
+ * Supports: ${refPath}, @if (refPath) { ... }, @else { ... }, @for (item of refPath) { ... }
1568
+ */
1569
+ function parseFlowTemplate(template) {
1570
+ const nodes = [];
1571
+ let i = 0;
1572
+ while (i < template.length) {
1573
+ const rest = template.slice(i);
1574
+ const ifPrefix = rest.match(IF_PREFIX_RE);
1575
+ if (ifPrefix) {
1576
+ const openParenIdx = i + ifPrefix[0].length - 1;
1577
+ const closeParenIdx = findMatchingParen(template, openParenIdx);
1578
+ if (closeParenIdx === -1) {
1579
+ nodes.push({ type: 'text', value: rest });
1580
+ break;
1581
+ }
1582
+ const refPath = template.slice(openParenIdx + 1, closeParenIdx).trim();
1583
+ let bodyOpen = closeParenIdx + 1;
1584
+ while (bodyOpen < template.length && /\s/.test(template[bodyOpen]))
1585
+ bodyOpen++;
1586
+ if (template[bodyOpen] !== '{') {
1587
+ nodes.push({ type: 'text', value: rest });
1588
+ break;
1589
+ }
1590
+ const bodyStart = bodyOpen + 1;
1591
+ const bodyEnd = findMatchingBrace(template, bodyStart);
1592
+ if (bodyEnd === -1) {
1593
+ nodes.push({ type: 'text', value: rest });
1594
+ break;
1595
+ }
1596
+ const body = template.slice(bodyStart, bodyEnd);
1597
+ let next = bodyEnd + 1;
1598
+ let elseBody;
1599
+ let cursor = next;
1600
+ while (cursor < template.length && /\s/.test(template[cursor] ?? '')) {
1601
+ cursor++;
1602
+ }
1603
+ if (template.startsWith('@else', cursor)) {
1604
+ let elseCursor = cursor + '@else'.length;
1605
+ while (elseCursor < template.length && /\s/.test(template[elseCursor] ?? '')) {
1606
+ elseCursor++;
1607
+ }
1608
+ if (template[elseCursor] === '{') {
1609
+ const elseEnd = findMatchingBrace(template, elseCursor + 1);
1610
+ if (elseEnd !== -1) {
1611
+ elseBody = parseFlowTemplate(template.slice(elseCursor + 1, elseEnd));
1612
+ next = elseEnd + 1;
1613
+ }
1614
+ }
1615
+ }
1616
+ nodes.push({ type: 'if', refPath, body: parseFlowTemplate(body), elseBody });
1617
+ i = next;
1618
+ continue;
1619
+ }
1620
+ const forPrefix = rest.match(FOR_PREFIX_RE);
1621
+ if (forPrefix) {
1622
+ const openParenIdx = i + forPrefix[0].length - 1;
1623
+ const closeParenIdx = findMatchingParen(template, openParenIdx);
1624
+ if (closeParenIdx === -1) {
1625
+ nodes.push({ type: 'text', value: rest });
1626
+ break;
1627
+ }
1628
+ const inner = template.slice(openParenIdx + 1, closeParenIdx).trim();
1629
+ const ofMatch = inner.match(/^(\w+)\s+of\s+([\s\S]+)$/);
1630
+ if (!ofMatch) {
1631
+ nodes.push({ type: 'text', value: rest });
1632
+ break;
1633
+ }
1634
+ const itemVar = ofMatch[1];
1635
+ const refPath = ofMatch[2].trim();
1636
+ let bodyOpen = closeParenIdx + 1;
1637
+ while (bodyOpen < template.length && /\s/.test(template[bodyOpen]))
1638
+ bodyOpen++;
1639
+ if (template[bodyOpen] !== '{') {
1640
+ nodes.push({ type: 'text', value: rest });
1641
+ break;
1642
+ }
1643
+ const bodyStart = bodyOpen + 1;
1644
+ const bodyEnd = findMatchingBrace(template, bodyStart);
1645
+ if (bodyEnd === -1) {
1646
+ nodes.push({ type: 'text', value: rest });
1647
+ break;
1648
+ }
1649
+ const body = template.slice(bodyStart, bodyEnd);
1650
+ nodes.push({ type: 'for', itemVar, refPath, body: parseFlowTemplate(body) });
1651
+ i = bodyEnd + 1;
1652
+ continue;
1653
+ }
1654
+ const refMatch = rest.match(REF_AT_START_RE);
1655
+ if (refMatch) {
1656
+ const refPath = refMatch[1].trim();
1657
+ nodes.push({ type: 'ref', refPath });
1658
+ i += refMatch[0].length;
1659
+ continue;
1660
+ }
1661
+ const nextAt = rest.search(/@(?:if|else|for)\s/);
1662
+ const nextDollar = rest.indexOf('${');
1663
+ let end;
1664
+ if (nextAt >= 0 && (nextDollar < 0 || nextAt < nextDollar))
1665
+ end = i + nextAt;
1666
+ else if (nextDollar >= 0)
1667
+ end = i + nextDollar;
1668
+ else
1669
+ end = template.length;
1670
+ if (end > i) {
1671
+ nodes.push({ type: 'text', value: template.slice(i, end) });
1672
+ i = end;
1673
+ }
1674
+ else {
1675
+ nodes.push({ type: 'text', value: rest[0] ?? '' });
1676
+ i++;
1677
+ }
1678
+ }
1679
+ return nodes;
1680
+ }
1681
+ /** Collect all ref paths used in the AST (for reactivity). */
1682
+ function collectRefPaths(nodes) {
1683
+ const paths = new Set();
1684
+ function walk(ns) {
1685
+ for (const n of ns) {
1686
+ if (n.type === 'ref')
1687
+ paths.add(n.refPath);
1688
+ else if (n.type === 'if') {
1689
+ paths.add(n.refPath);
1690
+ walk(n.body);
1691
+ if (n.elseBody)
1692
+ walk(n.elseBody);
1693
+ }
1694
+ else if (n.type === 'for') {
1695
+ paths.add(n.refPath);
1696
+ walk(n.body);
1697
+ }
1698
+ }
1699
+ }
1700
+ walk(nodes);
1701
+ return paths;
1702
+ }
1703
+ function escapeHtml(s) {
1704
+ const el = typeof document !== 'undefined' ? document.createElement('div') : null;
1705
+ if (el) {
1706
+ el.textContent = s;
1707
+ return el.innerHTML;
1708
+ }
1709
+ return s
1710
+ .replace(/&/g, '&amp;')
1711
+ .replace(/</g, '&lt;')
1712
+ .replace(/>/g, '&gt;')
1713
+ .replace(/"/g, '&quot;')
1714
+ .replace(/'/g, '&#39;');
1715
+ }
1716
+ function resolveInScope(refPath, scope) {
1717
+ const parts = refPath.trim().split('.');
1718
+ let v = scope[parts[0]];
1719
+ for (let i = 1; i < parts.length && v !== undefined && v !== null; i++) {
1720
+ v = v[parts[i]];
1721
+ }
1722
+ return v;
1723
+ }
1724
+ function isTruthyFlowValue(value) {
1725
+ if (typeof value === 'string') {
1726
+ const normalized = value.trim().toLowerCase();
1727
+ if (!normalized)
1728
+ return false;
1729
+ if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
1730
+ return false;
1731
+ }
1732
+ return true;
1733
+ }
1734
+ return !!value;
1735
+ }
1736
+ /**
1737
+ * Same resolution rules as `interpolateTemplateMixed` for refs: named block ref, else current block `model` path.
1738
+ */
1739
+ function resolveRefOrModel(refPath, ctx) {
1740
+ const ref = refPath.trim();
1741
+ if (!ref)
1742
+ return undefined;
1743
+ const resolved = resolveRefPath(ref, ctx);
1744
+ if (resolved != null) {
1745
+ return getRefValue(ref, ctx);
1746
+ }
1747
+ return getValueByPath(ctx.currentInstance?.model?.() ?? {}, ref);
1748
+ }
1749
+ /**
1750
+ * Interprets the AST with the given resolver context and optional scope (e.g. item, $index).
1751
+ * Escapes interpolated values for safe innerHTML.
1752
+ */
1753
+ function interpretFlow(nodes, ctx, scope = {}, options) {
1754
+ const interpText = options?.interpolateText ?? ((s) => s);
1755
+ const escapeRefs = options?.escapeRefValues !== false;
1756
+ let out = '';
1757
+ for (const node of nodes) {
1758
+ if (node.type === 'text') {
1759
+ out += interpText(node.value);
1760
+ continue;
1761
+ }
1762
+ if (node.type === 'ref') {
1763
+ const refPath = node.refPath.trim();
1764
+ const firstSegment = refPath.split('.')[0];
1765
+ let value;
1766
+ if (firstSegment && firstSegment in scope) {
1767
+ value = resolveInScope(refPath, scope);
1768
+ }
1769
+ else {
1770
+ value = resolveRefOrModel(refPath, ctx);
1771
+ }
1772
+ if (value !== undefined && value !== null) {
1773
+ const chunk = String(value);
1774
+ out += escapeRefs ? escapeHtml(chunk) : chunk;
1775
+ }
1776
+ continue;
1777
+ }
1778
+ if (node.type === 'if') {
1779
+ const rawExpr = node.refPath.trim();
1780
+ const cond = options?.resolveCondition
1781
+ ? options.resolveCondition(rawExpr, scope)
1782
+ : resolveRefOrModel(rawExpr, ctx);
1783
+ if (isTruthyFlowValue(cond)) {
1784
+ out += interpretFlow(node.body, ctx, scope, options);
1785
+ }
1786
+ else if (node.elseBody?.length) {
1787
+ out += interpretFlow(node.elseBody, ctx, scope, options);
1788
+ }
1789
+ continue;
1790
+ }
1791
+ if (node.type === 'for') {
1792
+ const arr = resolveRefOrModel(node.refPath, ctx);
1793
+ const list = Array.isArray(arr) ? arr : [];
1794
+ for (let idx = 0; idx < list.length; idx++) {
1795
+ out += interpretFlow(node.body, ctx, {
1796
+ ...scope,
1797
+ [node.itemVar]: list[idx],
1798
+ $index: idx,
1799
+ }, options);
1800
+ }
1801
+ continue;
1802
+ }
1803
+ }
1804
+ return out;
1805
+ }
1806
+ /** True if the string uses flow syntax (not plain `{{ }}` only). */
1807
+ function looksLikeFlowTemplate(s) {
1808
+ return /@(?:if|for)\s*\(/.test(s) || /\$\{[^}]+\}/.test(s);
1809
+ }
1810
+
1281
1811
  function isIdentStart(ch) {
1282
1812
  return /[A-Za-z_$]/.test(ch);
1283
1813
  }
@@ -1303,6 +1833,16 @@ function tokenize(input) {
1303
1833
  i++;
1304
1834
  continue;
1305
1835
  }
1836
+ if (ch === '[') {
1837
+ tokens.push({ type: 'lbracket', value: ch });
1838
+ i++;
1839
+ continue;
1840
+ }
1841
+ if (ch === ']') {
1842
+ tokens.push({ type: 'rbracket', value: ch });
1843
+ i++;
1844
+ continue;
1845
+ }
1306
1846
  if (ch === '.') {
1307
1847
  tokens.push({ type: 'dot', value: ch });
1308
1848
  i++;
@@ -1483,6 +2023,13 @@ class Parser {
1483
2023
  expr = { kind: 'member', object: expr, property: id.value };
1484
2024
  continue;
1485
2025
  }
2026
+ if (this.peek().type === 'lbracket') {
2027
+ this.next();
2028
+ const index = this.parseConditional();
2029
+ this.expect('rbracket');
2030
+ expr = { kind: 'index', object: expr, index };
2031
+ continue;
2032
+ }
1486
2033
  if (this.peek().type === 'lparen') {
1487
2034
  this.next();
1488
2035
  const args = [];
@@ -1530,25 +2077,55 @@ function evalExpr(expr, ctx) {
1530
2077
  }
1531
2078
  if (expr.name in ctx.model)
1532
2079
  return ctx.model[expr.name];
1533
- if (ctx.currentInstance != null && expr.name in ctx.currentInstance)
1534
- return ctx.currentInstance[expr.name];
2080
+ if (ctx.services != null && expr.name in ctx.services)
2081
+ return ctx.services[expr.name];
1535
2082
  return undefined;
1536
2083
  case 'member': {
1537
- const obj = evalExpr(expr.object, ctx);
2084
+ let obj = evalExpr(expr.object, ctx);
2085
+ // Support expressions like ref("Block:model").title where ref(...) can return a signal/function.
2086
+ if (expr.object.kind === 'call' &&
2087
+ expr.object.callee.kind === 'identifier' &&
2088
+ expr.object.callee.name === 'ref' &&
2089
+ typeof obj === 'function') {
2090
+ obj = obj();
2091
+ }
1538
2092
  if (obj == null || typeof obj !== 'object')
1539
2093
  return undefined;
1540
2094
  return obj[expr.property];
1541
2095
  }
2096
+ case 'index': {
2097
+ let obj = evalExpr(expr.object, ctx);
2098
+ if (expr.object.kind === 'call' &&
2099
+ expr.object.callee.kind === 'identifier' &&
2100
+ expr.object.callee.name === 'ref' &&
2101
+ typeof obj === 'function') {
2102
+ obj = obj();
2103
+ }
2104
+ if (obj == null || (typeof obj !== 'object' && typeof obj !== 'function'))
2105
+ return undefined;
2106
+ const key = evalExpr(expr.index, ctx);
2107
+ if (typeof key !== 'string' && typeof key !== 'number')
2108
+ return undefined;
2109
+ return obj[key];
2110
+ }
1542
2111
  case 'call': {
1543
2112
  if (expr.callee.kind !== 'identifier' || expr.callee.name !== 'ref') {
1544
2113
  throw new Error('Only ref(...) calls are allowed');
1545
2114
  }
1546
- if (expr.args.length !== 1)
1547
- throw new Error('ref(...) requires one argument');
2115
+ if (expr.args.length < 1 || expr.args.length > 2) {
2116
+ throw new Error('ref(...) requires one or two arguments');
2117
+ }
1548
2118
  const arg = evalExpr(expr.args[0], ctx);
1549
2119
  if (typeof arg !== 'string')
1550
2120
  return undefined;
1551
- return ctx.resolveRefValue(arg);
2121
+ const resolved = ctx.resolveRefValue(arg);
2122
+ const shouldInvoke = expr.args.length === 2
2123
+ ? evalExpr(expr.args[1], ctx) === true
2124
+ : false;
2125
+ if (shouldInvoke && typeof resolved === 'function') {
2126
+ return resolved();
2127
+ }
2128
+ return resolved;
1552
2129
  }
1553
2130
  case 'unary': {
1554
2131
  const v = evalExpr(expr.argument, ctx);
@@ -1593,33 +2170,22 @@ function evaluateSafeExpression(expression, resolverContext, payload, resolveRef
1593
2170
  return evalExpr(ast, {
1594
2171
  payload,
1595
2172
  model: (resolverContext.currentInstance?.model?.() ?? {}),
1596
- currentInstance: resolverContext.currentInstance,
2173
+ services: resolverContext.currentInstance?.services,
1597
2174
  resolveRefValue: (ref) => resolveRefValue(ref, resolverContext),
1598
2175
  });
1599
2176
  }
1600
2177
 
1601
- function getCallTarget(reference, ctx) {
1602
- const resolved = resolveRefPath(reference, ctx);
1603
- if (resolved == null)
1604
- return undefined;
1605
- const { target, path } = resolved;
1606
- if (path.length === 0)
1607
- return target;
1608
- const parent = target;
1609
- return parent?.[path[0]];
1610
- }
1611
- function getMethodOnTarget(target, methodName) {
1612
- if (target == null)
1613
- return null;
1614
- const m = target[methodName];
1615
- return typeof m === 'function' ? m : null;
1616
- }
2178
+ const MUSTACHE_MAX_SEGMENTS = 200;
1617
2179
  function resolveReferenceValue(ref, ctx) {
1618
2180
  const resolved = resolveRefPath(ref, ctx);
1619
2181
  if (resolved != null)
1620
2182
  return getRefValue(ref, ctx);
1621
2183
  return getValueByPath(ctx.currentInstance?.model?.() ?? {}, ref);
1622
2184
  }
2185
+ /**
2186
+ * Safe expression evaluation (same as output `{{ }}` / `@if (...)` when used with this module).
2187
+ * Falls back to `resolveReferenceValue` if parsing fails (treat whole string as a ref path).
2188
+ */
1623
2189
  function evaluateExpression(expression, ctx, payload) {
1624
2190
  const trimmed = expression.trim();
1625
2191
  if (!trimmed)
@@ -1631,20 +2197,29 @@ function evaluateExpression(expression, ctx, payload) {
1631
2197
  return resolveReferenceValue(trimmed, ctx);
1632
2198
  }
1633
2199
  }
1634
- function resolveInterpolatedString(value, ctx, payload) {
2200
+ /**
2201
+ * Resolves only `{{ … }}` segments via `evaluateExpression`.
2202
+ * Single full-string `{{ expr }}` preserves the evaluated type; mixed templates return a string.
2203
+ */
2204
+ function resolveMustacheInterpolatedString(value, ctx, payload) {
1635
2205
  if (!value.includes('{{') || !value.includes('}}'))
1636
2206
  return value;
1637
2207
  const re = /\{\{([\s\S]*?)\}\}/g;
1638
2208
  const matches = [...value.matchAll(re)];
1639
2209
  if (matches.length === 0)
1640
2210
  return value;
1641
- // If the whole string is a single interpolation, preserve the original type.
1642
- if (matches.length === 1 && matches[0][0].length === value.trim().length && matches[0][0] === value.trim()) {
1643
- return evaluateExpression(matches[0][1], ctx, payload);
2211
+ if (matches.length > MUSTACHE_MAX_SEGMENTS) {
2212
+ console.warn(`Template has more than ${MUSTACHE_MAX_SEGMENTS} mustache segments; truncating interpolation.`);
2213
+ }
2214
+ const limited = matches.slice(0, MUSTACHE_MAX_SEGMENTS);
2215
+ if (limited.length === 1 &&
2216
+ limited[0][0].length === value.trim().length &&
2217
+ limited[0][0] === value.trim()) {
2218
+ return evaluateExpression(limited[0][1], ctx, payload);
1644
2219
  }
1645
2220
  let out = '';
1646
2221
  let lastIndex = 0;
1647
- for (const match of matches) {
2222
+ for (const match of limited) {
1648
2223
  const start = match.index ?? 0;
1649
2224
  out += value.slice(lastIndex, start);
1650
2225
  const evaluated = evaluateExpression(match[1], ctx, payload);
@@ -1654,14 +2229,91 @@ function resolveInterpolatedString(value, ctx, payload) {
1654
2229
  out += value.slice(lastIndex);
1655
2230
  return out;
1656
2231
  }
2232
+ function mustacheSegmentToString(segment, ctx, payload) {
2233
+ const v = resolveMustacheInterpolatedString(segment, ctx, payload);
2234
+ if (v == null)
2235
+ return '';
2236
+ if (typeof v === 'string')
2237
+ return v;
2238
+ return String(v);
2239
+ }
2240
+ /**
2241
+ * Unified template resolution: flow (`@if`, `@for`, `${}`) plus `{{ }}` in flow text segments.
2242
+ * `@if` conditions use `evaluateExpression` (safe expressions + ref fallback).
2243
+ */
2244
+ function resolveTemplateString(value, ctx, payload = undefined, options) {
2245
+ const escapeFlowRefs = options?.escapeFlowRefs !== false;
2246
+ if (looksLikeFlowTemplate(value)) {
2247
+ return interpretFlow(parseFlowTemplate(value), ctx, {}, {
2248
+ escapeRefValues: escapeFlowRefs,
2249
+ resolveCondition: (expr) => evaluateExpression(expr, ctx, payload),
2250
+ interpolateText: (segment) => mustacheSegmentToString(segment, ctx, payload),
2251
+ });
2252
+ }
2253
+ return resolveMustacheInterpolatedString(value, ctx, payload);
2254
+ }
2255
+
2256
+ function getCallTarget(reference, ctx) {
2257
+ const resolved = resolveRefPath(reference, ctx);
2258
+ if (resolved == null)
2259
+ return undefined;
2260
+ const { target, path } = resolved;
2261
+ if (path.length === 0)
2262
+ return target;
2263
+ const parent = target;
2264
+ return parent?.[path[0]];
2265
+ }
2266
+ function resolveReferencePath(reference, ctx, payload) {
2267
+ const resolved = resolveTemplateString(reference, ctx, payload, { escapeFlowRefs: false });
2268
+ return typeof resolved === 'string' ? resolved : String(resolved ?? '');
2269
+ }
2270
+ /**
2271
+ * Split `Block:path.to.method` or `service.path.method` (current block) into ref path + method name.
2272
+ * Last dot-separated segment after `:` is the method; before that is the ref path.
2273
+ */
2274
+ function splitCallableRef(fullPath) {
2275
+ const trimmed = fullPath.trim();
2276
+ if (!trimmed)
2277
+ return null;
2278
+ const colonIdx = trimmed.indexOf(':');
2279
+ if (colonIdx !== -1) {
2280
+ const prefix = trimmed.slice(0, colonIdx).trim();
2281
+ const rest = trimmed.slice(colonIdx + 1).trim();
2282
+ const parts = rest.split('.').filter(Boolean);
2283
+ if (parts.length < 2)
2284
+ return null;
2285
+ const method = parts[parts.length - 1];
2286
+ const parentRest = parts.slice(0, -1).join('.');
2287
+ return { refPath: `${prefix}:${parentRest}`, method };
2288
+ }
2289
+ const parts = trimmed.split('.').filter(Boolean);
2290
+ if (parts.length < 2)
2291
+ return null;
2292
+ const method = parts[parts.length - 1];
2293
+ const refPath = parts.slice(0, -1).join('.');
2294
+ return { refPath, method };
2295
+ }
2296
+ function getMethodOnTarget(target, methodName) {
2297
+ if (target == null)
2298
+ return null;
2299
+ const m = target[methodName];
2300
+ return typeof m === 'function' ? m : null;
2301
+ }
2302
+ function warnNotCallable(resolvedFullRef, refPath, method, callTarget) {
2303
+ const leafDesc = callTarget != null && typeof callTarget === 'object'
2304
+ ? typeof callTarget[method]
2305
+ : 'n/a';
2306
+ console.warn(`Output call "${resolvedFullRef}": "${method}" is not a callable function ` +
2307
+ `(ref path "${refPath}"); resolved leaf type: ${leafDesc}.`);
2308
+ }
1657
2309
  function resolveOutputParamValue(value, ctx, payload) {
1658
2310
  if (typeof value === 'string') {
1659
- return resolveInterpolatedString(value, ctx, payload);
2311
+ return resolveTemplateString(value, ctx, payload, { escapeFlowRefs: false });
1660
2312
  }
1661
2313
  if (Array.isArray(value)) {
1662
2314
  return value.map((item) => resolveOutputParamValue(item, ctx, payload));
1663
2315
  }
1664
- if (value != null && typeof value === 'object') {
2316
+ if (value != null && typeof value === 'object' && !('component' in value)) {
1665
2317
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [
1666
2318
  key,
1667
2319
  resolveOutputParamValue(item, ctx, payload),
@@ -1669,59 +2321,141 @@ function resolveOutputParamValue(value, ctx, payload) {
1669
2321
  }
1670
2322
  return value;
1671
2323
  }
1672
- function resolveOutputReference(ref, eventValue, ctx) {
2324
+ function isTruthyOutputCondition(value) {
2325
+ if (typeof value === 'string') {
2326
+ const normalized = value.trim().toLowerCase();
2327
+ if (!normalized)
2328
+ return false;
2329
+ if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
2330
+ return false;
2331
+ }
2332
+ return true;
2333
+ }
2334
+ return !!value;
2335
+ }
2336
+ function shouldRunWhen(when, ctx, payload) {
2337
+ if (when == null)
2338
+ return true;
2339
+ const resolved = resolveOutputParamValue(when, ctx, payload);
2340
+ return isTruthyOutputCondition(resolved);
2341
+ }
2342
+ function buildOutputHandlerFromCall(refTemplate, extras, ctx, eventValue) {
2343
+ const def = { ref: refTemplate, ...extras };
1673
2344
  return (value) => {
1674
2345
  const payload = value ?? eventValue;
1675
- const callTarget = getCallTarget(ref.reference, ctx);
1676
- const method = getMethodOnTarget(callTarget, ref.method);
1677
- if (method == null) {
1678
- console.warn(`Output reference ${ref.reference} has no method ${ref.method}`);
2346
+ if (!shouldRunWhen(def.when, ctx, payload)) {
1679
2347
  return;
1680
2348
  }
1681
- const params = ref.params != null
1682
- ? (Array.isArray(ref.params) ? ref.params : [ref.params]).map((param) => resolveOutputParamValue(param, ctx, payload))
2349
+ const resolvedCallable = resolveReferencePath(refTemplate, ctx, payload);
2350
+ const split = splitCallableRef(resolvedCallable);
2351
+ if (split == null) {
2352
+ console.warn(`Invalid output callable ref "${resolvedCallable}" (expected e.g. Block:path.method or service.path.method)`);
2353
+ return;
2354
+ }
2355
+ const { refPath, method } = split;
2356
+ const callTarget = getCallTarget(refPath, ctx);
2357
+ const fn = getMethodOnTarget(callTarget, method);
2358
+ if (fn == null) {
2359
+ warnNotCallable(resolvedCallable, refPath, method, callTarget);
2360
+ return;
2361
+ }
2362
+ const params = def.params != null
2363
+ ? (Array.isArray(def.params) ? def.params : [def.params]).map((param) => resolveOutputParamValue(param, ctx, payload))
1683
2364
  : [payload];
1684
- const result = method.call(callTarget, ...params);
2365
+ const result = fn.call(callTarget, ...params);
1685
2366
  if (result != null && typeof result.then === 'function') {
1686
- result.then(() => runThenOrSuccess(ref, ctx), () => runOnError(ref, ctx));
2367
+ result.then(() => runThenOrSuccess(def, ctx, payload), () => runOnError(def, ctx, payload));
1687
2368
  }
1688
2369
  else {
1689
- runThenOrSuccess(ref, ctx);
2370
+ runThenOrSuccess(def, ctx, payload);
1690
2371
  }
1691
2372
  };
1692
2373
  }
1693
- function toParams(p) {
2374
+ function resolveStepParams(p, ctx, payload) {
1694
2375
  if (p == null)
1695
2376
  return [];
1696
- return Array.isArray(p) ? p : [p];
2377
+ const arr = Array.isArray(p) ? p : [p];
2378
+ return arr.map((item) => resolveOutputParamValue(item, ctx, payload));
1697
2379
  }
1698
- function runThenOrSuccess(ref, ctx) {
1699
- if (ref.then?.length) {
1700
- for (const step of ref.then) {
1701
- invokeRefMethod(ctx, step.reference, step.method, toParams(step.params));
2380
+ function runThenOrSuccess(def, ctx, payload) {
2381
+ if (def.then?.length) {
2382
+ runThenChain(def.then, 0, ctx, def, payload, () => { });
2383
+ }
2384
+ }
2385
+ function runThenChain(steps, index, ctx, parentDef, payload, onComplete) {
2386
+ if (index >= steps.length) {
2387
+ onComplete();
2388
+ return;
2389
+ }
2390
+ const step = steps[index];
2391
+ const runNextSibling = () => runThenChain(steps, index + 1, ctx, parentDef, payload, onComplete);
2392
+ const refTemplate = typeof step === 'string' ? step : step.ref;
2393
+ const when = typeof step === 'string' ? undefined : step.when;
2394
+ const stepParams = typeof step === 'string' ? undefined : step.params;
2395
+ const nestedThen = typeof step === 'string' ? undefined : step.then;
2396
+ if (!shouldRunWhen(when, ctx, payload)) {
2397
+ runNextSibling();
2398
+ return;
2399
+ }
2400
+ const resolvedCallable = resolveReferencePath(refTemplate, ctx, payload);
2401
+ const split = splitCallableRef(resolvedCallable);
2402
+ if (split == null) {
2403
+ console.warn(`then chain: invalid callable ref "${resolvedCallable}" (expected e.g. Block:path.method)`);
2404
+ return;
2405
+ }
2406
+ const { refPath, method } = split;
2407
+ const callTarget = getCallTarget(refPath, ctx);
2408
+ const fn = getMethodOnTarget(callTarget, method);
2409
+ if (fn == null) {
2410
+ warnNotCallable(resolvedCallable, refPath, method, callTarget);
2411
+ return;
2412
+ }
2413
+ const params = resolveStepParams(stepParams, ctx, payload);
2414
+ const result = fn.call(callTarget, ...params);
2415
+ const afterStep = () => {
2416
+ if (nestedThen?.length) {
2417
+ runThenChain(nestedThen, 0, ctx, parentDef, payload, runNextSibling);
1702
2418
  }
2419
+ else {
2420
+ runNextSibling();
2421
+ }
2422
+ };
2423
+ if (result != null && typeof result.then === 'function') {
2424
+ result.then(afterStep, () => runOnError(parentDef, ctx, payload));
1703
2425
  }
1704
- else if (ref.onSuccess) {
1705
- invokeRefMethod(ctx, ref.onSuccess.reference, ref.onSuccess.method, toParams(ref.onSuccess.params));
2426
+ else {
2427
+ afterStep();
1706
2428
  }
1707
2429
  }
1708
- function runOnError(ref, ctx) {
1709
- if (ref.onError) {
1710
- invokeRefMethod(ctx, ref.onError.reference, ref.onError.method, toParams(ref.onError.params));
2430
+ function runOnError(def, ctx, payload) {
2431
+ if (def.onError && shouldRunWhen(def.onError.when, ctx, payload)) {
2432
+ invokeRefMethod(ctx, def.onError.ref, payload, resolveStepParams(def.onError.params, ctx, payload));
1711
2433
  }
1712
2434
  }
1713
- function invokeRefMethod(ctx, reference, method, params) {
1714
- const callTarget = getCallTarget(reference, ctx);
2435
+ function invokeRefMethod(ctx, refTemplate, payload, params) {
2436
+ const resolvedCallable = resolveReferencePath(refTemplate, ctx, payload);
2437
+ const split = splitCallableRef(resolvedCallable);
2438
+ if (split == null) {
2439
+ console.warn(`onError: invalid callable ref "${resolvedCallable}"`);
2440
+ return;
2441
+ }
2442
+ const { refPath, method } = split;
2443
+ const callTarget = getCallTarget(refPath, ctx);
1715
2444
  const fn = getMethodOnTarget(callTarget, method);
1716
- if (fn)
1717
- fn.call(callTarget, ...params);
2445
+ if (fn == null) {
2446
+ warnNotCallable(resolvedCallable, refPath, method, callTarget);
2447
+ return;
2448
+ }
2449
+ fn.call(callTarget, ...params);
1718
2450
  }
1719
- function createOutputHandler(outputValue, outputKey, ctx, directiveHandlers) {
1720
- if (isOutputReference(outputValue)) {
1721
- return resolveOutputReference(outputValue, undefined, ctx);
2451
+ function createOutputHandler(outputValue, _outputKey, ctx) {
2452
+ if (typeof outputValue === 'string') {
2453
+ return buildOutputHandlerFromCall(outputValue, {}, ctx, undefined);
1722
2454
  }
1723
- const fromDirective = directiveHandlers?.[outputKey];
1724
- return fromDirective ?? (() => { });
2455
+ if (isOutputCallObject(outputValue)) {
2456
+ return buildOutputHandlerFromCall(outputValue.ref, outputValue, ctx, undefined);
2457
+ }
2458
+ return () => { };
1725
2459
  }
1726
2460
 
1727
2461
  function getBlockInputsAndOutputs(componentOrDirectives) {
@@ -1756,7 +2490,7 @@ function getDirectiveMetadata(type) {
1756
2490
  isComponent: !!type.ɵcmp,
1757
2491
  };
1758
2492
  }
1759
- function resolveBlockInputsAndOutputs(description, inputKeys, outputKeys, twoWayKeys, ctx, outputHandlers) {
2493
+ function resolveBlockInputsAndOutputs(description, inputKeys, outputKeys, twoWayKeys, ctx) {
1760
2494
  const resolvedInputs = {};
1761
2495
  const resolvedOutputs = {};
1762
2496
  const resolvedTwoWay = {};
@@ -1782,7 +2516,7 @@ function resolveBlockInputsAndOutputs(description, inputKeys, outputKeys, twoWay
1782
2516
  const valueSignal = getRefSignal(refPath, ctx);
1783
2517
  if (twoWayKeys.has(key))
1784
2518
  if (!isWritableSignal(valueSignal)) {
1785
- console.warn(`Input "${key}" is a two-way ref but is not a writable signal.`);
2519
+ console.warn(`Input "${key}" of ref "${refPath}" is a two-way ref but is not a writable signal. Path might not be resolved correctly.`);
1786
2520
  resolvedTwoWay[key] = linkedSignal(() => valueSignal?.());
1787
2521
  }
1788
2522
  else {
@@ -1796,13 +2530,24 @@ function resolveBlockInputsAndOutputs(description, inputKeys, outputKeys, twoWay
1796
2530
  continue;
1797
2531
  }
1798
2532
  const str = value;
1799
- if (typeof value === 'string' &&
2533
+ const hasMustache = typeof value === 'string' &&
1800
2534
  str.indexOf('{{') !== -1 &&
1801
- str.indexOf('}}', str.indexOf('{{')) !== -1) {
1802
- resolvedInputs[key] = computed(() => interpolateTemplateMixed(str, ctx));
2535
+ str.indexOf('}}', str.indexOf('{{')) !== -1;
2536
+ const hasFlow = typeof value === 'string' && looksLikeFlowTemplate(str);
2537
+ if (hasMustache || hasFlow) {
2538
+ resolvedInputs[key] = computed(() => resolveTemplateString(str, ctx, undefined, { escapeFlowRefs: true }));
1803
2539
  continue;
1804
2540
  }
1805
2541
  }
2542
+ if (Array.isArray(value)) {
2543
+ resolvedInputs[key] = computed(() => value.map((item) => {
2544
+ if (typeof item === 'string') {
2545
+ return resolveTemplateString(item, ctx, undefined, { escapeFlowRefs: true });
2546
+ }
2547
+ return item;
2548
+ }));
2549
+ continue;
2550
+ }
1806
2551
  if (inputKeys.has(key))
1807
2552
  resolvedInputs[key] = () => value;
1808
2553
  if (twoWayKeys.has(key))
@@ -1813,54 +2558,10 @@ function resolveBlockInputsAndOutputs(description, inputKeys, outputKeys, twoWay
1813
2558
  console.warn(`Block output "${key}" is not defined on the component or any host directive; skipping.`);
1814
2559
  continue;
1815
2560
  }
1816
- resolvedOutputs[key] = createOutputHandler(value, key, ctx, outputHandlers);
2561
+ resolvedOutputs[key] = createOutputHandler(value, key, ctx);
1817
2562
  }
1818
2563
  return { resolvedInputs, resolvedOutputs, resolvedTwoWay };
1819
2564
  }
1820
- function interpolateTemplateMixed(template, ctx) {
1821
- const trimmed = template.trim();
1822
- if (trimmed.startsWith('{{')) {
1823
- const close = trimmed.indexOf('}}', 2);
1824
- if (close !== -1 &&
1825
- close + 2 === trimmed.length &&
1826
- trimmed.indexOf('{{', 2) === -1) {
1827
- const ref = trimmed.slice(2, close).trim();
1828
- const resolved = resolveRefPath(ref, ctx);
1829
- const val = ref
1830
- ? resolved != null
1831
- ? getRefValue(ref, ctx)
1832
- : getValueByPath(ctx.currentInstance?.model?.() ?? {}, ref)
1833
- : null;
1834
- return val != null ? val : '';
1835
- }
1836
- }
1837
- const parts = [];
1838
- let s = template;
1839
- for (let i = 0; i < INTERPOLATE_MAX_PLACEHOLDERS; i++) {
1840
- const start = s.indexOf('{{');
1841
- if (start === -1) {
1842
- parts.push(s);
1843
- break;
1844
- }
1845
- parts.push(s.slice(0, start));
1846
- const end = s.indexOf('}}', start);
1847
- if (end === -1) {
1848
- parts.push(s.slice(start));
1849
- break;
1850
- }
1851
- const ref = s.slice(start + 2, end).trim();
1852
- /** Resolve ref path or get value from model if ref path is not found. */
1853
- const resolved = resolveRefPath(ref, ctx);
1854
- const val = ref
1855
- ? resolved != null
1856
- ? getRefValue(ref, ctx)
1857
- : getValueByPath(ctx.currentInstance?.model?.() ?? {}, ref)
1858
- : null;
1859
- parts.push(val != null ? String(val) : '');
1860
- s = s.slice(end + 2);
1861
- }
1862
- return parts.join('');
1863
- }
1864
2565
 
1865
2566
  const INTERPOLATE_MAX_PLACEHOLDERS = 200;
1866
2567
  function createInputBindingsWithWarnings(ownerName, inputKeys, twoWayKeys, outputKeys, resolvedInputs, resolvedTwoWay, resolvedOutputs) {
@@ -1874,13 +2575,14 @@ function createInputBindingsWithWarnings(ownerName, inputKeys, twoWayKeys, outpu
1874
2575
  .map((key) => inputBinding(key, resolvedInputs[key]));
1875
2576
  const twoWayBindingsArray = Array.from(twoWayKeys)
1876
2577
  .filter((key) => {
1877
- if (resolvedTwoWay[key] == null) {
2578
+ if (resolvedTwoWay[key] == null && resolvedInputs[key] == null) {
1878
2579
  return false;
1879
2580
  }
1880
2581
  return true;
1881
2582
  })
1882
2583
  // resolvedTwoWay is guaranteed non-null after the filter.
1883
- .map((key) => twoWayBinding(key, resolvedTwoWay[key]));
2584
+ .map((key) => twoWayBinding(key, (resolvedTwoWay[key] ??
2585
+ linkedSignal(() => resolvedInputs[key]?.()))));
1884
2586
  const outputBindingsArray = Array.from(outputKeys)
1885
2587
  .filter((key) => {
1886
2588
  if (resolvedOutputs[key] == null) {
@@ -1891,10 +2593,20 @@ function createInputBindingsWithWarnings(ownerName, inputKeys, twoWayKeys, outpu
1891
2593
  .map((key) => outputBinding(key, resolvedOutputs[key]));
1892
2594
  return [...inputBindingsArray, ...twoWayBindingsArray, ...outputBindingsArray];
1893
2595
  }
2596
+ /** JSON-like objects only — skip class instances, Date, Map, etc. */
2597
+ function isPlainObject(value) {
2598
+ if (value === null || typeof value !== 'object')
2599
+ return false;
2600
+ if (Array.isArray(value))
2601
+ return false;
2602
+ const proto = Object.getPrototypeOf(value);
2603
+ return proto === null || proto === Object.prototype;
2604
+ }
1894
2605
  class BlockLoaderService {
1895
2606
  componentRegistry = ComponentRegistry.getInstance();
1896
2607
  directiveRegistry = DirectiveRegistry.getInstance();
1897
2608
  serviceRegistry = ServiceRegistry.getInstance();
2609
+ blockRegistryService = inject(BlockRegistryService);
1898
2610
  async load(description, viewContainerRef, model, options) {
1899
2611
  let resolved = description;
1900
2612
  if (isBlockReference(description)) {
@@ -1906,7 +2618,7 @@ class BlockLoaderService {
1906
2618
  }
1907
2619
  const desc = parsed.data;
1908
2620
  const registry = options?.registry ?? new BlockRegistryImpl();
1909
- if (desc.id != null && desc.id !== '' && registry.has(desc.id)) {
2621
+ if (desc.id != null && desc.id !== '' && registry.hasLocal(desc.id)) {
1910
2622
  throw new Error(`Block id "${desc.id}" is already registered.`);
1911
2623
  }
1912
2624
  const componentType = await this.componentRegistry.get(desc.component);
@@ -1914,11 +2626,12 @@ class BlockLoaderService {
1914
2626
  throw new Error(`Component "${desc.component}" is not registered.`);
1915
2627
  }
1916
2628
  //... Generate and new resolver context
1917
- const blockInstance = {};
2629
+ const blockInstance = { services: {} };
1918
2630
  const ctx = {
1919
2631
  registry,
1920
2632
  currentBlockId: desc.id ?? undefined,
1921
2633
  currentInstance: blockInstance,
2634
+ getRegistryForScope: (scopeKey) => this.blockRegistryService.getRegistryForScope(scopeKey),
1922
2635
  };
1923
2636
  //... Resolve model for the block
1924
2637
  this.resolveContextModel(ctx, model);
@@ -1969,6 +2682,9 @@ class BlockLoaderService {
1969
2682
  return componentRef;
1970
2683
  }
1971
2684
  async resolveServices(viewContainerRef, blockInstance, desc, resolvedModel, model) {
2685
+ // services are stored under `blockInstance.services`, so `model` no longer collides structurally,
2686
+ // but `model` is a reserved ref keyword (`BlockId:model...`). Warn to avoid confusion.
2687
+ const reservedAliases = new Set(['services', 'model']);
1972
2688
  const services = normalizeServices(desc.services);
1973
2689
  const selfServices = services.filter((s) => typeof s === 'object' && s.scope === 'self');
1974
2690
  /** Root = string id or object with scope undefined/not 'self' (e.g. [{ id: 'State' }]). */
@@ -1981,8 +2697,12 @@ class BlockLoaderService {
1981
2697
  const serviceType = rootServiceTypes[i];
1982
2698
  if (serviceType) {
1983
2699
  const svc = viewContainerRef.injector.get(serviceType, null);
1984
- if (svc != null)
1985
- blockInstance[alias] = svc;
2700
+ if (reservedAliases.has(alias)) {
2701
+ console.warn(`Service alias "${alias}" is reserved and conflicts with ref keywords; skipping assignment.`);
2702
+ }
2703
+ else if (svc != null) {
2704
+ blockInstance.services[alias] = svc;
2705
+ }
1986
2706
  // if the service is not found, add it to the selfServices array
1987
2707
  else
1988
2708
  selfServices.push({ id: alias, scope: 'self' });
@@ -1998,12 +2718,18 @@ class BlockLoaderService {
1998
2718
  self: true,
1999
2719
  });
2000
2720
  if (svc != null) {
2001
- blockInstance[alias] = svc;
2721
+ if (reservedAliases.has(alias)) {
2722
+ console.warn(`Service alias "${alias}" is reserved and conflicts with ref keywords; skipping assignment.`);
2723
+ }
2724
+ else {
2725
+ blockInstance.services[alias] = svc;
2726
+ }
2002
2727
  if (model() != null &&
2003
2728
  resolvedModel != null &&
2004
2729
  'model' in svc &&
2005
2730
  isSignal(svc.model)) {
2006
- svc.model = resolvedModel;
2731
+ svc.model.set(resolvedModel);
2732
+ svc.setModel?.();
2007
2733
  }
2008
2734
  }
2009
2735
  }
@@ -2033,22 +2759,31 @@ class BlockLoaderService {
2033
2759
  resolveContextModel(ctx, blockModel) {
2034
2760
  const directiveModel = blockModel();
2035
2761
  const blockInstance = ctx.currentInstance;
2762
+ const templateCtx = this.withModelOverrideContext(ctx, blockModel);
2036
2763
  let blockInstanceModel;
2037
2764
  if (typeof directiveModel === 'string') {
2038
- const refPath = directiveModel;
2039
- if (refPath.length > 0) {
2040
- const refSignal = getRefSignal(refPath, ctx);
2041
- if (refSignal != null) {
2042
- blockInstanceModel = refSignal;
2043
- }
2044
- else {
2045
- console.warn(`Model ref "${directiveModel}" not found.`);
2046
- return;
2765
+ const interpolated = resolveTemplateString(directiveModel, templateCtx);
2766
+ if (typeof interpolated !== 'string') {
2767
+ blockInstanceModel = linkedSignal(() => resolveTemplateString(directiveModel, templateCtx));
2768
+ }
2769
+ else {
2770
+ const refPath = interpolated.trim();
2771
+ if (refPath.length > 0) {
2772
+ const refSignal = getRefSignal(refPath, ctx);
2773
+ if (refSignal != null) {
2774
+ blockInstanceModel = refSignal;
2775
+ }
2776
+ else {
2777
+ blockInstanceModel = linkedSignal(() => resolveTemplateString(directiveModel, templateCtx));
2778
+ }
2047
2779
  }
2048
2780
  }
2049
2781
  }
2050
2782
  else {
2051
- blockInstanceModel = blockModel;
2783
+ const hasTemplates = this.valueContainsTemplate(directiveModel);
2784
+ blockInstanceModel = hasTemplates
2785
+ ? linkedSignal(() => this.resolveNestedModelTemplates(blockModel(), this.withModelOverrideContext(ctx, blockModel)))
2786
+ : blockModel;
2052
2787
  }
2053
2788
  if (blockInstanceModel != null) {
2054
2789
  if (!blockInstance) {
@@ -2057,10 +2792,58 @@ class BlockLoaderService {
2057
2792
  blockInstance.model = blockInstanceModel;
2058
2793
  }
2059
2794
  }
2060
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: BlockLoaderService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2061
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: BlockLoaderService, providedIn: 'root' });
2795
+ valueContainsTemplate(value, visited = new WeakSet()) {
2796
+ if (typeof value === 'string') {
2797
+ return value.includes('{{') && value.includes('}}');
2798
+ }
2799
+ if (Array.isArray(value)) {
2800
+ return value.some((item) => this.valueContainsTemplate(item, visited));
2801
+ }
2802
+ if (value != null && typeof value === 'object') {
2803
+ if (!isPlainObject(value))
2804
+ return false;
2805
+ if (visited.has(value))
2806
+ return false;
2807
+ visited.add(value);
2808
+ return Object.values(value).some((item) => this.valueContainsTemplate(item, visited));
2809
+ }
2810
+ return false;
2811
+ }
2812
+ resolveNestedModelTemplates(value, ctx, visited = new WeakSet()) {
2813
+ if (typeof value === 'string') {
2814
+ return resolveTemplateString(value, ctx);
2815
+ }
2816
+ if (Array.isArray(value)) {
2817
+ return value.map((item) => this.resolveNestedModelTemplates(item, ctx, visited));
2818
+ }
2819
+ if (value != null && typeof value === 'object') {
2820
+ if (!isPlainObject(value))
2821
+ return value;
2822
+ if (visited.has(value))
2823
+ return value;
2824
+ visited.add(value);
2825
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
2826
+ key,
2827
+ this.resolveNestedModelTemplates(item, ctx, visited),
2828
+ ]));
2829
+ }
2830
+ return value;
2831
+ }
2832
+ withModelOverrideContext(ctx, model) {
2833
+ if (ctx.currentInstance == null)
2834
+ return ctx;
2835
+ return {
2836
+ ...ctx,
2837
+ currentInstance: {
2838
+ ...ctx.currentInstance,
2839
+ model,
2840
+ },
2841
+ };
2842
+ }
2843
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: BlockLoaderService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2844
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: BlockLoaderService, providedIn: 'root' });
2062
2845
  }
2063
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: BlockLoaderService, decorators: [{
2846
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: BlockLoaderService, decorators: [{
2064
2847
  type: Injectable,
2065
2848
  args: [{ providedIn: 'root' }]
2066
2849
  }] });
@@ -2076,25 +2859,43 @@ function getServicesKey(services) {
2076
2859
  : `${s.id}:${s.scope ?? ''}`)
2077
2860
  .join(',');
2078
2861
  }
2862
+ function safeJsonKey(value) {
2863
+ try {
2864
+ return JSON.stringify(value) ?? 'null';
2865
+ }
2866
+ catch {
2867
+ return String(value);
2868
+ }
2869
+ }
2079
2870
  class BlockDirective {
2080
2871
  viewContainerRef = inject(ViewContainerRef);
2081
2872
  loader = inject(BlockLoaderService);
2082
2873
  destroyRef = inject(DestroyRef);
2083
2874
  /** Full description, or { id } / { blockId, blockDefinition? } to reuse/override from blockDefinitions. */
2084
- description = input(null, ...(ngDevMode ? [{ debugName: "description" }] : []));
2085
- /** Handlers for component outputs; keys match descriptor.outputs. */
2086
- outputHandlers = input({}, ...(ngDevMode ? [{ debugName: "outputHandlers" }] : []));
2875
+ description = input(null, /* @ts-ignore */
2876
+ ...(ngDevMode ? [{ debugName: "description" }] : /* istanbul ignore next */ []));
2087
2877
  /** Registry for block instances by id; pass from root so nested blocks share it. */
2088
- blockRegistry = input(null, ...(ngDevMode ? [{ debugName: "blockRegistry" }] : []));
2878
+ blockRegistry = input(null, /* @ts-ignore */
2879
+ ...(ngDevMode ? [{ debugName: "blockRegistry" }] : /* istanbul ignore next */ []));
2089
2880
  /** Map id → full description or loader; used when description is a block reference (id/blockId). */
2090
- blockDefinitions = input(null, ...(ngDevMode ? [{ debugName: "blockDefinitions" }] : []));
2881
+ blockDefinitions = input(null, /* @ts-ignore */
2882
+ ...(ngDevMode ? [{ debugName: "blockDefinitions" }] : /* istanbul ignore next */ []));
2091
2883
  /** Model for the block. */
2092
- model = input(undefined, ...(ngDevMode ? [{ debugName: "model" }] : []));
2884
+ model = input(undefined, /* @ts-ignore */
2885
+ ...(ngDevMode ? [{ debugName: "model" }] : /* istanbul ignore next */ []));
2886
+ /**
2887
+ * Forces a reload when this value changes, even if component/services are the same.
2888
+ * Useful for "same component, different inputs" scenarios like tabs/switch blocks.
2889
+ */
2890
+ reloadKey = input(null, /* @ts-ignore */
2891
+ ...(ngDevMode ? [{ debugName: "reloadKey" }] : /* istanbul ignore next */ []));
2093
2892
  loadResult = null;
2094
2893
  lastRegistry = null;
2095
2894
  lastId = null;
2096
2895
  loadedComponent = null;
2097
2896
  loadedServicesKey = null;
2897
+ loadedReloadKey = null;
2898
+ loadedDescKey = null;
2098
2899
  loadGeneration = 0;
2099
2900
  /** Invalidates in-flight block-reference resolution when the effect re-runs. */
2100
2901
  effectRunId = 0;
@@ -2104,8 +2905,8 @@ class BlockDirective {
2104
2905
  constructor() {
2105
2906
  effect(() => {
2106
2907
  const desc = this.description();
2107
- const outputHandlers = this.outputHandlers();
2108
2908
  const inputDefs = this.blockDefinitions();
2909
+ const reloadKey = this.reloadKey();
2109
2910
  const runId = ++this.effectRunId;
2110
2911
  if (desc == null) {
2111
2912
  this.lastDescRef = null;
@@ -2131,11 +2932,15 @@ class BlockDirective {
2131
2932
  data = parsed.data;
2132
2933
  }
2133
2934
  const servicesKey = getServicesKey(data.services);
2935
+ const descKey = safeJsonKey(resolved);
2936
+ const effectiveReloadKey = reloadKey ?? descKey;
2134
2937
  // Only clear when the logical description changed (component or services). Avoids
2135
2938
  // clear+reload when the parent passes a new reference with the same content (e.g. BlockFor).
2136
2939
  const mustReload = this.loadResult == null ||
2137
2940
  this.loadedComponent !== data.component ||
2138
- this.loadedServicesKey !== servicesKey;
2941
+ this.loadedServicesKey !== servicesKey ||
2942
+ this.loadedReloadKey !== effectiveReloadKey ||
2943
+ this.loadedDescKey !== descKey;
2139
2944
  if (!mustReload) {
2140
2945
  // Description is logically unchanged (same component + services); no work needed.
2141
2946
  return;
@@ -2147,7 +2952,6 @@ class BlockDirective {
2147
2952
  const generation = ++this.loadGeneration;
2148
2953
  this.loader
2149
2954
  .load(resolved, this.viewContainerRef, this.model, {
2150
- outputHandlers: Object.keys(outputHandlers).length > 0 ? outputHandlers : undefined,
2151
2955
  registry,
2152
2956
  blockDefinitions: inputDefs ?? undefined,
2153
2957
  })
@@ -2157,6 +2961,8 @@ class BlockDirective {
2157
2961
  this.loadResult = result;
2158
2962
  this.loadedComponent = data.component;
2159
2963
  this.loadedServicesKey = servicesKey;
2964
+ this.loadedReloadKey = effectiveReloadKey;
2965
+ this.loadedDescKey = descKey;
2160
2966
  })
2161
2967
  .catch((err) => {
2162
2968
  if (generation !== this.loadGeneration)
@@ -2193,18 +2999,20 @@ class BlockDirective {
2193
2999
  this.loadResult = null;
2194
3000
  this.loadedComponent = null;
2195
3001
  this.loadedServicesKey = null;
3002
+ this.loadedReloadKey = null;
3003
+ this.loadedDescKey = null;
2196
3004
  }
2197
3005
  }
2198
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: BlockDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
2199
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.0", type: BlockDirective, isStandalone: true, selector: "[block]", inputs: { description: { classPropertyName: "description", publicName: "description", isSignal: true, isRequired: false, transformFunction: null }, outputHandlers: { classPropertyName: "outputHandlers", publicName: "outputHandlers", isSignal: true, isRequired: false, transformFunction: null }, blockRegistry: { classPropertyName: "blockRegistry", publicName: "blockRegistry", isSignal: true, isRequired: false, transformFunction: null }, blockDefinitions: { classPropertyName: "blockDefinitions", publicName: "blockDefinitions", isSignal: true, isRequired: false, transformFunction: null }, model: { classPropertyName: "model", publicName: "model", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
3006
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: BlockDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
3007
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: BlockDirective, isStandalone: true, selector: "[block]", inputs: { description: { classPropertyName: "description", publicName: "description", isSignal: true, isRequired: false, transformFunction: null }, blockRegistry: { classPropertyName: "blockRegistry", publicName: "blockRegistry", isSignal: true, isRequired: false, transformFunction: null }, blockDefinitions: { classPropertyName: "blockDefinitions", publicName: "blockDefinitions", isSignal: true, isRequired: false, transformFunction: null }, model: { classPropertyName: "model", publicName: "model", isSignal: true, isRequired: false, transformFunction: null }, reloadKey: { classPropertyName: "reloadKey", publicName: "reloadKey", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
2200
3008
  }
2201
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: BlockDirective, decorators: [{
3009
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: BlockDirective, decorators: [{
2202
3010
  type: Directive,
2203
3011
  args: [{
2204
3012
  selector: '[block]',
2205
3013
  standalone: true,
2206
3014
  }]
2207
- }], ctorParameters: () => [], propDecorators: { description: [{ type: i0.Input, args: [{ isSignal: true, alias: "description", required: false }] }], outputHandlers: [{ type: i0.Input, args: [{ isSignal: true, alias: "outputHandlers", required: false }] }], blockRegistry: [{ type: i0.Input, args: [{ isSignal: true, alias: "blockRegistry", required: false }] }], blockDefinitions: [{ type: i0.Input, args: [{ isSignal: true, alias: "blockDefinitions", required: false }] }], model: [{ type: i0.Input, args: [{ isSignal: true, alias: "model", required: false }] }] } });
3015
+ }], ctorParameters: () => [], propDecorators: { description: [{ type: i0.Input, args: [{ isSignal: true, alias: "description", required: false }] }], blockRegistry: [{ type: i0.Input, args: [{ isSignal: true, alias: "blockRegistry", required: false }] }], blockDefinitions: [{ type: i0.Input, args: [{ isSignal: true, alias: "blockDefinitions", required: false }] }], model: [{ type: i0.Input, args: [{ isSignal: true, alias: "model", required: false }] }], reloadKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "reloadKey", required: false }] }] } });
2208
3016
 
2209
3017
  /*
2210
3018
  * Public API Surface of blocks-studio
@@ -2214,5 +3022,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
2214
3022
  * Generated bundle index. Do not edit.
2215
3023
  */
2216
3024
 
2217
- export { BlockDefinitionsRegistry, BlockDescriptionSchema, BlockDirective, BlockLoaderService, BlockRegistryImpl, ComponentRegistry, DirectiveRegistry, GuardRegistry, INTERPOLATE_MAX_PLACEHOLDERS, RegistryMetadataStore, RouteLoader, ServiceRegistry, buildComputedForTemplate, classifyTwoWayString, createOutputHandler, deepMergeBlockDefinition, extractReadonlyRefs, getRefPathFromReadonly, getRefSignal, getRefValue, getValueByPath, isBlockDefinitionLoader, isBlockReference, isInvalidTwoWayMix, isOutputReference, isTwoWayRefString, normalizeDirectives, normalizeServices, parseRefPath, parseTwoWayRef, refPathResolvesToSignal, resolveBlockReference, resolveOutputReference, resolveRefPath, safeParseBlockDescription, setRefValue };
3025
+ export { BlockDefinitionsRegistry, BlockDirective, BlockLoaderService, BlockRegistryImpl, BlockRegistryService, ChainedBlockRegistry, ComponentRegistry, DirectiveRegistry, GuardRegistry, INTERPOLATE_MAX_PLACEHOLDERS, RegistryMetadataStore, RouteLoader, ServiceRegistry, buildComputedForTemplate, classifyTwoWayString, collectRefPaths, createOutputHandler, deepMergeBlockDefinition, evaluateExpression, extractReadonlyRefs, getRefPathFromReadonly, getRefSignal, getRefValue, getValueByPath, interpretFlow, isBlockDefinitionLoader, isBlockReference, isInvalidTwoWayMix, isOutputCallObject, isOutputReference, isTwoWayRefString, looksLikeFlowTemplate, mustacheSegmentToString, normalizeDirectives, normalizeServices, parseFlowTemplate, parseRefPath, parseTwoWayRef, refPathResolvesToSignal, resolveBlockReference, resolveMustacheInterpolatedString, resolveRefOrModel, resolveRefPath, resolveReferenceValue, resolveTemplateString, safeParseBlockDescription, setRefValue, splitCallableRef };
2218
3026
  //# sourceMappingURL=ngx-blocks-studio.mjs.map