glashjs 0.13.4 → 0.14.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glashjs",
3
- "version": "0.13.4",
3
+ "version": "0.14.0",
4
4
  "description": "glashjs — The Postgres-native full-stack framework for builders who want to ship without DevOps. Framework, hosting, database, auth, and deploy in one GlashDB-native runtime.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,8 @@
21
21
  "./image": "./src/components/image.mjs",
22
22
  "./video": "./src/components/video.mjs",
23
23
  "./link": "./src/components/link.mjs",
24
- "./package.json": "./package.json"
24
+ "./package.json": "./package.json",
25
+ "./i18n": "./src/i18n.mjs"
25
26
  },
26
27
  "files": [
27
28
  "bin",
package/src/create.mjs CHANGED
@@ -42,6 +42,8 @@ async function collectAnswers(options, rl) {
42
42
  const auth = boolAnswer(options.auth, await ask(rl, 'Add glashAuth routes?', 'yes'));
43
43
  const sqlRunner = boolAnswer(options.sqlRunner, await ask(rl, 'Add SQL runner support?', 'yes'));
44
44
  const aiPrompts = boolAnswer(options.aiPrompts, await ask(rl, 'Add AI deployment prompts?', 'yes'));
45
+ const translate = boolAnswer(options.translate, await ask(rl, 'Add built-in IP auto-translation?', 'yes'));
46
+ const animation = boolAnswer(options.animation, await ask(rl, 'Add motion + 3D (motion, three.js)?', 'no'));
45
47
  const install = boolAnswer(options.install, await ask(rl, 'Install dependencies now?', 'yes'));
46
48
  const packageManager = normalizeChoice(options.packageManager || await ask(rl, 'Package manager (npm/pnpm/yarn/bun)', detectPackageManager()), PM_CHOICES, 'npm');
47
49
  const git = boolAnswer(options.git, await ask(rl, 'Initialize git?', 'yes'));
@@ -54,6 +56,8 @@ async function collectAnswers(options, rl) {
54
56
  auth,
55
57
  sqlRunner,
56
58
  aiPrompts,
59
+ translate,
60
+ animation,
57
61
  install,
58
62
  packageManager,
59
63
  git,
@@ -85,6 +89,8 @@ function projectFiles(answers) {
85
89
  pg: '^8.16.3',
86
90
  preact: '^10.29.2',
87
91
  'preact-render-to-string': '^6.7.0',
92
+ // Motion (animation) + three.js (3D), bundled client-side via esbuild.
93
+ ...(answers.animation ? { motion: '^11.15.0', three: '^0.171.0' } : {}),
88
94
  };
89
95
  const devDependencies = answers.css === 'tailwind'
90
96
  ? { tailwindcss: '^4.1.0', '@tailwindcss/cli': '^4.1.0' }
@@ -105,7 +111,7 @@ function projectFiles(answers) {
105
111
  '.env.local': envLocal(answers),
106
112
  'glash.config.mjs': configFile(answers, hasCss),
107
113
  'README.md': projectReadme(answers),
108
- 'routes/_layout.jsx': layoutRoute(),
114
+ 'routes/_layout.jsx': layoutRoute(answers),
109
115
  'routes/index.jsx': indexRoute(answers),
110
116
  'routes/api/health.mjs': healthRoute(),
111
117
  'db/schema.sql': schemaSql(answers),
@@ -113,6 +119,8 @@ function projectFiles(answers) {
113
119
  ...(answers.css === 'tailwind' ? { 'styles/input.css': tailwindInput() } : {}),
114
120
  ...(answers.auth ? authRoutes() : {}),
115
121
  ...(answers.sqlRunner ? sqlRunnerRoutes() : {}),
122
+ ...(answers.translate ? { 'routes/api/_glash/geo.mjs': geoRoute() } : {}),
123
+ ...(answers.animation ? { 'routes/demo/motion.jsx': motionDemoRoute() } : {}),
116
124
  ...(answers.aiPrompts ? { '.glash/prompts/deploy.md': aiDeployPrompt(answers) } : {}),
117
125
  'public/favicon.svg': faviconSvg(),
118
126
  };
@@ -140,10 +148,19 @@ export default defineConfig({
140
148
  `;
141
149
  }
142
150
 
143
- function layoutRoute() {
144
- return `import { Link } from 'glashjs/link';
151
+ function layoutRoute(answers = {}) {
152
+ const t = answers.translate;
153
+ const imports = [`import { Link } from 'glashjs/link';`];
154
+ if (t) {
155
+ imports.push(`import { useEffect } from 'preact/hooks';`);
156
+ imports.push(`import { glashAutoTranslate } from 'glashjs/i18n';`);
157
+ }
158
+ return `${imports.join('\n')}
145
159
 
146
- export default function RootLayout({ children }) {
160
+ export default function RootLayout({ children }) {${t ? `
161
+ // Built-in IP auto-translation: detect the visitor's country and offer to
162
+ // translate the page into their native language. Reads /api/_glash/geo.
163
+ useEffect(() => { glashAutoTranslate(); }, []);` : ''}
147
164
  return (
148
165
  <div className="shell">
149
166
  <header className="nav">
@@ -160,6 +177,58 @@ export default function RootLayout({ children }) {
160
177
  `;
161
178
  }
162
179
 
180
+ function geoRoute() {
181
+ return `// IP -> country -> native language, read from the edge geo header
182
+ // (CF-IPCountry / X-Glash-Country on glashDB hosting). The client i18n runtime
183
+ // calls this to decide whether to offer auto-translation.
184
+ import { geoRouteHandler } from 'glashjs/i18n';
185
+
186
+ export const GET = geoRouteHandler;
187
+ `;
188
+ }
189
+
190
+ function motionDemoRoute() {
191
+ return `import { useEffect, useRef } from 'preact/hooks';
192
+ import { animate } from 'motion';
193
+ import * as THREE from 'three';
194
+
195
+ export const metadata = { title: 'Motion + 3D' };
196
+
197
+ export default function MotionDemo() {
198
+ const card = useRef(null);
199
+ const canvas = useRef(null);
200
+
201
+ useEffect(() => {
202
+ if (card.current) {
203
+ animate(card.current, { rotate: [0, 8, -8, 0], scale: [1, 1.06, 1] }, { duration: 2.4, repeat: Infinity });
204
+ }
205
+ const el = canvas.current;
206
+ if (!el) return;
207
+ const renderer = new THREE.WebGLRenderer({ canvas: el, antialias: true, alpha: true });
208
+ renderer.setSize(240, 240, false);
209
+ const scene = new THREE.Scene();
210
+ const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100);
211
+ camera.position.z = 3;
212
+ const cube = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial());
213
+ scene.add(cube);
214
+ let raf;
215
+ const loop = () => { cube.rotation.x += 0.01; cube.rotation.y += 0.013; renderer.render(scene, camera); raf = requestAnimationFrame(loop); };
216
+ loop();
217
+ return () => cancelAnimationFrame(raf);
218
+ }, []);
219
+
220
+ return (
221
+ <main className="hero">
222
+ <h1>Motion + 3D</h1>
223
+ <p className="lede">motion drives the card animation; three.js renders the cube.</p>
224
+ <div ref={card} style={{ width: '120px', height: '120px', borderRadius: '16px', background: '#e8eaed' }} />
225
+ <canvas ref={canvas} width={240} height={240} />
226
+ </main>
227
+ );
228
+ }
229
+ `;
230
+ }
231
+
163
232
  function indexRoute(answers) {
164
233
  return `import { useState } from 'preact/hooks';
165
234
  import { callServerFunction } from 'glashjs/server-functions';
package/src/i18n.mjs ADDED
@@ -0,0 +1,185 @@
1
+ // glashjs/i18n — built-in IP-based auto-translation.
2
+ //
3
+ // Server:
4
+ // detectCountry(req) -> 'US' | null (reads edge/CDN geo headers)
5
+ // languageForCountry('FR') -> { code: 'fr', name: 'Français' } | null
6
+ //
7
+ // Client:
8
+ // glashAutoTranslate(opts) -> looks up the visitor's country (via a glashjs
9
+ // geo API route, falling back to a public IP service), maps it to a language,
10
+ // and — if that differs from the page language — shows a small prompt to
11
+ // translate the page into the visitor's native language. No API key: it uses
12
+ // the Google website-translate widget (cookie-driven, in-place).
13
+ //
14
+ // Wire it up in a client effect:
15
+ // import { glashAutoTranslate } from 'glashjs/i18n';
16
+ // useEffect(() => glashAutoTranslate(), []);
17
+ // and scaffold `routes/api/_glash/geo.mjs` (see geoRouteHandler) so detection
18
+ // works on glashDB hosting, where the edge sets the country header.
19
+
20
+ // country code -> [BCP-47 language, native name]
21
+ export const COUNTRY_LANGUAGE = {
22
+ US: ['en', 'English'], GB: ['en', 'English'], AU: ['en', 'English'], CA: ['en', 'English'],
23
+ IE: ['en', 'English'], NZ: ['en', 'English'], NG: ['en', 'English'], GH: ['en', 'English'],
24
+ KE: ['en', 'English'], ZA: ['en', 'English'], IN: ['hi', 'हिन्दी'], PK: ['ur', 'اردو'],
25
+ FR: ['fr', 'Français'], BE: ['fr', 'Français'], LU: ['fr', 'Français'], CI: ['fr', 'Français'],
26
+ SN: ['fr', 'Français'], CM: ['fr', 'Français'], ES: ['es', 'Español'], MX: ['es', 'Español'],
27
+ AR: ['es', 'Español'], CO: ['es', 'Español'], CL: ['es', 'Español'], PE: ['es', 'Español'],
28
+ VE: ['es', 'Español'], DE: ['de', 'Deutsch'], AT: ['de', 'Deutsch'], CH: ['de', 'Deutsch'],
29
+ IT: ['it', 'Italiano'], PT: ['pt', 'Português'], BR: ['pt', 'Português'], AO: ['pt', 'Português'],
30
+ NL: ['nl', 'Nederlands'], RU: ['ru', 'Русский'], UA: ['uk', 'Українська'], PL: ['pl', 'Polski'],
31
+ CZ: ['cs', 'Čeština'], SK: ['sk', 'Slovenčina'], RO: ['ro', 'Română'], HU: ['hu', 'Magyar'],
32
+ GR: ['el', 'Ελληνικά'], TR: ['tr', 'Türkçe'], SE: ['sv', 'Svenska'], NO: ['no', 'Norsk'],
33
+ DK: ['da', 'Dansk'], FI: ['fi', 'Suomi'], IS: ['is', 'Íslenska'], CN: ['zh-CN', '中文'],
34
+ TW: ['zh-TW', '繁體中文'], HK: ['zh-TW', '繁體中文'], JP: ['ja', '日本語'], KR: ['ko', '한국어'],
35
+ TH: ['th', 'ไทย'], VN: ['vi', 'Tiếng Việt'], ID: ['id', 'Bahasa Indonesia'], MY: ['ms', 'Bahasa Melayu'],
36
+ PH: ['tl', 'Filipino'], SA: ['ar', 'العربية'], AE: ['ar', 'العربية'], EG: ['ar', 'العربية'],
37
+ MA: ['ar', 'العربية'], DZ: ['ar', 'العربية'], IQ: ['ar', 'العربية'], IL: ['he', 'עברית'],
38
+ IR: ['fa', 'فارسی'], BD: ['bn', 'বাংলা'], ET: ['am', 'አማርኛ'],
39
+ };
40
+
41
+ export function detectCountry(req) {
42
+ const h = (req && req.headers) || {};
43
+ const get = (k) => (typeof h.get === 'function' ? h.get(k) : (h[k] ?? h[k.toLowerCase()]));
44
+ const cc = String(
45
+ get('cf-ipcountry') || get('x-glash-country') || get('x-vercel-ip-country') || get('x-country-code') || '',
46
+ ).split(',')[0].trim().toUpperCase();
47
+ return /^[A-Z]{2}$/.test(cc) && cc !== 'XX' ? cc : null;
48
+ }
49
+
50
+ export function languageForCountry(country) {
51
+ const entry = COUNTRY_LANGUAGE[String(country || '').toUpperCase()];
52
+ return entry ? { code: entry[0], name: entry[1] } : null;
53
+ }
54
+
55
+ /** Drop-in handler for `routes/api/_glash/geo.mjs` (export const GET = geoRouteHandler). */
56
+ export function geoRouteHandler(ctx) {
57
+ const country = detectCountry({ headers: (ctx && ctx.headers) || {} });
58
+ const language = country ? languageForCountry(country) : null;
59
+ return { country, language };
60
+ }
61
+
62
+ // ---- client ---------------------------------------------------------------
63
+
64
+ function baseLang(code) { return String(code || '').toLowerCase().split('-')[0]; }
65
+
66
+ async function lookupLanguage(geoEndpoint) {
67
+ // 1) glashjs geo route (works on glashDB hosting via the edge country header).
68
+ try {
69
+ const r = await fetch(geoEndpoint, { headers: { accept: 'application/json' } });
70
+ if (r.ok) {
71
+ const d = await r.json();
72
+ if (d && d.language && d.language.code) return d.language;
73
+ if (d && d.country) { const l = languageForCountry(d.country); if (l) return l; }
74
+ }
75
+ } catch { /* fall through */ }
76
+ // 2) Public IP geolocation fallback (no key, CORS-enabled).
77
+ try {
78
+ const r = await fetch('https://ipapi.co/json/');
79
+ if (r.ok) {
80
+ const d = await r.json();
81
+ const l = d && d.country_code ? languageForCountry(d.country_code) : null;
82
+ if (l) return l;
83
+ }
84
+ } catch { /* fall through */ }
85
+ return null;
86
+ }
87
+
88
+ function setGoogTransCookie(from, to) {
89
+ const val = `/${from}/${to}`;
90
+ const host = location.hostname;
91
+ document.cookie = `googtrans=${val};path=/`;
92
+ document.cookie = `googtrans=${val};path=/;domain=${host}`;
93
+ if (host.split('.').length > 1) document.cookie = `googtrans=${val};path=/;domain=.${host}`;
94
+ }
95
+
96
+ function loadGoogleWidget(pageLanguage) {
97
+ if (window.__glashGTLoaded) return;
98
+ window.__glashGTLoaded = true;
99
+ if (!document.getElementById('glash-gt')) {
100
+ const el = document.createElement('div');
101
+ el.id = 'glash-gt';
102
+ el.style.display = 'none';
103
+ document.body.appendChild(el);
104
+ }
105
+ window.googleTranslateElementInit = function () {
106
+ try { new window.google.translate.TranslateElement({ pageLanguage, autoDisplay: false }, 'glash-gt'); } catch { /* ignore */ }
107
+ };
108
+ const s = document.createElement('script');
109
+ s.src = 'https://translate.google.com/translate_a/element.js?cb=googleTranslateElementInit';
110
+ document.head.appendChild(s);
111
+ }
112
+
113
+ function showPrompt(language, onAccept, onDismiss) {
114
+ if (document.getElementById('glash-translate-prompt')) return;
115
+ const box = document.createElement('div');
116
+ box.id = 'glash-translate-prompt';
117
+ box.setAttribute('role', 'dialog');
118
+ box.style.cssText = [
119
+ 'position:fixed', 'z-index:2147483646', 'left:50%', 'bottom:20px', 'transform:translateX(-50%)',
120
+ 'max-width:min(92vw,460px)', 'display:flex', 'gap:12px', 'align-items:center',
121
+ 'padding:12px 14px', 'border-radius:12px', 'font:14px/1.4 system-ui,sans-serif',
122
+ 'background:#0b0d12', 'color:#e8eaed', 'border:1px solid rgba(255,255,255,0.12)',
123
+ 'box-shadow:0 8px 30px rgba(0,0,0,0.45)',
124
+ ].join(';');
125
+ const text = document.createElement('span');
126
+ text.style.cssText = 'flex:1';
127
+ text.textContent = `View this site in ${language.name}?`;
128
+ const yes = document.createElement('button');
129
+ yes.textContent = 'Translate';
130
+ yes.style.cssText = 'cursor:pointer;border:0;border-radius:8px;padding:7px 12px;font:inherit;background:#e8eaed;color:#0b0d12;font-weight:600';
131
+ const no = document.createElement('button');
132
+ no.textContent = 'No thanks';
133
+ no.style.cssText = 'cursor:pointer;border:0;border-radius:8px;padding:7px 10px;font:inherit;background:transparent;color:#9aa0a6';
134
+ yes.addEventListener('click', () => { box.remove(); onAccept(); });
135
+ no.addEventListener('click', () => { box.remove(); onDismiss(); });
136
+ box.append(text, yes, no);
137
+ document.body.appendChild(box);
138
+ }
139
+
140
+ /**
141
+ * Detect the visitor's country by IP, map it to their native language, and (if it
142
+ * differs from the page language) prompt to auto-translate the page in place.
143
+ *
144
+ * Options:
145
+ * pageLanguage language the site is authored in (default: <html lang> or 'en')
146
+ * geoEndpoint glashjs geo route (default: '/api/_glash/geo')
147
+ * auto translate immediately without prompting (default: false)
148
+ * storageKey localStorage key remembering the choice
149
+ */
150
+ export function glashAutoTranslate(opts = {}) {
151
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
152
+ const {
153
+ pageLanguage = baseLang(document.documentElement.lang) || 'en',
154
+ geoEndpoint = '/api/_glash/geo',
155
+ auto = false,
156
+ storageKey = 'glash:autotranslate',
157
+ } = opts;
158
+
159
+ // If a previous choice already translated the page, re-apply the widget so the
160
+ // translation persists across navigations, then stop.
161
+ if (/(^|;\s*)googtrans=\//.test(document.cookie)) { loadGoogleWidget(pageLanguage); return; }
162
+ let saved = null;
163
+ try { saved = localStorage.getItem(storageKey); } catch { /* ignore */ }
164
+ if (saved === 'dismissed') return;
165
+
166
+ const translateTo = (code) => {
167
+ setGoogTransCookie(pageLanguage, code);
168
+ loadGoogleWidget(pageLanguage);
169
+ setTimeout(() => location.reload(), 60);
170
+ };
171
+
172
+ if (saved && saved !== pageLanguage) { translateTo(saved); return; }
173
+
174
+ lookupLanguage(geoEndpoint).then((language) => {
175
+ if (!language || baseLang(language.code) === pageLanguage) return;
176
+ if (auto) { try { localStorage.setItem(storageKey, language.code); } catch {} translateTo(language.code); return; }
177
+ showPrompt(
178
+ language,
179
+ () => { try { localStorage.setItem(storageKey, language.code); } catch {} translateTo(language.code); },
180
+ () => { try { localStorage.setItem(storageKey, 'dismissed'); } catch {} },
181
+ );
182
+ }).catch(() => {});
183
+ }
184
+
185
+ export default { detectCountry, languageForCountry, geoRouteHandler, glashAutoTranslate, COUNTRY_LANGUAGE };
@@ -54,6 +54,15 @@ const REACT_ALIAS = {
54
54
  'next/headers': path.join(pkgRoot, 'next/headers.mjs'),
55
55
  };
56
56
 
57
+ // CJS deps bundled into an ESM server module (e.g. lucide-react -> require('react')
58
+ // -> aliased preact/compat, which is external) emit a dynamic require that ESM
59
+ // can't run ("Dynamic require of X is not supported"). Defining `require` via
60
+ // createRequire makes esbuild's __require shim use the real Node require, so those
61
+ // runtime requires resolve from the app's node_modules.
62
+ const NODE_ESM_REQUIRE_BANNER = {
63
+ js: "import { createRequire as __glashCreateRequire } from 'node:module';\nconst require = __glashCreateRequire(import.meta.url);",
64
+ };
65
+
57
66
  export function isComponentRoute(file) {
58
67
  return /\.(jsx|tsx)$/.test(file);
59
68
  }
@@ -76,7 +85,7 @@ export async function compileModule(file, root, dev) {
76
85
  entryPoints: [file], bundle: true, platform: 'node', format: 'esm',
77
86
  jsx: 'automatic', jsxImportSource: 'preact',
78
87
  external: ['preact', 'preact/*', 'preact-render-to-string'],
79
- alias: REACT_ALIAS, outfile: out, logLevel: 'silent',
88
+ alias: REACT_ALIAS, banner: NODE_ESM_REQUIRE_BANNER, outfile: out, logLevel: 'silent',
80
89
  });
81
90
  return import(pathToFileURL(out).href + (dev ? `?t=${Date.now()}` : ''));
82
91
  }
@@ -158,7 +167,7 @@ export async function loadComponentRoute(pageFile, layouts, root, dev, force = f
158
167
  stdin: { contents: serverEntry(pageFile, layouts), resolveDir: path.dirname(pageFile), loader: 'js', sourcefile: 'glash-server-entry.js' },
159
168
  bundle: true, platform: 'node', format: 'esm', jsx: 'automatic', jsxImportSource: 'preact',
160
169
  external: ['preact', 'preact/*', 'preact-render-to-string'],
161
- alias: REACT_ALIAS,
170
+ alias: REACT_ALIAS, banner: NODE_ESM_REQUIRE_BANNER,
162
171
  outfile: out, logLevel: 'silent',
163
172
  });
164
173
  const mod = await import(pathToFileURL(out).href + (dev ? `?t=${Date.now()}` : ''));