kreftjs 0.0.22-beta → 0.0.23-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/build.js +4 -1
- package/bin/home-variants.js +278 -0
- package/bin/kreftjs.js +14 -1
- package/bin/mobile.js +176 -0
- package/bin/pwa.js +76 -0
- package/docs/README.md +55 -0
- package/kreftjs.js +50 -3
- package/package.json +2 -2
- package/templates/app/frontend/kreftjs.js +50 -3
- package/templates/app/frontend/ui.css +15 -0
package/bin/build.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* kreft build --prerender also save every page as ready HTML (needs: npm i -D jsdom)
|
|
9
9
|
* kreft build --prerender --urls urls.js ... and the addresses in that file (pages that come from your data)
|
|
10
10
|
* kreft build --include docs also copy another folder into the output
|
|
11
|
+
* kreft build --pwa also write manifest.webmanifest + sw.js (installable, works offline)
|
|
11
12
|
* kreft build --no-htaccess do not write dist/.htaccess (the Apache / Hostinger page-address rules)
|
|
12
13
|
*
|
|
13
14
|
* What it does:
|
|
@@ -34,11 +35,12 @@ const SERVER_DIRS = new Set();
|
|
|
34
35
|
const SKIP = new Set(['node_modules', 'dist', 'dist-electron', '.git', '.vscode', '.idea', 'package.json', 'package-lock.json', '.DS_Store']);
|
|
35
36
|
|
|
36
37
|
module.exports = function build(args) {
|
|
37
|
-
let folder = null, out = null, base = null, prerender = null, urlsFile = null, htaccess = true, minify = true;
|
|
38
|
+
let pwa = false, folder = null, out = null, base = null, prerender = null, urlsFile = null, htaccess = true, minify = true;
|
|
38
39
|
const include = [];
|
|
39
40
|
for (let i = 0; i < args.length; i++) {
|
|
40
41
|
if (args[i] === '--out' || args[i] === '-o') out = args[++i];
|
|
41
42
|
else if (args[i] === '--base') base = args[++i];
|
|
43
|
+
else if (args[i] === '--pwa') pwa = true; // manifest + service worker: installable and works offline
|
|
42
44
|
else if (args[i] === '--no-htaccess') htaccess = false;
|
|
43
45
|
else if (args[i] === '--no-minify') minify = false; // keep kreftjs.js readable (for debugging)
|
|
44
46
|
else if (args[i] === '--include') include.push(args[++i]); // another folder to copy in, like the docs
|
|
@@ -241,6 +243,7 @@ ${indent(components.join('\n\n'))}
|
|
|
241
243
|
}
|
|
242
244
|
write(dist, 'index.html', html);
|
|
243
245
|
addCacheBusters(dist);
|
|
246
|
+
if (pwa) require('./pwa.js').apply(dist, root, { project });
|
|
244
247
|
if (htaccess) writeHtaccess(dist, html);
|
|
245
248
|
|
|
246
249
|
const report = () => {
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Home pages made for apps, not for the web:
|
|
3
|
+
*
|
|
4
|
+
* kreft new my-app --mobile the home page is a phone screen: top bar, cards, big touch targets, bottom tab bar
|
|
5
|
+
* kreft new my-app --electron the home page is a desktop workspace: sidebar, toolbar, cards, shortcuts
|
|
6
|
+
* kreft make:home --mobile | --desktop (re)write them in an existing project (replaces pages/Home.kreft)
|
|
7
|
+
*
|
|
8
|
+
* Both together: pages/Home.kreft shows the desktop one inside the Electron window and the phone one everywhere else.
|
|
9
|
+
* Add ?platform=mobile or ?platform=desktop to the address to preview either in a normal browser.
|
|
10
|
+
*
|
|
11
|
+
* The generated files are plain .kreft files in the project: change them freely.
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
// what the project has: no-auth projects have no dashboard, users or profile pages
|
|
17
|
+
function features(front) {
|
|
18
|
+
const has = (f) => fs.existsSync(path.join(front, f));
|
|
19
|
+
return { dashboard: has('pages/Dashboard.kreft'), users: has('pages/Users.kreft'), profile: has('pages/Profile.kreft'), docs: has('docs/Docs.kreft'), auth: has('pages/auth') };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const js = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
|
|
23
|
+
|
|
24
|
+
function mobileSource(f) {
|
|
25
|
+
const tiles = [
|
|
26
|
+
f.dashboard && { icon: '▦', label: 'Dashboard', to: '/dashboard' },
|
|
27
|
+
f.users && { icon: '☺', label: 'Users', to: '/users' },
|
|
28
|
+
f.profile && { icon: '⚙', label: 'Profile', to: '/profile' },
|
|
29
|
+
f.docs && { icon: '☰', label: 'Docs', to: '/docs' },
|
|
30
|
+
{ icon: '!', label: 'Errors', to: '/error-monitor' },
|
|
31
|
+
].filter(Boolean);
|
|
32
|
+
const tabs = [{ icon: '⌂', label: 'Home', to: '/' }, ...tiles.filter((t) => t.to !== '/error-monitor').slice(0, 3)];
|
|
33
|
+
return `<!-- The home page of the phone app: a top bar, cards and a bottom tab bar, all sized for a thumb.
|
|
34
|
+
@tap, @swipeleft and @longpress are Kreft's touch events. Add ?platform=mobile in a browser to preview it. -->
|
|
35
|
+
|
|
36
|
+
state app = Kreftjs.env.PUBLIC_APP_NAME || 'My app'
|
|
37
|
+
state tiles = ${js(tiles)}
|
|
38
|
+
state tabs = ${js(tabs)}
|
|
39
|
+
state taps = 0
|
|
40
|
+
state note = 'Tap the card. Swipe left to reset. Hold to double.'
|
|
41
|
+
|
|
42
|
+
action hit() { taps++; note = 'Tapped ' + taps + (taps === 1 ? ' time' : ' times') }
|
|
43
|
+
action reset() { taps = 0; note = 'Reset by swipe' }
|
|
44
|
+
action boost() { taps = taps * 2; note = 'Doubled by long press' }
|
|
45
|
+
|
|
46
|
+
<view>
|
|
47
|
+
<div class="phone">
|
|
48
|
+
<header class="bar">
|
|
49
|
+
<img src="logo-mark.svg" alt="" width="28" height="28">
|
|
50
|
+
<h1>{app}</h1>
|
|
51
|
+
</header>
|
|
52
|
+
|
|
53
|
+
<main>
|
|
54
|
+
<section class="hello">
|
|
55
|
+
<p class="small">Welcome back</p>
|
|
56
|
+
<h2>What do you want to do?</h2>
|
|
57
|
+
</section>
|
|
58
|
+
|
|
59
|
+
<section class="grid" aria-label="Shortcuts">
|
|
60
|
+
<kreft-link k-for="t in tiles" to={t.to} class="tile">
|
|
61
|
+
<span class="ico" aria-hidden="true">{t.icon}</span>
|
|
62
|
+
<span class="lbl">{t.label}</span>
|
|
63
|
+
</kreft-link>
|
|
64
|
+
</section>
|
|
65
|
+
|
|
66
|
+
<section class="card try" @tap={hit} @swipeleft={reset} @longpress={boost}>
|
|
67
|
+
<p class="small">Touch events</p>
|
|
68
|
+
<p class="count">{taps}</p>
|
|
69
|
+
<p class="note">{note}</p>
|
|
70
|
+
</section>
|
|
71
|
+
|
|
72
|
+
<section class="card">
|
|
73
|
+
<p class="small">This app</p>
|
|
74
|
+
<p>One Kreft codebase: the website, the phone app and the desktop app.</p>
|
|
75
|
+
</section>
|
|
76
|
+
</main>
|
|
77
|
+
|
|
78
|
+
<nav class="tabs" aria-label="Main">
|
|
79
|
+
<kreft-link k-for="t in tabs" to={t.to} class="tab" active-class="on" exact-active-class="on">
|
|
80
|
+
<span class="ico" aria-hidden="true">{t.icon}</span>
|
|
81
|
+
<span class="lbl">{t.label}</span>
|
|
82
|
+
</kreft-link>
|
|
83
|
+
</nav>
|
|
84
|
+
</div>
|
|
85
|
+
</view>
|
|
86
|
+
|
|
87
|
+
<style scoped>
|
|
88
|
+
.phone { min-height: 100vh; display: flex; flex-direction: column; background: var(--accent); }
|
|
89
|
+
.bar { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 10px; padding: 14px 16px; background: var(--background); border-bottom: 1px solid var(--border); }
|
|
90
|
+
.bar h1 { margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.02em; }
|
|
91
|
+
main { flex: 1; padding: 16px 16px 96px; display: flex; flex-direction: column; gap: 16px; }
|
|
92
|
+
.small { margin: 0 0 4px; font-size: 13px; color: var(--muted); }
|
|
93
|
+
.hello h2 { margin: 0; font-size: 26px; line-height: 1.2; letter-spacing: -0.03em; }
|
|
94
|
+
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
|
95
|
+
.tile { display: flex; flex-direction: column; justify-content: space-between; gap: 18px; min-height: 104px; padding: 16px; background: var(--card); border: 1px solid var(--card-border); border-radius: 16px; color: var(--foreground); text-decoration: none; }
|
|
96
|
+
.tile:active { background: var(--accent-strong); }
|
|
97
|
+
.tile .ico { font-size: 26px; line-height: 1; }
|
|
98
|
+
.tile .lbl { font-weight: 600; font-size: 16px; }
|
|
99
|
+
.card { padding: 18px; background: var(--card); border: 1px solid var(--card-border); border-radius: 16px; }
|
|
100
|
+
.card p:last-child { margin-bottom: 0; }
|
|
101
|
+
.try { user-select: none; -webkit-user-select: none; text-align: center; }
|
|
102
|
+
.try:active { background: var(--accent-strong); }
|
|
103
|
+
.count { margin: 4px 0; font-size: 56px; font-weight: 700; letter-spacing: -0.04em; }
|
|
104
|
+
.note { margin: 0; color: var(--muted); font-size: 14px; }
|
|
105
|
+
.tabs { position: fixed; left: 0; right: 0; bottom: 0; z-index: 10; display: flex; padding: 6px 8px calc(6px + env(safe-area-inset-bottom)); background: var(--background); border-top: 1px solid var(--border); }
|
|
106
|
+
.tab { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 6px 0; min-height: 48px; color: var(--muted); text-decoration: none; font-size: 12px; border-radius: 12px; }
|
|
107
|
+
.tab .ico { font-size: 22px; line-height: 1.1; }
|
|
108
|
+
.tab.on { color: var(--foreground); font-weight: 700; }
|
|
109
|
+
@media (min-width: 700px) { .phone { max-width: 520px; margin: 0 auto; border-inline: 1px solid var(--border); } .tabs { max-width: 520px; margin: 0 auto; } }
|
|
110
|
+
</style>
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function desktopSource(f) {
|
|
115
|
+
const nav = [
|
|
116
|
+
{ label: 'Home', to: '/' },
|
|
117
|
+
f.dashboard && { label: 'Dashboard', to: '/dashboard' },
|
|
118
|
+
f.users && { label: 'Users', to: '/users' },
|
|
119
|
+
f.profile && { label: 'Profile', to: '/profile' },
|
|
120
|
+
f.docs && { label: 'Docs', to: '/docs' },
|
|
121
|
+
{ label: 'Error monitor', to: '/error-monitor' },
|
|
122
|
+
].filter(Boolean);
|
|
123
|
+
return `<!-- The home page of the desktop app: a sidebar, a toolbar and a workspace, like a real desktop program.
|
|
124
|
+
Inside the Electron window window.kreftDesktop gives the app name, version and system.
|
|
125
|
+
Add ?platform=desktop in a browser to preview it. -->
|
|
126
|
+
|
|
127
|
+
state app = Kreftjs.env.PUBLIC_APP_NAME || 'My app'
|
|
128
|
+
state nav = ${js(nav)}
|
|
129
|
+
state query = ''
|
|
130
|
+
state info = { name: app, version: '', platform: 'browser' }
|
|
131
|
+
state shortcuts = [
|
|
132
|
+
{ keys: 'F12', does: 'Open or close the developer tools' },
|
|
133
|
+
{ keys: 'Ctrl / ⌘ + R', does: 'Reload the window' },
|
|
134
|
+
{ keys: 'Ctrl / ⌘ + + / −', does: 'Zoom in and out' },
|
|
135
|
+
{ keys: 'F11', does: 'Full screen' },
|
|
136
|
+
]
|
|
137
|
+
derive shown = nav.filter((n) => n.label.toLowerCase().includes(query.trim().toLowerCase()))
|
|
138
|
+
|
|
139
|
+
on ready {
|
|
140
|
+
if (window.kreftDesktop && window.kreftDesktop.getInfo) {
|
|
141
|
+
window.kreftDesktop.getInfo().then((i) => { info = { name: i.name, version: i.version, platform: i.platform + ' ' + i.arch } }).catch(() => {})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
<view>
|
|
146
|
+
<div class="shell">
|
|
147
|
+
<aside class="side">
|
|
148
|
+
<div class="brand"><img src="logo-mark.svg" alt="" width="22" height="22"><span>{app}</span></div>
|
|
149
|
+
<nav aria-label="Main">
|
|
150
|
+
<kreft-link k-for="n in nav" to={n.to} class="item" active-class="on" exact-active-class="on">{n.label}</kreft-link>
|
|
151
|
+
</nav>
|
|
152
|
+
<p class="foot">{info.name} {info.version}<br>{info.platform}</p>
|
|
153
|
+
</aside>
|
|
154
|
+
|
|
155
|
+
<section class="work">
|
|
156
|
+
<header class="toolbar">
|
|
157
|
+
<h1>Home</h1>
|
|
158
|
+
<input type="search" placeholder="Search pages…" k-model="query" aria-label="Search pages">
|
|
159
|
+
</header>
|
|
160
|
+
|
|
161
|
+
<div class="content">
|
|
162
|
+
<div class="cards">
|
|
163
|
+
<article class="card"><p class="small">Application</p><p class="big">{info.name}</p></article>
|
|
164
|
+
<article class="card"><p class="small">Version</p><p class="big">{info.version || 'dev'}</p></article>
|
|
165
|
+
<article class="card"><p class="small">System</p><p class="big">{info.platform}</p></article>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<h2>Open</h2>
|
|
169
|
+
<div class="list">
|
|
170
|
+
<kreft-link k-for="n in shown" to={n.to} class="row"><span>{n.label}</span><span class="arrow">→</span></kreft-link>
|
|
171
|
+
<p k-if="shown.length === 0" class="empty">No page matches “{query}”.</p>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<h2>Shortcuts</h2>
|
|
175
|
+
<table class="keys">
|
|
176
|
+
<tr k-for="s in shortcuts"><td><kbd>{s.keys}</kbd></td><td>{s.does}</td></tr>
|
|
177
|
+
</table>
|
|
178
|
+
</div>
|
|
179
|
+
</section>
|
|
180
|
+
</div>
|
|
181
|
+
</view>
|
|
182
|
+
|
|
183
|
+
<style scoped>
|
|
184
|
+
.shell { display: grid; grid-template-columns: 232px 1fr; height: 100vh; background: var(--background); color: var(--foreground); }
|
|
185
|
+
.side { display: flex; flex-direction: column; gap: 8px; padding: 16px 12px; background: var(--accent); border-right: 1px solid var(--border); overflow: auto; }
|
|
186
|
+
.brand { display: flex; align-items: center; gap: 8px; padding: 4px 8px 12px; font-weight: 700; letter-spacing: -0.02em; }
|
|
187
|
+
.item { display: block; padding: 8px 10px; border-radius: 6px; color: var(--muted); text-decoration: none; font-size: 14px; }
|
|
188
|
+
.item:hover { background: var(--accent-strong); color: var(--foreground); }
|
|
189
|
+
.item.on { background: var(--accent-strong); color: var(--foreground); font-weight: 600; }
|
|
190
|
+
.foot { margin: auto 0 0; padding: 8px; font-size: 12px; color: var(--muted); }
|
|
191
|
+
.work { display: flex; flex-direction: column; min-width: 0; }
|
|
192
|
+
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 24px; border-bottom: 1px solid var(--border); }
|
|
193
|
+
.toolbar h1 { margin: 0; font-size: 18px; }
|
|
194
|
+
.toolbar input { width: 260px; max-width: 50%; }
|
|
195
|
+
.content { padding: 24px; overflow: auto; }
|
|
196
|
+
.content h2 { margin: 28px 0 12px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
|
197
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
|
|
198
|
+
.card { padding: 16px; background: var(--card); border: 1px solid var(--card-border); border-radius: var(--radius-lg); }
|
|
199
|
+
.small { margin: 0 0 4px; font-size: 12px; color: var(--muted); }
|
|
200
|
+
.big { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -0.02em; overflow-wrap: anywhere; }
|
|
201
|
+
.list { border: 1px solid var(--card-border); border-radius: var(--radius-lg); overflow: hidden; }
|
|
202
|
+
.row { display: flex; justify-content: space-between; padding: 12px 16px; color: var(--foreground); text-decoration: none; border-bottom: 1px solid var(--border-subtle); }
|
|
203
|
+
.row:last-child { border-bottom: 0; }
|
|
204
|
+
.row:hover { background: var(--accent); }
|
|
205
|
+
.arrow { color: var(--muted); }
|
|
206
|
+
.empty { margin: 0; padding: 12px 16px; color: var(--muted); }
|
|
207
|
+
.keys { border-collapse: collapse; }
|
|
208
|
+
.keys td { padding: 6px 24px 6px 0; font-size: 14px; }
|
|
209
|
+
kbd { padding: 2px 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--accent); font-size: 12px; }
|
|
210
|
+
@media (max-width: 720px) { .shell { grid-template-columns: 1fr; height: auto; } .side { border-right: 0; border-bottom: 1px solid var(--border); } }
|
|
211
|
+
</style>
|
|
212
|
+
`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// pages/Home.kreft: the platform switch when both exist, otherwise the one that exists
|
|
216
|
+
function homeSource(mobile, desktop) {
|
|
217
|
+
if (mobile && desktop) {
|
|
218
|
+
return `<!-- The home page: the desktop workspace inside the Electron window, the phone screen everywhere else.
|
|
219
|
+
Preview in a browser with ?platform=desktop or ?platform=mobile. -->
|
|
220
|
+
state desktop = (function () {
|
|
221
|
+
const asked = new URLSearchParams(location.search).get('platform')
|
|
222
|
+
if (asked) return asked === 'desktop'
|
|
223
|
+
return Boolean(window.kreftDesktop || window.__KREFT_ELECTRON__)
|
|
224
|
+
})()
|
|
225
|
+
|
|
226
|
+
<view>
|
|
227
|
+
<home-desktop k-if="desktop"></home-desktop>
|
|
228
|
+
<home-mobile k-else></home-mobile>
|
|
229
|
+
</view>
|
|
230
|
+
`;
|
|
231
|
+
}
|
|
232
|
+
return `<!-- The home page of the ${mobile ? 'phone' : 'desktop'} app. Its design is in components/${mobile ? 'HomeMobile' : 'HomeDesktop'}.kreft -->
|
|
233
|
+
<view>
|
|
234
|
+
<home-${mobile ? 'mobile' : 'desktop'}></home-${mobile ? 'mobile' : 'desktop'}>
|
|
235
|
+
</view>
|
|
236
|
+
`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// index.html lists every component: .component('Field', 'components/Field.kreft')
|
|
240
|
+
function register(front, names) {
|
|
241
|
+
const file = path.join(front, 'index.html');
|
|
242
|
+
if (!fs.existsSync(file)) return;
|
|
243
|
+
let html = fs.readFileSync(file, 'utf8');
|
|
244
|
+
const add = names.filter((n) => !html.includes(`.component('${n}'`));
|
|
245
|
+
if (!add.length) return;
|
|
246
|
+
const lines = add.map((n) => `.component('${n}', 'components/${n}.kreft')`);
|
|
247
|
+
const m = html.match(/^([ \t]*)\.component\(/m) || html.match(/^([ \t]*)\.use\(router\)/m);
|
|
248
|
+
if (!m) { console.warn(' Could not find where components are listed in index.html. Add: ' + lines.join(' ')); return; }
|
|
249
|
+
const indent = m[1];
|
|
250
|
+
html = html.replace(m[0], lines.map((l) => indent + l + '\n').join('') + m[0]);
|
|
251
|
+
fs.writeFileSync(file, html);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// existing: what a project that already had one of them keeps when the other is added
|
|
255
|
+
function apply(target, { mobile = false, desktop = false } = {}) {
|
|
256
|
+
const lay = require('./layout.js').layout(target);
|
|
257
|
+
const front = lay.frontend;
|
|
258
|
+
const f = features(front);
|
|
259
|
+
fs.mkdirSync(path.join(front, 'components'), { recursive: true });
|
|
260
|
+
fs.mkdirSync(path.join(front, 'pages'), { recursive: true });
|
|
261
|
+
const has = (n) => fs.existsSync(path.join(front, 'components', n));
|
|
262
|
+
const wantMobile = mobile || has('HomeMobile.kreft');
|
|
263
|
+
const wantDesktop = desktop || has('HomeDesktop.kreft');
|
|
264
|
+
if (mobile) fs.writeFileSync(path.join(front, 'components', 'HomeMobile.kreft'), mobileSource(f));
|
|
265
|
+
if (desktop) fs.writeFileSync(path.join(front, 'components', 'HomeDesktop.kreft'), desktopSource(f));
|
|
266
|
+
fs.writeFileSync(path.join(front, 'pages', 'Home.kreft'), homeSource(wantMobile, wantDesktop));
|
|
267
|
+
register(front, [wantMobile && 'HomeMobile', wantDesktop && 'HomeDesktop'].filter(Boolean));
|
|
268
|
+
return { mobile: wantMobile, desktop: wantDesktop };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function make(args = []) {
|
|
272
|
+
const mobile = args.includes('--mobile'), desktop = args.includes('--desktop') || args.includes('--electron');
|
|
273
|
+
if (!mobile && !desktop) { console.error(' Use kreft make:home --mobile or --desktop (or both).'); process.exit(1); }
|
|
274
|
+
const r = apply(process.cwd(), { mobile, desktop });
|
|
275
|
+
console.log(` Wrote the ${[r.mobile && 'phone', r.desktop && 'desktop'].filter(Boolean).join(' and ')} home page (pages/Home.kreft and components/).`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = { apply, make, mobileSource, desktopSource, homeSource, features };
|
package/bin/kreftjs.js
CHANGED
|
@@ -48,6 +48,13 @@ const commands = {
|
|
|
48
48
|
'electron:init': (a) => require('./electron.js').init(a),
|
|
49
49
|
'electron:help': () => require('./electron.js').help(),
|
|
50
50
|
'make:electron': (a) => require('./electron.js').init(a),
|
|
51
|
+
'make:home': (a) => require('./home-variants.js').make(a),
|
|
52
|
+
'mobile:init': (a) => require('./mobile.js').init(a[0]),
|
|
53
|
+
'mobile:build': (a) => require('./mobile.js').build(a),
|
|
54
|
+
'mobile:open': (a) => require('./mobile.js').open(a),
|
|
55
|
+
'mobile:dev': (a) => require('./mobile.js').dev(a),
|
|
56
|
+
'mobile:help': () => require('./mobile.js').help(),
|
|
57
|
+
'make:mobile': (a) => require('./mobile.js').init(a[0]),
|
|
51
58
|
vite: (a) => require('./vite.js').dev(a),
|
|
52
59
|
'vite:dev': (a) => require('./vite.js').dev(a),
|
|
53
60
|
'vite:build': (a) => require('./vite.js').build(a),
|
|
@@ -91,6 +98,7 @@ function help() {
|
|
|
91
98
|
--ts …use TypeScript across frontend (.kreft, .ts) and backend (.ts)
|
|
92
99
|
--js …use JavaScript (default when non-interactive)
|
|
93
100
|
--electron …with Electron desktop app support (main.js, preload.js, packaging)
|
|
101
|
+
--mobile …with iOS / Android support (Capacitor)
|
|
94
102
|
--vite …with Vite.js bundling & instant HMR (vite.config.js, npm run vite)
|
|
95
103
|
kreft dev [folder] Start the dev server (default: this folder)
|
|
96
104
|
--port <number> Port to use (default: 8000, or the next free one)
|
|
@@ -121,6 +129,8 @@ function help() {
|
|
|
121
129
|
kreft electron Open the app in a desktop window only: no browser, no URL (alias: kreft electron:dev)
|
|
122
130
|
kreft electron:build Build and make installers (--mac, --win, --linux; or npm run electron:build:all)
|
|
123
131
|
kreft electron:init Add Electron support to an existing project (alias: kreft make:electron)
|
|
132
|
+
kreft make:home Write an app-style home page: --mobile (phone screen) or --desktop (sidebar workspace)
|
|
133
|
+
kreft mobile:build Build for iOS / Android with Capacitor (--android, --ios, --api-url; see kreft mobile:help)
|
|
124
134
|
kreft electron:help Everything about desktop apps: menu, icon, installers, errors
|
|
125
135
|
|
|
126
136
|
The backend (kreft list shows every command, yours too):
|
|
@@ -196,11 +206,12 @@ function unknown() {
|
|
|
196
206
|
|
|
197
207
|
/* ------------------------------------------------------------------ kreft new */
|
|
198
208
|
async function createProject(args) {
|
|
199
|
-
let name = null, dbKind = null, ui = null, ts = false, langChosen = false, electron = false, vite = false, redis = false, auth = true, authChosen = false;
|
|
209
|
+
let name = null, dbKind = null, ui = null, ts = false, langChosen = false, electron = false, mobile = false, vite = false, redis = false, auth = true, authChosen = false;
|
|
200
210
|
for (let i = 0; i < args.length; i++) {
|
|
201
211
|
if (args[i] === '--ts' || args[i] === '--typescript') { ts = true; langChosen = true; }
|
|
202
212
|
else if (args[i] === '--js' || args[i] === '--javascript') { ts = false; langChosen = true; }
|
|
203
213
|
else if (args[i] === '--electron' || args[i] === '--desktop') electron = true;
|
|
214
|
+
else if (args[i] === '--mobile') mobile = true;
|
|
204
215
|
else if (args[i] === '--vite') vite = true;
|
|
205
216
|
else if (args[i] === '--auth') { auth = true; authChosen = true; }
|
|
206
217
|
else if (args[i] === '--no-auth') { auth = false; authChosen = true; }
|
|
@@ -295,6 +306,8 @@ async function createProject(args) {
|
|
|
295
306
|
}
|
|
296
307
|
fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify(projectPkg, null, 2) + '\n');
|
|
297
308
|
if (electron) require('./electron.js').init(target);
|
|
309
|
+
if (mobile) require('./mobile.js').init(target);
|
|
310
|
+
if (mobile || electron) require('./home-variants.js').apply(target, { mobile, desktop: electron }); // a home page made for the app, not the web
|
|
298
311
|
if (vite) require('./vite.js').init(target, { ts });
|
|
299
312
|
|
|
300
313
|
console.log(`
|
package/bin/mobile.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Mobile apps (iOS and Android) for Kreft, with Capacitor.
|
|
3
|
+
*
|
|
4
|
+
* kreft mobile:init add Capacitor to this project (capacitor.config.json, package.json entries)
|
|
5
|
+
* kreft mobile:build [--android|--ios] [--api-url https://…]
|
|
6
|
+
* build the site (relative base, PWA files), then `cap add` (first time) and `cap sync`
|
|
7
|
+
* kreft mobile:open [--android|--ios] open Android Studio or Xcode
|
|
8
|
+
* kreft mobile:dev [--android|--ios] run on a device or emulator with the dev server (live reload)
|
|
9
|
+
* kreft mobile:help
|
|
10
|
+
*
|
|
11
|
+
* The backend does not run inside the phone: --api-url sets the server the app talks to (written to .env
|
|
12
|
+
* as PUBLIC_API_URL before the build). In the app: Kreftjs.api.setup({ base: Kreftjs.env.PUBLIC_API_URL, token: … }).
|
|
13
|
+
*/
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { start: spawn } = require('./win-spawn.js');
|
|
17
|
+
|
|
18
|
+
const CAP = '^7.0.0';
|
|
19
|
+
|
|
20
|
+
function projectRoot(start = process.cwd()) {
|
|
21
|
+
const lay = require('./layout.js').layout(start);
|
|
22
|
+
return lay.split ? lay.project : start;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readPkg(root) {
|
|
26
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch (_) { return null; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// an app id like com.example.myapp, from the package name
|
|
30
|
+
function appId(name) {
|
|
31
|
+
const slug = String(name || 'app').replace(/^@[^/]+\//, '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
32
|
+
return 'com.example.' + (/^[a-z]/.test(slug) ? slug : 'app' + slug);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function configFor(root) {
|
|
36
|
+
const pkg = readPkg(root) || {};
|
|
37
|
+
const name = pkg.productName || pkg.name || path.basename(root);
|
|
38
|
+
return {
|
|
39
|
+
appId: appId(pkg.name || name),
|
|
40
|
+
appName: String(name),
|
|
41
|
+
webDir: 'dist',
|
|
42
|
+
server: { androidScheme: 'https' },
|
|
43
|
+
android: { adjustMarginsForEdgeToEdge: 'force' }, // Android 15+ draws apps under the status bar: keep the page below it
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function init(target) {
|
|
48
|
+
const root = typeof target === 'string' ? target : projectRoot();
|
|
49
|
+
const cfgFile = path.join(root, 'capacitor.config.json');
|
|
50
|
+
if (!fs.existsSync(cfgFile)) {
|
|
51
|
+
fs.writeFileSync(cfgFile, JSON.stringify(configFor(root), null, 2) + '\n');
|
|
52
|
+
console.log(' Wrote capacitor.config.json');
|
|
53
|
+
}
|
|
54
|
+
const pkgFile = path.join(root, 'package.json');
|
|
55
|
+
const pkg = readPkg(root);
|
|
56
|
+
if (pkg) {
|
|
57
|
+
pkg.dependencies = { ...pkg.dependencies };
|
|
58
|
+
pkg.devDependencies = { ...pkg.devDependencies };
|
|
59
|
+
pkg.scripts = { ...pkg.scripts };
|
|
60
|
+
let changed = false;
|
|
61
|
+
const need = (obj, k, v) => { if (!obj[k]) { obj[k] = v; changed = true; } };
|
|
62
|
+
need(pkg.dependencies, '@capacitor/core', CAP);
|
|
63
|
+
need(pkg.dependencies, '@capacitor/android', CAP);
|
|
64
|
+
need(pkg.dependencies, '@capacitor/ios', CAP);
|
|
65
|
+
need(pkg.devDependencies, '@capacitor/cli', CAP);
|
|
66
|
+
need(pkg.scripts, 'mobile:build', 'kreft mobile:build');
|
|
67
|
+
need(pkg.scripts, 'mobile:android', 'kreft mobile:build --android');
|
|
68
|
+
need(pkg.scripts, 'mobile:ios', 'kreft mobile:build --ios');
|
|
69
|
+
if (changed) { fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + '\n'); console.log(' Added Capacitor to package.json. Run: npm install'); }
|
|
70
|
+
}
|
|
71
|
+
const ignore = path.join(root, '.gitignore');
|
|
72
|
+
if (fs.existsSync(ignore)) {
|
|
73
|
+
const txt = fs.readFileSync(ignore, 'utf8');
|
|
74
|
+
const add = ['android/app/build', 'ios/App/Pods', 'ios/App/build'].filter((l) => !txt.includes(l));
|
|
75
|
+
if (add.length) fs.appendFileSync(ignore, (txt.endsWith('\n') ? '' : '\n') + add.join('\n') + '\n');
|
|
76
|
+
}
|
|
77
|
+
console.log('\n Mobile support is ready. Next:\n npm install\n kreft mobile:build --android --api-url https://your-server.example\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function platforms(args) {
|
|
81
|
+
const p = ['android', 'ios'].filter((x) => args.includes('--' + x));
|
|
82
|
+
return p;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function setApiUrl(root, url) {
|
|
86
|
+
if (!/^https?:\/\/[^\s]+$/i.test(url)) { console.error(' --api-url must be a full address, like https://api.example.com'); process.exit(1); }
|
|
87
|
+
const file = path.join(root, '.env');
|
|
88
|
+
let txt = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
89
|
+
const line = 'PUBLIC_API_URL=' + url;
|
|
90
|
+
txt = /^PUBLIC_API_URL=.*$/m.test(txt) ? txt.replace(/^PUBLIC_API_URL=.*$/m, line) : txt + (txt && !txt.endsWith('\n') ? '\n' : '') + line + '\n';
|
|
91
|
+
fs.writeFileSync(file, txt);
|
|
92
|
+
console.log(' PUBLIC_API_URL=' + url + ' (in .env)');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function run(cmd, args, cwd) {
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
const p = spawn(cmd, args, { cwd, stdio: 'inherit' });
|
|
98
|
+
p.on('error', (e) => { console.error(' Could not run ' + cmd + ': ' + e.message); resolve(1); });
|
|
99
|
+
p.on('exit', (c) => resolve(c || 0));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function capBin(root) {
|
|
104
|
+
const bin = path.join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'cap.cmd' : 'cap');
|
|
105
|
+
return fs.existsSync(bin) ? bin : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function build(args = []) {
|
|
109
|
+
const root = projectRoot();
|
|
110
|
+
if (!fs.existsSync(path.join(root, 'capacitor.config.json'))) { console.log(' Capacitor is not set up yet. Initializing…'); init(root); }
|
|
111
|
+
const i = args.indexOf('--api-url');
|
|
112
|
+
if (i >= 0) setApiUrl(root, args[i + 1] || '');
|
|
113
|
+
const plats = platforms(args);
|
|
114
|
+
|
|
115
|
+
console.log('\n Step 1: Building the site (relative base, PWA files)…');
|
|
116
|
+
const kreftCli = path.join(__dirname, 'kreftjs.js');
|
|
117
|
+
const code = await run(process.execPath, [kreftCli, 'build', '--base', './', '--pwa', '--no-htaccess'], root);
|
|
118
|
+
if (code) { console.error(' Build failed with code ' + code); process.exit(code); }
|
|
119
|
+
|
|
120
|
+
const cap = capBin(root);
|
|
121
|
+
if (!cap) { console.error('\n Capacitor is not installed. Run: npm install\n'); process.exit(1); }
|
|
122
|
+
for (const p of plats.length ? plats : ['android', 'ios']) {
|
|
123
|
+
if (!plats.length && !fs.existsSync(path.join(root, p))) continue; // no flag: sync only the platforms that exist
|
|
124
|
+
if (!fs.existsSync(path.join(root, p))) {
|
|
125
|
+
console.log(`\n Adding ${p}…`);
|
|
126
|
+
const c = await run(cap, ['add', p], root);
|
|
127
|
+
if (c) process.exit(c);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
console.log('\n Step 2: Copying the build into the native projects…');
|
|
131
|
+
const c = await run(cap, ['sync', ...plats], root);
|
|
132
|
+
if (c) process.exit(c);
|
|
133
|
+
console.log('\n Done. Open the native project: kreft mobile:open --android (or --ios)\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function open(args = []) {
|
|
137
|
+
const root = projectRoot();
|
|
138
|
+
const cap = capBin(root);
|
|
139
|
+
if (!cap) { console.error('\n Capacitor is not installed. Run: npm install\n'); process.exit(1); }
|
|
140
|
+
process.exit(await run(cap, ['open', platforms(args)[0] || 'android'], root));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function dev(args = []) {
|
|
144
|
+
const root = projectRoot();
|
|
145
|
+
const cap = capBin(root);
|
|
146
|
+
if (!cap) { console.error('\n Capacitor is not installed. Run: npm install\n'); process.exit(1); }
|
|
147
|
+
const plat = platforms(args)[0] || 'android';
|
|
148
|
+
const port = process.env.PORT || '8000';
|
|
149
|
+
const ip = Object.values(require('os').networkInterfaces()).flat().find((n) => n && n.family === 'IPv4' && !n.internal);
|
|
150
|
+
const url = `http://${ip ? ip.address : 'localhost'}:${port}`;
|
|
151
|
+
console.log(` Dev server: ${url} (the phone must be on the same network)`);
|
|
152
|
+
const server = spawn(process.execPath, [path.join(__dirname, 'kreftjs.js'), 'dev'], { cwd: root, stdio: 'inherit', env: { ...process.env, PORT: port, HOST: '0.0.0.0' } });
|
|
153
|
+
const stop = () => { try { server.kill('SIGTERM'); } catch (_) {} process.exit(0); };
|
|
154
|
+
process.on('SIGINT', stop); process.on('SIGTERM', stop);
|
|
155
|
+
const c = await run(cap, ['run', plat, '--live-reload', '--host', ip ? ip.address : 'localhost', '--port', port], root);
|
|
156
|
+
try { server.kill('SIGTERM'); } catch (_) {}
|
|
157
|
+
process.exit(c);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function help() {
|
|
161
|
+
console.log(`
|
|
162
|
+
Mobile apps (iOS and Android) with Capacitor
|
|
163
|
+
|
|
164
|
+
kreft new my-app --mobile a new project with mobile support
|
|
165
|
+
kreft mobile:init add Capacitor to this project
|
|
166
|
+
kreft mobile:build --android build the site and sync it into android/ (--ios for iOS; no flag: both that exist)
|
|
167
|
+
kreft mobile:build --api-url https://api.example.com
|
|
168
|
+
the address of your server (the backend does not run inside the phone)
|
|
169
|
+
kreft mobile:open --android open Android Studio (--ios: Xcode)
|
|
170
|
+
kreft mobile:dev --android run on a device or emulator with live reload
|
|
171
|
+
|
|
172
|
+
Also: kreft build --pwa makes a normal website installable and usable offline (manifest.webmanifest + sw.js).
|
|
173
|
+
`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { init, build, open, dev, help, appId, configFor };
|
package/bin/pwa.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PWA output for `kreft build --pwa` (also used by the mobile commands):
|
|
3
|
+
* manifest.webmanifest name, colours, icon, start_url
|
|
4
|
+
* sw.js offline shell: pages and files are cached after the first visit; /api/ is never cached
|
|
5
|
+
* pwa-register.js registers sw.js (a file, not inline code, so it works under a strict CSP)
|
|
6
|
+
* and the matching <link>, <meta> and <script> tags in index.html.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
|
|
12
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
13
|
+
|
|
14
|
+
function listFiles(dir, base = dir, out = []) {
|
|
15
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
16
|
+
const p = path.join(dir, e.name);
|
|
17
|
+
if (e.isDirectory()) listFiles(p, base, out);
|
|
18
|
+
else out.push(path.relative(base, p).split(path.sep).join('/'));
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function apply(dist, root, opts = {}) {
|
|
24
|
+
const read = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (_) { return {}; } };
|
|
25
|
+
const pkg = read(path.join(opts.project || root, 'package.json'));
|
|
26
|
+
const name = opts.name || pkg.productName || pkg.name || path.basename(path.resolve(root));
|
|
27
|
+
const themeColor = opts.themeColor || '#ffffff';
|
|
28
|
+
const scope = './';
|
|
29
|
+
|
|
30
|
+
const icon = [path.join(root, 'icon.png'), path.join(opts.project || root, 'build', 'icon.png')].find((f) => fs.existsSync(f));
|
|
31
|
+
const icons = [];
|
|
32
|
+
if (icon) { fs.copyFileSync(icon, path.join(dist, 'icon.png')); icons.push({ src: 'icon.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }); }
|
|
33
|
+
|
|
34
|
+
const manifest = { name, short_name: String(name).slice(0, 12), start_url: scope, scope, display: 'standalone', background_color: themeColor, theme_color: themeColor, icons };
|
|
35
|
+
fs.writeFileSync(path.join(dist, 'manifest.webmanifest'), JSON.stringify(manifest, null, 2) + '\n');
|
|
36
|
+
|
|
37
|
+
const skip = new Set(['sw.js', '.htaccess']);
|
|
38
|
+
const files = listFiles(dist).filter((f) => !skip.has(f) && !f.endsWith('.map'));
|
|
39
|
+
const version = crypto.createHash('sha1').update(files.map((f) => f + fs.statSync(path.join(dist, f)).size).join('|')).digest('hex').slice(0, 10);
|
|
40
|
+
fs.writeFileSync(path.join(dist, 'sw.js'), `// Written by kreft build --pwa. A new build has a new version, so the old cache is dropped.
|
|
41
|
+
const CACHE = 'kreft-${version}';
|
|
42
|
+
const SHELL = ${JSON.stringify(['./', ...files.filter((f) => f !== 'index.html')])};
|
|
43
|
+
self.addEventListener('install', (e) => {
|
|
44
|
+
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
|
45
|
+
});
|
|
46
|
+
self.addEventListener('activate', (e) => {
|
|
47
|
+
e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
|
|
48
|
+
});
|
|
49
|
+
self.addEventListener('fetch', (e) => {
|
|
50
|
+
const req = e.request, url = new URL(req.url);
|
|
51
|
+
if (req.method !== 'GET' || url.origin !== location.origin || url.pathname.includes('/api/')) return; // data always goes to the network
|
|
52
|
+
if (req.mode === 'navigate') { // page addresses: network first, the cached index.html when offline
|
|
53
|
+
e.respondWith(fetch(req).catch(() => caches.match('./').then((r) => r || caches.match('index.html'))));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
e.respondWith(caches.match(req, { ignoreSearch: true }).then((hit) => hit || fetch(req).then((res) => {
|
|
57
|
+
if (res.ok) { const copy = res.clone(); caches.open(CACHE).then((c) => c.put(req, copy)); }
|
|
58
|
+
return res;
|
|
59
|
+
})));
|
|
60
|
+
});
|
|
61
|
+
`);
|
|
62
|
+
fs.writeFileSync(path.join(dist, 'pwa-register.js'),
|
|
63
|
+
`if ('serviceWorker' in navigator && location.protocol !== 'file:' && !location.protocol.startsWith('capacitor')) {\n window.addEventListener('load', () => navigator.serviceWorker.register('sw.js').catch(() => {}));\n}\n`);
|
|
64
|
+
|
|
65
|
+
const file = path.join(dist, 'index.html');
|
|
66
|
+
let html = fs.readFileSync(file, 'utf8');
|
|
67
|
+
const tags = ` <link rel="manifest" href="manifest.webmanifest">\n <meta name="theme-color" content="${esc(themeColor)}">\n <meta name="mobile-web-app-capable" content="yes">\n <meta name="apple-mobile-web-app-capable" content="yes">\n <meta name="apple-mobile-web-app-title" content="${esc(manifest.short_name)}">\n${icons.length ? ' <link rel="apple-touch-icon" href="icon.png">\n' : ''}`;
|
|
68
|
+
if (!html.includes('manifest.webmanifest')) html = html.replace(/<\/head>/i, () => tags + '</head>');
|
|
69
|
+
if (!html.includes('pwa-register.js')) html = html.replace(/<\/body>/i, () => ' <script src="pwa-register.js"></script>\n</body>');
|
|
70
|
+
// phones: draw under the notch; the app pads itself with env(safe-area-inset-*)
|
|
71
|
+
html = html.replace(/(<meta[^>]*name=["']viewport["'][^>]*content=["'])([^"']*)(["'])/i, (m, a, c, z) => /viewport-fit/.test(c) ? m : a + c + ', viewport-fit=cover' + z);
|
|
72
|
+
fs.writeFileSync(file, html);
|
|
73
|
+
return { name, files: files.length + 3 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { apply };
|
package/docs/README.md
CHANGED
|
@@ -2221,6 +2221,61 @@ Do not turn these off to make something work. Add a bridge function instead.
|
|
|
2221
2221
|
| The window opens before the server is ready | It waits up to 15 seconds. Check the terminal for a dev server error. |
|
|
2222
2222
|
| Port 8000 is taken | Nothing to do: the hidden server picks the next free port. |
|
|
2223
2223
|
|
|
2224
|
+
## Mobile apps (iOS and Android)
|
|
2225
|
+
|
|
2226
|
+
Kreft apps run on phones in two ways. Both start from the same `.kreft` code.
|
|
2227
|
+
|
|
2228
|
+
**1. An installable website (PWA).** `kreft build --pwa` also writes `manifest.webmanifest`, a service worker `sw.js` and `pwa-register.js`. Visitors can add the site to their home screen, and pages they have seen open without a connection. Requests to `/api/` are never cached. Put a 512x512 `icon.png` next to `index.html` for the home screen icon.
|
|
2229
|
+
|
|
2230
|
+
**2. A real app with Capacitor.** The built site runs inside a native WebView and goes to the App Store or Google Play.
|
|
2231
|
+
|
|
2232
|
+
```bash
|
|
2233
|
+
kreft new my-app --mobile # or: kreft mobile:init in an existing app
|
|
2234
|
+
npm install
|
|
2235
|
+
kreft mobile:build --android --api-url https://api.example.com
|
|
2236
|
+
kreft mobile:open --android # Android Studio (--ios: Xcode, needs a Mac)
|
|
2237
|
+
```
|
|
2238
|
+
|
|
2239
|
+
| Command | What it does |
|
|
2240
|
+
|---|---|
|
|
2241
|
+
| `kreft mobile:init` | Adds `capacitor.config.json`, the Capacitor packages and the `mobile:*` scripts |
|
|
2242
|
+
| `kreft mobile:build [--android] [--ios]` | Builds the site with a relative base and PWA files, adds the platform the first time, then runs `cap sync`. Without a flag it syncs the platforms that exist |
|
|
2243
|
+
| `kreft mobile:open` | Opens the native project |
|
|
2244
|
+
| `kreft mobile:dev` | Runs on a device or emulator with live reload from your dev server (same Wi-Fi) |
|
|
2245
|
+
|
|
2246
|
+
**Building the Android app needs Java 21** (Capacitor 7). Android Studio includes it; on a command line set `JAVA_HOME` to it, for example on a Mac `export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`. Running from Android Studio ( `kreft mobile:open --android` ) needs no extra setup. The iOS Simulator needs a full Xcode install, not only the command line tools.
|
|
2247
|
+
|
|
2248
|
+
**The backend does not run inside the phone.** Run it on a server and give the app its address with `--api-url` (written to `.env` as `PUBLIC_API_URL`), then in your code:
|
|
2249
|
+
|
|
2250
|
+
```js
|
|
2251
|
+
Kreftjs.api.setup({ base: Kreftjs.env.PUBLIC_API_URL, token: () => Kreftjs.storage.get('token') })
|
|
2252
|
+
```
|
|
2253
|
+
|
|
2254
|
+
Use API tokens for sign-in in the app, not cookie sessions.
|
|
2255
|
+
|
|
2256
|
+
### Home pages made for apps
|
|
2257
|
+
|
|
2258
|
+
A website's welcome page is not what an app should open on. `kreft new my-app --mobile` writes a **phone home screen** (top bar, shortcut cards, a bottom tab bar, a card that shows the touch events), and `kreft new my-app --electron` writes a **desktop workspace** (sidebar, toolbar with search, info cards, shortcuts). With both flags, `pages/Home.kreft` shows the desktop one inside the Electron window and the phone one everywhere else.
|
|
2259
|
+
|
|
2260
|
+
```bash
|
|
2261
|
+
kreft make:home --mobile # in an existing project: phone home screen (replaces pages/Home.kreft)
|
|
2262
|
+
kreft make:home --desktop # desktop workspace
|
|
2263
|
+
```
|
|
2264
|
+
|
|
2265
|
+
They are normal files (`components/HomeMobile.kreft`, `components/HomeDesktop.kreft`), so change them freely. Preview either in a browser with `?platform=mobile` or `?platform=desktop`. Links to the dashboard, users and profile appear only when the project has them (not with `--no-auth`).
|
|
2266
|
+
|
|
2267
|
+
### Touch events
|
|
2268
|
+
|
|
2269
|
+
```html
|
|
2270
|
+
<div @tap={open}>Open</div>
|
|
2271
|
+
<li @swipeleft={remove($event)} @longpress={select}>Row</li>
|
|
2272
|
+
<section @swipe={go($event)}>…</section> <!-- $event.direction: left | right | up | down -->
|
|
2273
|
+
```
|
|
2274
|
+
|
|
2275
|
+
`@tap` (moves less than 10px, under 500ms), `@longpress` (held still for 500ms), `@swipe` and `@swipeleft`, `@swiperight`, `@swipeup`, `@swipedown` (40px or more in under 800ms). They use pointer events, so they work with a finger, a pen and a mouse. `.once`, `.prevent` and `.stop` work. A horizontal swipe keeps vertical scrolling working.
|
|
2276
|
+
|
|
2277
|
+
The starter `ui.css` already pads the page for the notch (`env(safe-area-inset-*)`), removes the tap delay and makes buttons and fields at least 44px high on touch screens. New projects have `viewport-fit=cover` set by `kreft build --pwa`.
|
|
2278
|
+
|
|
2224
2279
|
## Packages
|
|
2225
2280
|
|
|
2226
2281
|
`kreft add` is a safer `npm install`:
|
package/kreftjs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Kreft v0.0.
|
|
2
|
+
* Kreft v0.0.23-beta.1 — 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.
|
|
@@ -1630,9 +1630,56 @@
|
|
|
1630
1630
|
if (mods.includes('stop')) e.stopPropagation();
|
|
1631
1631
|
invokeHandler(scope, expr, [e]);
|
|
1632
1632
|
};
|
|
1633
|
+
if (gestureTypes.has(type)) return bindGesture(el, type, mods, scope, expr);
|
|
1633
1634
|
el.addEventListener(type, handler, options);
|
|
1634
1635
|
}
|
|
1635
1636
|
|
|
1637
|
+
/* ---------- touch gestures: @tap, @longpress, @swipe, @swipeleft/right/up/down ----------
|
|
1638
|
+
* Built on pointer events, so they work with a finger, a pen and a mouse. The handler gets
|
|
1639
|
+
* { type, target, currentTarget, direction, dx, dy, duration, originalEvent, preventDefault(), stopPropagation() }.
|
|
1640
|
+
* A swipe needs 40px of travel (more along one axis than the other) in under 800ms; a tap moves less than 10px and
|
|
1641
|
+
* ends before 500ms; a longpress is held 500ms without moving. Modifiers: .prevent .stop .once. */
|
|
1642
|
+
const gestureTypes = new Set(['tap', 'longpress', 'swipe', 'swipeleft', 'swiperight', 'swipeup', 'swipedown']);
|
|
1643
|
+
function bindGesture(el, type, mods, scope, expr) {
|
|
1644
|
+
let start = null, timer = null, done = false;
|
|
1645
|
+
const fire = (extra, orig) => {
|
|
1646
|
+
if (done) return;
|
|
1647
|
+
if (mods.includes('once')) done = true;
|
|
1648
|
+
if (mods.includes('prevent')) orig.preventDefault();
|
|
1649
|
+
if (mods.includes('stop')) orig.stopPropagation();
|
|
1650
|
+
invokeHandler(scope, expr, [Object.assign({
|
|
1651
|
+
type, target: orig.target, currentTarget: el, originalEvent: orig,
|
|
1652
|
+
preventDefault: () => orig.preventDefault(), stopPropagation: () => orig.stopPropagation(),
|
|
1653
|
+
}, extra)]);
|
|
1654
|
+
};
|
|
1655
|
+
const clear = () => { if (timer) clearTimeout(timer); timer = null; };
|
|
1656
|
+
// let a vertical scroll keep working, while a horizontal swipe is ours
|
|
1657
|
+
if (type.startsWith('swipe') && el.style) el.style.touchAction = el.style.touchAction || (type === 'swipeup' || type === 'swipedown' ? 'pan-x' : 'pan-y');
|
|
1658
|
+
el.addEventListener('pointerdown', (e) => {
|
|
1659
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
1660
|
+
start = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false };
|
|
1661
|
+
clear();
|
|
1662
|
+
if (type === 'longpress') timer = setTimeout(() => { if (start && !start.moved) { const s = start; start = null; fire({ dx: 0, dy: 0, duration: Date.now() - s.t }, e); } }, 500);
|
|
1663
|
+
});
|
|
1664
|
+
el.addEventListener('pointermove', (e) => {
|
|
1665
|
+
if (!start) return;
|
|
1666
|
+
if (Math.abs(e.clientX - start.x) > 10 || Math.abs(e.clientY - start.y) > 10) { start.moved = true; if (type === 'longpress') clear(); }
|
|
1667
|
+
});
|
|
1668
|
+
el.addEventListener('pointercancel', () => { start = null; clear(); });
|
|
1669
|
+
el.addEventListener('pointerup', (e) => {
|
|
1670
|
+
clear();
|
|
1671
|
+
if (!start) return;
|
|
1672
|
+
const s = start; start = null;
|
|
1673
|
+
const dx = e.clientX - s.x, dy = e.clientY - s.y, duration = Date.now() - s.t;
|
|
1674
|
+
if (type === 'tap') { if (!s.moved && duration < 500) fire({ dx, dy, duration }, e); return; }
|
|
1675
|
+
if (type === 'longpress') return;
|
|
1676
|
+
const horizontal = Math.abs(dx) >= Math.abs(dy);
|
|
1677
|
+
if (duration > 800 || Math.max(Math.abs(dx), Math.abs(dy)) < 40) return;
|
|
1678
|
+
const direction = horizontal ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
|
|
1679
|
+
if (type === 'swipe' || type === 'swipe' + direction) fire({ direction, dx, dy, duration }, e);
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1636
1683
|
/* ---------- k-model on native elements ---------- */
|
|
1637
1684
|
const toNumber = (v) => { const n = parseFloat(v); return isNaN(n) ? v : n; };
|
|
1638
1685
|
const valueOf = (el) => ('_value' in el ? el._value : el.value);
|
|
@@ -5601,7 +5648,7 @@
|
|
|
5601
5648
|
stores, storeLog, busLog,
|
|
5602
5649
|
};
|
|
5603
5650
|
|
|
5604
|
-
const VERSION = '0.0.
|
|
5651
|
+
const VERSION = '0.0.23-beta.1';
|
|
5605
5652
|
const Kreftjs = {
|
|
5606
5653
|
version: VERSION,
|
|
5607
5654
|
// PUBLIC_ values from .env (kreft dev and kreft build put them here); never secrets
|
|
@@ -5669,7 +5716,7 @@
|
|
|
5669
5716
|
|
|
5670
5717
|
// the text-only helpers the build tool reuses, so built code matches the browser exactly
|
|
5671
5718
|
Object.defineProperty(Kreftjs, '__internal', {
|
|
5672
|
-
value: { expandBlocks, prepareTemplate, splitKreft, splitInterp, exactBrace, stripBrace, interpToExpr, quoteBraceAttrs, expandSelfClosing, scopeCss, hashString, propType, typeCheck, caseCond, simplePathRE, fnExpRE, forAliasRE, makeKreftDef, compileFn, evaluate, stripTypeScript, stripTsParams },
|
|
5719
|
+
value: { expandBlocks, prepareTemplate, splitKreft, splitInterp, exactBrace, stripBrace, interpToExpr, quoteBraceAttrs, expandSelfClosing, scopeCss, hashString, propType, typeCheck, caseCond, simplePathRE, fnExpRE, forAliasRE, makeKreftDef, compileFn, evaluate, stripTypeScript, stripTsParams, bindEvent },
|
|
5673
5720
|
});
|
|
5674
5721
|
|
|
5675
5722
|
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.23-beta.1",
|
|
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 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",
|
|
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 test/mobile.test.js test/gestures.test.js test/home-variants.test.js",
|
|
18
18
|
"electron": "kreftjs electron",
|
|
19
19
|
"electron:dev": "kreftjs electron:dev",
|
|
20
20
|
"electron:build": "kreftjs electron:build"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Kreft v0.0.
|
|
2
|
+
* Kreft v0.0.23-beta.1 — 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.
|
|
@@ -1630,9 +1630,56 @@
|
|
|
1630
1630
|
if (mods.includes('stop')) e.stopPropagation();
|
|
1631
1631
|
invokeHandler(scope, expr, [e]);
|
|
1632
1632
|
};
|
|
1633
|
+
if (gestureTypes.has(type)) return bindGesture(el, type, mods, scope, expr);
|
|
1633
1634
|
el.addEventListener(type, handler, options);
|
|
1634
1635
|
}
|
|
1635
1636
|
|
|
1637
|
+
/* ---------- touch gestures: @tap, @longpress, @swipe, @swipeleft/right/up/down ----------
|
|
1638
|
+
* Built on pointer events, so they work with a finger, a pen and a mouse. The handler gets
|
|
1639
|
+
* { type, target, currentTarget, direction, dx, dy, duration, originalEvent, preventDefault(), stopPropagation() }.
|
|
1640
|
+
* A swipe needs 40px of travel (more along one axis than the other) in under 800ms; a tap moves less than 10px and
|
|
1641
|
+
* ends before 500ms; a longpress is held 500ms without moving. Modifiers: .prevent .stop .once. */
|
|
1642
|
+
const gestureTypes = new Set(['tap', 'longpress', 'swipe', 'swipeleft', 'swiperight', 'swipeup', 'swipedown']);
|
|
1643
|
+
function bindGesture(el, type, mods, scope, expr) {
|
|
1644
|
+
let start = null, timer = null, done = false;
|
|
1645
|
+
const fire = (extra, orig) => {
|
|
1646
|
+
if (done) return;
|
|
1647
|
+
if (mods.includes('once')) done = true;
|
|
1648
|
+
if (mods.includes('prevent')) orig.preventDefault();
|
|
1649
|
+
if (mods.includes('stop')) orig.stopPropagation();
|
|
1650
|
+
invokeHandler(scope, expr, [Object.assign({
|
|
1651
|
+
type, target: orig.target, currentTarget: el, originalEvent: orig,
|
|
1652
|
+
preventDefault: () => orig.preventDefault(), stopPropagation: () => orig.stopPropagation(),
|
|
1653
|
+
}, extra)]);
|
|
1654
|
+
};
|
|
1655
|
+
const clear = () => { if (timer) clearTimeout(timer); timer = null; };
|
|
1656
|
+
// let a vertical scroll keep working, while a horizontal swipe is ours
|
|
1657
|
+
if (type.startsWith('swipe') && el.style) el.style.touchAction = el.style.touchAction || (type === 'swipeup' || type === 'swipedown' ? 'pan-x' : 'pan-y');
|
|
1658
|
+
el.addEventListener('pointerdown', (e) => {
|
|
1659
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
1660
|
+
start = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false };
|
|
1661
|
+
clear();
|
|
1662
|
+
if (type === 'longpress') timer = setTimeout(() => { if (start && !start.moved) { const s = start; start = null; fire({ dx: 0, dy: 0, duration: Date.now() - s.t }, e); } }, 500);
|
|
1663
|
+
});
|
|
1664
|
+
el.addEventListener('pointermove', (e) => {
|
|
1665
|
+
if (!start) return;
|
|
1666
|
+
if (Math.abs(e.clientX - start.x) > 10 || Math.abs(e.clientY - start.y) > 10) { start.moved = true; if (type === 'longpress') clear(); }
|
|
1667
|
+
});
|
|
1668
|
+
el.addEventListener('pointercancel', () => { start = null; clear(); });
|
|
1669
|
+
el.addEventListener('pointerup', (e) => {
|
|
1670
|
+
clear();
|
|
1671
|
+
if (!start) return;
|
|
1672
|
+
const s = start; start = null;
|
|
1673
|
+
const dx = e.clientX - s.x, dy = e.clientY - s.y, duration = Date.now() - s.t;
|
|
1674
|
+
if (type === 'tap') { if (!s.moved && duration < 500) fire({ dx, dy, duration }, e); return; }
|
|
1675
|
+
if (type === 'longpress') return;
|
|
1676
|
+
const horizontal = Math.abs(dx) >= Math.abs(dy);
|
|
1677
|
+
if (duration > 800 || Math.max(Math.abs(dx), Math.abs(dy)) < 40) return;
|
|
1678
|
+
const direction = horizontal ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
|
|
1679
|
+
if (type === 'swipe' || type === 'swipe' + direction) fire({ direction, dx, dy, duration }, e);
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1636
1683
|
/* ---------- k-model on native elements ---------- */
|
|
1637
1684
|
const toNumber = (v) => { const n = parseFloat(v); return isNaN(n) ? v : n; };
|
|
1638
1685
|
const valueOf = (el) => ('_value' in el ? el._value : el.value);
|
|
@@ -5601,7 +5648,7 @@
|
|
|
5601
5648
|
stores, storeLog, busLog,
|
|
5602
5649
|
};
|
|
5603
5650
|
|
|
5604
|
-
const VERSION = '0.0.
|
|
5651
|
+
const VERSION = '0.0.23-beta.1';
|
|
5605
5652
|
const Kreftjs = {
|
|
5606
5653
|
version: VERSION,
|
|
5607
5654
|
// PUBLIC_ values from .env (kreft dev and kreft build put them here); never secrets
|
|
@@ -5669,7 +5716,7 @@
|
|
|
5669
5716
|
|
|
5670
5717
|
// the text-only helpers the build tool reuses, so built code matches the browser exactly
|
|
5671
5718
|
Object.defineProperty(Kreftjs, '__internal', {
|
|
5672
|
-
value: { expandBlocks, prepareTemplate, splitKreft, splitInterp, exactBrace, stripBrace, interpToExpr, quoteBraceAttrs, expandSelfClosing, scopeCss, hashString, propType, typeCheck, caseCond, simplePathRE, fnExpRE, forAliasRE, makeKreftDef, compileFn, evaluate, stripTypeScript, stripTsParams },
|
|
5719
|
+
value: { expandBlocks, prepareTemplate, splitKreft, splitInterp, exactBrace, stripBrace, interpToExpr, quoteBraceAttrs, expandSelfClosing, scopeCss, hashString, propType, typeCheck, caseCond, simplePathRE, fnExpRE, forAliasRE, makeKreftDef, compileFn, evaluate, stripTypeScript, stripTsParams, bindEvent },
|
|
5673
5720
|
});
|
|
5674
5721
|
|
|
5675
5722
|
if (typeof module !== 'undefined' && module.exports) module.exports = Kreftjs;
|
|
@@ -515,3 +515,18 @@ tbody tr:hover {
|
|
|
515
515
|
animation: none !important;
|
|
516
516
|
}
|
|
517
517
|
}
|
|
518
|
+
|
|
519
|
+
/* ---- phones and tablets (also inside the Capacitor iOS / Android apps) ---- */
|
|
520
|
+
body {
|
|
521
|
+
padding-top: env(safe-area-inset-top);
|
|
522
|
+
padding-right: env(safe-area-inset-right);
|
|
523
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
524
|
+
padding-left: env(safe-area-inset-left);
|
|
525
|
+
-webkit-tap-highlight-color: transparent;
|
|
526
|
+
-webkit-text-size-adjust: 100%;
|
|
527
|
+
}
|
|
528
|
+
button, a, [role="button"], input, select, textarea { touch-action: manipulation; } /* no 300ms click delay, no double-tap zoom */
|
|
529
|
+
@media (pointer: coarse) {
|
|
530
|
+
button, .btn, input, select, textarea { min-height: 44px; } /* a finger needs about 44px */
|
|
531
|
+
input, select, textarea { font-size: 16px; } /* iOS zooms into smaller fields */
|
|
532
|
+
}
|