rip-lang 3.16.1 → 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 +2 -3
- package/bin/rip +39 -8
- package/bin/rip-schema +175 -0
- package/docs/RIP-APP.md +91 -2
- package/docs/RIP-DUCKDB.md +64 -1
- package/docs/RIP-INTRO.md +4 -4
- package/docs/RIP-LANG.md +32 -33
- package/docs/RIP-SCHEMA.md +1204 -364
- package/docs/dist/rip.js +3245 -611
- package/docs/dist/rip.min.js +1161 -289
- package/docs/dist/rip.min.js.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-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/hljs-rip.js +1 -1
- package/package.json +7 -4
- package/src/AGENTS.md +39 -8
- package/src/compiler.js +220 -36
- package/src/components.js +315 -14
- package/src/dts.js +18 -3
- 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 +24 -17
- 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 +1049 -89
- package/src/typecheck.js +283 -55
- package/src/types.js +5 -1
- package/src/grammar/lunar.rip +0 -2412
package/src/components.js
CHANGED
|
@@ -18,6 +18,12 @@ const BIND_PREFIX = '__bind_';
|
|
|
18
18
|
const BIND_SUFFIX = '__';
|
|
19
19
|
|
|
20
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',
|
|
@@ -90,6 +96,73 @@ function getMemberOptional(target) {
|
|
|
90
96
|
return false;
|
|
91
97
|
}
|
|
92
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
|
+
|
|
93
166
|
// ============================================================================
|
|
94
167
|
// Prototype Installation
|
|
95
168
|
// ============================================================================
|
|
@@ -730,6 +803,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
730
803
|
// Categorize statements
|
|
731
804
|
const stateVars = [];
|
|
732
805
|
const derivedVars = [];
|
|
806
|
+
const gateVars = [];
|
|
733
807
|
const readonlyVars = [];
|
|
734
808
|
const methods = [];
|
|
735
809
|
const lifecycleHooks = [];
|
|
@@ -786,6 +860,22 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
786
860
|
memberNames.add(varName);
|
|
787
861
|
reactiveMembers.add(varName);
|
|
788
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 });
|
|
876
|
+
memberNames.add(varName);
|
|
877
|
+
reactiveMembers.add(varName);
|
|
878
|
+
}
|
|
789
879
|
} else if (op === 'readonly') {
|
|
790
880
|
const varName = getMemberName(stmt[1]);
|
|
791
881
|
if (varName) {
|
|
@@ -803,7 +893,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
803
893
|
methods.push({ name: varName, func: val });
|
|
804
894
|
memberNames.add(varName);
|
|
805
895
|
} else {
|
|
806
|
-
stateVars.push({ name: varName, value: val, isPublic: isPublicProp(stmt[1]), srcLine: stmt.loc?.r });
|
|
896
|
+
stateVars.push({ name: varName, value: val, isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]), optional: getMemberOptional(stmt[1]), srcLine: stmt.loc?.r });
|
|
807
897
|
memberNames.add(varName);
|
|
808
898
|
reactiveMembers.add(varName);
|
|
809
899
|
}
|
|
@@ -828,6 +918,17 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
828
918
|
}
|
|
829
919
|
}
|
|
830
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
|
+
|
|
831
932
|
// Auto-event map: onClick → 'click', onKeydown → 'keydown', etc.
|
|
832
933
|
const autoEventHandlers = new Map();
|
|
833
934
|
for (const { name } of methods) {
|
|
@@ -883,14 +984,14 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
883
984
|
// method bodies past where interpolation expects them — breaking
|
|
884
985
|
// @ts-expect-error injection. Keeping a single header line preserves
|
|
885
986
|
// the relative offsets.
|
|
886
|
-
sl.push(' declare _root: Element | null; declare app: any; declare router: any; declare params: Record<string, string>; declare query:
|
|
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;');
|
|
887
988
|
const userHookNames = new Set(lifecycleHooks.map(h => h.name));
|
|
888
989
|
const hookDecls = [];
|
|
889
990
|
if (!userHookNames.has('beforeMount')) hookDecls.push('beforeMount?(): void;');
|
|
890
991
|
if (!userHookNames.has('mounted')) hookDecls.push('mounted?(): void;');
|
|
891
992
|
if (!userHookNames.has('beforeUnmount')) hookDecls.push('beforeUnmount?(): void;');
|
|
892
993
|
if (!userHookNames.has('unmounted')) hookDecls.push('unmounted?(): void;');
|
|
893
|
-
if (!userHookNames.has('onError')) hookDecls.push(
|
|
994
|
+
if (!userHookNames.has('onError')) hookDecls.push(`onError?(err: ${ON_ERROR_PARAM_TYPE}): void;`);
|
|
894
995
|
if (hookDecls.length) sl.push(' ' + hookDecls.join(' '));
|
|
895
996
|
sl.push(' emit(_name: string, _detail?: any): void {}');
|
|
896
997
|
|
|
@@ -927,16 +1028,55 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
927
1028
|
return null;
|
|
928
1029
|
};
|
|
929
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
|
+
|
|
930
1051
|
// Property declarations (declare avoids definite-assignment errors)
|
|
931
|
-
|
|
1052
|
+
const deferredStateInits = [];
|
|
1053
|
+
for (const { name, type, value, isPublic, optional, srcLine } of stateVars) {
|
|
932
1054
|
const ts = expandType(type) || inferLiteralType(value);
|
|
933
1055
|
// Optional prop with no default: field can be undefined at runtime
|
|
934
1056
|
// (we don't synthesize a `?? null` fallback below), so the Signal's
|
|
935
1057
|
// payload type must include undefined.
|
|
936
|
-
|
|
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;
|
|
937
1065
|
const wrapped = ts ? (optNoDefault ? `${ts} | undefined` : ts) : null;
|
|
938
1066
|
const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
|
|
939
|
-
|
|
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
|
+
}
|
|
940
1080
|
}
|
|
941
1081
|
if (inheritsTag) {
|
|
942
1082
|
sl.push(` declare rest: Signal<__RipProps<'${inheritsTag}'>>;`);
|
|
@@ -945,6 +1085,19 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
945
1085
|
const ts = expandType(type) || inferLiteralType(value);
|
|
946
1086
|
sl.push(ts ? ` declare ${name}: ${ts};` : ` declare ${name}: any;`);
|
|
947
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
|
+
}
|
|
948
1101
|
for (const { name, expr, type } of derivedVars) {
|
|
949
1102
|
const ts = expandType(type);
|
|
950
1103
|
const typeAnnot = ts ? `: Computed<${ts}>` : '';
|
|
@@ -958,6 +1111,13 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
958
1111
|
}
|
|
959
1112
|
}
|
|
960
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
|
+
|
|
961
1121
|
// _init body — readonly, state, computed assignments (skip accepted/offered)
|
|
962
1122
|
sl.push(' _init(props) {');
|
|
963
1123
|
for (const { name, value, isPublic, srcLine } of readonlyVars) {
|
|
@@ -966,6 +1126,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
966
1126
|
sl.push((isPublic ? ` this.${name} = props.${name} ?? ${val};` : ` this.${name} = ${val};`) + marker);
|
|
967
1127
|
}
|
|
968
1128
|
for (const { name, value, isPublic, required, type, srcLine } of stateVars) {
|
|
1129
|
+
if (deferredStateInits.some(d => d.name === name)) continue;
|
|
969
1130
|
const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
|
|
970
1131
|
if (isPublic && (required || value === undefined)) {
|
|
971
1132
|
sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name});` + marker);
|
|
@@ -1072,7 +1233,22 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1072
1233
|
for (const { name, value } of lifecycleHooks) {
|
|
1073
1234
|
if (Array.isArray(value) && (value[0] === '->' || value[0] === '=>')) {
|
|
1074
1235
|
const [, params, hookBody] = value;
|
|
1075
|
-
|
|
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
|
+
}
|
|
1076
1252
|
const transformed = this.reactiveMembers ? this.transformComponentMembers(hookBody) : hookBody;
|
|
1077
1253
|
const isAsync = this.containsAwait(hookBody);
|
|
1078
1254
|
let bodyCode = this.emitFunctionBody(transformed, params || []);
|
|
@@ -1087,6 +1263,18 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1087
1263
|
const constructions = [];
|
|
1088
1264
|
let constructionIdx = 0;
|
|
1089
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;
|
|
1090
1278
|
const extractProps = (args) => {
|
|
1091
1279
|
const props = [];
|
|
1092
1280
|
const sideExprs = [];
|
|
@@ -1130,7 +1318,8 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1130
1318
|
props.push({ code: `${key}: ${member}`, srcLine });
|
|
1131
1319
|
} else {
|
|
1132
1320
|
const val = this.emitInComponent(value, 'value');
|
|
1133
|
-
|
|
1321
|
+
const finalVal = key === 'href' ? wrapCompHref(val) : val;
|
|
1322
|
+
props.push({ code: `${key}: ${finalVal}`, srcLine });
|
|
1134
1323
|
}
|
|
1135
1324
|
}
|
|
1136
1325
|
}
|
|
@@ -1173,9 +1362,25 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1173
1362
|
if (Array.isArray(key) && key[0] === '.' && key[1] === 'this') {
|
|
1174
1363
|
let memberName = typeof key[2] === 'string' ? key[2] : key[2]?.valueOf?.();
|
|
1175
1364
|
if (!memberName) continue;
|
|
1176
|
-
const
|
|
1177
|
-
|
|
1178
|
-
|
|
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 });
|
|
1179
1384
|
} else if (typeof key === 'string') {
|
|
1180
1385
|
if (key === 'key') {
|
|
1181
1386
|
// key: is not an HTML attribute, but emit its value
|
|
@@ -1269,14 +1474,38 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1269
1474
|
const condCode = this.emitInComponent(condition, 'value');
|
|
1270
1475
|
const srcLine = node.loc?.r;
|
|
1271
1476
|
const srcMarker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
|
|
1272
|
-
|
|
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
|
|
1273
1495
|
}
|
|
1274
1496
|
} else if (head === '?:') {
|
|
1275
|
-
// 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.
|
|
1276
1504
|
const ternCode = this.emitInComponent(node, 'value');
|
|
1277
1505
|
const srcLine = node.loc?.r;
|
|
1278
1506
|
const srcMarker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
|
|
1279
1507
|
constructions.push(` ${ternCode};${srcMarker}`);
|
|
1508
|
+
return;
|
|
1280
1509
|
} else if (head === 'switch') {
|
|
1281
1510
|
const discriminant = node[1];
|
|
1282
1511
|
if (discriminant != null) {
|
|
@@ -1345,6 +1574,26 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1345
1574
|
constructions.push(` ${exprCode};${srcMarker}`);
|
|
1346
1575
|
} catch {}
|
|
1347
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;
|
|
1348
1597
|
}
|
|
1349
1598
|
|
|
1350
1599
|
// Emit a bare lowercase identifier as either a property access
|
|
@@ -1526,9 +1775,28 @@ export function installComponentSupport(CodeEmitter, Lexer) {
|
|
|
1526
1775
|
|
|
1527
1776
|
lines.push('class extends __Component {');
|
|
1528
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
|
+
|
|
1529
1789
|
// --- Init (called by __Component constructor) ---
|
|
1530
1790
|
lines.push(' _init(props) {');
|
|
1531
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
|
+
|
|
1532
1800
|
// Constants (readonly)
|
|
1533
1801
|
for (const { name, value, isPublic } of readonlyVars) {
|
|
1534
1802
|
const val = this.emitInComponent(value, 'value');
|
|
@@ -3526,6 +3794,28 @@ function __transition(el, name, dir, done) {
|
|
|
3526
3794
|
});
|
|
3527
3795
|
}
|
|
3528
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
|
+
|
|
3529
3819
|
function __handleComponentError(error, component) {
|
|
3530
3820
|
let current = component;
|
|
3531
3821
|
// Defensive cycle guard: if the parent chain is corrupted (e.g. by
|
|
@@ -3547,6 +3837,17 @@ class __Component {
|
|
|
3547
3837
|
constructor(props = {}) {
|
|
3548
3838
|
Object.assign(this, props);
|
|
3549
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
|
+
}
|
|
3550
3851
|
const prev = __pushComponent(this);
|
|
3551
3852
|
try {
|
|
3552
3853
|
this._init(props);
|
|
@@ -3646,7 +3947,7 @@ class __Component {
|
|
|
3646
3947
|
|
|
3647
3948
|
// Register on globalThis for runtime deduplication
|
|
3648
3949
|
if (typeof globalThis !== 'undefined') {
|
|
3649
|
-
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 };
|
|
3650
3951
|
}
|
|
3651
3952
|
|
|
3652
3953
|
`;
|
package/src/dts.js
CHANGED
|
@@ -83,7 +83,7 @@ export function ripDestructuredNames(source) {
|
|
|
83
83
|
// emitTypes — generate .d.ts from annotated tokens + s-expression tree
|
|
84
84
|
// ============================================================================
|
|
85
85
|
|
|
86
|
-
export function emitTypes(tokens, sexpr = null, source = '') {
|
|
86
|
+
export function emitTypes(tokens, sexpr = null, source = '', schemaBehavior = null, schemaAnon = null) {
|
|
87
87
|
let lines = [];
|
|
88
88
|
let indentLevel = 0;
|
|
89
89
|
let indentStr = ' ';
|
|
@@ -797,6 +797,12 @@ export function emitTypes(tokens, sexpr = null, source = '') {
|
|
|
797
797
|
lines.push(`${indent()}${exp}${declare}const ${varName}: Computed<${type}>;`);
|
|
798
798
|
} else if (next[0] === 'EFFECT') {
|
|
799
799
|
lines.push(`${indent()}${exp}${declare}const ${varName}: () => void;`);
|
|
800
|
+
} else if (next[0] === 'GATE') {
|
|
801
|
+
// Render-ready gate (<~) — only valid inside component
|
|
802
|
+
// bodies, where the component stub (not this hoist) types the
|
|
803
|
+
// binding. Explicit no-op so an annotated gate never falls
|
|
804
|
+
// through to the `=` arrow-scan below.
|
|
805
|
+
continue;
|
|
800
806
|
} else if (next[0] === '=') {
|
|
801
807
|
// Check if RHS is an arrow function with return type
|
|
802
808
|
let arrowIdx = i + 2;
|
|
@@ -821,7 +827,16 @@ export function emitTypes(tokens, sexpr = null, source = '') {
|
|
|
821
827
|
} else if (inClass) {
|
|
822
828
|
lines.push(`${indent()}${varName}: ${type};`);
|
|
823
829
|
classFields.add(varName);
|
|
824
|
-
} else {
|
|
830
|
+
} else if (bodyDepth === 0) {
|
|
831
|
+
// Module-scope typed binding: typecheck.js's inline-let pass
|
|
832
|
+
// merges this header line into the body's hoist by NAME. That
|
|
833
|
+
// name-based merge is only sound at module scope — for a
|
|
834
|
+
// function-local (`bodyDepth > 0`) the same name can exist
|
|
835
|
+
// untyped in sibling functions, and the merge would stamp this
|
|
836
|
+
// annotation onto every one of them. Function-locals don't
|
|
837
|
+
// need the header line anyway: the compiler's typed-local
|
|
838
|
+
// hoist (collectTypedLocals) annotates the owning function's
|
|
839
|
+
// own `let` in the body, scope-correctly.
|
|
825
840
|
lines.push(`${indent()}${exp}let ${varName}: ${type};`);
|
|
826
841
|
}
|
|
827
842
|
} else if (inClass) {
|
|
@@ -852,7 +867,7 @@ export function emitTypes(tokens, sexpr = null, source = '') {
|
|
|
852
867
|
// Schema declarations — strip any prior auto-emitted `declare let Foo`
|
|
853
868
|
// for the same bindings (they are re-emitted as typed Schema<T>).
|
|
854
869
|
let schemaLines = [];
|
|
855
|
-
hasSchemaDecls = emitSchemaTypes(sexpr, schemaLines);
|
|
870
|
+
hasSchemaDecls = emitSchemaTypes(sexpr, schemaLines, schemaBehavior, schemaAnon);
|
|
856
871
|
if (hasSchemaDecls) {
|
|
857
872
|
let bindings = new Set();
|
|
858
873
|
for (let line of schemaLines) {
|