rip-lang 3.16.0 → 3.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.
- package/README.md +3 -4
- package/bin/rip +201 -18
- package/bin/rip-schema +175 -0
- package/docs/AGENTS.md +1 -1
- package/docs/RIP-APP.md +200 -19
- package/docs/RIP-DUCKDB.md +64 -1
- package/docs/RIP-INTRO.md +4 -4
- package/docs/RIP-LANG.md +36 -38
- package/docs/RIP-SCHEMA.md +1204 -364
- package/docs/RIP-TYPES.md +74 -103
- package/docs/demo/README.md +4 -3
- package/docs/dist/rip.js +4195 -966
- package/docs/dist/rip.min.js +1161 -284
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/example/index.json +7 -7
- package/docs/example/index.json.br +0 -0
- package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
- package/docs/extensions/vscode/print/print-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/index.html +2 -1
- package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
- package/docs/index.html +1 -1
- package/docs/ui/bundle.json +55 -55
- package/docs/ui/bundle.json.br +0 -0
- package/docs/ui/hljs-rip.js +1 -1
- package/docs/ui/index.html +1 -1
- package/package.json +15 -7
- package/rip-loader.js +59 -2
- package/src/AGENTS.md +43 -12
- package/src/browser.js +52 -11
- package/src/compiler.js +538 -80
- package/src/components.js +488 -48
- package/src/dts.js +80 -50
- package/src/grammar/README.md +29 -170
- package/src/grammar/grammar.rip +17 -12
- package/src/grammar/solar.rip +4 -17
- package/src/lexer.js +82 -32
- package/src/parser.js +229 -229
- package/src/schema/dts.js +328 -54
- package/src/schema/loader-server.js +2 -1
- package/src/schema/runtime-browser-stubs.js +20 -9
- package/src/schema/runtime-ddl.js +161 -44
- package/src/schema/runtime-migrate.js +681 -0
- package/src/schema/runtime-orm.js +698 -54
- package/src/schema/runtime-validate.js +808 -24
- package/src/schema/runtime.generated.js +2395 -135
- package/src/schema/schema.js +1054 -94
- package/src/typecheck.js +1610 -127
- package/src/types.js +90 -6
- package/src/grammar/lunar.rip +0 -2412
- /package/docs/demo/{components → routes}/_layout.rip +0 -0
- /package/docs/demo/{components → routes}/about.rip +0 -0
- /package/docs/demo/{components → routes}/card.rip +0 -0
- /package/docs/demo/{components → routes}/counter.rip +0 -0
- /package/docs/demo/{components → routes}/index.rip +0 -0
- /package/docs/demo/{components → routes}/todos.rip +0 -0
package/src/components.js
CHANGED
|
@@ -17,7 +17,13 @@ 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', '
|
|
20
|
+
const LIFECYCLE_HOOKS = new Set(['beforeMount', 'mounted', 'beforeUnmount', 'unmounted', 'onError']);
|
|
21
|
+
|
|
22
|
+
// Canonical payload type for the onError lifecycle hook — the single source
|
|
23
|
+
// of truth for both the optional declaration emitted when a component does
|
|
24
|
+
// NOT define the hook and the shadow param type of a user-defined hook
|
|
25
|
+
// whose param is unannotated.
|
|
26
|
+
const ON_ERROR_PARAM_TYPE = '{ status?: number; message?: string; error?: Error; path?: string }';
|
|
21
27
|
const BOOLEAN_ATTRS = new Set([
|
|
22
28
|
'disabled', 'hidden', 'readonly', 'required', 'checked', 'selected',
|
|
23
29
|
'autofocus', 'autoplay', 'controls', 'loop', 'muted', 'multiple',
|
|
@@ -79,6 +85,84 @@ function getMemberType(target) {
|
|
|
79
85
|
return null;
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Extract `?` optionality flag from s-expression target node.
|
|
90
|
+
* Set by the lexer as `.optional` on the prop-name String wrapper when
|
|
91
|
+
* the source wrote `@label?:: T` or similar.
|
|
92
|
+
*/
|
|
93
|
+
function getMemberOptional(target) {
|
|
94
|
+
if (target instanceof String && target.optional) return true;
|
|
95
|
+
if (Array.isArray(target) && target[2] instanceof String && target[2].optional) return true;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Flatten a pure `.` chain s-expression into its segment names.
|
|
101
|
+
* `(. (. this app) data)` → ['this', 'app', 'data']. Returns null when the
|
|
102
|
+
* node isn't a plain identifier chain (computed access, calls, etc.).
|
|
103
|
+
*/
|
|
104
|
+
function chainSegments(node) {
|
|
105
|
+
const segs = [];
|
|
106
|
+
while (Array.isArray(node) && node[0] === '.') {
|
|
107
|
+
const prop = node[2];
|
|
108
|
+
if (!(typeof prop === 'string' || prop instanceof String)) return null;
|
|
109
|
+
segs.unshift(prop.valueOf());
|
|
110
|
+
node = node[1];
|
|
111
|
+
}
|
|
112
|
+
if (typeof node === 'string' || node instanceof String) segs.unshift(node.valueOf());
|
|
113
|
+
else return null;
|
|
114
|
+
return segs;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse a render gate's RHS (`user <~ @app.data.user`) into its hoistable
|
|
119
|
+
* parts. The RHS must be a literal `@app.data.…` chain, optionally applied
|
|
120
|
+
* to exactly one key argument (keyed source). Returns
|
|
121
|
+
* { path, keyExpr? } or { error }.
|
|
122
|
+
*/
|
|
123
|
+
function parseGatePath(rhs) {
|
|
124
|
+
let keyExpr;
|
|
125
|
+
let chain = rhs;
|
|
126
|
+
// Application form: [calleeChain, keyArg]
|
|
127
|
+
if (Array.isArray(rhs) && Array.isArray(rhs[0]) && rhs[0][0] === '.') {
|
|
128
|
+
if (rhs.length !== 2) return { error: 'a keyed gate takes exactly one key argument' };
|
|
129
|
+
chain = rhs[0];
|
|
130
|
+
keyExpr = rhs[1];
|
|
131
|
+
}
|
|
132
|
+
const segs = chainSegments(chain);
|
|
133
|
+
if (!segs) return { error: "'<~' requires a literal @app.data.… path on the right-hand side" };
|
|
134
|
+
if (segs[0] === 'this') segs.shift();
|
|
135
|
+
if (segs[0] !== 'app' || segs[1] !== 'data' || segs.length < 3) {
|
|
136
|
+
return { error: "'<~' requires a literal @app.data.… path on the right-hand side" };
|
|
137
|
+
}
|
|
138
|
+
return { path: segs.slice(2).join('.'), keyExpr };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Parse a keyed gate's key expression. The key must be available
|
|
143
|
+
* to the renderer before construction, so it is restricted to a literal or
|
|
144
|
+
* a params/query access (`params.id`, `@query.tab`). Returns
|
|
145
|
+
* { code, stubCode } (runtime and type-stub spellings) or { error }.
|
|
146
|
+
*/
|
|
147
|
+
function parseGateKey(expr) {
|
|
148
|
+
const KEY_ERROR = { error: 'a keyed gate key may only reference params or query (e.g. params.id)' };
|
|
149
|
+
const lit = (typeof expr === 'string' || expr instanceof String) ? expr.valueOf() : null;
|
|
150
|
+
if (lit != null) {
|
|
151
|
+
if (/^-?[\d.]/.test(lit) || lit.startsWith('"') || lit.startsWith("'")) {
|
|
152
|
+
return { code: lit, stubCode: lit };
|
|
153
|
+
}
|
|
154
|
+
return KEY_ERROR; // a bare identifier (incl. `params`/`query` alone) isn't a key
|
|
155
|
+
}
|
|
156
|
+
const segs = chainSegments(expr);
|
|
157
|
+
if (!segs) return KEY_ERROR;
|
|
158
|
+
if (segs[0] === 'this') segs.shift();
|
|
159
|
+
if ((segs[0] !== 'params' && segs[0] !== 'query') || segs.length < 2) return KEY_ERROR;
|
|
160
|
+
const code = segs.join('.');
|
|
161
|
+
// `params` and `query` are both Record<string, string> on the component, so
|
|
162
|
+
// the shadow reads the key the same way the runtime does — no cast needed.
|
|
163
|
+
return { code, stubCode: `this.${code}` };
|
|
164
|
+
}
|
|
165
|
+
|
|
82
166
|
// ============================================================================
|
|
83
167
|
// Prototype Installation
|
|
84
168
|
// ============================================================================
|
|
@@ -575,9 +659,52 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
575
659
|
const _transferMeta = (from, to) => {
|
|
576
660
|
if (!(from instanceof String)) return to;
|
|
577
661
|
const s = new String(to);
|
|
578
|
-
if (from.
|
|
579
|
-
if (from.
|
|
580
|
-
return (s.
|
|
662
|
+
if (from.optional) s.optional = true;
|
|
663
|
+
if (from.bang) s.bang = true;
|
|
664
|
+
return (s.optional || s.bang) ? s : to;
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// Inject `// @rip-src:N` markers onto each statement line of a method body
|
|
668
|
+
// emitted into a component stub. The emitter doesn't carry source-map data
|
|
669
|
+
// for stub method bodies, so without explicit markers, typecheck.js falls
|
|
670
|
+
// back to linear gap-fill interpolation which silently misaligns when stub
|
|
671
|
+
// sizes change. Walks the body s-expression to recover statement source
|
|
672
|
+
// lines and pairs them with the rendered body's non-trivial lines.
|
|
673
|
+
proto.addBodyRipSrcMarkers = function(bodyCode, bodySexpr) {
|
|
674
|
+
if (typeof bodyCode !== 'string' || !bodyCode) return bodyCode;
|
|
675
|
+
const stmts = Array.isArray(bodySexpr) && bodySexpr[0] === 'block'
|
|
676
|
+
? bodySexpr.slice(1)
|
|
677
|
+
: (Array.isArray(bodySexpr) ? [bodySexpr] : []);
|
|
678
|
+
if (stmts.length === 0) return bodyCode;
|
|
679
|
+
const getLoc = (s) => {
|
|
680
|
+
if (s == null) return null;
|
|
681
|
+
if (!Array.isArray(s)) return s?.loc?.r ?? null;
|
|
682
|
+
if (s.loc?.r) return s.loc.r;
|
|
683
|
+
if (s[0]?.loc?.r) return s[0].loc.r;
|
|
684
|
+
for (const child of s) {
|
|
685
|
+
if (child?.loc?.r) return child.loc.r;
|
|
686
|
+
if (Array.isArray(child)) {
|
|
687
|
+
const l = getLoc(child);
|
|
688
|
+
if (l != null) return l;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return null;
|
|
692
|
+
};
|
|
693
|
+
const srcLines = stmts.map(getLoc);
|
|
694
|
+
if (srcLines.every(l => l == null)) return bodyCode;
|
|
695
|
+
const lines = bodyCode.split('\n');
|
|
696
|
+
let si = 0;
|
|
697
|
+
for (let i = 0; i < lines.length && si < srcLines.length; i++) {
|
|
698
|
+
const trimmed = lines[i].trim();
|
|
699
|
+
if (!trimmed) continue;
|
|
700
|
+
if (trimmed === '{' || trimmed === '}' || trimmed.startsWith('//')) continue;
|
|
701
|
+
if (lines[i].includes('@rip-src:')) { si++; continue; }
|
|
702
|
+
if (srcLines[si] != null) {
|
|
703
|
+
lines[i] = `${lines[i]} // @rip-src:${srcLines[si]}`;
|
|
704
|
+
}
|
|
705
|
+
si++;
|
|
706
|
+
}
|
|
707
|
+
return lines.join('\n');
|
|
581
708
|
};
|
|
582
709
|
|
|
583
710
|
proto.transformComponentMembers = function(sexpr, localScope = new Set()) {
|
|
@@ -676,6 +803,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
676
803
|
// Categorize statements
|
|
677
804
|
const stateVars = [];
|
|
678
805
|
const derivedVars = [];
|
|
806
|
+
const gateVars = [];
|
|
679
807
|
const readonlyVars = [];
|
|
680
808
|
const methods = [];
|
|
681
809
|
const lifecycleHooks = [];
|
|
@@ -707,31 +835,51 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
707
835
|
reactiveMembers.add(varName);
|
|
708
836
|
}
|
|
709
837
|
} else if (op === '.' && stmt[1] === 'this' && getMemberName(stmt)) {
|
|
710
|
-
//
|
|
838
|
+
// Bare prop form: `(. this name)` — no default value.
|
|
839
|
+
// `@name` → required (caller must pass)
|
|
840
|
+
// `@name?` → optional (caller may omit; value will be undefined)
|
|
841
|
+
// `@name?:: T`→ optional, typed
|
|
711
842
|
const varName = (typeof stmt[2] === 'string' || stmt[2] instanceof String) ? stmt[2].valueOf() : null;
|
|
712
843
|
if (varName) {
|
|
713
|
-
|
|
844
|
+
const optional = getMemberOptional(stmt);
|
|
845
|
+
stateVars.push({ name: varName, value: undefined, isPublic: true, type: stmt[2]?.type || null, required: !optional, optional, srcLine: stmt.loc?.r });
|
|
714
846
|
memberNames.add(varName);
|
|
715
847
|
reactiveMembers.add(varName);
|
|
716
848
|
}
|
|
717
849
|
} else if (op === 'state') {
|
|
718
850
|
const varName = getMemberName(stmt[1]);
|
|
719
851
|
if (varName) {
|
|
720
|
-
stateVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]) });
|
|
852
|
+
stateVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]), optional: getMemberOptional(stmt[1]), srcLine: stmt.loc?.r });
|
|
721
853
|
memberNames.add(varName);
|
|
722
854
|
reactiveMembers.add(varName);
|
|
723
855
|
}
|
|
724
856
|
} else if (op === 'computed') {
|
|
725
857
|
const varName = getMemberName(stmt[1]);
|
|
726
858
|
if (varName) {
|
|
727
|
-
derivedVars.push({ name: varName, expr: stmt[2], type: getMemberType(stmt[1]) });
|
|
859
|
+
derivedVars.push({ name: varName, expr: stmt[2], type: getMemberType(stmt[1]), srcLine: stmt.loc?.r });
|
|
860
|
+
memberNames.add(varName);
|
|
861
|
+
reactiveMembers.add(varName);
|
|
862
|
+
}
|
|
863
|
+
} else if (op === 'gate') {
|
|
864
|
+
// Render-ready binding: hoist the literal stash path into the
|
|
865
|
+
// component's static gate-set; bind the member as a reactive read.
|
|
866
|
+
const varName = getMemberName(stmt[1]);
|
|
867
|
+
if (varName) {
|
|
868
|
+
const parsed = parseGatePath(stmt[2]);
|
|
869
|
+
if (parsed.error) this.error(parsed.error, stmt);
|
|
870
|
+
let key = null;
|
|
871
|
+
if (parsed.keyExpr !== undefined) {
|
|
872
|
+
key = parseGateKey(parsed.keyExpr);
|
|
873
|
+
if (key.error) this.error(key.error, stmt);
|
|
874
|
+
}
|
|
875
|
+
gateVars.push({ name: varName, path: parsed.path, key, type: getMemberType(stmt[1]), srcLine: stmt.loc?.r });
|
|
728
876
|
memberNames.add(varName);
|
|
729
877
|
reactiveMembers.add(varName);
|
|
730
878
|
}
|
|
731
879
|
} else if (op === 'readonly') {
|
|
732
880
|
const varName = getMemberName(stmt[1]);
|
|
733
881
|
if (varName) {
|
|
734
|
-
readonlyVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]) });
|
|
882
|
+
readonlyVars.push({ name: varName, value: stmt[2], isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]), optional: getMemberOptional(stmt[1]), srcLine: stmt.loc?.r });
|
|
735
883
|
memberNames.add(varName);
|
|
736
884
|
}
|
|
737
885
|
} else if (op === '=') {
|
|
@@ -745,7 +893,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
745
893
|
methods.push({ name: varName, func: val });
|
|
746
894
|
memberNames.add(varName);
|
|
747
895
|
} else {
|
|
748
|
-
stateVars.push({ name: varName, value: val, isPublic: isPublicProp(stmt[1]) });
|
|
896
|
+
stateVars.push({ name: varName, value: val, isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]), optional: getMemberOptional(stmt[1]), srcLine: stmt.loc?.r });
|
|
749
897
|
memberNames.add(varName);
|
|
750
898
|
reactiveMembers.add(varName);
|
|
751
899
|
}
|
|
@@ -770,6 +918,17 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
770
918
|
}
|
|
771
919
|
}
|
|
772
920
|
|
|
921
|
+
// Record gate declarations on the generator so the compile result can
|
|
922
|
+
// expose them (compiler.js threads `_gateDecls` through as `gates`).
|
|
923
|
+
// `rip check` validates each path against app/stash.rip's source-key
|
|
924
|
+
// set — the compile-time mirror of the renderer's mount check.
|
|
925
|
+
if (gateVars.length > 0) {
|
|
926
|
+
if (!this._gateDecls) this._gateDecls = [];
|
|
927
|
+
for (const { path, key, srcLine } of gateVars) {
|
|
928
|
+
this._gateDecls.push({ path, keyed: !!key, line: (srcLine ?? 0) + 1 });
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
773
932
|
// Auto-event map: onClick → 'click', onKeydown → 'keydown', etc.
|
|
774
933
|
const autoEventHandlers = new Map();
|
|
775
934
|
for (const { name } of methods) {
|
|
@@ -804,28 +963,44 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
804
963
|
|
|
805
964
|
// --- Type-check stub: typed member declarations + body expressions, no DOM ---
|
|
806
965
|
if (this.options.stubComponents) {
|
|
807
|
-
//
|
|
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;
|
|
966
|
+
// Strip Rip's `::` annotation sigil to TypeScript's `:` separator.
|
|
967
|
+
const expandType = (t) => t ? t.replace(/::/g, ':') : null;
|
|
812
968
|
|
|
813
969
|
const sl = [];
|
|
814
970
|
const componentTypeParams = this._componentTypeParams || '';
|
|
815
971
|
sl.push(`class ${componentTypeParams}{`);
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
|
|
972
|
+
// Injected `this` shape for every component. The compiler stays
|
|
973
|
+
// context-free; `declare app: any` and `declare router: any` are
|
|
974
|
+
// rewritten by typecheck.js to typed shapes when the project anchor /
|
|
975
|
+
// stash file is discoverable. `params`, `query`, `children`, and the
|
|
976
|
+
// lifecycle hooks are stable across all projects, so they're typed
|
|
977
|
+
// here directly. User-defined hooks are emitted later as real methods;
|
|
978
|
+
// skip the optional-signature declaration for those names to avoid
|
|
979
|
+
// overload-optionality mismatches.
|
|
980
|
+
// Combine all injected declarations onto a single line to preserve the
|
|
981
|
+
// stub's prior line count. Source-map gap-fill interpolates linearly
|
|
982
|
+
// between marker lines (e.g. between @rip-src markers from `@count`
|
|
983
|
+
// and a downstream method), so adding stub header lines pushes the
|
|
984
|
+
// method bodies past where interpolation expects them — breaking
|
|
985
|
+
// @ts-expect-error injection. Keeping a single header line preserves
|
|
986
|
+
// the relative offsets.
|
|
987
|
+
sl.push(' declare _root: Element | null; declare app: any; declare router: any; declare params: Record<string, string>; declare query: Record<string, string>; declare children: any;');
|
|
988
|
+
const userHookNames = new Set(lifecycleHooks.map(h => h.name));
|
|
989
|
+
const hookDecls = [];
|
|
990
|
+
if (!userHookNames.has('beforeMount')) hookDecls.push('beforeMount?(): void;');
|
|
991
|
+
if (!userHookNames.has('mounted')) hookDecls.push('mounted?(): void;');
|
|
992
|
+
if (!userHookNames.has('beforeUnmount')) hookDecls.push('beforeUnmount?(): void;');
|
|
993
|
+
if (!userHookNames.has('unmounted')) hookDecls.push('unmounted?(): void;');
|
|
994
|
+
if (!userHookNames.has('onError')) hookDecls.push(`onError?(err: ${ON_ERROR_PARAM_TYPE}): void;`);
|
|
995
|
+
if (hookDecls.length) sl.push(' ' + hookDecls.join(' '));
|
|
821
996
|
sl.push(' emit(_name: string, _detail?: any): void {}');
|
|
822
997
|
|
|
823
998
|
// Constructor — typed props for public state/readonly (matches DTS)
|
|
824
999
|
const propEntries = [];
|
|
825
|
-
for (const { name, type, isPublic, required } of stateVars) {
|
|
1000
|
+
for (const { name, type, isPublic, required, optional } of stateVars) {
|
|
826
1001
|
if (!isPublic) continue;
|
|
827
1002
|
const ts = expandType(type);
|
|
828
|
-
const opt = required ? '' : '
|
|
1003
|
+
const opt = (optional ?? !required) ? '?' : '';
|
|
829
1004
|
propEntries.push(`${name}${opt}: ${ts || 'any'}`);
|
|
830
1005
|
// Two-way binding: allow parent to pass Signal<T> for this prop
|
|
831
1006
|
propEntries.push(`__bind_${name}__?: Signal<${ts || 'any'}>`);
|
|
@@ -836,7 +1011,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
836
1011
|
propEntries.push(`${name}?: ${ts || 'any'}`);
|
|
837
1012
|
}
|
|
838
1013
|
{
|
|
839
|
-
const hasRequired = propEntries.length > 0 && stateVars.some(v => v.isPublic && v.required);
|
|
1014
|
+
const hasRequired = propEntries.length > 0 && stateVars.some(v => v.isPublic && v.required && !v.optional);
|
|
840
1015
|
const propsOpt = hasRequired ? '' : '?';
|
|
841
1016
|
let propsType = propEntries.length > 0 ? `{${propEntries.join('; ')}}` : '{}';
|
|
842
1017
|
if (inheritsTag) propsType += ` & __RipProps<'${inheritsTag}'>`;
|
|
@@ -853,10 +1028,55 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
853
1028
|
return null;
|
|
854
1029
|
};
|
|
855
1030
|
|
|
1031
|
+
// A value references `this` synchronously if it reads `this`/`@…` or a
|
|
1032
|
+
// reactive member (which emits as `this.x.value`) OUTSIDE any nested
|
|
1033
|
+
// function. Refs inside a `->`/`=>` are deferred to call time, so they
|
|
1034
|
+
// don't run during field initialization. Used below to decide whether a
|
|
1035
|
+
// value is safe to emit as a field initializer (the injected `app`,
|
|
1036
|
+
// `router`, … are `declare`-only, so a synchronous `this.app` read in a
|
|
1037
|
+
// field initializer would trip TS2729 "used before initialization").
|
|
1038
|
+
const refsThisSync = (node) => {
|
|
1039
|
+
if (!Array.isArray(node)) {
|
|
1040
|
+
if (typeof node === 'string' || node instanceof String) {
|
|
1041
|
+
const s = node.valueOf();
|
|
1042
|
+
return s === 'this' || reactiveMembers.has(s);
|
|
1043
|
+
}
|
|
1044
|
+
return false;
|
|
1045
|
+
}
|
|
1046
|
+
const head = node[0]?.valueOf?.() ?? node[0];
|
|
1047
|
+
if (head === '->' || head === '=>') return false; // nested fn — deferred
|
|
1048
|
+
return node.some(refsThisSync);
|
|
1049
|
+
};
|
|
1050
|
+
|
|
856
1051
|
// Property declarations (declare avoids definite-assignment errors)
|
|
857
|
-
|
|
1052
|
+
const deferredStateInits = [];
|
|
1053
|
+
for (const { name, type, value, isPublic, optional, srcLine } of stateVars) {
|
|
858
1054
|
const ts = expandType(type) || inferLiteralType(value);
|
|
859
|
-
|
|
1055
|
+
// Optional prop with no default: field can be undefined at runtime
|
|
1056
|
+
// (we don't synthesize a `?? null` fallback below), so the Signal's
|
|
1057
|
+
// payload type must include undefined.
|
|
1058
|
+
// `?` widens the Signal payload to `| undefined` when the member has
|
|
1059
|
+
// no initializer (`@label?:: T` — caller may omit) OR an explicit
|
|
1060
|
+
// `:= undefined` (`x?:: T := undefined` — starts empty). A `?` with a
|
|
1061
|
+
// real default stays `T`: the default fills the gap, so the field is
|
|
1062
|
+
// never undefined.
|
|
1063
|
+
const noValue = value === undefined || (value?.valueOf?.() ?? value) === 'undefined';
|
|
1064
|
+
const optNoDefault = optional && noValue;
|
|
1065
|
+
const wrapped = ts ? (optNoDefault ? `${ts} | undefined` : ts) : null;
|
|
1066
|
+
const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
|
|
1067
|
+
// No declared or literal-inferred type, but a private initializer is
|
|
1068
|
+
// present and reads nothing from `this` synchronously: emit a real
|
|
1069
|
+
// field initializer (deferred below) so TS infers the Signal payload
|
|
1070
|
+
// from the value — e.g. `x = createMutation(...)` → Signal<Mutation<R>>
|
|
1071
|
+
// — instead of falling back to Signal<any>. Mirrors how computed/gate
|
|
1072
|
+
// members already infer from their `__computed(...)` initializers.
|
|
1073
|
+
// Values that touch `this` synchronously keep the declare form (they
|
|
1074
|
+
// can't be field initializers — see refsThisSync above).
|
|
1075
|
+
if (!wrapped && !isPublic && !noValue && !refsThisSync(value)) {
|
|
1076
|
+
deferredStateInits.push({ name, value, marker });
|
|
1077
|
+
} else {
|
|
1078
|
+
sl.push((wrapped ? ` declare ${name}: Signal<${wrapped}>;` : ` declare ${name}: Signal<any>;`) + marker);
|
|
1079
|
+
}
|
|
860
1080
|
}
|
|
861
1081
|
if (inheritsTag) {
|
|
862
1082
|
sl.push(` declare rest: Signal<__RipProps<'${inheritsTag}'>>;`);
|
|
@@ -865,6 +1085,19 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
865
1085
|
const ts = expandType(type) || inferLiteralType(value);
|
|
866
1086
|
sl.push(ts ? ` declare ${name}: ${ts};` : ` declare ${name}: any;`);
|
|
867
1087
|
}
|
|
1088
|
+
// Gated bindings (<~): non-null by construction — the renderer loads
|
|
1089
|
+
// the source before the component exists. __ripGate is the generic
|
|
1090
|
+
// narrow `(v: T | null | undefined) => T`; soundness comes from the
|
|
1091
|
+
// runtime gate, not from the type system proving the load ran.
|
|
1092
|
+
for (const { name, path, key, type, srcLine } of gateVars) {
|
|
1093
|
+
const ts = expandType(type);
|
|
1094
|
+
const typeAnnot = ts ? `: Computed<${ts}>` : '';
|
|
1095
|
+
const access = key
|
|
1096
|
+
? `this.app.data.${path}(${key.stubCode})`
|
|
1097
|
+
: `this.app.data.${path}`;
|
|
1098
|
+
const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
|
|
1099
|
+
sl.push(` ${name}${typeAnnot} = __computed(() => __ripGate(${access}));` + marker);
|
|
1100
|
+
}
|
|
868
1101
|
for (const { name, expr, type } of derivedVars) {
|
|
869
1102
|
const ts = expandType(type);
|
|
870
1103
|
const typeAnnot = ts ? `: Computed<${ts}>` : '';
|
|
@@ -878,21 +1111,31 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
878
1111
|
}
|
|
879
1112
|
}
|
|
880
1113
|
|
|
1114
|
+
// Inferred-type private state vars: field initializers emitted after the
|
|
1115
|
+
// computed members above, so any member they read is already declared.
|
|
1116
|
+
for (const { name, value, marker } of deferredStateInits) {
|
|
1117
|
+
const val = this.emitInComponent(value, 'value');
|
|
1118
|
+
sl.push(` ${name} = __state(${val});` + marker);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
881
1121
|
// _init body — readonly, state, computed assignments (skip accepted/offered)
|
|
882
1122
|
sl.push(' _init(props) {');
|
|
883
|
-
for (const { name, value, isPublic } of readonlyVars) {
|
|
1123
|
+
for (const { name, value, isPublic, srcLine } of readonlyVars) {
|
|
884
1124
|
const val = this.emitInComponent(value, 'value');
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1125
|
+
const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
|
|
1126
|
+
sl.push((isPublic ? ` this.${name} = props.${name} ?? ${val};` : ` this.${name} = ${val};`) + marker);
|
|
1127
|
+
}
|
|
1128
|
+
for (const { name, value, isPublic, required, type, srcLine } of stateVars) {
|
|
1129
|
+
if (deferredStateInits.some(d => d.name === name)) continue;
|
|
1130
|
+
const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
|
|
1131
|
+
if (isPublic && (required || value === undefined)) {
|
|
1132
|
+
sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name});` + marker);
|
|
890
1133
|
} else if (isPublic) {
|
|
891
1134
|
const val = this.emitInComponent(value, 'value');
|
|
892
|
-
sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name} ?? ${val});`);
|
|
1135
|
+
sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name} ?? ${val});` + marker);
|
|
893
1136
|
} else {
|
|
894
1137
|
const val = this.emitInComponent(value, 'value');
|
|
895
|
-
sl.push(` this.${name} = __state(${val});`);
|
|
1138
|
+
sl.push(` this.${name} = __state(${val});` + marker);
|
|
896
1139
|
}
|
|
897
1140
|
}
|
|
898
1141
|
|
|
@@ -976,7 +1219,12 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
976
1219
|
}
|
|
977
1220
|
const transformed = this.reactiveMembers ? this.transformComponentMembers(methodBody) : methodBody;
|
|
978
1221
|
const isAsync = this.containsAwait(methodBody);
|
|
979
|
-
|
|
1222
|
+
let bodyCode = this.emitFunctionBody(transformed, params || []);
|
|
1223
|
+
// Inject @rip-src markers on each body statement line so that
|
|
1224
|
+
// @ts-expect-error injection and hover/diagnostics resolve to the
|
|
1225
|
+
// user's actual source line, instead of relying on linear gap-fill
|
|
1226
|
+
// interpolation across the stub (which is brittle to stub size).
|
|
1227
|
+
bodyCode = this.addBodyRipSrcMarkers(bodyCode, methodBody);
|
|
980
1228
|
sl.push(` ${isAsync ? 'async ' : ''}${name}(${paramStr}) ${bodyCode}`);
|
|
981
1229
|
}
|
|
982
1230
|
}
|
|
@@ -985,10 +1233,26 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
985
1233
|
for (const { name, value } of lifecycleHooks) {
|
|
986
1234
|
if (Array.isArray(value) && (value[0] === '->' || value[0] === '=>')) {
|
|
987
1235
|
const [, params, hookBody] = value;
|
|
988
|
-
|
|
1236
|
+
let paramStr = Array.isArray(params) ? params.map(p => this.formatParam(p)).join(', ') : '';
|
|
1237
|
+
// onError's payload shape is known to the framework, but its typed
|
|
1238
|
+
// optional declaration above is only emitted when the user does
|
|
1239
|
+
// NOT define the hook (a real method would collide with it) — so a
|
|
1240
|
+
// user-defined `onError: (err) ->` has no signature to inherit
|
|
1241
|
+
// from, and an unannotated param would be an implicit any in
|
|
1242
|
+
// strict projects. Type it from the same canonical constant the
|
|
1243
|
+
// declaration uses. Detection is structural (the parse tree's
|
|
1244
|
+
// type metadata, like getMemberType), and an explicit user
|
|
1245
|
+
// annotation always wins.
|
|
1246
|
+
if (name === 'onError' && Array.isArray(params) && params.length === 1) {
|
|
1247
|
+
const p = params[0];
|
|
1248
|
+
if ((typeof p === 'string' || p instanceof String) && !meta(p, 'type')) {
|
|
1249
|
+
paramStr = `${p.valueOf()}: ${ON_ERROR_PARAM_TYPE}`;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
989
1252
|
const transformed = this.reactiveMembers ? this.transformComponentMembers(hookBody) : hookBody;
|
|
990
1253
|
const isAsync = this.containsAwait(hookBody);
|
|
991
|
-
|
|
1254
|
+
let bodyCode = this.emitFunctionBody(transformed, params || []);
|
|
1255
|
+
bodyCode = this.addBodyRipSrcMarkers(bodyCode, hookBody);
|
|
992
1256
|
sl.push(` ${isAsync ? 'async ' : ''}${name}(${paramStr}) ${bodyCode}`);
|
|
993
1257
|
}
|
|
994
1258
|
}
|
|
@@ -999,6 +1263,18 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
999
1263
|
const constructions = [];
|
|
1000
1264
|
let constructionIdx = 0;
|
|
1001
1265
|
const sourceLines = this.options.source?.split('\n');
|
|
1266
|
+
// Route-check any `href:` prop passed to a component the same way
|
|
1267
|
+
// intrinsic `<a href>` is checked. A component that forwards href to
|
|
1268
|
+
// an anchor (e.g. `component extends a`) exposes it via `@rest` typed
|
|
1269
|
+
// as plain `string`, so without this `ButtonLink href: "/typo"` would
|
|
1270
|
+
// escape route validation entirely. We can't see the component's tag
|
|
1271
|
+
// cross-module, so we key on the prop name — `href` is a URL wherever
|
|
1272
|
+
// it appears. Wrap slash-prefixed string and template literals in
|
|
1273
|
+
// `__ripRoute(...)` (strengthened to the route union when the project
|
|
1274
|
+
// has routes); external URLs and variables (no leading-slash literal)
|
|
1275
|
+
// pass through untouched.
|
|
1276
|
+
const wrapCompHref = (val) =>
|
|
1277
|
+
typeof val === 'string' && /^["'`]\//.test(val) ? `__ripRoute(${val})` : val;
|
|
1002
1278
|
const extractProps = (args) => {
|
|
1003
1279
|
const props = [];
|
|
1004
1280
|
const sideExprs = [];
|
|
@@ -1042,7 +1318,8 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1042
1318
|
props.push({ code: `${key}: ${member}`, srcLine });
|
|
1043
1319
|
} else {
|
|
1044
1320
|
const val = this.emitInComponent(value, 'value');
|
|
1045
|
-
|
|
1321
|
+
const finalVal = key === 'href' ? wrapCompHref(val) : val;
|
|
1322
|
+
props.push({ code: `${key}: ${finalVal}`, srcLine });
|
|
1046
1323
|
}
|
|
1047
1324
|
}
|
|
1048
1325
|
}
|
|
@@ -1051,8 +1328,22 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1051
1328
|
props.sideExprs = sideExprs;
|
|
1052
1329
|
return props;
|
|
1053
1330
|
};
|
|
1054
|
-
const extractIntrinsicProps = (args) => {
|
|
1331
|
+
const extractIntrinsicProps = (args, tagName) => {
|
|
1055
1332
|
const props = [];
|
|
1333
|
+
// For <a href: ...>, wrap interpolated route literals (templates
|
|
1334
|
+
// starting with `/...`) in __ripRoute so TS checks the dynamic
|
|
1335
|
+
// template against __RipRoutes. Static literals fall through to
|
|
1336
|
+
// the existing __ripEl Option-C conditional, which still gives
|
|
1337
|
+
// the "Did you mean ..." hint. Variables and external URLs are
|
|
1338
|
+
// left untouched.
|
|
1339
|
+
const wrapHrefVal = (val) => {
|
|
1340
|
+
if (tagName !== 'a') return val;
|
|
1341
|
+
if (typeof val !== 'string') return val;
|
|
1342
|
+
if (val.length < 2) return val;
|
|
1343
|
+
if (val[0] !== '`') return val;
|
|
1344
|
+
if (val[1] !== '/') return val;
|
|
1345
|
+
return `__ripRoute(${val})`;
|
|
1346
|
+
};
|
|
1056
1347
|
for (const arg of args) {
|
|
1057
1348
|
let obj = null;
|
|
1058
1349
|
if (this.is(arg, 'object')) {
|
|
@@ -1071,9 +1362,25 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1071
1362
|
if (Array.isArray(key) && key[0] === '.' && key[1] === 'this') {
|
|
1072
1363
|
let memberName = typeof key[2] === 'string' ? key[2] : key[2]?.valueOf?.();
|
|
1073
1364
|
if (!memberName) continue;
|
|
1074
|
-
const
|
|
1075
|
-
|
|
1076
|
-
|
|
1365
|
+
const eventName = memberName.split('.')[0];
|
|
1366
|
+
let val = this.emitInComponent(value, 'value');
|
|
1367
|
+
// Inline arrow handler: explicitly annotate its untyped first
|
|
1368
|
+
// param with the event type. Letting TS contextually type it
|
|
1369
|
+
// through __ripEl's generic props is fragile — certain prop
|
|
1370
|
+
// combinations silently drop the param to `any`. This gives
|
|
1371
|
+
// inline handlers the same reliable typing the `@event: @method`
|
|
1372
|
+
// path already gets, matching __RipEvents' shape exactly.
|
|
1373
|
+
if (Array.isArray(value) && (value[0] === '->' || value[0] === '=>')) {
|
|
1374
|
+
const p0 = value[1]?.[0];
|
|
1375
|
+
const pName = typeof p0 === 'string' ? p0 : p0?.valueOf?.();
|
|
1376
|
+
if (pName && !(p0 && p0.type) && /^[A-Za-z_$][\w$]*$/.test(pName)) {
|
|
1377
|
+
const T = `RipEvent<HTMLElementEventMap['${eventName}'], __RipElementMap['${tagName}']>`;
|
|
1378
|
+
val = val[0] === '('
|
|
1379
|
+
? val.replace(new RegExp('^\\(\\s*' + pName + '\\b'), `(${pName}: ${T}`)
|
|
1380
|
+
: val.replace(new RegExp('^' + pName + '\\b'), `(${pName}: ${T})`);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
props.push({ code: `'@${eventName}': ${val}`, srcLine });
|
|
1077
1384
|
} else if (typeof key === 'string') {
|
|
1078
1385
|
if (key === 'key') {
|
|
1079
1386
|
// key: is not an HTML attribute, but emit its value
|
|
@@ -1089,7 +1396,8 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1089
1396
|
props.push({ code: `${propName}: ${val}`, srcLine });
|
|
1090
1397
|
} else {
|
|
1091
1398
|
const val = this.emitInComponent(value, 'value');
|
|
1092
|
-
|
|
1399
|
+
const finalVal = key === 'href' ? wrapHrefVal(val) : val;
|
|
1400
|
+
props.push({ code: `${key}: ${finalVal}`, srcLine });
|
|
1093
1401
|
}
|
|
1094
1402
|
}
|
|
1095
1403
|
}
|
|
@@ -1166,14 +1474,38 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1166
1474
|
const condCode = this.emitInComponent(condition, 'value');
|
|
1167
1475
|
const srcLine = node.loc?.r;
|
|
1168
1476
|
const srcMarker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
|
|
1169
|
-
|
|
1477
|
+
// Emit a real `if (...) { } else { }` (mirroring the for-loop
|
|
1478
|
+
// case below) instead of a bare condition statement with the
|
|
1479
|
+
// branches flattened as unguarded siblings. The structure is
|
|
1480
|
+
// what lets TS narrow the condition inside the then-branch —
|
|
1481
|
+
// `if order then order.total` type-checks against `T | null`
|
|
1482
|
+
// without `?.` laundering. Reads are plain property chains
|
|
1483
|
+
// (`this.order.value`), which TS keeps narrowed across the
|
|
1484
|
+
// intervening `__ripEl(...)` stub calls.
|
|
1485
|
+
const guard = head === 'unless' ? `!(${condCode})` : condCode;
|
|
1486
|
+
constructions.push(` if (${guard}) {${srcMarker}`);
|
|
1487
|
+
if (node[2] != null) walkRender(node[2]);
|
|
1488
|
+
const elses = node.slice(3).filter(n => n != null);
|
|
1489
|
+
if (elses.length) {
|
|
1490
|
+
constructions.push(` } else {`);
|
|
1491
|
+
for (const e of elses) walkRender(e);
|
|
1492
|
+
}
|
|
1493
|
+
constructions.push(` }`);
|
|
1494
|
+
return; // branches fully handled — don't re-walk children below
|
|
1170
1495
|
}
|
|
1171
1496
|
} else if (head === '?:') {
|
|
1172
|
-
// Emit the full ternary so all branches are type-checked
|
|
1497
|
+
// Emit the full ternary so all branches are type-checked, then
|
|
1498
|
+
// return — like the `__text__`/`str` cases. The emitted expression
|
|
1499
|
+
// already covers every branch, so re-walking children is redundant
|
|
1500
|
+
// and actively harmful: a nested member access (e.g. `opt.label`
|
|
1501
|
+
// in `typeof opt is "string" ? opt : opt.label`) would hit the
|
|
1502
|
+
// bare member-access case below and be re-emitted *unguarded*,
|
|
1503
|
+
// stripping the `typeof` narrowing and producing a false error.
|
|
1173
1504
|
const ternCode = this.emitInComponent(node, 'value');
|
|
1174
1505
|
const srcLine = node.loc?.r;
|
|
1175
1506
|
const srcMarker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
|
|
1176
1507
|
constructions.push(` ${ternCode};${srcMarker}`);
|
|
1508
|
+
return;
|
|
1177
1509
|
} else if (head === 'switch') {
|
|
1178
1510
|
const discriminant = node[1];
|
|
1179
1511
|
if (discriminant != null) {
|
|
@@ -1242,6 +1574,26 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1242
1574
|
constructions.push(` ${exprCode};${srcMarker}`);
|
|
1243
1575
|
} catch {}
|
|
1244
1576
|
return;
|
|
1577
|
+
} else if (head === '.' || head === '?.') {
|
|
1578
|
+
// Bare member-access expression as element content
|
|
1579
|
+
// (e.g. `div config.site.theme.logoMark`). Walk to the chain's
|
|
1580
|
+
// base identifier: emit it for type-checking only when the base
|
|
1581
|
+
// is a value reference, NOT an HTML tag-shorthand like `div.image`
|
|
1582
|
+
// (whose base IS a tag). Without this, static text-content
|
|
1583
|
+
// expressions never reach the stub and escape type-checking —
|
|
1584
|
+
// only interpolated "#{...}" content was being checked.
|
|
1585
|
+
let base = node;
|
|
1586
|
+
while (Array.isArray(base) && (base[0] === '.' || base[0] === '?.')) base = base[1];
|
|
1587
|
+
const baseName = typeof base === 'string' ? base : base?.valueOf?.();
|
|
1588
|
+
if (typeof baseName === 'string' && !this.isHtmlTag(baseName) && !CodeEmitter.GENERATORS[baseName]) {
|
|
1589
|
+
try {
|
|
1590
|
+
const exprCode = this.emitInComponent(node, 'value');
|
|
1591
|
+
const srcLine = node.loc?.r;
|
|
1592
|
+
const srcMarker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
|
|
1593
|
+
constructions.push(` ${exprCode};${srcMarker}`);
|
|
1594
|
+
} catch {}
|
|
1595
|
+
}
|
|
1596
|
+
return;
|
|
1245
1597
|
}
|
|
1246
1598
|
|
|
1247
1599
|
// Emit a bare lowercase identifier as either a property access
|
|
@@ -1373,7 +1725,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1373
1725
|
} else if (typeof head === 'string' && !CodeEmitter.GENERATORS[head] && (TEMPLATE_TAGS.has(head.split(/[.#]/)[0]) ||
|
|
1374
1726
|
(/^[a-z][\w-]*$/.test(head) && node.length > 1))) {
|
|
1375
1727
|
const tagName = head.split(/[.#]/)[0];
|
|
1376
|
-
const iProps = extractIntrinsicProps(node.slice(1));
|
|
1728
|
+
const iProps = extractIntrinsicProps(node.slice(1), tagName);
|
|
1377
1729
|
const tagLine = node.loc?.r;
|
|
1378
1730
|
const srcMarker = tagLine != null ? ` // @rip-src:${tagLine}` : '';
|
|
1379
1731
|
if (iProps.length === 0) {
|
|
@@ -1423,9 +1775,28 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1423
1775
|
|
|
1424
1776
|
lines.push('class extends __Component {');
|
|
1425
1777
|
|
|
1778
|
+
// Static gate-set — readable by the renderer without
|
|
1779
|
+
// constructing the component. Strings are plain stash paths; keyed
|
|
1780
|
+
// gates carry a key fn the renderer evaluates with the matched
|
|
1781
|
+
// params/query before construction.
|
|
1782
|
+
if (gateVars.length > 0) {
|
|
1783
|
+
const items = gateVars.map(({ path, key }) => key
|
|
1784
|
+
? `{ path: '${path}', key: (params, query) => ${key.code} }`
|
|
1785
|
+
: `'${path}'`);
|
|
1786
|
+
lines.push(` static __gates = [${items.join(', ')}];`);
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1426
1789
|
// --- Init (called by __Component constructor) ---
|
|
1427
1790
|
lines.push(' _init(props) {');
|
|
1428
1791
|
|
|
1792
|
+
// Gated bindings (<~) — first, so any readonly/state initializer can
|
|
1793
|
+
// consume the loaded value synchronously (`form := { ...user }`).
|
|
1794
|
+
for (const { name, path, key } of gateVars) {
|
|
1795
|
+
lines.push(key
|
|
1796
|
+
? ` this.${name} = __gateBind(this, '${path}', (params, query) => ${key.code});`
|
|
1797
|
+
: ` this.${name} = __gateBind(this, '${path}');`);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1429
1800
|
// Constants (readonly)
|
|
1430
1801
|
for (const { name, value, isPublic } of readonlyVars) {
|
|
1431
1802
|
const val = this.emitInComponent(value, 'value');
|
|
@@ -1441,7 +1812,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1441
1812
|
|
|
1442
1813
|
// State variables (__state handles signal passthrough)
|
|
1443
1814
|
for (const { name, value, isPublic, required } of stateVars) {
|
|
1444
|
-
if (isPublic && required) {
|
|
1815
|
+
if (isPublic && (required || value === undefined)) {
|
|
1445
1816
|
lines.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name});`);
|
|
1446
1817
|
} else if (isPublic) {
|
|
1447
1818
|
const val = this.emitInComponent(value, 'value');
|
|
@@ -2727,7 +3098,13 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
2727
3098
|
const outerParams = this._loopVarStack.map(v => `${v.itemVar}, ${v.indexVar}`).join(', ');
|
|
2728
3099
|
const outerExtra = outerParams ? `, ${outerParams}` : '';
|
|
2729
3100
|
|
|
2730
|
-
|
|
3101
|
+
// If the iterated collection is reactive, treat direct member access
|
|
3102
|
+
// on the iter var (`item.qty`, `item[0]`, `item.a.b`) as reactive
|
|
3103
|
+
// inside the loop body. hasReactiveDeps consults this flag, so the
|
|
3104
|
+
// existing emit paths wrap those reads in __effect and the per-row
|
|
3105
|
+
// patch function gets populated.
|
|
3106
|
+
const reactiveSource = this.hasReactiveDeps(collection);
|
|
3107
|
+
this._loopVarStack.push({ itemVar, indexVar, reactiveSource });
|
|
2731
3108
|
const itemNode = this.emitTemplateBlock(body);
|
|
2732
3109
|
this._loopVarStack.pop();
|
|
2733
3110
|
const itemCreateLines = this._createLines;
|
|
@@ -3037,6 +3414,16 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
3037
3414
|
return true;
|
|
3038
3415
|
}
|
|
3039
3416
|
|
|
3417
|
+
// Property chain rooted at a `for`-loop iter var whose source is
|
|
3418
|
+
// reactive (e.g. `for item in cart.items` → `item.qty`, `item[0]`,
|
|
3419
|
+
// `item.foo.bar`). Same rationale as the this-rooted case: the source
|
|
3420
|
+
// proxy is reactive at runtime, so any member read may track. Limited
|
|
3421
|
+
// to direct member access on the iter var — aliases and destructuring
|
|
3422
|
+
// are explicitly out of scope.
|
|
3423
|
+
if ((sexpr[0] === '.' || sexpr[0] === '[]') && this._rootsAtReactiveLoopVar(sexpr)) {
|
|
3424
|
+
return true;
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3040
3427
|
// Method call on component: [['.', 'this', method], ...args]
|
|
3041
3428
|
// Methods may read reactive state internally — treat as reactive so the
|
|
3042
3429
|
// call gets wrapped in __effect and re-runs when dependencies change.
|
|
@@ -3118,6 +3505,26 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
3118
3505
|
return this._rootsAtThis(sexpr[1]);
|
|
3119
3506
|
};
|
|
3120
3507
|
|
|
3508
|
+
// _rootsAtReactiveLoopVar — true when a `.` / `[]` access chain bottoms
|
|
3509
|
+
// out at a string identifier that matches the itemVar of some frame on
|
|
3510
|
+
// _loopVarStack whose `reactiveSource` is true. See emitTemplateLoop.
|
|
3511
|
+
proto._rootsAtReactiveLoopVar = function(sexpr) {
|
|
3512
|
+
if (typeof sexpr === 'string') {
|
|
3513
|
+
if (!this._loopVarStack || this._loopVarStack.length === 0) return false;
|
|
3514
|
+
// Iterate innermost-first so a shadowed name resolves to its nearest
|
|
3515
|
+
// binding: `for item in reactive` containing `for item in [1,2,3]`
|
|
3516
|
+
// must treat inner reads of `item` as non-reactive.
|
|
3517
|
+
for (let i = this._loopVarStack.length - 1; i >= 0; i--) {
|
|
3518
|
+
const v = this._loopVarStack[i];
|
|
3519
|
+
if (v.itemVar === sexpr) return !!v.reactiveSource;
|
|
3520
|
+
}
|
|
3521
|
+
return false;
|
|
3522
|
+
}
|
|
3523
|
+
if (!Array.isArray(sexpr)) return false;
|
|
3524
|
+
if (sexpr[0] === '.' || sexpr[0] === '[]') return this._rootsAtReactiveLoopVar(sexpr[1]);
|
|
3525
|
+
return false;
|
|
3526
|
+
};
|
|
3527
|
+
|
|
3121
3528
|
// ==========================================================================
|
|
3122
3529
|
// Component Runtime
|
|
3123
3530
|
// ==========================================================================
|
|
@@ -3387,6 +3794,28 @@ function __transition(el, name, dir, done) {
|
|
|
3387
3794
|
});
|
|
3388
3795
|
}
|
|
3389
3796
|
|
|
3797
|
+
// Render-ready binding (\`x <~ @app.data.x\`): a reactive read of a
|
|
3798
|
+
// stash source key that retains the last non-null value for the life of
|
|
3799
|
+
// this instance. A null write or reset() invalidates the CELL (ungated
|
|
3800
|
+
// readers see it immediately) without yanking mounted gates — the
|
|
3801
|
+
// live-binding invariant. Last-good lives in this closure, so it dies
|
|
3802
|
+
// with the instance.
|
|
3803
|
+
function __gateBind(self, path, keyFn) {
|
|
3804
|
+
// The component runtime ships both inlined after the reactive runtime
|
|
3805
|
+
// (where __computed is a local) and as a standalone bundle chunk (where
|
|
3806
|
+
// it is not) — resolve through globalThis.__rip in the latter case.
|
|
3807
|
+
const __c = typeof __computed !== 'undefined' ? __computed : globalThis.__rip.__computed;
|
|
3808
|
+
let last;
|
|
3809
|
+
return __c(() => {
|
|
3810
|
+
const data = self.app && self.app.data;
|
|
3811
|
+
if (!data) return last;
|
|
3812
|
+
const v = keyFn
|
|
3813
|
+
? data[path](keyFn(self.params || {}, self.query || {}))
|
|
3814
|
+
: data[path]; // dotted paths route through the stash's path-key get
|
|
3815
|
+
return v != null ? (last = v) : last;
|
|
3816
|
+
});
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3390
3819
|
function __handleComponentError(error, component) {
|
|
3391
3820
|
let current = component;
|
|
3392
3821
|
// Defensive cycle guard: if the parent chain is corrupted (e.g. by
|
|
@@ -3408,6 +3837,17 @@ class __Component {
|
|
|
3408
3837
|
constructor(props = {}) {
|
|
3409
3838
|
Object.assign(this, props);
|
|
3410
3839
|
if (!this.app && globalThis.__ripApp) this.app = globalThis.__ripApp;
|
|
3840
|
+
// A component with render gates (<~) is honored only via route
|
|
3841
|
+
// matching — the renderer sets __ripGateMount around each route/layout
|
|
3842
|
+
// construction. Constructed as an embedded child (a parent scope is
|
|
3843
|
+
// active but the renderer flag doesn't match), its gates were never
|
|
3844
|
+
// loaded, so the T-typed bindings would be silent nulls. Fail loud and
|
|
3845
|
+
// deterministically instead. Direct construction outside any component
|
|
3846
|
+
// scope (tests: seed the stash, then construct) stays allowed.
|
|
3847
|
+
const __g = this.constructor.__gates;
|
|
3848
|
+
if (__g && __g.length && globalThis.__ripGateMount !== this.constructor && __currentComponent) {
|
|
3849
|
+
throw new Error('[Rip] component declares render gates (<~) but was constructed as an embedded child; gates are honored only via route matching — lift the gate to the route/layout and pass the value as a prop');
|
|
3850
|
+
}
|
|
3411
3851
|
const prev = __pushComponent(this);
|
|
3412
3852
|
try {
|
|
3413
3853
|
this._init(props);
|
|
@@ -3507,7 +3947,7 @@ class __Component {
|
|
|
3507
3947
|
|
|
3508
3948
|
// Register on globalThis for runtime deduplication
|
|
3509
3949
|
if (typeof globalThis !== 'undefined') {
|
|
3510
|
-
globalThis.__ripComponent = { __pushComponent, __popComponent, __getCurrentComponent, setContext, getContext, hasContext, __clsx, __lis, __reconcile, __transition, __handleComponentError, __Component };
|
|
3950
|
+
globalThis.__ripComponent = { __pushComponent, __popComponent, __getCurrentComponent, setContext, getContext, hasContext, __clsx, __lis, __reconcile, __transition, __handleComponentError, __gateBind, __Component };
|
|
3511
3951
|
}
|
|
3512
3952
|
|
|
3513
3953
|
`;
|