kreftjs 0.0.20-beta → 0.0.21-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/backend/http.js +6 -2
- package/bin/backend/index.js +4 -0
- package/bin/backend/stream.js +74 -0
- package/bin/build.js +12 -5
- package/bin/kreftjs.js +35 -11
- 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/book/~$eft-book-ar.docx +0 -0
- package/docs/README.md +110 -12
- package/editors/sublime/Comments.tmPreferences +23 -0
- package/editors/sublime/Component.sublime-snippet +20 -0
- package/editors/sublime/Kreft.sublime-syntax +10 -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 +2 -2
- package/server.d.ts +2 -0
- package/templates/app/frontend/kreftjs.d.ts +1 -1
- package/templates/app/frontend/kreftjs.js +219 -14
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ action add() { count++ }
|
|
|
24
24
|
You need [Node.js](https://nodejs.org) 22.5 or newer.
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
npm
|
|
27
|
+
npm install -g kreftjs@latest # once: makes the `kreft` command available everywhere
|
|
28
28
|
kreft new my-app # asks: JavaScript or TypeScript?
|
|
29
29
|
cd my-app
|
|
30
30
|
npm run dev
|
|
@@ -42,6 +42,15 @@ Open the address in the terminal (normally `http://localhost:8000`). A new proje
|
|
|
42
42
|
| **Authentication** | Cookie sessions with CSRF protection, API tokens with permissions, device list, two-factor codes, email verification, account lockout. |
|
|
43
43
|
| **Tooling** | `kreft dev` with hot reload, DevTools, an error monitor, `kreft build` (CSP-safe), prerendering, TypeScript, Vite and Electron support, a safer `kreft add`, an MCP server for AI agents. |
|
|
44
44
|
|
|
45
|
+
## Put it online
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install kreftjs@latest # the newest version
|
|
49
|
+
npm run build # the finished site, ready to upload
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Upload what is inside the output folder (`dist/`; for this repository's own website, `dist/kreftjs-com`) to your host. The kreftjs.com steps, and releasing a new package version with `npm publish`, are in [Publish the website](docs/README.md#publish-the-website-kreftjscom).
|
|
53
|
+
|
|
45
54
|
## Desktop apps (Electron)
|
|
46
55
|
|
|
47
56
|
```bash
|
package/bin/backend/http.js
CHANGED
|
@@ -96,6 +96,8 @@ const cookieSecure = () => {
|
|
|
96
96
|
function response(body, status = 200, headers = {}) { return new Response(body, status, headers); }
|
|
97
97
|
response.json = (body, status = 200, headers = {}) => new Response(body, status, headers);
|
|
98
98
|
response.noContent = () => new Response(undefined, 204);
|
|
99
|
+
// response.stream('/path/video.mp4') - sends a file with byte-range support (seeking, Safari); { type, download, headers }
|
|
100
|
+
response.stream = (file, opts = {}) => { const r = new Response(undefined, 200); r.streamFile = { file, ...opts }; return r; };
|
|
99
101
|
const noContent = () => new Response(undefined, 204);
|
|
100
102
|
const toResponse = (v) => (v instanceof Response ? v : v === undefined ? new Response(undefined, 204) : new Response(v, 200));
|
|
101
103
|
|
|
@@ -313,8 +315,10 @@ class Request {
|
|
|
313
315
|
if (kind === 'unique' && ignore && ignore !== 'NULL') { sql += ` AND ${conn.q(idColumn)} != ?`; params.push(ignore); }
|
|
314
316
|
const found = (await conn.query(sql + ' LIMIT 1', params)).length > 0;
|
|
315
317
|
const label = (names && names[field]) || field.replace(/_/g, ' ');
|
|
316
|
-
|
|
317
|
-
|
|
318
|
+
// your own message for the rule: { 'title.unique': 'That title is already used.' }
|
|
319
|
+
const own = messages && (messages[`${field}.${kind}`] || messages[kind]);
|
|
320
|
+
if (kind === 'unique' && found) (errors[field] = errors[field] || []).push(own || `The ${label} has already been taken.`);
|
|
321
|
+
if (kind === 'exists' && !found) (errors[field] = errors[field] || []).push(own || `The selected ${label} is invalid.`);
|
|
318
322
|
}
|
|
319
323
|
}
|
|
320
324
|
if (Object.keys(errors).length) throw new ValidationError(errors);
|
package/bin/backend/index.js
CHANGED
|
@@ -273,6 +273,10 @@ const SECURITY_HEADERS = {
|
|
|
273
273
|
});
|
|
274
274
|
const send = (res, r) => {
|
|
275
275
|
if (res.headersSent || res.writableEnded) return;
|
|
276
|
+
if (r.streamFile) { // response.stream(file): byte ranges for video and audio
|
|
277
|
+
const { file, ...o } = r.streamFile;
|
|
278
|
+
return require('./stream.js').sendFile(res.req, res, file, { ...o, headers: { 'Cache-Control': 'no-store', ...SECURITY_HEADERS, ...r.headers, ...(o.headers || {}) } });
|
|
279
|
+
}
|
|
276
280
|
const status = r.statusCode || 200;
|
|
277
281
|
const noBody = status === 204 || status === 304 || r.body === undefined;
|
|
278
282
|
const isHtml = typeof r.body === 'string' && (r.headers && r.headers['Content-Type'] && r.headers['Content-Type'].includes('text/html'));
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Sending a file the way a video player needs it: byte ranges (206), so the browser can seek and Safari plays at all.
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const MEDIA_TYPES = {
|
|
6
|
+
'.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.ogv': 'video/ogg', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska',
|
|
7
|
+
'.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.wav': 'audio/wav', '.opus': 'audio/ogg', '.flac': 'audio/flac',
|
|
8
|
+
'.m3u8': 'application/vnd.apple.mpegurl', '.ts': 'video/mp2t', '.m4s': 'video/iso.segment', '.mpd': 'application/dash+xml', '.vtt': 'text/vtt',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// "bytes=0-99", "bytes=100-", "bytes=-500" -> { start, end } | null (no range) | false (cannot be satisfied)
|
|
12
|
+
function parseRange(header, size) {
|
|
13
|
+
if (!header) return null;
|
|
14
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim()); // one range only; several ranges are answered with the whole file
|
|
15
|
+
if (!m || (m[1] === '' && m[2] === '')) return null;
|
|
16
|
+
let start, end;
|
|
17
|
+
if (m[1] === '') { // the last N bytes
|
|
18
|
+
const n = Number(m[2]);
|
|
19
|
+
if (n === 0) return false;
|
|
20
|
+
start = Math.max(0, size - n); end = size - 1;
|
|
21
|
+
} else {
|
|
22
|
+
start = Number(m[1]);
|
|
23
|
+
end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
|
|
24
|
+
}
|
|
25
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) return false;
|
|
26
|
+
return { start, end };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// sendFile(req, res, file, { type, headers, status, download })
|
|
30
|
+
function sendFile(req, res, file, opts = {}) {
|
|
31
|
+
req = req || res.req;
|
|
32
|
+
let stat;
|
|
33
|
+
try { stat = fs.statSync(file); if (!stat.isFile()) throw new Error('not a file'); } catch (_) {
|
|
34
|
+
if (!res.headersSent) res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
35
|
+
return res.end('Not found');
|
|
36
|
+
}
|
|
37
|
+
const size = stat.size;
|
|
38
|
+
const type = opts.type || MEDIA_TYPES[path.extname(file).toLowerCase()] || 'application/octet-stream';
|
|
39
|
+
const etag = `W/"${size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
|
|
40
|
+
const headers = {
|
|
41
|
+
'Content-Type': type,
|
|
42
|
+
'Accept-Ranges': 'bytes',
|
|
43
|
+
'Last-Modified': stat.mtime.toUTCString(),
|
|
44
|
+
ETag: etag,
|
|
45
|
+
'X-Content-Type-Options': 'nosniff',
|
|
46
|
+
...(opts.download ? { 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(path.basename(file))}` } : {}),
|
|
47
|
+
...(opts.headers || {}),
|
|
48
|
+
};
|
|
49
|
+
const method = (req && req.method) || 'GET';
|
|
50
|
+
const h = (req && req.headers) || {};
|
|
51
|
+
|
|
52
|
+
if (h['if-none-match'] === etag && !h.range) { res.writeHead(304, headers); return res.end(); }
|
|
53
|
+
|
|
54
|
+
let range = parseRange(h.range, size);
|
|
55
|
+
// If-Range: only honor the range when the file is still the one the client started with
|
|
56
|
+
if (range && h['if-range'] && h['if-range'] !== etag && h['if-range'] !== headers['Last-Modified']) range = null;
|
|
57
|
+
if (range === false) {
|
|
58
|
+
res.writeHead(416, { 'Content-Range': `bytes */${size}`, 'Content-Type': 'text/plain; charset=utf-8' });
|
|
59
|
+
return res.end('Range not satisfiable');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let status = opts.status || 200, start = 0, end = size - 1;
|
|
63
|
+
if (range) { status = 206; ({ start, end } = range); headers['Content-Range'] = `bytes ${start}-${end}/${size}`; }
|
|
64
|
+
headers['Content-Length'] = size === 0 ? 0 : end - start + 1;
|
|
65
|
+
res.writeHead(status, headers);
|
|
66
|
+
if (method === 'HEAD' || size === 0) return res.end();
|
|
67
|
+
|
|
68
|
+
const stream = fs.createReadStream(file, { start, end });
|
|
69
|
+
stream.on('error', () => res.destroy());
|
|
70
|
+
res.on('close', () => stream.destroy()); // the viewer seeked or left: stop reading
|
|
71
|
+
stream.pipe(res);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { sendFile, parseRange, MEDIA_TYPES };
|
package/bin/build.js
CHANGED
|
@@ -25,7 +25,7 @@ const path = require('path');
|
|
|
25
25
|
const Kreftjs = require('../kreftjs.js');
|
|
26
26
|
|
|
27
27
|
const { expandBlocks, prepareTemplate, splitKreft, splitInterp, exactBrace, stripBrace, interpToExpr, quoteBraceAttrs, expandSelfClosing,
|
|
28
|
-
scopeCss, hashString, propType, simplePathRE, fnExpRE, forAliasRE, stripTypeScript } = Kreftjs.__internal;
|
|
28
|
+
scopeCss, hashString, propType, typeCheck, caseCond, simplePathRE, fnExpRE, forAliasRE, stripTypeScript } = Kreftjs.__internal;
|
|
29
29
|
|
|
30
30
|
const deps = require('./deps.js');
|
|
31
31
|
// component files: .kreft, or .kreftjs in a project from the 0.0.x betas
|
|
@@ -329,12 +329,16 @@ function compileFile(file, rel, root) {
|
|
|
329
329
|
|
|
330
330
|
// props: keep the default as a function so every instance gets its own copy
|
|
331
331
|
const props = Object.entries(sp.props).map(([p, expr]) => {
|
|
332
|
-
|
|
332
|
+
// `prop size: 'sm' | 'lg'`: the type text is kept, so the built app warns about a wrong value too
|
|
333
|
+
const ti = sp.propTypes && sp.propTypes[p];
|
|
334
|
+
const simple = ti && { string: 'String', number: 'Number', boolean: 'Boolean' }[ti.type.trim()];
|
|
335
|
+
const typed = ti ? `, typeText: ${JSON.stringify(ti.type)}, check: Kreftjs.__typeCheck(${JSON.stringify(ti.type)})${ti.required ? ', required: true' : ''}` : '';
|
|
336
|
+
if (expr == null) return `${JSON.stringify(p)}: { ${simple ? `type: ${simple}` : ''}${typed} }`.replace('{ , ', '{ ').replace('{ }', '{}');
|
|
333
337
|
let sample;
|
|
334
338
|
try { sample = new Function(`return (${expr})`)(); } catch (_) { /* uses something only known at runtime */ }
|
|
335
|
-
if (typeof sample === 'function') return `${JSON.stringify(p)}: { type: Function, default: (${expr}) }`;
|
|
339
|
+
if (typeof sample === 'function') return `${JSON.stringify(p)}: { type: Function, default: (${expr})${typed} }`;
|
|
336
340
|
const type = propType(sample);
|
|
337
|
-
return `${JSON.stringify(p)}: { ${type ? `type: ${type.name}, ` : ''}default: function () { return (${expr}); } }`;
|
|
341
|
+
return `${JSON.stringify(p)}: { ${type ? `type: ${type.name}, ` : ''}default: function () { return (${expr}); }${typed} }`;
|
|
338
342
|
});
|
|
339
343
|
|
|
340
344
|
// imports: other .kreft files become component references; .js modules load with import()
|
|
@@ -439,12 +443,15 @@ function addRouterTemplates(fns) {
|
|
|
439
443
|
addHandler(fns, 'navigate($event)');
|
|
440
444
|
}
|
|
441
445
|
|
|
442
|
-
const STRUCTURAL = new Set(['if', 'elif', 'else', 'each', 'key', 'ref', 'skip', 'cloak', 'slot', 'empty', 'once', 'animate', 'waiting', 'failed']);
|
|
446
|
+
const STRUCTURAL = new Set(['match', 'case', 'default', 'if', 'elif', 'else', 'each', 'key', 'ref', 'skip', 'cloak', 'slot', 'empty', 'once', 'animate', 'waiting', 'failed']);
|
|
443
447
|
|
|
444
448
|
function collectExpressions(template, fns) {
|
|
445
449
|
const html = prepareTemplate(template);
|
|
450
|
+
const subjects = []; // k-match subjects seen so far: a k-case is compared with one of them
|
|
446
451
|
scanHtml(html, {
|
|
447
452
|
attr(name, value) {
|
|
453
|
+
if (name === 'k-match') { const v = stripBrace(value); subjects.push(v); return addEval(fns, v); }
|
|
454
|
+
if (name === 'k-case') { const raw = stripBrace(value); for (const sub of subjects) addEval(fns, caseCond(sub, raw)); return; }
|
|
448
455
|
if (name[0] === ':' || name === 'k-bind' || name.startsWith('k-bind:')) return addEval(fns, value);
|
|
449
456
|
if (name[0] === '@' || name.startsWith('k-on:')) return addHandler(fns, stripBrace(value));
|
|
450
457
|
if (name.startsWith('k-')) {
|
package/bin/kreftjs.js
CHANGED
|
@@ -583,6 +583,30 @@ function copyFolder(from, to) {
|
|
|
583
583
|
}
|
|
584
584
|
|
|
585
585
|
/* ------------------------------------------------------------------ kreft vscode */
|
|
586
|
+
/* VS Code only loads extensions listed in extensions.json, and after "Uninstall" it keeps the folder in .obsolete and
|
|
587
|
+
deletes it on the next start. Copying the folder is not enough after an uninstall: list it again and clear .obsolete. */
|
|
588
|
+
function registerExtension(dir, folder, version) {
|
|
589
|
+
const id = 'kreft.kreftjs';
|
|
590
|
+
const location = path.join(dir, folder);
|
|
591
|
+
try {
|
|
592
|
+
const file = path.join(dir, 'extensions.json');
|
|
593
|
+
let list = [];
|
|
594
|
+
if (fs.existsSync(file)) { try { list = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { list = []; } }
|
|
595
|
+
if (!Array.isArray(list)) list = [];
|
|
596
|
+
list = list.filter((e) => !(e && e.identifier && /^kreft\.kreft(js)?$/.test(e.identifier.id)));
|
|
597
|
+
list.push({ identifier: { id }, version, location: { $mid: 1, fsPath: location, external: 'file://' + location, path: location, scheme: 'file' }, relativeLocation: folder, metadata: { installedTimestamp: Date.now(), pinned: false, source: 'resource', isApplicationScoped: false, isMachineScoped: false, isBuiltin: false, targetPlatform: 'undefined' } });
|
|
598
|
+
fs.writeFileSync(file, JSON.stringify(list));
|
|
599
|
+
} catch {}
|
|
600
|
+
try {
|
|
601
|
+
const file = path.join(dir, '.obsolete');
|
|
602
|
+
if (fs.existsSync(file)) {
|
|
603
|
+
const o = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
604
|
+
for (const k of Object.keys(o)) if (k.startsWith('kreft.kreftjs-') || k.startsWith('kreft.kreft-')) delete o[k];
|
|
605
|
+
fs.writeFileSync(file, JSON.stringify(o));
|
|
606
|
+
}
|
|
607
|
+
} catch {}
|
|
608
|
+
}
|
|
609
|
+
|
|
586
610
|
function installVscode() {
|
|
587
611
|
const os = require('os');
|
|
588
612
|
const from = path.join(__dirname, '..', 'editors', 'vscode');
|
|
@@ -595,6 +619,7 @@ function installVscode() {
|
|
|
595
619
|
fs.mkdirSync(dir, { recursive: true });
|
|
596
620
|
fs.readdirSync(dir).filter((n) => n.startsWith('kreft.kreftjs-') || n.startsWith('kreft.kreft-')) /* the older extensions go too */.forEach((n) => fs.rmSync(path.join(dir, n), { recursive: true, force: true }));
|
|
597
621
|
copyFolder(from, path.join(dir, `kreft.kreftjs-${ext.version}`));
|
|
622
|
+
registerExtension(dir, `kreft.kreftjs-${ext.version}`, ext.version);
|
|
598
623
|
console.log(` Installed in ${path.join(dir, `kreft.kreftjs-${ext.version}`)}`);
|
|
599
624
|
}
|
|
600
625
|
// Ensure current project .vscode/settings.json maps .kreft files to Kreft and configures formatter
|
|
@@ -736,7 +761,7 @@ async function devServer(args, { production = false } = {}) {
|
|
|
736
761
|
'.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.kreft': 'text/plain', '.kreftjs': 'text/plain',
|
|
737
762
|
'.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
|
|
738
763
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
|
739
|
-
'.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain', '.md': 'text/markdown',
|
|
764
|
+
'.ico': 'image/x-icon', ...(({ '.ts': _ts, ...media }) => media)(require('./backend/stream.js').MEDIA_TYPES), '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain', '.md': 'text/markdown',
|
|
740
765
|
};
|
|
741
766
|
const isFile = (p) => { try { return fs.statSync(p).isFile(); } catch (_) { return false; } };
|
|
742
767
|
|
|
@@ -754,12 +779,15 @@ async function devServer(args, { production = false } = {}) {
|
|
|
754
779
|
}
|
|
755
780
|
}
|
|
756
781
|
const type = types[path.extname(file).toLowerCase()] || 'application/octet-stream';
|
|
757
|
-
|
|
782
|
+
const fileHeaders = {
|
|
758
783
|
'Content-Type': type + (type.startsWith('text/') || type.endsWith('json') ? '; charset=utf-8' : ''),
|
|
759
784
|
'Cache-Control': isProd ? 'no-cache' : 'no-store', // dev: always pick up your latest changes
|
|
760
785
|
'X-Content-Type-Options': 'nosniff',
|
|
761
786
|
...prodHeaders,
|
|
762
|
-
}
|
|
787
|
+
};
|
|
788
|
+
// everything but pages is sent with byte ranges (video and audio seek, Safari plays)
|
|
789
|
+
if (type !== 'text/html') return require('./backend/stream.js').sendFile(res.req, res, file, { status: opts.status, type: fileHeaders['Content-Type'], headers: fileHeaders });
|
|
790
|
+
res.writeHead(opts.status || 200, fileHeaders);
|
|
763
791
|
if (!production && type === 'text/html') {
|
|
764
792
|
let html = fs.readFileSync(file, 'utf8');
|
|
765
793
|
// PUBLIC_ values from .env, as Kreftjs.env (kreft build puts them in app.js)
|
|
@@ -794,12 +822,9 @@ async function devServer(args, { production = false } = {}) {
|
|
|
794
822
|
return res.end(opts.seo ? require('./backend/seo-pages.js').inject(html, opts.seo) : html);
|
|
795
823
|
}
|
|
796
824
|
if (type === 'text/html' && opts.seo) return res.end(require('./backend/seo-pages.js').inject(fs.readFileSync(file, 'utf8'), opts.seo));
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
res.end('Not found');
|
|
801
|
-
});
|
|
802
|
-
stream.pipe(res);
|
|
825
|
+
const page = fs.createReadStream(file); // a page in production: headers are written, send it whole
|
|
826
|
+
page.on('error', () => res.end('Not found'));
|
|
827
|
+
page.pipe(res);
|
|
803
828
|
}
|
|
804
829
|
|
|
805
830
|
// ----- hot reload: watch the folder, tell open pages what changed -----
|
|
@@ -1109,8 +1134,7 @@ async function devServer(args, { production = false } = {}) {
|
|
|
1109
1134
|
const realPub = real(pub), realF = real(f);
|
|
1110
1135
|
if ((f.startsWith(pub + path.sep)) && !secret(urlPath) && isFile(f) && realPub && realF && realF.startsWith(realPub + path.sep)) {
|
|
1111
1136
|
const type = types[path.extname(f).toLowerCase()] || 'application/octet-stream';
|
|
1112
|
-
|
|
1113
|
-
return fs.createReadStream(f).pipe(res);
|
|
1137
|
+
return require('./backend/stream.js').sendFile(req, res, f, { type, headers: { 'Content-Security-Policy': "default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'; sandbox", 'Cache-Control': /\.(m3u8|mpd)$/i.test(f) ? 'no-cache' : 'public, max-age=3600' } }); // a live playlist changes every few seconds
|
|
1114
1138
|
}
|
|
1115
1139
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1116
1140
|
return res.end('Not found: ' + urlPath);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# الفصل السادس عشر (خبير): قوائم ضخمة بلا تباطؤ — النافذة الافتراضية
|
|
2
|
+
|
|
3
|
+
عرض 1,000 صف سهل. عرض 100,000 صف في جدول واحد يغرق المتصفح: بناء عشرات آلاف العقد في الـ DOM يكلف وقتًا وذاكرة. الحل الذي تستعمله كل الجداول الاحترافية هو **النافذة الافتراضية (Virtualization)**: نرسم الصفوف المرئية فقط، وعند التمرير نستبدل الصفوف التي خرجت من الشاشة.
|
|
4
|
+
|
|
5
|
+
سنبني هنا مكوّن `VirtualList` عامًا يتحمل أي عدد من العناصر ويبقى التمرير سلسًا. **كل الشيفرة جرِّبت في متصفح** مع 100,000 عنصر.
|
|
6
|
+
|
|
7
|
+
## 1) الفكرة
|
|
8
|
+
|
|
9
|
+
بدل أن نرسم القائمة كلها:
|
|
10
|
+
|
|
11
|
+
1. نعرف ارتفاع الصف الواحد `itemHeight` (يجب أن يكون ثابتًا).
|
|
12
|
+
2. من موضع التمرير `scrollTop` وارتفاع الحاوية نعرف **أي نطاق من العناصر ظاهر**.
|
|
13
|
+
3. نرسم ذلك النطاق فقط، فوق نطاق صغير إضافي (`overscan`) كي لا يظهر فراغ عند التمرير السريع.
|
|
14
|
+
4. نضبط ارتفاع "العمود الفارغ" على `items.length * itemHeight` ليبقى شريط التمرير صادقًا، ونزيح النافذة المرئية بـ `translateY`.
|
|
15
|
+
|
|
16
|
+
## 2) المكوّن
|
|
17
|
+
|
|
18
|
+
```html
|
|
19
|
+
<!-- components/VirtualList.kreft -->
|
|
20
|
+
prop items = [] // كل العناصر (قد تكون ملايين)
|
|
21
|
+
prop itemHeight = 48 // ارتفاع الصف الواحد بالبكسل
|
|
22
|
+
prop overscan = 6 // صفوف إضافية فوق وتحت النافذة المرئية
|
|
23
|
+
|
|
24
|
+
state viewport = null // يملؤه k-ref
|
|
25
|
+
state scrollTop = 0
|
|
26
|
+
state viewH = 0
|
|
27
|
+
|
|
28
|
+
// ارتفاع العمود الكامل (شريط التمرير يعتمد عليه)
|
|
29
|
+
derive total = items.length * itemHeight
|
|
30
|
+
|
|
31
|
+
// النطاق المرئي + هامش الأمان
|
|
32
|
+
derive start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan)
|
|
33
|
+
derive end = Math.min(items.length, Math.ceil((scrollTop + viewH) / itemHeight) + overscan)
|
|
34
|
+
derive visible = items.slice(start, end)
|
|
35
|
+
derive offset = start * itemHeight
|
|
36
|
+
|
|
37
|
+
action onScroll() {
|
|
38
|
+
scrollTop = viewport.scrollTop
|
|
39
|
+
viewH = viewport.clientHeight
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// نراقب حجم الحاوية (نافذة صغرت أو كبرت) وننظف حين يزول المكوّن
|
|
43
|
+
let ro
|
|
44
|
+
on ready {
|
|
45
|
+
viewH = viewport.clientHeight
|
|
46
|
+
ro = new ResizeObserver(() => { viewH = viewport.clientHeight })
|
|
47
|
+
ro.observe(viewport)
|
|
48
|
+
}
|
|
49
|
+
on destroy { ro && ro.disconnect() }
|
|
50
|
+
|
|
51
|
+
<view>
|
|
52
|
+
<div class="vlist" k-ref="viewport" @scroll="onScroll">
|
|
53
|
+
<div class="vlist__sizer" style="height: {total}px">
|
|
54
|
+
<div class="vlist__window" style="transform: translateY({offset}px)">
|
|
55
|
+
{for (item, i) in visible key item.id}
|
|
56
|
+
<div class="vlist__row" style="height: {itemHeight}px">
|
|
57
|
+
<slot item={item} index={start + i}>{item}</slot>
|
|
58
|
+
</div>
|
|
59
|
+
{/for}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</view>
|
|
64
|
+
|
|
65
|
+
<style scoped>
|
|
66
|
+
.vlist { overflow-y: auto; height: 400px; border: 1px solid var(--border, #ddd); }
|
|
67
|
+
.vlist__sizer { position: relative; }
|
|
68
|
+
.vlist__window { position: absolute; inset-block-start: 0; inset-inline: 0; }
|
|
69
|
+
.vlist__row { box-sizing: border-box; }
|
|
70
|
+
</style>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
لماذا يعمل هذا؟
|
|
74
|
+
|
|
75
|
+
- **`derive visible`** يُعاد حسابه وحده كلما تغيّر `scrollTop` أو `viewH` أو `items`، فيرسم Kreft الصفوف الجديدة ويزيل الخارجة (وهو يعرف كيف يطابقها بمفتاح `key item.id`).
|
|
76
|
+
- **`translateY`** على نافذة الصفوف أرخص من `padding-top` كبير: لا نعيد حساب التخطيط.
|
|
77
|
+
- **`k-ref`** يضع عنصر التمرير في `state viewport`، فنقرؤه في `onScroll` بلا بحث في الـ DOM كل مرة.
|
|
78
|
+
- **`ResizeObserver`** يلتقط تغيّر حجم الحاوية، و`on destroy` يقطع المراقبة.
|
|
79
|
+
|
|
80
|
+
## 3) الاستعمال: 100,000 صف
|
|
81
|
+
|
|
82
|
+
```html
|
|
83
|
+
<!-- pages/BigTable.kreft -->
|
|
84
|
+
import VirtualList from '../components/VirtualList.kreft'
|
|
85
|
+
|
|
86
|
+
state rows = Array.from({ length: 100000 }, (_, i) => ({
|
|
87
|
+
id: i + 1,
|
|
88
|
+
name: 'الاسم ' + (i + 1),
|
|
89
|
+
price: (i % 100) + 1,
|
|
90
|
+
}))
|
|
91
|
+
|
|
92
|
+
<view>
|
|
93
|
+
<h1>100,000 صف، ورسم فوري</h1>
|
|
94
|
+
<virtual-list items={rows} itemHeight={44}>
|
|
95
|
+
<template k-slot="{ item, index }">
|
|
96
|
+
<div style="display:flex; gap:12px; align-items:center; height:100%; padding-inline:12px">
|
|
97
|
+
<b>{index + 1}</b>
|
|
98
|
+
<span>{item.name}</span>
|
|
99
|
+
<span style="margin-inline-start:auto">{item.price} $</span>
|
|
100
|
+
</div>
|
|
101
|
+
</template>
|
|
102
|
+
</virtual-list>
|
|
103
|
+
</view>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- الفتحة الافتراضية هنا **بمعاملات**: `k-slot="{ item, index }"` يفكّك ما مرّره `<slot item={item} index={start + i}>`.
|
|
107
|
+
- `index` هو الرقم الحقيقي في القائمة (لأننا أضفنا `start`) لا رقم الصف المرسوم.
|
|
108
|
+
|
|
109
|
+
## 4) لمسات أداء تكميلية
|
|
110
|
+
|
|
111
|
+
النافذة الافتراضية وحدها تكفي غالبًا. هذه أدوات إضافية للصفوف الثقيلة:
|
|
112
|
+
|
|
113
|
+
| الأداة | متى تستعملها | مثال |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `k-once` | صف/خلية لا تتغير أبدًا بعد أول رسم | `<td k-once>{row.id}</td>` |
|
|
116
|
+
| `k-memo` | خلية تعتمد على قيمتين فقط | `<tr k-memo="[row.id === selected]">` |
|
|
117
|
+
| `key item.id` | دائمًا مع الحلقات | `{for item in visible key item.id}` |
|
|
118
|
+
| `derive` للقيم الغالية | احسب مرة لا في كل خلية | `derive priceLabel = (p) => p.toFixed(2) + ' $'` |
|
|
119
|
+
| سكربت عادي لما لا تحتاجه الصفحة | ثوابت ودوال مساعدة | `const clamp = (n, a, b) => …` |
|
|
120
|
+
|
|
121
|
+
تذكّر فخ الفصل الثالث عشر: ما تكتبه بـ `const`/`let` لا تراه الصفحة. كل ما يظهر في `view` يجب أن يكون `state` أو `derive` أو `action` أو `prop`.
|
|
122
|
+
|
|
123
|
+
## 5) القياس
|
|
124
|
+
|
|
125
|
+
جرّب الفرق بنفسك: اعرض 100,000 عنصر بدون النافذة الافتراضية (حلقة عادية) ثم معها، وراقب زمن أول رسم وذاكرة التبويب في أدوات المطوّر. في تجربتنا كانت الحلقة العادية تُرسم في مئات المللي ثانية وتشبع الذاكرة، بينما النافذة الافتراضية تُبقي في الـ DOM نحو 30 صفًا فقط مهما كان حجم القائمة.
|
|
126
|
+
|
|
127
|
+
## تمارين
|
|
128
|
+
|
|
129
|
+
1. أضف `prop itemKey` ليستعمل المفتاح `item[itemKey]` بدل `item.id` دائمًا.
|
|
130
|
+
2. اجعل `itemHeight` متغيّرًا: صفوف بارتفاعات مختلفة تُجمَع في "أقسام" (Variable size virtualization).
|
|
131
|
+
3. أضف تمريرًا إلى عنصر معيّن: دالة `scrollToIndex(i)` تضبط `viewport.scrollTop = i * itemHeight`.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# الفصل السابع عشر (خبير): مكوّن مركّب — قائمة اختيار ذكية (Combobox)
|
|
2
|
+
|
|
3
|
+
المكوّنات البسيطة تأخذ `props` وتعرض HTML. المكوّنات **المركّبة** تحزم عدة قطع تتعاون معًا: زناد وفتحة منبثقة وحقل بحث وقائمة خيارات ولوحة مفاتيح. هنا نبني `Combobox` كاملًا قابلًا لإعادة الاستخدام: يختار من قائمة، يبحث، يتنقّل بالأسهم، يغلق بـ Esc أو بالنقر خارجه، ويعمل بـ `k-model`.
|
|
4
|
+
|
|
5
|
+
**جرِّب هذه الشيفرة في المتصفح** كما هي.
|
|
6
|
+
|
|
7
|
+
## 1) المكوّن
|
|
8
|
+
|
|
9
|
+
```html
|
|
10
|
+
<!-- components/Combobox.kreft -->
|
|
11
|
+
prop modelValue = null // القيمة المختارة (id)
|
|
12
|
+
prop options = [] // [{ id, label }, …]
|
|
13
|
+
prop placeholder = 'اختر…'
|
|
14
|
+
prop searchable = false
|
|
15
|
+
event update:modelValue
|
|
16
|
+
|
|
17
|
+
state open = false
|
|
18
|
+
state query = ''
|
|
19
|
+
state active = 0 // فهرس الخيار المظلَّل
|
|
20
|
+
state trigger = null // يملؤه k-ref
|
|
21
|
+
state rect = { top: 0, left: 0, width: 0 }
|
|
22
|
+
|
|
23
|
+
derive selected = options.find((o) => o.id === modelValue) || null
|
|
24
|
+
derive filtered = !searchable || !query ? options : options.filter((o) => o.label.includes(query))
|
|
25
|
+
derive shown = filtered.slice(0, 50) // لا نرسم أكثر من 50
|
|
26
|
+
|
|
27
|
+
action toggle() {
|
|
28
|
+
if (!open && trigger) {
|
|
29
|
+
const r = trigger.getBoundingClientRect()
|
|
30
|
+
rect = { top: r.bottom, left: r.left, width: r.width }
|
|
31
|
+
}
|
|
32
|
+
open = !open
|
|
33
|
+
active = 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
action pick(o) {
|
|
37
|
+
send('update:modelValue', o.id)
|
|
38
|
+
open = false
|
|
39
|
+
query = ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
action onKey(e) {
|
|
43
|
+
if (e.key === 'Escape') { open = false; return }
|
|
44
|
+
if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) { toggle(); return }
|
|
45
|
+
if (!open) return
|
|
46
|
+
if (e.key === 'ArrowDown') active = Math.min(active + 1, shown.length - 1)
|
|
47
|
+
else if (e.key === 'ArrowUp') active = Math.max(active - 1, 0)
|
|
48
|
+
else if (e.key === 'Enter') { if (shown[active]) pick(shown[active]) }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
<view>
|
|
52
|
+
<div class="combobox" tabindex="0" @keydown="onKey($event)" k-click-outside={() => open = false}>
|
|
53
|
+
<button type="button" class="combobox__trigger" k-ref="trigger" @click="toggle" aria-haspopup="listbox">
|
|
54
|
+
<span>{selected ? selected.label : placeholder}</span> <span>▾</span>
|
|
55
|
+
</button>
|
|
56
|
+
|
|
57
|
+
<kreft-portal to="body">
|
|
58
|
+
<div k-if="open" class="combobox__menu" role="listbox" k-animate="scale"
|
|
59
|
+
style="position:fixed; top:{rect.top}px; left:{rect.left}px; width:{rect.width}px">
|
|
60
|
+
<input k-if="searchable" class="combobox__search" k-model="query" placeholder="ابحث…">
|
|
61
|
+
<ul>
|
|
62
|
+
{for (o, i) in shown key o.id}
|
|
63
|
+
<li role="option" class={i === active ? 'active' : ''}
|
|
64
|
+
@mouseenter="active = i" @click="pick(o)">
|
|
65
|
+
<slot name="option" option={o}>{o.label}</slot>
|
|
66
|
+
</li>
|
|
67
|
+
{empty}
|
|
68
|
+
<li class="combobox__empty">لا نتائج</li>
|
|
69
|
+
{/for}
|
|
70
|
+
</ul>
|
|
71
|
+
</div>
|
|
72
|
+
</kreft-portal>
|
|
73
|
+
</div>
|
|
74
|
+
</view>
|
|
75
|
+
|
|
76
|
+
<style scoped>
|
|
77
|
+
.combobox { position: relative; }
|
|
78
|
+
.combobox__trigger { width: 100%; display: flex; justify-content: space-between; gap: 8px; }
|
|
79
|
+
.combobox__menu { background: var(--background, #fff); color: var(--foreground, #111);
|
|
80
|
+
border: 1px solid var(--border, #ddd); max-height: 240px; overflow-y: auto; z-index: 200; }
|
|
81
|
+
.combobox__search { width: 100%; border: 0; border-block-end: 1px solid var(--border, #ddd); padding: 8px; }
|
|
82
|
+
.combobox__menu ul { list-style: none; margin: 0; padding: 4px; }
|
|
83
|
+
.combobox__menu li { padding: 6px 8px; cursor: pointer; border-radius: 4px; }
|
|
84
|
+
.combobox__menu li.active { background: var(--accent, #8883); }
|
|
85
|
+
.combobox__empty { padding: 8px; opacity: .6; }
|
|
86
|
+
</style>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
النقاط المهمة:
|
|
90
|
+
|
|
91
|
+
- **`k-model` على مكوّن** (كما في الفصل الثالث): المكوّن يملك `prop modelValue` ويرسل `update:modelValue`، فيكتب الأب `<combobox k-model="city">`.
|
|
92
|
+
- **`k-click-outside`** توجيه مخصص من الفصل الثالث عشر يغلق القائمة عند النقر خارجها.
|
|
93
|
+
- **`kreft-portal`** يرسم القائمة في `<body>` فلا يقتطعها `overflow` لآب؛ ولذلك نحسب موضعها يدويًا من `trigger.getBoundingClientRect()`.
|
|
94
|
+
- **فتحة `option` بمعاملات**: من يستعمل المكوّن يقرر شكل كل خيار.
|
|
95
|
+
- **`shown = filtered.slice(0, 50)`**: حد أقصى للرسم مهما كثرت النتائج (تكمّله البحث).
|
|
96
|
+
- **`k-animate="scale"`** لحركة دخول وخروج خفيفة.
|
|
97
|
+
|
|
98
|
+
## 2) الاستعمال
|
|
99
|
+
|
|
100
|
+
```html
|
|
101
|
+
<!-- pages/City.kreft -->
|
|
102
|
+
import Combobox from '../components/Combobox.kreft'
|
|
103
|
+
|
|
104
|
+
state cities = [
|
|
105
|
+
{ id: 'mo', label: 'مقديشو' },
|
|
106
|
+
{ id: 'hr', label: 'هرجيسا' },
|
|
107
|
+
{ id: 'bos', label: 'بوصاصو' },
|
|
108
|
+
{ id: 'ki', label: 'كيسمايو' },
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
state city = null
|
|
112
|
+
|
|
113
|
+
<view>
|
|
114
|
+
<p>المدينة المختارة: {cities.find((c) => c.id === city)?.label || '—'}</p>
|
|
115
|
+
<combobox k-model="city" options={cities} searchable placeholder="اختر مدينة">
|
|
116
|
+
<template k-slot:option="{ option }">
|
|
117
|
+
<b>{option.label}</b> <small style="opacity:.6">{option.id.toUpperCase()}</small>
|
|
118
|
+
</template>
|
|
119
|
+
</combobox>
|
|
120
|
+
</view>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## 3) خيارات تُحمَّل من الخادم (غير متزامن)
|
|
124
|
+
|
|
125
|
+
لن تفيدك القائمة الثابتة في تطبيق حقيقي. حوّل البحث إلى طلب خادم بتأخير بسيط، كما فعلنا في بحث المدونة:
|
|
126
|
+
|
|
127
|
+
```html
|
|
128
|
+
<!-- components/AsyncCombobox.kreft -->
|
|
129
|
+
prop modelValue = null
|
|
130
|
+
event update:modelValue
|
|
131
|
+
|
|
132
|
+
state open = false
|
|
133
|
+
state text = ''
|
|
134
|
+
state query = '' // ما نبحث به فعلًا (بعد التأخير)
|
|
135
|
+
|
|
136
|
+
let timer
|
|
137
|
+
watch text {
|
|
138
|
+
clearTimeout(timer)
|
|
139
|
+
timer = setTimeout(() => { query = value }, 300)
|
|
140
|
+
}
|
|
141
|
+
on destroy { clearTimeout(timer) }
|
|
142
|
+
|
|
143
|
+
// يعيد الجلب وحده كلما تغيّر query
|
|
144
|
+
load results = api.get('/api/cities', { query: { q: query || undefined } })
|
|
145
|
+
|
|
146
|
+
action pick(c) { send('update:modelValue', c.id); open = false; text = c.label }
|
|
147
|
+
|
|
148
|
+
<view>
|
|
149
|
+
<input k-model="text" @focus="open = true" placeholder="ابحث عن مدينة…"
|
|
150
|
+
k-click-outside={() => open = false}>
|
|
151
|
+
<div k-if="open" class="menu">
|
|
152
|
+
<p k-if="results.loading">جارٍ البحث…</p>
|
|
153
|
+
{for c in results.data || [] key c.id}
|
|
154
|
+
<button @click="pick(c)">{c.label}</button>
|
|
155
|
+
{empty}
|
|
156
|
+
<p>لا نتائج لـ "{query}".</p>
|
|
157
|
+
{/for}
|
|
158
|
+
</div>
|
|
159
|
+
</view>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- **`text` و`query`**: نفصل ما يكتبه المستخدم عمّا نطلبه، فيتجمع الإدخال السريع في طلب واحد.
|
|
163
|
+
- **`load results`** يقرأ `query` فيجلب من جديد عند تغيّرها، ويعطيك `loading` و`error` و`reload()`.
|
|
164
|
+
|
|
165
|
+
## تمارين
|
|
166
|
+
|
|
167
|
+
1. أضف إلى `Combobox` خاصية `prop clearable` مع زر ✕ يفرغ الاختيار.
|
|
168
|
+
2. اجعل القائمة تفتح للأعلى إذا لم يتسع أسفلها (قارن `rect.top` بارتفاع النافذة).
|
|
169
|
+
3. حوّل `AsyncCombobox` ليدعم اختيارًا متعددًا: `modelValue` مصفوفة و`pick` يبدّل العنصر بدل اختياره وحيدًا.
|