glashjs 0.14.1 → 0.15.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/README.md CHANGED
@@ -179,7 +179,8 @@ export default defineConfig({
179
179
  outDir: '.glash/out',
180
180
  stylesheets: ['/app.css'],
181
181
  offline: true,
182
- animatedFavicon: true,
182
+ favicon: 'node_modules/glashjs/templates/glash_favicon.svg',
183
+ animatedFavicon: false,
183
184
  dataPrefixes: ['/api/', '/auth/', '/rest/', '/live', '/stream'],
184
185
  });
185
186
  ```
package/bin/glash.mjs CHANGED
@@ -56,6 +56,11 @@ function glashMark() {
56
56
  ];
57
57
  }
58
58
 
59
+ function faviconLabel(cfg) {
60
+ const source = String(cfg.favicon || 'glash_favicon.svg');
61
+ return source.split(/[\\/]/).pop() || 'glash_favicon.svg';
62
+ }
63
+
59
64
  // LAN IPv4 addresses, so the dev server prints a Network URL you can open from
60
65
  // your phone or another device on the same network.
61
66
  function lanAddresses() {
@@ -79,6 +84,7 @@ async function serve(dev) {
79
84
  console.log('');
80
85
  for (const line of glashMark()) console.log(line);
81
86
  console.log(` glashjs ${dev ? 'dev' : 'serve'} — "${cfg.name}"`);
87
+ console.log(` favicon ${faviconLabel(cfg)} -> /favicon.svg`);
82
88
  if (port !== preferredPort) console.log(` Port ${preferredPort} is in use; using ${port} instead.`);
83
89
  console.log(` ${pages} page route(s), ${apis} api route(s)`);
84
90
  routes.forEach((r) => console.log(` ${r.isApi ? 'api ' : 'page'} ${r.pattern}`));
@@ -118,6 +124,8 @@ async function main() {
118
124
  auth: optionBool('--no-auth', undefined),
119
125
  sqlRunner: optionBool('--no-sql-runner', undefined),
120
126
  aiPrompts: optionBool('--no-ai-prompts', undefined),
127
+ translate: optionBool('--no-translate', undefined),
128
+ animation: flag('--animation') ? true : undefined,
121
129
  });
122
130
  break;
123
131
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glashjs",
3
- "version": "0.14.1",
3
+ "version": "0.15.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": {
package/src/config.mjs CHANGED
@@ -25,6 +25,14 @@ export const DEFAULT_CONFIG = {
25
25
  outDir: '.glash/out',
26
26
  themeColor: '#0b0d12',
27
27
  offline: true,
28
+ // Built-in IP auto-translation. true = detect the visitor's country (from the
29
+ // edge geo header, falling back to a public IP service) and, if their native
30
+ // language differs from the page, offer a one-tap in-place translation. The
31
+ // framework serves the runtime (/_glash/i18n.js) and a geo endpoint
32
+ // (/api/_glash/geo) automatically — no route or layout wiring per app.
33
+ // true | false | { auto?: boolean, geoEndpoint?: string }
34
+ // auto: translate immediately instead of prompting.
35
+ i18n: true,
28
36
  // Requests under these prefixes are treated as live/updated data: network-first
29
37
  // in the Service Worker, so offline mode degrades exactly there (no stale live data).
30
38
  dataPrefixes: ['/api/', '/rest/', '/auth/', '/realtime', '/live', '/stream'],
package/src/create.mjs CHANGED
@@ -111,7 +111,7 @@ function projectFiles(answers) {
111
111
  '.env.local': envLocal(answers),
112
112
  'glash.config.mjs': configFile(answers, hasCss),
113
113
  'README.md': projectReadme(answers),
114
- 'routes/_layout.jsx': layoutRoute(answers),
114
+ 'routes/_layout.jsx': layoutRoute(),
115
115
  'routes/index.jsx': indexRoute(answers),
116
116
  'routes/api/health.mjs': healthRoute(),
117
117
  'db/schema.sql': schemaSql(answers),
@@ -119,7 +119,6 @@ function projectFiles(answers) {
119
119
  ...(answers.css === 'tailwind' ? { 'styles/input.css': tailwindInput() } : {}),
120
120
  ...(answers.auth ? authRoutes() : {}),
121
121
  ...(answers.sqlRunner ? sqlRunnerRoutes() : {}),
122
- ...(answers.translate ? { 'routes/api/_glash/geo.mjs': geoRoute() } : {}),
123
122
  ...(answers.animation ? { 'routes/demo/motion.jsx': motionDemoRoute() } : {}),
124
123
  ...(answers.aiPrompts ? { '.glash/prompts/deploy.md': aiDeployPrompt(answers) } : {}),
125
124
  'public/favicon.svg': faviconSvg(),
@@ -136,7 +135,13 @@ export default defineConfig({
136
135
  publicDir: 'public',
137
136
  outDir: '.glash/out',
138
137
  offline: true,
139
- animatedFavicon: true,
138
+ // Built-in IP auto-translation: detect the visitor's country and offer a
139
+ // one-tap in-place translation into their native language. The framework
140
+ // serves the runtime + geo endpoint and relaxes the CSP for the translate
141
+ // widget automatically — no route or layout wiring needed.
142
+ i18n: ${answers.translate ? 'true' : 'false'},
143
+ favicon: 'node_modules/glashjs/templates/glash_favicon.svg',
144
+ animatedFavicon: false,
140
145
  stylesheets: ${hasCss ? "['/app.css']" : '[]'},
141
146
  dataPrefixes: ['/api/', '/auth/', '/rest/', '/live', '/stream'],
142
147
  security: {
@@ -148,19 +153,10 @@ export default defineConfig({
148
153
  `;
