rip-lang 3.16.0 → 3.16.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.
package/src/components.js CHANGED
@@ -17,7 +17,7 @@ import { HTML_TAGS, SVG_TAGS, TEMPLATE_TAGS } from './generated/dom-tags.js';
17
17
  const BIND_PREFIX = '__bind_';
18
18
  const BIND_SUFFIX = '__';
19
19
 
20
- const LIFECYCLE_HOOKS = new Set(['beforeMount', 'mounted', 'updated', 'beforeUnmount', 'unmounted', 'onError']);
20
+ const LIFECYCLE_HOOKS = new Set(['beforeMount', 'mounted', 'beforeUnmount', 'unmounted', 'onError']);
21
21
  const BOOLEAN_ATTRS = new Set([
22
22
  'disabled', 'hidden', 'readonly', 'required', 'checked', 'selected',
23
23
  'autofocus', 'autoplay', 'controls', 'loop', 'muted', 'multiple',
@@ -79,6 +79,17 @@ function getMemberType(target) {
79
79
  return null;
80
80
  }
81
81
 
82
+ /**
83
+ * Extract `?` optionality flag from s-expression target node.
84
+ * Set by the lexer as `.optional` on the prop-name String wrapper when
85
+ * the source wrote `@label?:: T` or similar.
86
+ */
87
+ function getMemberOptional(target) {
88
+ if (target instanceof String && target.optional) return true;
89
+ if (Array.isArray(target) && target[2] instanceof String && target[2].optional) return true;
90
+ return false;
91
+ }
92
+
82
93
  // ============================================================================
83
94
  // Prototype Installation
84
95
  // ============================================================================
@@ -575,9 +586,52 @@ export function installComponentSupport(CodeEmitter, Lexer) {
575
586
  const _transferMeta = (from, to) => {
576
587
  if (!(from instanceof String)) return to;
577
588
  const s = new String(to);
578
- if (from.predicate) s.predicate = true;
579
- if (from.await) s.await = true;
580
- return (s.predicate || s.await) ? s : to;
589
+ if (from.optional) s.optional = true;
590
+ if (from.bang) s.bang = true;
591
+ return (s.optional || s.bang) ? s : to;
592
+ };
593
+
594
+ // Inject `// @rip-src:N` markers onto each statement line of a method body
595
+ // emitted into a component stub. The emitter doesn't carry source-map data
596
+ // for stub method bodies, so without explicit markers, typecheck.js falls
597
+ // back to linear gap-fill interpolation which silently misaligns when stub
598
+ // sizes change. Walks the body s-expression to recover statement source
599
+ // lines and pairs them with the rendered body's non-trivial lines.
600
+ proto.addBodyRipSrcMarkers = function(bodyCode, bodySexpr) {
601
+ if (typeof bodyCode !== 'string' || !bodyCode) return bodyCode;
602
+ const stmts = Array.isArray(bodySexpr) && bodySexpr[0] === 'block'
603
+ ? bodySexpr.slice(1)
604
+ : (Array.isArray(bodySexpr) ? [bodySexpr] : []);
605
+ if (stmts.length === 0) return bodyCode;
606
+ const getLoc = (s) => {
607
+ if (s == null) return null;
608
+ if (!Array.isArray(s)) return s?.loc?.r ?? null;
609
+ if (s.loc?.r) return s.loc.r;
610
+ if (s[0]?.loc?.r) return s[0].loc.r;
611
+ for (const child of s) {
612
+ if (child?.loc?.r) return child.loc.r;
613
+ if (Array.isArray(child)) {
614
+ const l = getLoc(child);
615
+ if (l != null) return l;
616
+ }
617
+ }
618
+ return null;
619
+ };
620
+ const srcLines = stmts.map(getLoc);
621
+ if (srcLines.every(l => l == null)) return bodyCode;
622
+ const lines = bodyCode.split('\n');
623
+ let si = 0;
624
+ for (let i = 0; i < lines.length && si < srcLines.length; i++) {
625
+ const trimmed = lines[i].trim();
626
+ if (!trimmed) continue;
627
+ if (trimmed === '{' || trimmed === '}' || trimmed.startsWith('//')) continue;
628
+ if (lines[i].includes('@rip-src:')) { si++; continue; }
629
+ if (srcLines[si] != null) {
630
+ lines[i] = `${lines[i]} // @rip-src:${srcLines[si]}`;
631
+ }
632
+ si++;
633
+ }
634
+ return lines.join('\n');
581
635
  };
582
636
 
583
637
  proto.transformComponentMembers = function(sexpr, localScope = new Set()) {
@@ -707,31 +761,35 @@ export function installComponentSupport(CodeEmitter, Lexer) {
707
761
  reactiveMembers.add(varName);
708
762
  }
709
763
  } else if (op === '.' && stmt[1] === 'this' && getMemberName(stmt)) {
710
- // Required prop: (. this name) — no default value
764
+ // Bare prop form: `(. this name)` — no default value.
765
+ // `@name` → required (caller must pass)
766
+ // `@name?` → optional (caller may omit; value will be undefined)
767
+ // `@name?:: T`→ optional, typed
711
768
  const varName = (typeof stmt[2] === 'string' || stmt[2] instanceof String) ? stmt[2].valueOf() : null;
712
769
  if (varName) {
713
- stateVars.push({ name: varName, value: undefined, isPublic: true, type: stmt[2]?.type || null, required: true });
770
+ const optional = getMemberOptional(stmt);
771
+ stateVars.push({ name: varName, value: undefined, isPublic: true, type: stmt[2]?.type || null, required: !optional, optional, srcLine: stmt.loc?.r });
714
772
  memberNames.add(varName);
715
773
  reactiveMembers.add(varName);
716
774
  }
717
775
  } else if (op === 'state') {
718
776
  const varName = getMemberName(stmt[1]);
719
777
  if (varName) {
720
- stateVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]) });
778
+ stateVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]), optional: getMemberOptional(stmt[1]), srcLine: stmt.loc?.r });
721
779
  memberNames.add(varName);
