kreftjs 0.0.20-beta → 0.0.22-beta
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 +10 -1
- package/bin/artisan.js +4 -2
- package/bin/backend/cache.js +31 -5
- package/bin/backend/gc.js +193 -43
- package/bin/backend/http.js +6 -2
- package/bin/backend/index.js +6 -0
- package/bin/backend/mcp-client.js +133 -0
- package/bin/backend/mcp.js +17 -0
- package/bin/backend/model.js +15 -1
- package/bin/backend/shield.js +266 -0
- package/bin/backend/stream.js +74 -0
- package/bin/build.js +12 -5
- package/bin/db-api.js +9 -1
- package/bin/kreftjs.js +50 -12
- package/book/ar/16-virtual-list.md +131 -0
- package/book/ar/17-combobox.md +169 -0
- package/book/ar/18-tabs-offline.md +168 -0
- package/book/ar/19-wizard.md +196 -0
- package/book/ar/20-pipes-match-props.md +75 -0
- package/book/ar/README.md +12 -1
- package/book/kreft-book-ar.docx +0 -0
- package/docs/README.md +209 -13
- package/editors/sublime/Comments.tmPreferences +26 -0
- package/editors/sublime/Component.sublime-snippet +20 -0
- package/editors/sublime/Kreft.sublime-syntax +39 -0
- package/editors/sublime/LICENSE +95 -0
- package/editors/sublime/README.md +7 -0
- package/editors/sublime/action.sublime-snippet +10 -0
- package/editors/sublime/derive.sublime-snippet +8 -0
- package/editors/sublime/for.sublime-snippet +12 -0
- package/editors/sublime/k-case.sublime-snippet +8 -0
- package/editors/sublime/k-if.sublime-snippet +8 -0
- package/editors/sublime/k-match.sublime-snippet +12 -0
- package/editors/sublime/kreft-wait.sublime-snippet +12 -0
- package/editors/sublime/layout.sublime-snippet +8 -0
- package/editors/sublime/load.sublime-snippet +8 -0
- package/editors/sublime/on.sublime-snippet +10 -0
- package/editors/sublime/pipe.sublime-snippet +8 -0
- package/editors/sublime/prop-typed.sublime-snippet +8 -0
- package/editors/sublime/saved.sublime-snippet +8 -0
- package/editors/sublime/section.sublime-snippet +10 -0
- package/editors/sublime/state.sublime-snippet +8 -0
- package/editors/sublime/watch.sublime-snippet +10 -0
- package/editors/sublime/when.sublime-snippet +10 -0
- package/editors/sublime/yield.sublime-snippet +8 -0
- package/editors/vscode/snippets/kreftjs.json +143 -15
- package/editors/zed/LICENSE +95 -0
- package/editors/zed/README.md +17 -0
- package/editors/zed/extension.toml +11 -0
- package/editors/zed/languages/kreft/brackets.scm +4 -0
- package/editors/zed/languages/kreft/config.toml +17 -0
- package/editors/zed/languages/kreft/highlights.scm +18 -0
- package/editors/zed/languages/kreft/indents.scm +5 -0
- package/editors/zed/languages/kreft/injections.scm +14 -0
- package/editors/zed/languages/kreft/outline.scm +3 -0
- package/kreftjs.d.ts +1 -1
- package/kreftjs.js +219 -14
- package/package.json +3 -2
- package/server.d.ts +44 -0
- package/templates/app/backend/app/Auth/Session.js +8 -4
- package/templates/app/backend/app/Http/Middleware/Authenticate.js +7 -4
- package/templates/app/backend/database/seeders/DatabaseSeeder.js +4 -0
- package/templates/app/frontend/kreftjs.d.ts +1 -1
- package/templates/app/frontend/kreftjs.js +219 -14
package/kreftjs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Kreft v0.0.
|
|
2
|
+
* Kreft v0.0.22-beta — reactive by nature
|
|
3
3
|
* The .kreft component language (state · derive · action · when · watch · on),
|
|
4
4
|
* { } views with k-if / k-each / k-model, components, slots, router, stores,
|
|
5
5
|
* share / receive, event bus and a template engine.
|
|
@@ -1026,6 +1026,7 @@
|
|
|
1026
1026
|
onceDepth++;
|
|
1027
1027
|
try { return compile(el, scope, inst); } finally { onceDepth--; }
|
|
1028
1028
|
}
|
|
1029
|
+
if (el.hasAttribute('k-match')) return compileMatch(el, scope, inst); // becomes k-if / k-elif / k-else on its children
|
|
1029
1030
|
if (el.hasAttribute('k-if')) return compileIf(el, scope, inst); // k-if is checked before k-each
|
|
1030
1031
|
if (el.hasAttribute('k-elif') || el.hasAttribute('k-else')) {
|
|
1031
1032
|
warn('k-elif / k-else has no k-if right before it.', el);
|
|
@@ -1060,6 +1061,157 @@
|
|
|
1060
1061
|
compileElement(el, scope, inst, needAttrs, hasAnim, info);
|
|
1061
1062
|
}
|
|
1062
1063
|
|
|
1064
|
+
/* ---------- pipes: { price | currency:'EUR' | upper } ---------- */
|
|
1065
|
+
// positions of `want` at the top level of an expression: not inside (), [], {}, strings or regular expressions.
|
|
1066
|
+
// For '|' a lone bar only: `a || b` and `a |= b` are not pipes.
|
|
1067
|
+
function topLevel(s, want) {
|
|
1068
|
+
const hits = [];
|
|
1069
|
+
let depth = 0, prev = '';
|
|
1070
|
+
for (let i = 0; i < s.length; i++) {
|
|
1071
|
+
const c = s[i];
|
|
1072
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
1073
|
+
for (i++; i < s.length && s[i] !== c; i++) if (s[i] === '\\') i++;
|
|
1074
|
+
prev = c;
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
if (c === '/' && s[i + 1] !== '/' && s[i + 1] !== '*' && (prev === '' || '(,=:?!&|{[;+-*%<>~^'.includes(prev))) { // a regular expression
|
|
1078
|
+
let cls = false;
|
|
1079
|
+
for (i++; i < s.length; i++) {
|
|
1080
|
+
if (s[i] === '\\') i++;
|
|
1081
|
+
else if (s[i] === '[') cls = true;
|
|
1082
|
+
else if (s[i] === ']') cls = false;
|
|
1083
|
+
else if (s[i] === '/' && !cls) break;
|
|
1084
|
+
}
|
|
1085
|
+
prev = '/';
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
1089
|
+
else if (c === ')' || c === ']' || c === '}') depth--;
|
|
1090
|
+
else if (depth === 0 && c === want && (want !== '|' || (s[i + 1] !== '|' && s[i - 1] !== '|' && s[i + 1] !== '='))) hits.push(i);
|
|
1091
|
+
if (!/\s/.test(c)) prev = c;
|
|
1092
|
+
}
|
|
1093
|
+
return hits;
|
|
1094
|
+
}
|
|
1095
|
+
const cutAt = (s, cuts) => { const out = []; let from = 0; for (const i of cuts) { out.push(s.slice(from, i)); from = i + 1; } out.push(s.slice(from)); return out; };
|
|
1096
|
+
const pipeCache = new Map();
|
|
1097
|
+
// "price | currency:'EUR' | upper" → Kreftjs.__pipe("upper")(Kreftjs.__pipe("currency")((price), 'EUR'))
|
|
1098
|
+
// A pipe is a function in your component (a local function wins) or one added with Kreftjs.pipe(name, fn).
|
|
1099
|
+
function applyPipes(expr) {
|
|
1100
|
+
if (typeof expr !== 'string' || expr.indexOf('|') < 0) return expr;
|
|
1101
|
+
let r = pipeCache.get(expr);
|
|
1102
|
+
if (r !== undefined) return r;
|
|
1103
|
+
r = expr;
|
|
1104
|
+
const cuts = topLevel(expr, '|');
|
|
1105
|
+
if (cuts.length) {
|
|
1106
|
+
const segs = cutAt(expr, cuts);
|
|
1107
|
+
let code = '(' + segs[0].trim() + ')', ok = true;
|
|
1108
|
+
for (const seg of segs.slice(1)) {
|
|
1109
|
+
const m = /^\s*([A-Za-z_$][\w$]*)\s*([\s\S]*)$/.exec(seg);
|
|
1110
|
+
if (!m) { ok = false; break; }
|
|
1111
|
+
const name = m[1];
|
|
1112
|
+
const rest = m[2].trim();
|
|
1113
|
+
let args = '';
|
|
1114
|
+
if (rest[0] === ':') args = cutAt(rest.slice(1), topLevel(rest.slice(1), ':')).map((a) => a.trim()).join(', ');
|
|
1115
|
+
else if (rest[0] === '(' && rest[rest.length - 1] === ')') args = rest.slice(1, -1).trim();
|
|
1116
|
+
else if (rest) { ok = false; break; }
|
|
1117
|
+
code = `Kreftjs.__pipe(${JSON.stringify(name)}, typeof ${name} === "function" ? ${name} : null)(${code}${args ? ', ' + args : ''})`;
|
|
1118
|
+
}
|
|
1119
|
+
if (ok) r = code;
|
|
1120
|
+
}
|
|
1121
|
+
if (pipeCache.size > 5000) pipeCache.clear();
|
|
1122
|
+
pipeCache.set(expr, r);
|
|
1123
|
+
return r;
|
|
1124
|
+
}
|
|
1125
|
+
const pipes = Object.create(null);
|
|
1126
|
+
const asText = (v) => (v == null ? '' : String(v));
|
|
1127
|
+
// "2026-11-05 15:39:58" (a date from the Kreft database) has no time zone: the database stores UTC, so read it as UTC
|
|
1128
|
+
const toDate = (v) => (v instanceof Date ? v : typeof v === 'string' && /^\d{4}-\d\d-\d\d \d\d:\d\d(:\d\d)?$/.test(v) ? new Date(v.replace(' ', 'T') + 'Z') : new Date(v));
|
|
1129
|
+
Object.assign(pipes, {
|
|
1130
|
+
upper: (v) => asText(v).toUpperCase(),
|
|
1131
|
+
lower: (v) => asText(v).toLowerCase(),
|
|
1132
|
+
capitalize: (v) => { const t = asText(v); return t.charAt(0).toUpperCase() + t.slice(1); },
|
|
1133
|
+
trim: (v) => asText(v).trim(),
|
|
1134
|
+
truncate: (v, n = 50, tail = '…') => { const t = asText(v); return t.length > n ? t.slice(0, Math.max(0, n)).trimEnd() + tail : t; },
|
|
1135
|
+
currency: (v, code = 'USD') => (v == null || v === '' ? '' : formatCurrency(Number(v), code)),
|
|
1136
|
+
number: (v, digits) => (v == null || v === '' ? '' : formatNumber(Number(v), digits && typeof digits === 'object' ? digits : digits == null ? undefined : { maximumFractionDigits: digits, minimumFractionDigits: digits })),
|
|
1137
|
+
percent: (v, digits = 0) => (v == null || v === '' ? '' : formatNumber(Number(v), { style: 'percent', maximumFractionDigits: digits, minimumFractionDigits: digits })),
|
|
1138
|
+
date: (v, style = 'medium') => (v == null || v === '' ? '' : formatDate(toDate(v), typeof style === 'object' ? style : { dateStyle: style })),
|
|
1139
|
+
time: (v, style = 'short') => (v == null || v === '' ? '' : formatDate(toDate(v), typeof style === 'object' ? style : { timeStyle: style })),
|
|
1140
|
+
ago: (v) => {
|
|
1141
|
+
if (v == null || v === '') return '';
|
|
1142
|
+
const secs = Math.round((toDate(v).getTime() - Date.now()) / 1000);
|
|
1143
|
+
if (isNaN(secs)) return '';
|
|
1144
|
+
const rtf = new Intl.RelativeTimeFormat(currentLocaleRef.value, { numeric: 'auto' });
|
|
1145
|
+
const steps = [['year', 31536000], ['month', 2592000], ['day', 86400], ['hour', 3600], ['minute', 60]];
|
|
1146
|
+
for (const [unit, size] of steps) if (Math.abs(secs) >= size) return rtf.format(Math.round(secs / size), unit);
|
|
1147
|
+
return rtf.format(secs, 'second');
|
|
1148
|
+
},
|
|
1149
|
+
json: (v, indent = 2) => { try { return JSON.stringify(v, null, indent); } catch (_) { return String(v); } },
|
|
1150
|
+
fallback: (v, d = '') => (v == null || v === '' ? d : v),
|
|
1151
|
+
join: (v, sep = ', ') => (isArray(v) ? v.join(sep) : asText(v)),
|
|
1152
|
+
first: (v, n) => (n == null ? (v == null ? v : v[0]) : (v || []).slice(0, n)),
|
|
1153
|
+
last: (v, n) => (n == null ? (v == null ? v : v[v.length - 1]) : (v || []).slice(-n)),
|
|
1154
|
+
slice: (v, a, b) => (v == null ? v : v.slice(a, b)),
|
|
1155
|
+
reverse: (v) => (isArray(v) ? [...v].reverse() : [...asText(v)].reverse().join('')),
|
|
1156
|
+
sortBy: (v, key, dir = 'asc') => (isArray(v) ? [...v].sort((x, y) => { const a = key ? x[key] : x, b = key ? y[key] : y; return (a > b ? 1 : a < b ? -1 : 0) * (dir === 'desc' ? -1 : 1); }) : v),
|
|
1157
|
+
count: (v) => (v == null ? 0 : typeof v === 'object' && !isArray(v) ? Object.keys(v).length : v.length || 0),
|
|
1158
|
+
bytes: (v) => {
|
|
1159
|
+
let n = Number(v);
|
|
1160
|
+
if (!isFinite(n)) return '';
|
|
1161
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
1162
|
+
let u = 0;
|
|
1163
|
+
while (Math.abs(n) >= 1024 && u < units.length - 1) { n /= 1024; u++; }
|
|
1164
|
+
return (u ? n.toFixed(n < 10 ? 1 : 0) : n) + ' ' + units[u];
|
|
1165
|
+
},
|
|
1166
|
+
});
|
|
1167
|
+
const definePipe = (name, fn) => { if (!isFunction(fn)) throw new Error(`Kreftjs.pipe("${name}", fn): fn must be a function.`); pipes[name] = fn; };
|
|
1168
|
+
function lookupPipe(name, local) {
|
|
1169
|
+
const f = local || pipes[name];
|
|
1170
|
+
if (f) return f;
|
|
1171
|
+
warn(`Unknown pipe "${name}". Use a function of your own named ${name}, or Kreftjs.pipe('${name}', fn).`);
|
|
1172
|
+
return (v) => v;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/* ---------- prop types: prop size: 'sm' | 'lg' = 'sm' ---------- */
|
|
1176
|
+
// TypeScript type text → (value) => boolean, or null when it can't be checked (your own type names, any)
|
|
1177
|
+
function typeCheck(text) {
|
|
1178
|
+
const members = cutAt(String(text), topLevel(String(text), '|')).map((t) => t.trim()).filter(Boolean);
|
|
1179
|
+
if (!members.length) return null;
|
|
1180
|
+
const checks = [];
|
|
1181
|
+
for (const m of members) {
|
|
1182
|
+
let c;
|
|
1183
|
+
if (m === 'string') c = (v) => typeof v === 'string';
|
|
1184
|
+
else if (m === 'number') c = (v) => typeof v === 'number' && !isNaN(v);
|
|
1185
|
+
else if (m === 'boolean') c = (v) => typeof v === 'boolean';
|
|
1186
|
+
else if (m === 'null') c = (v) => v === null;
|
|
1187
|
+
else if (m === 'undefined' || m === 'void') c = (v) => v === undefined;
|
|
1188
|
+
else if (m === 'true' || m === 'false') c = (v) => v === (m === 'true');
|
|
1189
|
+
else if (m === 'object') c = (v) => v !== null && typeof v === 'object';
|
|
1190
|
+
else if (m === 'Date') c = (v) => v instanceof Date;
|
|
1191
|
+
else if (m === 'Function' || /=>/.test(m)) c = isFunction;
|
|
1192
|
+
else if (/\[\]$/.test(m) || /^(?:readonly\s+)?Array\s*</.test(m) || /^\[/.test(m)) c = isArray;
|
|
1193
|
+
else if (/^(?:Record|Partial|Map|Set)\s*</.test(m) || m[0] === '{') c = (v) => v !== null && typeof v === 'object';
|
|
1194
|
+
else if (/^(['"`])[\s\S]*\1$/.test(m)) { const lit = m.slice(1, -1); c = (v) => v === lit; }
|
|
1195
|
+
else if (/^-?\d+(?:\.\d+)?$/.test(m)) { const n = Number(m); c = (v) => v === n; }
|
|
1196
|
+
else return null; // any, unknown, User, … : not checked
|
|
1197
|
+
checks.push(c);
|
|
1198
|
+
}
|
|
1199
|
+
return (v) => checks.some((c) => c(v));
|
|
1200
|
+
}
|
|
1201
|
+
const simpleType = (t) => ({ string: String, number: Number, boolean: Boolean }[String(t).trim()]);
|
|
1202
|
+
const describeValue = (v) => (v === null ? 'null' : isArray(v) ? 'an array' : typeof v === 'string' ? `"${v.length > 20 ? v.slice(0, 20) + '…' : v}"` : typeof v === 'object' ? 'an object' : typeof v + (typeof v === 'number' || typeof v === 'boolean' ? ' ' + v : ''));
|
|
1203
|
+
function checkProp(comp, k, opt, v) {
|
|
1204
|
+
if (opt.check && v !== undefined && !opt.check(v)) warn(`<${comp}> prop "${k}" should be ${opt.typeText}, but got ${describeValue(v)}.`);
|
|
1205
|
+
}
|
|
1206
|
+
function typedProp(opt, info) {
|
|
1207
|
+
opt.typeText = info.type;
|
|
1208
|
+
const chk = typeCheck(info.type);
|
|
1209
|
+
if (chk) opt.check = chk;
|
|
1210
|
+
if (info.required) opt.required = true;
|
|
1211
|
+
if (!opt.type) { const t = simpleType(info.type); if (t) opt.type = t; }
|
|
1212
|
+
return opt;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1063
1215
|
/* ---------- { expr } interpolation ---------- */
|
|
1064
1216
|
// "Hi {name}!" → [{ text: 'Hi ' }, { expr: 'name' }, { text: '!' }]; write \{ for a literal brace
|
|
1065
1217
|
const interpCache = new Map();
|
|
@@ -1087,7 +1239,7 @@
|
|
|
1087
1239
|
if (!expr) { buf += str.slice(i, end); i = end; continue; }
|
|
1088
1240
|
if (buf) parts.push({ text: buf });
|
|
1089
1241
|
buf = '';
|
|
1090
|
-
parts.push({ expr });
|
|
1242
|
+
parts.push({ expr: applyPipes(expr) });
|
|
1091
1243
|
i = end;
|
|
1092
1244
|
continue;
|
|
1093
1245
|
}
|
|
@@ -1108,7 +1260,7 @@
|
|
|
1108
1260
|
function exactBraceRaw(v) {
|
|
1109
1261
|
const t = (v || '').trim();
|
|
1110
1262
|
if (t[0] !== '{') return null;
|
|
1111
|
-
try { return matchBrace(t, 0) === t.length ? t.slice(1, -1).trim() : null; } catch (_) { return null; }
|
|
1263
|
+
try { return matchBrace(t, 0) === t.length ? applyPipes(t.slice(1, -1).trim()) : null; } catch (_) { return null; }
|
|
1112
1264
|
}
|
|
1113
1265
|
const stripBrace = (v) => { const e = exactBrace(v); return e != null ? e : v; };
|
|
1114
1266
|
// "btn {active ? 'on' : ''}" → "`btn ${…}`"; null when there is nothing to interpolate
|
|
@@ -1791,6 +1943,41 @@
|
|
|
1791
1943
|
};
|
|
1792
1944
|
}
|
|
1793
1945
|
|
|
1946
|
+
/* ---------- k-match / k-case / k-default ---------- */
|
|
1947
|
+
// <div k-match="status">
|
|
1948
|
+
// <b k-case="'ok'">Fine</b>
|
|
1949
|
+
// <i k-case="'wait', 'queued'">Waiting</i> several values: one of them
|
|
1950
|
+
// <u k-default>Unknown</u>
|
|
1951
|
+
// </div>
|
|
1952
|
+
// The children turn into k-if / k-elif / k-else, so animations, components and templates work as they do there.
|
|
1953
|
+
function caseCond(subject, raw) {
|
|
1954
|
+
const list = cutAt(raw, topLevel(raw, ',')).map((v) => v.trim()).filter(Boolean);
|
|
1955
|
+
return list.length > 1 ? `[${list.join(', ')}].includes(${subject})` : `(${subject}) === (${list[0] || 'undefined'})`;
|
|
1956
|
+
}
|
|
1957
|
+
function compileMatch(el, scope, inst) {
|
|
1958
|
+
const subject = stripBrace(el.getAttribute('k-match'));
|
|
1959
|
+
el.removeAttribute('k-match');
|
|
1960
|
+
let started = false, fallback = null;
|
|
1961
|
+
for (const child of [...el.children]) {
|
|
1962
|
+
if (child.hasAttribute('k-case')) {
|
|
1963
|
+
const raw = stripBrace(child.getAttribute('k-case'));
|
|
1964
|
+
child.removeAttribute('k-case');
|
|
1965
|
+
child.setAttribute(started ? 'k-elif' : 'k-if', caseCond(subject, raw));
|
|
1966
|
+
started = true;
|
|
1967
|
+
} else if (child.hasAttribute('k-default')) {
|
|
1968
|
+
child.removeAttribute('k-default');
|
|
1969
|
+
if (fallback) warn('k-match has more than one k-default; only the first is used.', child);
|
|
1970
|
+
else fallback = child;
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
if (fallback) {
|
|
1974
|
+
if (fallback.nextElementSibling) warn('k-default belongs last in k-match.', fallback);
|
|
1975
|
+
if (started) { fallback.setAttribute('k-else', ''); el.appendChild(fallback); } // right after the last case
|
|
1976
|
+
else fallback.setAttribute('k-if', 'true');
|
|
1977
|
+
}
|
|
1978
|
+
return compile(el, scope, inst);
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1794
1981
|
/* ---------- k-if / k-elif / k-else ---------- */
|
|
1795
1982
|
function compileIf(el, scope, inst) {
|
|
1796
1983
|
const anchor = text();
|
|
@@ -2811,12 +2998,13 @@
|
|
|
2811
2998
|
if (hasType(opt, Number) && v !== '' && !isNaN(v)) v = Number(v);
|
|
2812
2999
|
} else if (hasOwn(extra, k)) { v = extra[k]; present = true; }
|
|
2813
3000
|
props[k] = resolvePropValue(opt, v, present);
|
|
3001
|
+
checkProp(def.__name || def.name || 'component', k, opt, props[k]);
|
|
2814
3002
|
if (opt.required && !present) warn(`Missing required prop: "${k}"`);
|
|
2815
3003
|
}
|
|
2816
3004
|
for (const k in extra) if (!hasOwn(propsOptions, k)) attrs[k] = extra[k];
|
|
2817
3005
|
dynamicProps.forEach(([k, expr]) => {
|
|
2818
3006
|
if (!hasOwn(propsOptions, k)) return void dynamicAttrs.push([k, expr]);
|
|
2819
|
-
renderEffect(() => { props[k] = resolvePropValue(propsOptions[k], evaluate(parentScope, expr, el), true); });
|
|
3007
|
+
renderEffect(() => { props[k] = resolvePropValue(propsOptions[k], evaluate(parentScope, expr, el), true); checkProp(def.__name || def.name || 'component', k, propsOptions[k], props[k]); });
|
|
2820
3008
|
});
|
|
2821
3009
|
dynamicAttrs.forEach(([k, expr]) => renderEffect(() => { attrs[k] = evaluate(parentScope, expr, el); }));
|
|
2822
3010
|
if (options.liveProps) renderEffect(() => { // props that follow the route (<kreft-view>)
|
|
@@ -3993,7 +4181,17 @@
|
|
|
3993
4181
|
try { return parseKreftScriptInner(src, url); } finally { scanScript = false; }
|
|
3994
4182
|
}
|
|
3995
4183
|
function parseKreftScriptInner(src, url) {
|
|
3996
|
-
const out = [], imports = [], props = {}, events = [];
|
|
4184
|
+
const out = [], imports = [], props = {}, events = [], propTypes = {};
|
|
4185
|
+
// `prop size: 'sm' | 'lg' = 'sm'` keeps its type, to warn when a parent sends something else
|
|
4186
|
+
const notePropType = (lhs, hasDefault) => {
|
|
4187
|
+
const c = lhs.indexOf(':');
|
|
4188
|
+
if (c < 0) return;
|
|
4189
|
+
const raw = lhs.slice(0, c).trim();
|
|
4190
|
+
const name = raw.replace(/\?$/, '').trim();
|
|
4191
|
+
const type = lhs.slice(c + 1).trim();
|
|
4192
|
+
if (!type || !IDENT.test(name)) return;
|
|
4193
|
+
propTypes[name] = { type, required: !hasDefault && !/\?$/.test(raw) && !/(?:^|[|\s])(?:null|undefined|any|unknown)(?:[|\s]|$)/.test(type) };
|
|
4194
|
+
};
|
|
3997
4195
|
const kw = /(prop|state|derive|derived|action|when|watch|on|use|share|receive|event|import|saved|load|layout|interface|type)(?![\w$])/y;
|
|
3998
4196
|
let layout;
|
|
3999
4197
|
// `saved` and `load` are keywords when followed by `name =` or `name: Type =`, so `load(…)` stays a normal call
|
|
@@ -4089,6 +4287,7 @@
|
|
|
4089
4287
|
if (word === 'prop' && eq > 0) {
|
|
4090
4288
|
const { name, value } = nameValue(j, end, 'prop');
|
|
4091
4289
|
props[name] = value;
|
|
4290
|
+
notePropType(text.slice(0, eq), true);
|
|
4092
4291
|
} else if (word === 'use' && eq > 0) { // use mouse = trackMouse() or use mouse: MousePos = trackMouse()
|
|
4093
4292
|
const { name, value } = nameValue(j, end, 'use');
|
|
4094
4293
|
out.push(`$h.use(${JSON.stringify(name)}, () => (${value}));`);
|
|
@@ -4097,7 +4296,7 @@
|
|
|
4097
4296
|
const colon = text.indexOf(':');
|
|
4098
4297
|
const name = text.slice(0, colon).replace(/\?\s*$/, '').trim();
|
|
4099
4298
|
if (!IDENT.test(name) && word !== 'event') fail(j, `Invalid name "${name}"`);
|
|
4100
|
-
if (word === 'prop') props[name] = null;
|
|
4299
|
+
if (word === 'prop') { props[name] = null; notePropType(text, false); }
|
|
4101
4300
|
else events.push(name);
|
|
4102
4301
|
} else {
|
|
4103
4302
|
text.split(',').map((s) => s.trim()).filter(Boolean).forEach((rawN) => {
|
|
@@ -4194,7 +4393,7 @@
|
|
|
4194
4393
|
fail(i, e.message);
|
|
4195
4394
|
}
|
|
4196
4395
|
}
|
|
4197
|
-
return { code: out.join('\n'), imports, props, events, layout };
|
|
4396
|
+
return { code: out.join('\n'), imports, props, propTypes, events, layout };
|
|
4198
4397
|
}
|
|
4199
4398
|
|
|
4200
4399
|
// a CSS file imported by a .kreft file: one <link> for the whole page, before the components' own styles
|
|
@@ -4451,6 +4650,7 @@
|
|
|
4451
4650
|
code: parsed.code,
|
|
4452
4651
|
imports: parsed.imports,
|
|
4453
4652
|
props: parsed.props, // name → default expression (or null)
|
|
4653
|
+
propTypes: parsed.propTypes, // name → { type, required } from `prop name: Type`
|
|
4454
4654
|
events: parsed.events,
|
|
4455
4655
|
layout: parsed.layout, // undefined: not said; false: none; or a name / path
|
|
4456
4656
|
styles: styles.map((m) => ({ css: m[2], scoped: /\bscoped\b/.test(m[1] || '') })),
|
|
@@ -4483,11 +4683,14 @@
|
|
|
4483
4683
|
const props = {};
|
|
4484
4684
|
for (const p in sp.props) {
|
|
4485
4685
|
const expr = sp.props[p];
|
|
4486
|
-
if (expr == null)
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4686
|
+
if (expr == null) props[p] = {};
|
|
4687
|
+
else {
|
|
4688
|
+
const make = new Function(`return (${expr})`);
|
|
4689
|
+
let sample;
|
|
4690
|
+
try { sample = make(); } catch (_) { /* default uses something only known later */ }
|
|
4691
|
+
props[p] = isFunction(sample) ? { type: Function, default: sample } : { type: propType(sample), default: make };
|
|
4692
|
+
}
|
|
4693
|
+
if (sp.propTypes && sp.propTypes[p]) typedProp(props[p], sp.propTypes[p]);
|
|
4491
4694
|
}
|
|
4492
4695
|
|
|
4493
4696
|
let scopeId = null;
|
|
@@ -5398,7 +5601,7 @@
|
|
|
5398
5601
|
stores, storeLog, busLog,
|
|
5399
5602
|
};
|
|
5400
5603
|
|
|
5401
|
-
const VERSION = '0.0.
|
|
5604
|
+
const VERSION = '0.0.22-beta';
|
|
5402
5605
|
const Kreftjs = {
|
|
5403
5606
|
version: VERSION,
|
|
5404
5607
|
// PUBLIC_ values from .env (kreft dev and kreft build put them here); never secrets
|
|
@@ -5450,6 +5653,8 @@
|
|
|
5450
5653
|
choice: choiceTranslation, transChoice: choiceTranslation, trans_choice: choiceTranslation,
|
|
5451
5654
|
hasTranslation,
|
|
5452
5655
|
formatNumber, formatCurrency, formatDate,
|
|
5656
|
+
// pipes: { price | currency }; add yours with Kreftjs.pipe('name', fn)
|
|
5657
|
+
pipe: definePipe, pipes, __pipe: lookupPipe, __typeCheck: typeCheck,
|
|
5453
5658
|
// reacting to changes
|
|
5454
5659
|
watch, effect: watchEffect, tick: nextTick, onCleanup: onScopeDispose,
|
|
5455
5660
|
// sharing data
|
|
@@ -5464,7 +5669,7 @@
|
|
|
5464
5669
|
|
|
5465
5670
|
// the text-only helpers the build tool reuses, so built code matches the browser exactly
|
|
5466
5671
|
Object.defineProperty(Kreftjs, '__internal', {
|
|
5467
|
-
value: { expandBlocks, prepareTemplate, splitKreft, splitInterp, exactBrace, stripBrace, interpToExpr, quoteBraceAttrs, expandSelfClosing, scopeCss, hashString, propType, simplePathRE, fnExpRE, forAliasRE, makeKreftDef, compileFn, evaluate, stripTypeScript, stripTsParams },
|
|
5672
|
+
value: { expandBlocks, prepareTemplate, splitKreft, splitInterp, exactBrace, stripBrace, interpToExpr, quoteBraceAttrs, expandSelfClosing, scopeCss, hashString, propType, typeCheck, caseCond, simplePathRE, fnExpRE, forAliasRE, makeKreftDef, compileFn, evaluate, stripTypeScript, stripTsParams },
|
|
5468
5673
|
});
|
|
5469
5674
|
|
|
5470
5675
|
if (typeof module !== 'undefined' && module.exports) module.exports = Kreftjs;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kreftjs",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.22-beta",
|
|
4
4
|
"description": "A tiny reactive framework with its own component language (.kreft files): router with nested routes, stores, saved data, animations, an API client, hot reload, DevTools and a template engine.",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"main": "kreftjs.js",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"start": "node bin/kreftjs.js dev --api demo/db.json --db sqlite:demo/database/demo.db --migrations demo/database/migrations --server demo/server --backend demo --seed",
|
|
15
15
|
"build": "node bin/kreftjs.js build demo --out dist/kreftjs-com --base / --include docs",
|
|
16
16
|
"preview": "node bin/kreftjs.js dev dist --api demo/db.json --db sqlite:demo/database/demo.db --migrations demo/database/migrations --server demo/server --backend demo --seed",
|
|
17
|
-
"test": "node --test test/security.test.js test/prerender.test.js test/seo.test.js test/rebrand.test.js test/noauth.test.js test/winspawn.test.js test/htaccess.test.js test/cookie-secure.test.js test/password-rule.test.js test/headline.test.js test/menu.test.js test/electron-all.test.js test/build-minify.test.js",
|
|
17
|
+
"test": "node --test test/security.test.js test/prerender.test.js test/seo.test.js test/rebrand.test.js test/noauth.test.js test/winspawn.test.js test/htaccess.test.js test/cookie-secure.test.js test/password-rule.test.js test/headline.test.js test/menu.test.js test/electron-all.test.js test/build-minify.test.js test/stream.test.js test/syntax.test.js test/memory.test.js test/shield.test.js test/mcp-client.test.js test/dbapi-secrets.test.js test/auth-csrf.test.js test/not-a-project.test.js",
|
|
18
18
|
"electron": "kreftjs electron",
|
|
19
19
|
"electron:dev": "kreftjs electron:dev",
|
|
20
20
|
"electron:build": "kreftjs electron:build"
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"docs/",
|
|
32
32
|
"book/",
|
|
33
33
|
"assets/logo.png",
|
|
34
|
+
"!book/~$*",
|
|
34
35
|
"README.md"
|
|
35
36
|
],
|
|
36
37
|
"keywords": [
|
package/server.d.ts
CHANGED
|
@@ -79,6 +79,8 @@ export declare function response(body?: any, status?: number, headers?: Record<s
|
|
|
79
79
|
export declare namespace response {
|
|
80
80
|
function json(body: any, status?: number, headers?: Record<string, string>): Response
|
|
81
81
|
function noContent(): Response
|
|
82
|
+
/** send a file with byte ranges (206), so video and audio can seek: return response.stream(path, { type?, download?, headers? }) */
|
|
83
|
+
function stream(file: string, options?: { type?: string; download?: boolean; headers?: Record<string, string> }): Response
|
|
82
84
|
}
|
|
83
85
|
export declare function noContent(): Response
|
|
84
86
|
export declare function abort(status: number, message?: string): never
|
|
@@ -971,6 +973,23 @@ export declare function namespace(prefix: string): Record<string, any>
|
|
|
971
973
|
export declare function startAutoGarbageCollector(options?: { thresholdMB?: number; intervalMs?: number; log?: boolean }): void
|
|
972
974
|
export declare function stopAutoGarbageCollector(): void
|
|
973
975
|
export declare function getMemoryStats(): { rssMB: number; heapTotalMB: number; heapUsedMB: number; externalMB: number; gcExposed: boolean }
|
|
976
|
+
export interface MemoryAlert { type: 'memory-high' | 'memory-leak'; message: string; at: string; heapUsedMB: number; rssMB: number; [k: string]: any }
|
|
977
|
+
export interface MemoryReport {
|
|
978
|
+
heapUsedMB: number; heapTotalMB: number; rssMB: number; externalMB: number; gcExposed: boolean
|
|
979
|
+
running: boolean; startedAt: string | null; checks: number; cleanups: number; gcRuns: number; v8Collections: number
|
|
980
|
+
reclaimedMB: number; evictedEntries: number; trendMBPerMin: number | null; overStreak: number
|
|
981
|
+
leak: { suspected: boolean; riseMB: number; floorsMB: number[] }
|
|
982
|
+
alerts: MemoryAlert[]; history: Array<{ t: number; heapUsedMB: number; rssMB: number }>; config: any
|
|
983
|
+
}
|
|
984
|
+
/** Everything a dashboard or alert needs, as plain JSON */
|
|
985
|
+
export declare function getMemoryReport(): MemoryReport
|
|
986
|
+
/** Register a cache cleaner: fn(fraction) drops that share of its least-used entries and returns how many went */
|
|
987
|
+
export declare function onMemoryPressure(name: string, fn: (fraction: number) => number | void): () => void
|
|
988
|
+
/** Called when memory stays high after cleaning, or a leak is suspected */
|
|
989
|
+
export declare function onMemoryAlert(fn: (alert: MemoryAlert) => void): () => void
|
|
990
|
+
/** Writes a V8 heap snapshot (open in Chrome DevTools > Memory); returns the file path */
|
|
991
|
+
export declare function writeHeapSnapshot(file?: string): string
|
|
992
|
+
export declare function checkMemory(): { action: string; [k: string]: any }
|
|
974
993
|
|
|
975
994
|
/** Native Model Context Protocol (MCP) Server */
|
|
976
995
|
export declare class McpServer {
|
|
@@ -982,6 +1001,31 @@ export declare class McpServer {
|
|
|
982
1001
|
}
|
|
983
1002
|
export declare function getMcpServer(options?: { baseDir?: string }): McpServer
|
|
984
1003
|
|
|
1004
|
+
/** Kreft Shield: SHIELD=true in .env limits and bans abusive addresses */
|
|
1005
|
+
export interface ShieldBan { ip: string; until: string; level: number; secondsLeft: number }
|
|
1006
|
+
export declare const Shield: {
|
|
1007
|
+
enabled(): boolean
|
|
1008
|
+
bans(): ShieldBan[]
|
|
1009
|
+
ban(ip: string, minutes?: number, reason?: string): { ip: string; minutes: number; reason: string; until: string; level: number }
|
|
1010
|
+
unban(ip: string): boolean
|
|
1011
|
+
status(): { enabled: boolean; tracked: number; banned: number; blocked: number; limited: number; bans: number; eventLoopLagMs: number; overloaded: boolean; limits: Record<string, number> }
|
|
1012
|
+
on(event: 'ban', fn: (info: { ip: string; minutes: number; reason: string; until: string; level: number }) => void): typeof Shield
|
|
1013
|
+
off(event: 'ban', fn: (...a: any[]) => void): typeof Shield
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** MCP client (Streamable HTTP): call tools on a remote MCP server */
|
|
1017
|
+
export declare class McpClient {
|
|
1018
|
+
constructor(url: string, options?: { headers?: Record<string, string>; timeout?: number })
|
|
1019
|
+
server: any
|
|
1020
|
+
connect(): Promise<this>
|
|
1021
|
+
tools(): Promise<Array<{ name: string; description?: string; inputSchema?: any }>>
|
|
1022
|
+
call(name: string, args?: Record<string, any>): Promise<any>
|
|
1023
|
+
resources(): Promise<any[]>
|
|
1024
|
+
readResource(uri: string): Promise<any>
|
|
1025
|
+
close(): Promise<void>
|
|
1026
|
+
}
|
|
1027
|
+
export declare const Mcp: { connect(url: string, options?: { headers?: Record<string, string>; timeout?: number }): Promise<McpClient> }
|
|
1028
|
+
|
|
985
1029
|
export interface CleanHtmlOptions {
|
|
986
1030
|
allowedTags?: string[]
|
|
987
1031
|
allowedAttributes?: Record<string, string[]>
|
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
//
|
|
7
7
|
// An app or script that sends `device_name` when it logs in gets an API token in the answer instead:
|
|
8
8
|
// Authorization: Bearer 5|k3J9…
|
|
9
|
-
const { response, Str, Crypt, cookieSecure } = require('kreftjs/server')
|
|
9
|
+
const { response, Str, Crypt, Hash, cookieSecure } = require('kreftjs/server')
|
|
10
10
|
const PersonalAccessToken = require('../Models/PersonalAccessToken')
|
|
11
11
|
|
|
12
12
|
const SESSION = 'kreft_session'
|
|
13
13
|
const XSRF = 'XSRF-TOKEN'
|
|
14
|
+
// The XSRF-TOKEN cookie is not a random value anyone could plant: it is derived from this login's secret token, so a
|
|
15
|
+
// page on a sibling subdomain (or a script that can only set cookies) cannot make one that matches. Only a page that
|
|
16
|
+
// can read this browser's cookie, i.e. your own, can send it back in X-XSRF-TOKEN.
|
|
17
|
+
const xsrfFor = (sessionToken) => Hash.sha256('xsrf|' + sessionToken)
|
|
14
18
|
const days = () => Number(process.env.AUTH_TOKEN_DAYS || 30)
|
|
15
19
|
|
|
16
20
|
const resolveSecure = () => typeof cookieSecure === 'function' ? cookieSecure() : (process.env.SESSION_SECURE_COOKIE ? process.env.SESSION_SECURE_COOKIE === 'true' : (process.env.APP_ENV === 'production' || /^https:/i.test(process.env.APP_URL || '')))
|
|
@@ -57,7 +61,7 @@ async function signIn(req, user, { remember = false, status = 200 } = {}) {
|
|
|
57
61
|
path: '/',
|
|
58
62
|
maxAge: remember ? days() * 86400 : undefined,
|
|
59
63
|
})
|
|
60
|
-
res.cookie(XSRF,
|
|
64
|
+
res.cookie(XSRF, xsrfFor(plainTextToken), {
|
|
61
65
|
httpOnly: false,
|
|
62
66
|
secure: isSecure,
|
|
63
67
|
sameSite,
|
|
@@ -99,7 +103,7 @@ async function regenerate(req, res, user = null) {
|
|
|
99
103
|
path: '/',
|
|
100
104
|
maxAge: remember ? days() * 86400 : undefined,
|
|
101
105
|
})
|
|
102
|
-
res.cookie(XSRF,
|
|
106
|
+
res.cookie(XSRF, xsrfFor(plainTextToken), {
|
|
103
107
|
httpOnly: false,
|
|
104
108
|
secure: isSecure,
|
|
105
109
|
sameSite,
|
|
@@ -174,4 +178,4 @@ function browserName(ua) {
|
|
|
174
178
|
return os ? `${b} on ${os}` : b
|
|
175
179
|
}
|
|
176
180
|
|
|
177
|
-
module.exports = { signIn, signOut, regenerate, pendingTwoFactor, readPendingTwoFactor, recordTwoFactorFailure, consumeTwoFactorTicket, browserName, SESSION, XSRF }
|
|
181
|
+
module.exports = { xsrfFor, signIn, signOut, regenerate, pendingTwoFactor, readPendingTwoFactor, recordTwoFactorFailure, consumeTwoFactorTicket, browserName, SESSION, XSRF }
|
|
@@ -5,7 +5,7 @@ const { abort, Hash, Str, Crypt, response, cookieSecure } = require('kreftjs/ser
|
|
|
5
5
|
const crypto = require('crypto')
|
|
6
6
|
const PersonalAccessToken = require('../../Models/PersonalAccessToken')
|
|
7
7
|
const User = require('../../Models/User')
|
|
8
|
-
const { SESSION, XSRF } = require('../../Auth/Session')
|
|
8
|
+
const { SESSION, XSRF, xsrfFor } = require('../../Auth/Session')
|
|
9
9
|
|
|
10
10
|
const SAFE = ['GET', 'HEAD', 'OPTIONS']
|
|
11
11
|
const NEEDS = { GET: 'read', HEAD: 'read', OPTIONS: 'read', POST: 'create', PUT: 'update', PATCH: 'update', DELETE: 'delete' }
|
|
@@ -41,8 +41,11 @@ class Authenticate {
|
|
|
41
41
|
abort(401, 'Unauthenticated.', extra)
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
// a cookie login must prove the page is ours for every change: X-XSRF-TOKEN
|
|
45
|
-
|
|
44
|
+
// a cookie login must prove the page is ours for every change: X-XSRF-TOKEN must be the value made from this login's
|
|
45
|
+
// secret (Session.xsrfFor), which the XSRF-TOKEN cookie carries. A planted cookie is not enough, as it would be if
|
|
46
|
+
// the header only had to equal the cookie.
|
|
47
|
+
const expectedXsrf = fromCookie ? xsrfFor(sessionPayload) : null
|
|
48
|
+
if (fromCookie && !SAFE.includes(req.method) && !same(req.header('x-xsrf-token'), expectedXsrf)) {
|
|
46
49
|
abort(419, 'CSRF token mismatch. Reload the page and try again.')
|
|
47
50
|
}
|
|
48
51
|
|
|
@@ -76,7 +79,7 @@ class Authenticate {
|
|
|
76
79
|
req.accessToken = token
|
|
77
80
|
const res = await next(req)
|
|
78
81
|
// a cookie login without the XSRF cookie (cleared, or an old browser): give it a new one
|
|
79
|
-
if (fromCookie &&
|
|
82
|
+
if (fromCookie && req.cookie(XSRF) !== expectedXsrf) res.cookie(XSRF, expectedXsrf, { httpOnly: false, secure: cookieSecure(), sameSite: process.env.SESSION_SAME_SITE || 'Lax', path: '/', maxAge: token.remember ? days * 86400 : undefined })
|
|
80
83
|
return res
|
|
81
84
|
}
|
|
82
85
|
}
|
|
@@ -5,6 +5,10 @@ const User = require('../../app/Models/User')
|
|
|
5
5
|
// that user is an admin too, so /admin opens for it
|
|
6
6
|
class DatabaseSeeder extends Seeder {
|
|
7
7
|
async run() {
|
|
8
|
+
// an admin whose password is "password" must never exist on a live site: this seeder is for development only
|
|
9
|
+
if (process.env.APP_ENV === 'production' || process.env.NODE_ENV === 'production') {
|
|
10
|
+
throw new Error('DatabaseSeeder creates an admin with the password "password" and refuses to run in production. Seed real data instead.')
|
|
11
|
+
}
|
|
8
12
|
const user = await User.create({ name: 'Test User', email: 'test@example.com', password: Hash.make('password') })
|
|
9
13
|
user.email_verified_at = new Date()
|
|
10
14
|
user.is_admin = true // never fillable: only set like this, by your own code
|