149
154
  }
150
155
 
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')}
156
+ function layoutRoute() {
157
+ return `import { Link } from 'glashjs/link';
159
158
 
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(); }, []);` : ''}
159
+ export default function RootLayout({ children }) {
164
160
  return (
165
161
  <div className="shell">
166
162
  <header className="nav">
@@ -177,16 +173,6 @@ export default function RootLayout({ children }) {${t ? `
177
173
  `;
178
174
  }
179
175
 
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
176
  function motionDemoRoute() {
191
177
  return `import { useEffect, useRef } from 'preact/hooks';
192
178
  import { animate } from 'motion';
@@ -11,14 +11,37 @@ import { createHash } from 'node:crypto';
11
11
  * builds hash/nonce-based, so XSS via injected <script> is blocked by default.
12
12
  * Pass `connectSrc` to allow your API/realtime origins.
13
13
  */
14
+ // Google Translate widget origins used by the built-in i18n auto-translation.
15
+ // Scripts stay nonce-based (no 'unsafe-inline'): GT's element.js loads as an
16
+ // EXTERNAL script from these origins and fetches translations over XHR, so the
17
+ // framework's strict, nonce-based script CSP is preserved. Only style-src gains
18
+ // 'unsafe-inline' (low-risk) for the inline styles GT applies to translated text.
19
+ const I18N_CSP = {
20
+ script: ['https://translate.google.com', 'https://translate.googleapis.com', 'https://www.gstatic.com'],
21
+ style: ["'unsafe-inline'", 'https://www.gstatic.com'],
22
+ img: ['https://translate.google.com', 'https://translate.googleapis.com', 'https://www.gstatic.com'],
23
+ connect: ['https://translate.googleapis.com', 'https://ipapi.co'],
24
+ frame: ['https://translate.google.com'],
25
+ };
26
+ const merge = (base, extra) => [...new Set([...base, ...extra])];
27
+
14
28
  export function buildCsp({
15
29
  connectSrc = ["'self'"],
16
30
  imgSrc = ["'self'", 'data:', 'blob:'],
17
31
  mediaSrc = ["'self'", 'blob:'],
18
32
  styleSrc = ["'self'"],
19
33
  scriptSrc = ["'self'"],
34
+ frameSrc = ["'self'"],
20
35
  nonce,
36
+ i18n = false,
21
37
  } = {}) {
38
+ if (i18n) {
39
+ scriptSrc = merge(scriptSrc, I18N_CSP.script);
40
+ styleSrc = merge(styleSrc, I18N_CSP.style);
41
+ imgSrc = merge(imgSrc, I18N_CSP.img);
42
+ connectSrc = merge(connectSrc, I18N_CSP.connect);
43
+ frameSrc = merge(frameSrc, I18N_CSP.frame);
44
+ }
22
45
  const script = nonce ? [...scriptSrc, `'nonce-${nonce}'`] : scriptSrc;
23
46
  const directives = {
24
47
  'default-src': ["'self'"],
@@ -31,6 +54,7 @@ export function buildCsp({
31
54
  'img-src': imgSrc,
32
55
  'media-src': mediaSrc,
33
56
  'connect-src': connectSrc,
57
+ 'frame-src': frameSrc,
34
58
  'worker-src': ["'self'"],
35
59
  'manifest-src': ["'self'"],
36
60
  'upgrade-insecure-requests': [],
@@ -49,7 +49,7 @@ ${headHtml}
49
49
  `;
50
50
  }
51
51
 
52
- function shellTail({ offline = true, animatedFavicon = false, dev = false, nonce = '' }) {
52
+ function shellTail({ offline = true, animatedFavicon = false, dev = false, nonce = '', i18n = false }) {
53
53
  const n = nonce ? ` nonce="${escapeHtml(nonce)}"` : '';
54
54
  const fav = animatedFavicon
55
55
  ? `<script type="module"${n}>try{const m=await import("/glash-favicon.mjs");m.startGlashFavicon&&m.startGlashFavicon();}catch{}</script>`
@@ -57,6 +57,16 @@ function shellTail({ offline = true, animatedFavicon = false, dev = false, nonce
57
57
  const off = offline
58
58
  ? `<script type="module"${n}>try{const m=await import("/glash-offline.mjs");m.registerGlashOffline&&m.registerGlashOffline();}catch{}</script>`
59
59
  : '';
60
+ // Built-in IP auto-translation: the framework serves /_glash/i18n.js (the
61
+ // dependency-free runtime). It detects the visitor's country, maps it to their
62
+ // native language, and offers a one-tap in-place translation. CSP-safe: a
63
+ // nonce'd module import of a same-origin URL, no inline logic.
64
+ const i18nOpts = i18n && typeof i18n === 'object'
65
+ ? { auto: !!i18n.auto, ...(i18n.geoEndpoint ? { geoEndpoint: i18n.geoEndpoint } : {}) }
66
+ : {};
67
+ const intl = i18n
68
+ ? `<script type="module"${n}>try{const m=await import("/_glash/i18n.js");m.glashAutoTranslate&&m.glashAutoTranslate(${JSON.stringify(i18nOpts)});}catch{}</script>`
69
+ : '';
60
70
  // Dev HMR: a nonce'd inline script (CSP-safe). On a change event it does an
61
71
  // in-place soft refresh (no full reload, keeps scroll/focus/inputs), falling
62
72
  // back to a full reload if the nav runtime hasn't loaded yet.
@@ -65,7 +75,7 @@ function shellTail({ offline = true, animatedFavicon = false, dev = false, nonce
65
75
  : '';
66
76
  // Client-side navigation runtime (external 'self' module, CSP-safe).
67
77
  const nav = '<script type="module" src="/_glash/nav.js"></script>';
68
- return `\n${fav}${off}${hmr}${nav}\n</body>\n</html>`;
78
+ return `\n${fav}${off}${intl}${hmr}${nav}\n</body>\n</html>`;
69
79
  }
70
80
 
71
81
  /**
@@ -6,11 +6,12 @@
6
6
  // service worker, favicons) with Brotli negotiation.
7
7
  // Every response carries the secure-by-default headers.
8
8
  import http from 'node:http';
9
- import { promises as fs, existsSync, statSync, watch, createReadStream } from 'node:fs';
9
+ import { promises as fs, existsSync, statSync, watch, createReadStream, readFileSync } from 'node:fs';
10
10
  import { Transform } from 'node:stream';
11
11
  import { randomBytes } from 'node:crypto';
12
12
  import path from 'node:path';
13
- import { pathToFileURL } from 'node:url';
13
+ import { pathToFileURL, fileURLToPath } from 'node:url';
14
+ import { geoRouteHandler } from '../i18n.mjs';
14
15
  import { discoverRoutes, matchRoute, findMiddleware } from './router.mjs';
15
16
  import { renderDocument, documentParts, renderMeta, escapeHtml } from './html.mjs';
16
17
  import { NAV_CLIENT } from './nav-client.mjs';
@@ -28,6 +29,11 @@ const MIME = {
28
29
  };
29
30
  const mime = (file) => MIME[path.extname(file).toLowerCase()] || 'application/octet-stream';
30
31
 
32
+ // The built-in i18n runtime served at /_glash/i18n.js. i18n.mjs is dependency-free
33
+ // and valid browser ESM (its server-only exports are harmless in the browser), so
34
+ // we serve it raw — single source of truth, works identically in dev and prod.
35
+ const I18N_CLIENT = readFileSync(fileURLToPath(new URL('../i18n.mjs', import.meta.url)), 'utf8');
36
+
31
37
  /** Build the glashjs server. Returns { server, listen, cfg }. */
32
38
  export async function createGlashServer({ root = process.cwd(), dev = false } = {}) {
33
39
  loadEnvFiles(root);
@@ -70,6 +76,17 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
70
76
  if (pathname === '/_glash/nav.js') {
71
77
  return send(res, 200, 'text/javascript; charset=utf-8', NAV_CLIENT, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
72
78
  }
79
+ // Built-in IP auto-translation runtime + geo endpoint (config: i18n).
80
+ if (cfg.i18n) {
81
+ if (pathname === '/_glash/i18n.js') {
82
+ return send(res, 200, 'text/javascript; charset=utf-8', I18N_CLIENT, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
83
+ }
84
+ // Geo endpoint: an app-defined routes/api/_glash/geo.mjs wins; otherwise
85
+ // the framework answers from the edge country header.
86
+ if (pathname === '/api/_glash/geo' && !matchRoute(routes, pathname)) {
87
+ return send(res, 200, 'application/json', JSON.stringify(geoRouteHandler({ headers: req.headers })), { ...secHeaders, 'cache-control': 'no-store' });
88
+ }
89
+ }
73
90
  // Static first: in production this serves prebuilt /_glash/<id>.js bundles
74
91
  // (written by `glash build`) — no runtime esbuild needed.
75
92
  if (await serveStatic(res, outDir, pathname, req, secHeaders)) return;
@@ -176,6 +193,7 @@ async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
176
193
  body: page.body ?? '',
177
194
  offline: cfg.offline,
178
195
  animatedFavicon: !!cfg.animatedFavicon,
196
+ i18n: cfg.i18n,
179
197
  nonce, dev,
180
198
  });
181
199
  send(res, page.status || 200, 'text/html; charset=utf-8', docHtml, { ...pageHeaders(cfg, secHeaders, nonce), ...(page.headers || {}) });
@@ -201,7 +219,7 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
201
219
  // external 'self' module — both pass the strict CSP without 'unsafe-inline'.
202
220
  const head = renderStylesheets(cfg) + renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
203
221
  const { open, tail } = documentParts({
204
- title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev,
222
+ title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, i18n: cfg.i18n, nonce, dev,
205
223
  });
206
224
  const bundleTag = `</div><script type="module" src="/_glash/${id}.js"></script>`;
207
225
  res.writeHead(200, { ...pageHeaders(cfg, secHeaders, nonce), 'content-type': 'text/html; charset=utf-8' });
@@ -250,7 +268,7 @@ async function resolveMeta(metadata, ctx) {
250
268
  // Per-request page headers: a fresh CSP carrying this request's script nonce, so
251
269
  // the framework's own inline <script>s run while injected scripts stay blocked.
252
270
  function pageHeaders(cfg, secHeaders, nonce) {
253
- const csp = securityHeaders({ ...(cfg.security || {}), csp: { ...((cfg.security || {}).csp || {}), nonce } })['Content-Security-Policy'];
271
+ const csp = securityHeaders({ ...(cfg.security || {}), csp: { ...((cfg.security || {}).csp || {}), nonce, i18n: !!cfg.i18n } })['Content-Security-Policy'];
254
272
  return { ...secHeaders, 'Content-Security-Policy': csp };
255
273
  }
256
274