722
780
  reactiveMembers.add(varName);
723
781
  }
724
782
  } else if (op === 'computed') {
725
783
  const varName = getMemberName(stmt[1]);
726
784
  if (varName) {
727
- derivedVars.push({ name: varName, expr: stmt[2], type: getMemberType(stmt[1]) });
785
+ derivedVars.push({ name: varName, expr: stmt[2], type: getMemberType(stmt[1]), srcLine: stmt.loc?.r });
728
786
  memberNames.add(varName);
729
787
  reactiveMembers.add(varName);
730
788
  }
731
789
  } else if (op === 'readonly') {
732
790
  const varName = getMemberName(stmt[1]);
733
791
  if (varName) {
734
- readonlyVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]) });
792
+ readonlyVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]), optional: getMemberOptional(stmt[1]), srcLine: stmt.loc?.r });
735
793
  memberNames.add(varName);
736
794
  }
737
795
  } else if (op === '=') {
@@ -745,7 +803,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
745
803
  methods.push({ name: varName, func: val });
746
804
  memberNames.add(varName);
747
805
  } else {
748
- stateVars.push({ name: varName, value: val, isPublic: isPublicProp(stmt[1]) });
806
+ stateVars.push({ name: varName, value: val, isPublic: isPublicProp(stmt[1]), srcLine: stmt.loc?.r });
749
807
  memberNames.add(varName);
750
808
  reactiveMembers.add(varName);
751
809
  }
