@jsenv/navi 0.16.0 → 0.16.2

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.
@@ -7522,21 +7522,31 @@ setBaseUrl(
7522
7522
  // Pattern registry for building relationships before routes are created
7523
7523
  const patternRegistry = new Map(); // pattern -> patternData
7524
7524
  const patternRelationships = new Map(); // pattern -> relationships
7525
+ let patternsRegistered = false;
7525
7526
 
7526
7527
  // Function to detect signals in route patterns and connect them
7527
7528
  const detectSignals = (routePattern) => {
7528
7529
  const signalConnections = [];
7529
7530
  let updatedPattern = routePattern;
7530
7531
 
7531
- // Look for signals in the new syntax: :paramName={navi_state_signal:id} or ?paramName={navi_state_signal:id} or &paramName={navi_state_signal:id}
7532
- // Using curly braces to avoid conflicts with underscores in signal IDs
7533
- const signalParamRegex = /([?:&])(\w+)=(\{navi_state_signal:[^}]+\})/g;
7532
+ // Look for signals in two formats:
7533
+ // 1. Expected format: :paramName={navi_state_signal:id} or ?paramName={navi_state_signal:id} or &paramName={navi_state_signal:id}
7534
+ // 2. Typoe format (missing = sign): &paramName{navi_state_signal:id}
7535
+ const signalParamRegex = /([?:&])(\w+)(=)?(\{navi_state_signal:[^}]+\})/g;
7534
7536
  let match;
7535
7537
 
7536
7538
  while ((match = signalParamRegex.exec(routePattern)) !== null) {
7537
- const [fullMatch, prefix, paramName, signalString] = match;
7539
+ const [fullMatch, prefix, paramName, equalSign, signalString] = match;
7538
7540
 
7539
- // Extract the signal ID from the new format: {navi_state_signal:id}
7541
+ // Emit warning if equal sign is missing
7542
+ if (!equalSign) {
7543
+ console.warn(
7544
+ `[detectSignals] Missing '=' sign in route pattern: "${prefix}${paramName}${signalString}". ` +
7545
+ `Consider using "${prefix}${paramName}=${signalString}" for better clarity.`,
7546
+ );
7547
+ }
7548
+
7549
+ // Extract the signal ID from the format: {navi_state_signal:id}
7540
7550
  const signalIdMatch = signalString.match(/\{navi_state_signal:([^}]+)\}/);