@@ -804,28 +862,44 @@ export function installComponentSupport(CodeEmitter, Lexer) {
804
862
 
805
863
  // --- Type-check stub: typed member declarations + body expressions, no DOM ---
806
864
  if (this.options.stubComponents) {
807
- // Inline type suffix expansion (mirrors types.js expandSuffixes)
808
- const expandType = (t) => t ? t.replace(/::/g, ':')
809
- .replace(/(\w+(?:<[^>]+>)?)\?\?/g, '$1 | null | undefined')
810
- .replace(/(\w+(?:<[^>]+>)?)\?(?![.:])/g, '$1 | undefined')
811
- .replace(/(\w+(?:<[^>]+>)?)\!/g, 'NonNullable<$1>') : null;
865
+ // Strip Rip's `::` annotation sigil to TypeScript's `:` separator.
866
+ const expandType = (t) => t ? t.replace(/::/g, ':') : null;
812
867
 
813
868
  const sl = [];
814
869
  const componentTypeParams = this._componentTypeParams || '';
815
870
  sl.push(`class ${componentTypeParams}{`);
816
- // `declare app: any` is rewritten to a typed shape by typecheck.js when
817
- // the project has a typed `<root>/app/stash.rip`. The compiler stays
818
- // context-free; the rewrite happens in the same pass that splices
819
- // function-overload signatures into the stub.
820
- sl.push(' declare _root: Element | null; declare app: any;');
871
+ // Injected `this` shape for every component. The compiler stays
872
+ // context-free; `declare app: any` and `declare router: any` are
873
+ // rewritten by typecheck.js to typed shapes when the project anchor /
874
+ // stash file is discoverable. `params`, `query`, `children`, and the
875
+ // lifecycle hooks are stable across all projects, so they're typed
876
+ // here directly. User-defined hooks are emitted later as real methods;
877
+ // skip the optional-signature declaration for those names to avoid
878
+ // overload-optionality mismatches.
879
+ // Combine all injected declarations onto a single line to preserve the
880
+ // stub's prior line count. Source-map gap-fill interpolates linearly
881
+ // between marker lines (e.g. between @rip-src markers from `@count`
882
+ // and a downstream method), so adding stub header lines pushes the
883
+ // method bodies past where interpolation expects them — breaking
884
+ // @ts-expect-error injection. Keeping a single header line preserves
885
+ // the relative offsets.
886
+ sl.push(' declare _root: Element | null; declare app: any; declare router: any; declare params: Record<string, string>; declare query: URLSearchParams; declare children: any;');
887
+ const userHookNames = new Set(lifecycleHooks.map(h => h.name));
888
+ const hookDecls = [];
889
+ if (!userHookNames.has('beforeMount')) hookDecls.push('beforeMount?(): void;');
890
+ if (!userHookNames.has('mounted')) hookDecls.push('mounted?(): void;');
891
+ if (!userHookNames.has('beforeUnmount')) hookDecls.push('beforeUnmount?(): void;');
892
+ if (!userHookNames.has('unmounted')) hookDecls.push('unmounted?(): void;');
893
+ if (!userHookNames.has('onError')) hookDecls.push('onError?(err: { status?: number; message?: string; error?: Error; path?: string }): void;');
894
+ if (hookDecls.length) sl.push(' ' + hookDecls.join(' '));
821
895
  sl.push(' emit(_name: string, _detail?: any): void {}');
822
896
 
823
897
  // Constructor — typed props for public state/readonly (matches DTS)
824
898
  const propEntries = [];
825
- for (const { name, type, isPublic, required } of stateVars) {
899
+ for (const { name, type, isPublic, required, optional } of stateVars) {
826
900
  if (!isPublic) continue;
827
901
  const ts = expandType(type);
828
- const opt = required ? '' : '?';
902
+ const opt = (optional ?? !required) ? '?' : '';
829
903
  propEntries.push(`${name}${opt}: ${ts || 'any'}`);
830
904
  // Two-way binding: allow parent to pass Signal<T> for this prop
831
905
  propEntries.push(`__bind_${name}__?: Signal<${ts || 'any'}>`);
@@ -836,7 +910,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
836
910
  propEntries.push(`${name}?: ${ts || 'any'}`);
837
911
  }
838
912
  {
839
- const hasRequired = propEntries.length > 0 && stateVars.some(v => v.isPublic && v.required);
913
+ const hasRequired = propEntries.length > 0 && stateVars.some(v => v.isPublic && v.required && !v.optional);
840
914
  const propsOpt = hasRequired ? '' : '?';
841
915
  let propsType = propEntries.length > 0 ? `{${propEntries.join('; ')}}` : '{}';
842
916
  if (inheritsTag) propsType += ` & __RipProps<'${inheritsTag}'>`;
@@ -854,9 +928,15 @@ export function installComponentSupport(CodeEmitter, Lexer) {
854
928
  };
855
929
 
856
930
  // Property declarations (declare avoids definite-assignment errors)
857
- for (const { name, type, value } of stateVars) {
931
+ for (const { name, type, value, optional, srcLine } of stateVars) {
858
932
  const ts = expandType(type) || inferLiteralType(value);
859
- sl.push(ts ? ` declare ${name}: Signal<${ts}>;` : ` declare ${name}: Signal<any>;`);
933
+ // Optional prop with no default: field can be undefined at runtime
934
+ // (we don't synthesize a `?? null` fallback below), so the Signal's
935
+ // payload type must include undefined.
936
+ const optNoDefault = optional && value === undefined;
937
+ const wrapped = ts ? (optNoDefault ? `${ts} | undefined` : ts) : null;
938
+ const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
939
+ sl.push((wrapped ? ` declare ${name}: Signal<${wrapped}>;` : ` declare ${name}: Signal<any>;`) + marker);
860
940
  }
861
941
  if (inheritsTag) {
862
942
  sl.push(` declare rest: Signal<__RipProps<'${inheritsTag}'>>;`);
@@ -880,19 +960,21 @@ export function installComponentSupport(CodeEmitter, Lexer) {
880
960
 
881
961
  // _init body — readonly, state, computed assignments (skip accepted/offered)
882
962
  sl.push(' _init(props) {');
883
- for (const { name, value, isPublic } of readonlyVars) {
963
+ for (const { name, value, isPublic, srcLine } of readonlyVars) {
884
964
  const val = this.emitInComponent(value, 'value');
885
- sl.push(isPublic ? ` this.${name} = props.${name} ?? ${val};` : ` this.${name} = ${val};`);
965
+ const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
966
+ sl.push((isPublic ? ` this.${name} = props.${name} ?? ${val};` : ` this.${name} = ${val};`) + marker);
886
967
  }
887
- for (const { name, value, isPublic, required, type } of stateVars) {
888
- if (isPublic && required) {
889
- sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name});`);
968
+ for (const { name, value, isPublic, required, type, srcLine } of stateVars) {
969
+ const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
970
+ if (isPublic && (required || value === undefined)) {
971
+ sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name});` + marker);
890
972
  } else if (isPublic) {
891
973
  const val = this.emitInComponent(value, 'value');
892
- sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name} ?? ${val});`);
974
+ sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name} ?? ${val});` + marker);
893
975
  } else {
894
976
  const val = this.emitInComponent(value, 'value');
895
- sl.push(` this.${name} = __state(${val});`);
977
+ sl.push(` this.${name} = __state(${val});` + marker);
896
978
  }
897
979
  }
898
980
 
@@ -976,7 +1058,12 @@ export function installComponentSupport(CodeEmitter, Lexer) {
976
1058
  }
977
1059
  const transformed = this.reactiveMembers ? this.transformComponentMembers(methodBody) : methodBody;
978
1060
  const isAsync = this.containsAwait(methodBody);
979
- const bodyCode = this.emitFunctionBody(transformed, params || []);
1061
+ let bodyCode = this.emitFunctionBody(transformed, params || []);
1062
+ // Inject @rip-src markers on each body statement line so that
1063
+ // @ts-expect-error injection and hover/diagnostics resolve to the
1064
+ // user's actual source line, instead of relying on linear gap-fill
1065
+ // interpolation across the stub (which is brittle to stub size).
1066
+ bodyCode = this.addBodyRipSrcMarkers(bodyCode, methodBody);
980
1067
  sl.push(` ${isAsync ? 'async ' : ''}${name}(${paramStr}) ${bodyCode}`);
981
1068
  }
982
1069
  }