7541
7551
  if (!signalIdMatch) {
7542
7552
  console.warn(
@@ -7553,13 +7563,10 @@ const detectSignals = (routePattern) => {
7553
7563
 
7554
7564
  let replacement;
7555
7565
  if (prefix === ":") {
7556
- // Path parameter: :section=__jsenv_signal_1__ becomes :section
7557
- replacement = `${prefix}${paramName}`;
7558
- } else if (prefix === "?") {
7559
- // First search parameter: ?city=__jsenv_signal_1__ becomes ?city
7566
+ // Path parameter: :section={navi_state_signal:...} becomes :section
7560
7567
  replacement = `${prefix}${paramName}`;
7561
- } else if (prefix === "&") {
7562
- // Additional search parameter: &lon=__jsenv_signal_1__ becomes &lon
7568
+ } else if (prefix === "?" || prefix === "&") {
7569
+ // Query parameter: ?city={navi_state_signal:...} or &lon{navi_state_signal:...} becomes ?city or &lon
7563
7570
  replacement = `${prefix}${paramName}`;
7564
7571
  }
7565
7572
  updatedPattern = updatedPattern.replace(fullMatch, replacement);
@@ -7606,6 +7613,7 @@ const createRoutePattern = (pattern) => {
7606
7613
  const result = matchUrl(parsedPattern, url, {
7607
7614
  parameterDefaults,
7608
7615
  baseUrl,
7616
+ connections,
7609
7617
  });
7610
7618
 
7611
7619
  return result;
@@ -7793,6 +7801,41 @@ const createRoutePattern = (pattern) => {
7793
7801
  }
7794
7802
  }
7795
7803
 
7804
+ // PARENT PARAMETER INHERITANCE: Inherit query parameters from parent patterns
7805
+ // This allows child routes like "/map/isochrone" to inherit "zoom=15" from parent "/map/?zoom=..."
7806
+ const parentPatterns = relationships?.parentPatterns || [];
7807
+ for (const parentPattern of parentPatterns) {
7808
+ const parentPatternData = getPatternData(parentPattern);
7809
+ if (!parentPatternData) continue;
7810
+
7811
+ // Check parent's signal connections for non-default values to inherit
7812
+ for (const parentConnection of parentPatternData.connections) {
7813
+ const { paramName, signal, options } = parentConnection;
7814
+ const defaultValue = options.defaultValue;
7815
+
7816
+ // If we don't already have this parameter and parent signal has non-default value
7817
+ if (
7818
+ !(paramName in finalParams) &&
7819
+ signal?.value !== undefined &&
7820
+ signal.value !== defaultValue
7821
+ ) {
7822
+ // Check if this parameter corresponds to a literal segment in our path
7823
+ // E.g., don't inherit "section=analytics" if our path is "/admin/analytics"
7824
+ const shouldInherit = !isParameterRedundantWithLiteralSegments(
7825
+ parsedPattern,
7826
+ parentPatternData.parsedPattern,
7827
+ paramName,
7828
+ signal.value,
7829
+ );
7830
+
7831
+ if (shouldInherit) {
7832
+ // Inherit the parent's signal value
7833
+ finalParams[paramName] = signal.value;
7834
+ }
7835
+ }
7836
+ }
7837
+ }
7838
+
7796
7839
  if (!parsedPattern.segments) {
7797
7840
  return "/";
7798
7841
  }
@@ -7950,7 +7993,11 @@ const checkIfLiteralCanBeOptional = (literalValue, patternRegistry) => {
7950
7993
  /**
7951
7994
  * Match a URL against a parsed pattern
7952
7995
  */
7953
- const matchUrl = (parsedPattern, url, { parameterDefaults, baseUrl }) => {
7996
+ const matchUrl = (
7997
+ parsedPattern,
7998
+ url,
7999
+ { parameterDefaults, baseUrl, connections = [] },
8000
+ ) => {
7954
8001
  // Parse the URL
7955
8002
  const urlObj = new URL(url, baseUrl);
7956
8003
  let pathname = urlObj.pathname;
@@ -7972,14 +8019,14 @@ const matchUrl = (parsedPattern, url, { parameterDefaults, baseUrl }) => {
7972
8019
  // OR when URL exactly matches baseUrl (treating baseUrl as root)
7973
8020
  if (parsedPattern.segments.length === 0) {
7974
8021
  if (pathname === "/" || pathname === "") {
7975
- return extractSearchParams(urlObj);
8022
+ return extractSearchParams(urlObj, connections);
7976
8023
  }
7977
8024
 
7978
8025
  // Special case: if URL exactly matches baseUrl, treat as root route
7979
8026
  if (baseUrl) {
7980
8027
  const baseUrlObj = new URL(baseUrl);
7981
8028
  if (originalPathname === baseUrlObj.pathname) {
7982
- return extractSearchParams(urlObj);
8029
+ return extractSearchParams(urlObj, connections);
7983
8030
  }
7984
8031
  }
7985
8032
 
@@ -8074,7 +8121,7 @@ const matchUrl = (parsedPattern, url, { parameterDefaults, baseUrl }) => {
8074
8121
  // If pattern has trailing slash or wildcard, allow extra segments (no additional check needed)
8075
8122
 
8076
8123
  // Add search parameters
8077
- const searchParams = extractSearchParams(urlObj);
8124
+ const searchParams = extractSearchParams(urlObj, connections);
8078
8125
  Object.assign(params, searchParams);
8079
8126
 
8080
8127
  // Apply remaining parameter defaults for unmatched parameters
@@ -8090,10 +8137,29 @@ const matchUrl = (parsedPattern, url, { parameterDefaults, baseUrl }) => {
8090
8137
  /**
8091
8138
  * Extract search parameters from URL
8092
8139
  */
8093
- const extractSearchParams = (urlObj) => {
8140
+ const extractSearchParams = (urlObj, connections = []) => {
8094
8141
  const params = {};
8142
+
8143
+ // Create a map for quick signal type lookup
8144
+ const signalTypes = new Map();
8145
+ for (const connection of connections) {
8146
+ if (connection.options.type) {
8147
+ signalTypes.set(connection.paramName, connection.options.type);
8148
+ }
8149
+ }
8150
+
8095
8151
  for (const [key, value] of urlObj.searchParams) {
8096
- params[key] = value;
8152
+ const signalType = signalTypes.get(key);
8153
+
8154
+ // Cast value based on signal type
8155
+ if (signalType === "number" || signalType === "float") {
8156
+ const numberValue = Number(value);
8157
+ params[key] = isNaN(numberValue) ? value : numberValue;
8158
+ } else if (signalType === "boolean") {
8159
+ params[key] = value === "true" || value === "1";
8160
+ } else {
8161
+ params[key] = value;
8162
+ }
8097
8163
  }
8098
8164
  return params;
8099
8165
  };
@@ -8157,6 +8223,27 @@ const buildUrlFromPattern = (parsedPattern, params = {}) => {
8157
8223
  path = path.slice(0, -1);
8158
8224
  }
8159
8225
 
8226
+ // Check if we'll have query parameters to decide on trailing slash removal
8227
+ const willHaveQueryParams =
8228
+ parsedPattern.queryParams?.some((qp) => {
8229
+ const value = params[qp.name];
8230
+ return value !== undefined;
8231
+ }) ||
8232
+ Object.entries(params).some(([key, value]) => {
8233
+ const isPathParam = parsedPattern.segments.some(
8234
+ (s) => s.type === "param" && s.name === key,
8235
+ );
8236
+ const isQueryParam = parsedPattern.queryParams?.some(
8237
+ (qp) => qp.name === key,
8238
+ );
8239
+ return value !== undefined && !isPathParam && !isQueryParam;
8240
+ });
8241
+
8242
+ // Remove trailing slash when we have query params for prettier URLs
8243
+ if (willHaveQueryParams && path.endsWith("/") && path !== "/") {
8244
+ path = path.slice(0, -1);
8245
+ }
8246
+
8160
8247
  // Add search parameters
8161
8248
  const pathParamNames = new Set(
8162
8249
  parsedPattern.segments.filter((s) => s.type === "param").map((s) => s.name),
@@ -8255,6 +8342,43 @@ const isChildPattern = (childPattern, parentPattern) => {
8255
8342
  return childSegments.length > parentSegments.length || hasMoreSpecificSegment;
8256
8343
  };
8257
8344
 
8345
+ /**
8346
+ * Check if a parameter is redundant because the child pattern already has it as a literal segment
8347
+ * E.g., parameter "section" is redundant for pattern "/admin/settings/:tab" because "settings" is literal
8348
+ */
8349
+ const isParameterRedundantWithLiteralSegments = (
8350
+ childPattern,
8351
+ parentPattern,
8352
+ paramName,
8353
+ ) => {
8354
+ // Find which segment position corresponds to this parameter in the parent
8355
+ let paramSegmentIndex = -1;
8356
+ for (let i = 0; i < parentPattern.segments.length; i++) {
8357
+ const segment = parentPattern.segments[i];
8358
+ if (segment.type === "param" && segment.name === paramName) {
8359
+ paramSegmentIndex = i;
8360
+ break;
8361
+ }
8362
+ }
8363
+
8364
+ // If parameter not found in parent segments, it's not redundant with path
8365
+ if (paramSegmentIndex === -1) {
8366
+ return false;
8367
+ }
8368
+
8369
+ // Check if child has a literal segment at the same position
8370
+ if (childPattern.segments.length > paramSegmentIndex) {
8371
+ const childSegment = childPattern.segments[paramSegmentIndex];
8372
+ if (childSegment.type === "literal") {
8373
+ // Child has a literal segment where parent has parameter
8374
+ // This means the child is more specific and shouldn't inherit this parameter
8375
+ return true; // Redundant - child already specifies this position with a literal
8376
+ }
8377
+ }
8378
+
8379
+ return false;
8380
+ };
8381
+
8258
8382
  /**
8259
8383
  * Register all patterns at once and build their relationships
8260
8384
  */
@@ -8309,6 +8433,8 @@ const setupPatterns = (patternDefinitions) => {
8309
8433
  originalPattern: currentPattern,
8310
8434
  });
8311
8435
  }
8436
+
8437
+ patternsRegistered = true;
8312
8438
  };
8313
8439
 
8314
8440
  /**
@@ -8318,12 +8444,25 @@ const getPatternData = (urlPatternRaw) => {
8318
8444
  return patternRegistry.get(urlPatternRaw);
8319
8445
  };
8320
8446
 
8447
+ /**
8448
+ * Get pattern relationships for route creation
8449
+ */
8450
+ const getPatternRelationships = () => {
8451
+ if (!patternsRegistered) {
8452
+ throw new Error(
8453
+ "Patterns must be registered before accessing relationships",
8454
+ );
8455
+ }
8456
+ return patternRelationships;
8457
+ };
8458
+
8321
8459
  /**
8322
8460
  * Clear all registered patterns
8323
8461
  */
8324
8462
  const clearPatterns = () => {
8325
8463
  patternRegistry.clear();
8326
8464
  patternRelationships.clear();
8465
+ patternsRegistered = false;
8327
8466
  };
8328
8467
 
8329
8468
  const resolveRouteUrl = (relativeUrl) => {
@@ -8540,6 +8679,41 @@ const getRoutePrivateProperties = (route) => {
8540
8679
  return routePrivatePropertiesMap.get(route);
8541
8680
  };
8542
8681
 
8682
+ /**
8683
+ * Get child routes of a given route
8684
+ */
8685
+ const getRouteChildren = (route) => {
8686
+ const children = [];
8687
+ const routePrivateProperties = getRoutePrivateProperties(route);
8688
+ if (!routePrivateProperties) {
8689
+ return children;
8690
+ }
8691
+
8692
+ const { originalPattern } = routePrivateProperties;
8693
+ const relationships = getPatternRelationships();
8694
+ const relationshipData = relationships.get(originalPattern);
8695
+
8696
+ if (!relationshipData || !relationshipData.children) {
8697
+ return children;
8698
+ }
8699
+
8700
+ // Find child routes
8701
+ for (const childPattern of relationshipData.children) {
8702
+ for (const otherRoute of routeSet) {
8703
+ const otherRoutePrivateProperties = getRoutePrivateProperties(otherRoute);
8704
+ if (
8705
+ otherRoutePrivateProperties &&
8706
+ otherRoutePrivateProperties.originalPattern === childPattern
8707
+ ) {
8708
+ children.push(otherRoute);
8709
+ break;
8710
+ }
8711
+ }
8712
+ }
8713
+
8714
+ return children;
8715
+ };
8716
+
8543
8717
  const registerRoute = (routePattern) => {
8544
8718
  const urlPatternRaw = routePattern.originalPattern;
8545
8719
  const patternData = getPatternData(urlPatternRaw);
@@ -8626,6 +8800,13 @@ const registerRoute = (routePattern) => {
8626
8800
  for (const { signal: stateSignal, paramName, options = {} } of connections) {
8627
8801
  const { debug } = options;
8628
8802
 
8803
+ if (debug) {
8804
+ console.debug(
8805
+ `[route] connecting param "${paramName}" to signal`,
8806
+ stateSignal,
8807
+ );
8808
+ }
8809
+
8629
8810
  // URL -> Signal synchronization
8630
8811
  effect(() => {
8631
8812
  const matching = matchingSignal.value;
@@ -8684,13 +8865,39 @@ const registerRoute = (routePattern) => {
8684
8865
  );
8685
8866
  return null;
8686
8867
  }
8687
- if (route.action) {
8688
- // For action: merge with resolved params (includes defaults) so action gets complete params
8689
- const currentResolvedParams = routePattern.resolveParams();
8690
- const updatedActionParams = { ...currentResolvedParams, ...newParams };
8691
- route.action.replaceParams(updatedActionParams);
8868
+
8869
+ // Walk down the hierarchy updating action params and tracking most specific route
8870
+ let currentRoute = route;
8871
+ let mostSpecificRoute;
8872
+
8873
+ while (currentRoute) {
8874
+ if (!currentRoute.matching) {
8875
+ break;
8876
+ }
8877
+
8878
+ // Update the most specific route as we go
8879
+ mostSpecificRoute = currentRoute;
8880
+ // Update action params
8881
+ if (currentRoute.action) {
8882
+ const currentRoutePrivateProperties =
8883
+ getRoutePrivateProperties(currentRoute);
8884
+ if (currentRoutePrivateProperties) {
8885
+ const { routePattern: currentRoutePattern } =
8886
+ currentRoutePrivateProperties;
8887
+ const currentResolvedParams = currentRoutePattern.resolveParams();
8888
+ const updatedActionParams = {
8889
+ ...currentResolvedParams,
8890
+ ...newParams,
8891
+ };
8892
+ currentRoute.action.replaceParams(updatedActionParams);
8893
+ }
8894
+ }
8895
+
8896
+ // Find the first matching child to continue down the hierarchy
8897
+ const children = getRouteChildren(currentRoute);
8898
+ currentRoute = children.find((child) => child.matching) || null;
8692
8899
  }
8693
- return route.redirectTo(newParams);
8900
+ return mostSpecificRoute.redirectTo(newParams);
8694
8901
  };
8695
8902
  route.buildRelativeUrl = (params) => {
8696
8903
  // buildMostPreciseUrl now handles parameter resolution internally