@@ -988,7 +1075,8 @@ export function installComponentSupport(CodeEmitter, Lexer) {
988
1075
  const paramStr = Array.isArray(params) ? params.map(p => this.formatParam(p)).join(', ') : '';
989
1076
  const transformed = this.reactiveMembers ? this.transformComponentMembers(hookBody) : hookBody;
990
1077
  const isAsync = this.containsAwait(hookBody);
991
- const bodyCode = this.emitFunctionBody(transformed, params || []);
1078
+ let bodyCode = this.emitFunctionBody(transformed, params || []);
1079
+ bodyCode = this.addBodyRipSrcMarkers(bodyCode, hookBody);
992
1080
  sl.push(` ${isAsync ? 'async ' : ''}${name}(${paramStr}) ${bodyCode}`);
993
1081
  }
994
1082
  }
@@ -1051,8 +1139,22 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1051
1139
  props.sideExprs = sideExprs;
1052
1140
  return props;
1053
1141
  };
1054
- const extractIntrinsicProps = (args) => {
1142
+ const extractIntrinsicProps = (args, tagName) => {
1055
1143
  const props = [];
1144
+ // For <a href: ...>, wrap interpolated route literals (templates
1145
+ // starting with `/...`) in __ripRoute so TS checks the dynamic
1146
+ // template against __RipRoutes. Static literals fall through to
1147
+ // the existing __ripEl Option-C conditional, which still gives
1148
+ // the "Did you mean ..." hint. Variables and external URLs are
1149
+ // left untouched.
1150
+ const wrapHrefVal = (val) => {
1151
+ if (tagName !== 'a') return val;
1152
+ if (typeof val !== 'string') return val;
1153
+ if (val.length < 2) return val;
1154
+ if (val[0] !== '`') return val;
1155
+ if (val[1] !== '/') return val;
1156
+ return `__ripRoute(${val})`;
1157
+ };
1056
1158
  for (const arg of args) {
1057
1159
  let obj = null;
1058
1160
  if (this.is(arg, 'object')) {
@@ -1089,7 +1191,8 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1089
1191
  props.push({ code: `${propName}: ${val}`, srcLine });
1090
1192
  } else {
1091
1193
  const val = this.emitInComponent(value, 'value');
1092
- props.push({ code: `${key}: ${val}`, srcLine });
1194
+ const finalVal = key === 'href' ? wrapHrefVal(val) : val;
1195
+ props.push({ code: `${key}: ${finalVal}`, srcLine });
1093
1196
  }
1094
1197
  }
1095
1198
  }
@@ -1373,7 +1476,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1373
1476
  } else if (typeof head === 'string' && !CodeEmitter.GENERATORS[head] && (TEMPLATE_TAGS.has(head.split(/[.#]/)[0]) ||
1374
1477
  (/^[a-z][\w-]*$/.test(head) && node.length > 1))) {
1375
1478
  const tagName = head.split(/[.#]/)[0];
1376
- const iProps = extractIntrinsicProps(node.slice(1));
1479
+ const iProps = extractIntrinsicProps(node.slice(1), tagName);
1377
1480
  const tagLine = node.loc?.r;
1378
1481
  const srcMarker = tagLine != null ? ` // @rip-src:${tagLine}` : '';
1379
1482
  if (iProps.length === 0) {
@@ -1441,7 +1544,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1441
1544
 
1442
1545
  // State variables (__state handles signal passthrough)
1443
1546
  for (const { name, value, isPublic, required } of stateVars) {
1444
- if (isPublic && required) {
1547
+ if (isPublic && (required || value === undefined)) {
1445
1548
  lines.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name});`);
1446
1549
  } else if (isPublic) {
1447
1550
  const val = this.emitInComponent(value, 'value');
@@ -2727,7 +2830,13 @@ export function installComponentSupport(CodeEmitter, Lexer) {
2727
2830
  const outerParams = this._loopVarStack.map(v => `${v.itemVar}, ${v.indexVar}`).join(', ');
2728
2831
  const outerExtra = outerParams ? `, ${outerParams}` : '';
2729
2832
 
2730
- this._loopVarStack.push({ itemVar, indexVar });
2833
+ // If the iterated collection is reactive, treat direct member access
2834
+ // on the iter var (`item.qty`, `item[0]`, `item.a.b`) as reactive
2835
+ // inside the loop body. hasReactiveDeps consults this flag, so the
2836
+ // existing emit paths wrap those reads in __effect and the per-row
2837
+ // patch function gets populated.
2838
+ const reactiveSource = this.hasReactiveDeps(collection);
2839
+ this._loopVarStack.push({ itemVar, indexVar, reactiveSource });
2731
2840
  const itemNode = this.emitTemplateBlock(body);
2732
2841
  this._loopVarStack.pop();
2733
2842
  const itemCreateLines = this._createLines;
@@ -3037,6 +3146,16 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3037
3146
  return true;
3038
3147
  }
3039
3148
 
3149
+ // Property chain rooted at a `for`-loop iter var whose source is
3150
+ // reactive (e.g. `for item in cart.items` → `item.qty`, `item[0]`,
3151
+ // `item.foo.bar`). Same rationale as the this-rooted case: the source
3152
+ // proxy is reactive at runtime, so any member read may track. Limited
3153
+ // to direct member access on the iter var — aliases and destructuring
3154
+ // are explicitly out of scope.
3155
+ if ((sexpr[0] === '.' || sexpr[0] === '[]') && this._rootsAtReactiveLoopVar(sexpr)) {
3156
+ return true;
3157
+ }
3158
+
3040
3159
  // Method call on component: [['.', 'this', method], ...args]
3041
3160
  // Methods may read reactive state internally — treat as reactive so the
3042
3161
  // call gets wrapped in __effect and re-runs when dependencies change.
@@ -3118,6 +3237,26 @@ export function installComponentSupport(CodeEmitter, Lexer) {
3118
3237
  return this._rootsAtThis(sexpr[1]);
3119
3238
  };
3120
3239
 
3240
+ // _rootsAtReactiveLoopVar — true when a `.` / `[]` access chain bottoms
3241
+ // out at a string identifier that matches the itemVar of some frame on
3242
+ // _loopVarStack whose `reactiveSource` is true. See emitTemplateLoop.
3243
+ proto._rootsAtReactiveLoopVar = function(sexpr) {
3244
+ if (typeof sexpr === 'string') {
3245
+ if (!this._loopVarStack || this._loopVarStack.length === 0) return false;
3246
+ // Iterate innermost-first so a shadowed name resolves to its nearest
3247
+ // binding: `for item in reactive` containing `for item in [1,2,3]`
3248
+ // must treat inner reads of `item` as non-reactive.
3249
+ for (let i = this._loopVarStack.length - 1; i >= 0; i--) {
3250
+ const v = this._loopVarStack[i];
3251
+ if (v.itemVar === sexpr) return !!v.reactiveSource;
3252
+ }
3253
+ return false;
3254
+ }
3255
+ if (!Array.isArray(sexpr)) return false;
3256
+ if (sexpr[0] === '.' || sexpr[0] === '[]') return this._rootsAtReactiveLoopVar(sexpr[1]);
3257
+ return false;
3258
+ };
3259
+
3121
3260
  // ==========================================================================
3122
3261
  // Component Runtime
3123
3262
  // ==========================================================================