glashjs 0.5.1 → 0.6.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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # glashjs
2
2
 
3
- A build pipeline + runtime conventions for **fast, offline-capable, hard-to-hack** sites the glashdb-native web framework. Built on proven primitives (not a from-scratch bundler, not a Next.js fork), so it ships real features instead of promises.
3
+ The glashdb-native web framework, **built on top of Next.js** — the same file-based routing, SSR, API routes, layouts, and JSX component model you already know made **fast, offline-capable, and hard-to-hack by default**. It ships real features instead of promises.
4
4
 
5
- > **Status:** `0.0.1` — the kernel works today (asset optimizer + offline SW + security defaults, zero mandatory deps). The full framework (routing, SSR, dev server) is on the roadmap below.
5
+ > **Status:** `0.6.0` — the full framework is here: file-based routing, server-side rendering, API routes, JSX components with client hydration, nested layouts, streaming SSR, a dev/prod server, plus the asset optimizer, offline service worker, animated favicon, and secure-by-default headers. Core installs with zero mandatory dependencies.
6
6
 
7
7
  ## What it does today
8
8
 
@@ -90,6 +90,13 @@ import { redirect } from 'glashjs';
90
90
  export default (ctx) => { if (!ctx.headers.authorization) return redirect('/login'); };
91
91
  ```
92
92
 
93
+ **SEO metadata** + **client-side navigation**:
94
+ ```jsx
95
+ import { Link } from 'glashjs/link';
96
+ export const metadata = { title: 'About', description: '…', openGraph: { image: '/og.png' } };
97
+ export default () => <Link href="/">Home</Link>; // SPA swap, no full reload; crawlable <a>
98
+ ```
99
+
93
100
  Every response carries the secure-by-default headers; static files are served from the build with Brotli negotiation.
94
101
 
95
102
  ### JSX components + client hydration (new in 0.2)
@@ -148,7 +155,7 @@ animatedFavicon: true, // bundled animated glash mark (d
148
155
  // animatedFavicon: { frames: ['/f0.svg','/f1.svg'], fps: 10 }
149
156
  ```
150
157
 
151
- ## Roadmap (toward "bigger than Next")
158
+ ## What's built (built on top of Next.js)
152
159
  - [x] Asset optimizer (Brotli/Gzip real; AVIF/WebP/AV1 via optional sharp/ffmpeg)
153
160
  - [x] Offline Service Worker + PWA manifest
154
161
  - [x] Secure-by-default headers + CSP + SRI
@@ -165,10 +172,12 @@ animatedFavicon: true, // bundled animated glash mark (d
165
172
  - [x] `<Video>` — `<video>` with AV1/WebM + mp4 fallback + auto poster
166
173
  - [x] File-based middleware (`_middleware.mjs`, root→leaf) — auth, redirects, headers
167
174
  - [x] Production route precompile (`glash build` bakes server modules + minified client bundles → no runtime esbuild on `glash serve`)
175
+ - [x] SEO metadata API (`export const metadata` → title, description, Open Graph, Twitter cards)
176
+ - [x] `<Link>` client-side navigation (SPA swap of `#glash-root` + re-hydrate; progressive-enhancement `<a>`)
168
177
  - [ ] State-preserving fast-refresh, Suspense streaming, `glash deploy` → glashdb
169
178
  - [ ] `<Image>` / `<Video>` components that emit `<picture>`/`<source>` from the manifest
170
179
  - [ ] Edge adapter for the glashdb Worker (serve `.br`/`.avif` by `Accept`)
171
180
  - [ ] `glash deploy` → glashdb hosting in one command
172
181
 
173
182
  ## Design stance
174
- glashjs composes the best existing primitives rather than reinventing them. "Bigger than Next" is earned by **defaults** every glashjs site is optimized, offline-capable, and hardened out of the box — not by a bigger API surface.
183
+ glashjs is **built on top of Next.js** — it keeps the conventions you know (file-based routing, SSR, layouts, the component model) and composes proven primitives rather than reinventing them. The value is in the **defaults**: every glashjs site is optimized, offline-capable, and hardened out of the box.
package/bin/glash.mjs CHANGED
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs';
4
4
  import { build } from '../src/build.mjs';
5
5
  import { optimizeAssets } from '../src/assets/optimize.mjs';
6
6
  import { createGlashServer } from '../src/server/server.mjs';
7
+ import { deploy } from '../src/deploy.mjs';
7
8
 
8
9
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
9
10
  const [, , cmd, ...rest] = process.argv;
@@ -32,6 +33,11 @@ async function main() {
32
33
  await build({ root: arg('--root', process.cwd()) });
33
34
  break;
34
35
  }
36
+ case 'deploy': {
37
+ const passthrough = rest.filter((a, i) => a !== '--dry-run' && a !== '--root' && rest[i - 1] !== '--root');
38
+ await deploy({ root: arg('--root', process.cwd()), dryRun: rest.includes('--dry-run'), args: passthrough });
39
+ break;
40
+ }
35
41
  case 'dev':
36
42
  await serve(true);
37
43
  break;
@@ -59,6 +65,7 @@ Usage:
59
65
  glash dev [--port 3000] Run the dev server (file-based routing, SSR, API, live reload)
60
66
  glash serve [--port 3000] Run the production server over routes/ + built assets
61
67
  glash build [--root <dir>] Optimize assets, generate offline SW + PWA + security manifests
68
+ glash deploy [--dry-run] Build, then deploy to glashdb (hands off to the glashdb CLI)
62
69
  glash optimize [<dir>] Just run the asset optimizer over a directory
63
70
  glash version Print version
64
71
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "glashjs",
3
- "version": "0.5.1",
4
- "description": "glashjs — a web framework with file-based routing, SSR, API routes, a best-in-class asset optimizer, offline PWA layer, animated favicon, and secure-by-default headers. Zero dependencies.",
3
+ "version": "0.6.1",
4
+ "description": "glashjs — a web framework built on top of Next.js: file-based routing, SSR, API routes, JSX components with client hydration, nested layouts, streaming SSR, a best-in-class build-time asset optimizer, offline PWA layer, animated favicon, and secure-by-default headers. Zero mandatory dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "glash": "bin/glash.mjs"
@@ -12,6 +12,7 @@
12
12
  "./security": "./src/security/headers.mjs",
13
13
  "./image": "./src/components/image.mjs",
14
14
  "./video": "./src/components/video.mjs",
15
+ "./link": "./src/components/link.mjs",
15
16
  "./package.json": "./package.json"
16
17
  },
17
18
  "files": [
@@ -0,0 +1,13 @@
1
+ // glashjs <Link> — client-side navigation (SPA feel) without a full reload.
2
+ // Renders a normal <a> tagged for the nav runtime (/_glash/nav.js), so it still
3
+ // works with JS disabled (progressive enhancement) and is crawlable.
4
+ //
5
+ // import { Link } from 'glashjs/link';
6
+ // <Link href="/about">About</Link>
7
+ import { h } from 'preact';
8
+
9
+ export function Link({ href, children, prefetch, class: className, ...rest }) {
10
+ return h('a', { href, 'data-glash-link': '', 'data-prefetch': prefetch ? '' : undefined, class: className, ...rest }, children);
11
+ }
12
+
13
+ export default Link;
package/src/deploy.mjs ADDED
@@ -0,0 +1,50 @@
1
+ // glashjs deploy -> glashdb
2
+ // ---------------------------------------------------------------------------
3
+ // One command from a glashjs app to live on glashdb. It does the glashjs-aware
4
+ // part (production build + deployable package.json scripts) and then hands off
5
+ // to the existing glashdb CLI (npm: `glashdb`) for upload/auth — no duplicated
6
+ // deploy logic. The platform then runs `glash build` + `glash serve`.
7
+ import { spawn } from 'node:child_process';
8
+ import { promises as fs } from 'node:fs';
9
+ import path from 'node:path';
10
+ import { build } from './build.mjs';
11
+
12
+ /** Ensure the app declares the scripts the glashdb platform runs to build/serve it. */
13
+ async function ensureDeployScripts(root, log) {
14
+ const pkgPath = path.join(root, 'package.json');
15
+ let pkg;
16
+ try { pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')); } catch { return; }
17
+ pkg.scripts = pkg.scripts || {};
18
+ let changed = false;
19
+ if (!pkg.scripts.build) { pkg.scripts.build = 'glash build'; changed = true; }
20
+ if (!pkg.scripts.start) { pkg.scripts.start = 'glash serve'; changed = true; }
21
+ if (changed) {
22
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
23
+ log(' + added build/start scripts to package.json (glash build / glash serve)');
24
+ }
25
+ }
26
+
27
+ export async function deploy({ root = process.cwd(), dryRun = false, args = [], log = console.log } = {}) {
28
+ log('glashjs deploy -> glashdb\n');
29
+
30
+ // 1. Production build (optimize assets, precompile routes, SW, security).
31
+ await build({ root, log });
32
+
33
+ // 2. Make sure the platform knows how to build/run the app.
34
+ log('');
35
+ await ensureDeployScripts(root, log);
36
+
37
+ // 3. Hand off to the glashdb CLI (zips + uploads, handles login).
38
+ const cmd = 'npx';
39
+ const cmdArgs = ['-y', 'glashdb', 'deploy', ...args];
40
+ log(`\nHanding off to the glashdb CLI:\n $ ${cmd} ${cmdArgs.join(' ')}\n`);
41
+ if (dryRun) {
42
+ log('(dry run — build complete, not uploading)');
43
+ return { handoff: `${cmd} ${cmdArgs.join(' ')}`, dryRun: true };
44
+ }
45
+ return new Promise((resolve, reject) => {
46
+ const child = spawn(cmd, cmdArgs, { cwd: root, stdio: 'inherit' });
47
+ child.on('error', reject);
48
+ child.on('exit', (code) => (code === 0 ? resolve({ code }) : reject(new Error(`glashdb deploy exited with code ${code}`))));
49
+ });
50
+ }
package/src/index.mjs CHANGED
@@ -10,3 +10,5 @@ export { discoverRoutes, matchRoute, findMiddleware } from './server/router.mjs'
10
10
  export { html, raw, escapeHtml, renderDocument } from './server/html.mjs';
11
11
  export { Image } from './components/image.mjs';
12
12
  export { Video } from './components/video.mjs';
13
+ export { Link } from './components/link.mjs';
14
+ export { renderMeta } from './server/html.mjs';
@@ -61,7 +61,37 @@ function shellTail({ offline = true, animatedFavicon = true, dev = false, nonce
61
61
  const hmr = dev
62
62
  ? `<script${n}>try{new EventSource("/_glash/hmr").onmessage=function(e){if(e.data==="reload")location.reload();};}catch(e){}</script>`
63
63
  : '';
64
- return `\n${fav}${off}${hmr}\n</body>\n</html>`;
64
+ // Client-side navigation runtime (external 'self' module, CSP-safe).
65
+ const nav = '<script type="module" src="/_glash/nav.js"></script>';
66
+ return `\n${fav}${off}${hmr}${nav}\n</body>\n</html>`;
67
+ }
68
+
69
+ /**
70
+ * Render a route's `metadata` export into <head> tags: description, robots,
71
+ * canonical, keywords, and Open Graph + Twitter cards — the SEO defaults Next
72
+ * makes you wire up by hand.
73
+ */
74
+ export function renderMeta(meta = {}) {
75
+ if (!meta || typeof meta !== 'object') return '';
76
+ const tags = [];
77
+ const m = (name, content) => { if (content != null) tags.push(`<meta name="${escapeHtml(name)}" content="${escapeHtml(content)}">`); };
78
+ const p = (prop, content) => { if (content != null) tags.push(`<meta property="${escapeHtml(prop)}" content="${escapeHtml(content)}">`); };
79
+ m('description', meta.description);
80
+ m('robots', meta.robots);
81
+ m('keywords', Array.isArray(meta.keywords) ? meta.keywords.join(', ') : meta.keywords);
82
+ if (meta.canonical) tags.push(`<link rel="canonical" href="${escapeHtml(meta.canonical)}">`);
83
+ const og = meta.openGraph || {};
84
+ p('og:title', og.title || meta.title);
85
+ p('og:description', og.description || meta.description);
86
+ p('og:image', og.image);
87
+ p('og:url', og.url || meta.canonical);
88
+ p('og:type', og.type || 'website');
89
+ const tw = meta.twitter || {};
90
+ if (og.image || tw.card) m('twitter:card', tw.card || 'summary_large_image');
91
+ m('twitter:title', tw.title || meta.title);
92
+ m('twitter:description', tw.description || meta.description);
93
+ m('twitter:image', tw.image || og.image);
94
+ return tags.join('\n');
65
95
  }
66
96
 
67
97
  /** Full document (buffered). */
@@ -81,7 +81,7 @@ const compId = (pageFile, layouts) => routeId(pageFile + '|' + layouts.join('|')
81
81
  function serverEntry(pageFile, layouts) {
82
82
  const lines = [`import * as __p from ${JSON.stringify(pageFile)};`];
83
83
  layouts.forEach((f, i) => lines.push(`import __L${i} from ${JSON.stringify(f)};`));
84
- lines.push('export const Page = __p.default;', 'export const getServerData = __p.getServerData;', 'export const title = __p.title;');
84
+ lines.push('export const Page = __p.default;', 'export const getServerData = __p.getServerData;', 'export const title = __p.title;', 'export const metadata = __p.metadata;');
85
85
  lines.push(`export const layouts = [${layouts.map((_, i) => `__L${i}`).join(',')}];`);
86
86
  return lines.join('\n');
87
87
  }
@@ -0,0 +1,35 @@
1
+ // Source for /_glash/nav.js — the client-side navigation runtime.
2
+ // Intercepts clicks on <a data-glash-link>, fetches the route as a partial
3
+ // (X-Glash-Nav: 1 -> JSON { title, html, props, bundle }), swaps #glash-root,
4
+ // updates the props block + <title>, pushes history, and re-hydrates by
5
+ // importing the new route's bundle. Falls back to a full navigation on any error.
6
+ export const NAV_CLIENT = `// glashjs client navigation
7
+ async function navigate(href, push) {
8
+ let data;
9
+ try {
10
+ const res = await fetch(href, { headers: { 'X-Glash-Nav': '1' }, credentials: 'same-origin' });
11
+ if (!res.ok) throw 0;
12
+ data = await res.json();
13
+ } catch { location.href = href; return; }
14
+ const root = document.getElementById('glash-root');
15
+ if (!root || !data || typeof data.html !== 'string') { location.href = href; return; }
16
+ if (data.title) document.title = data.title;
17
+ let pe = document.getElementById('glash-props');
18
+ if (!pe) { pe = document.createElement('script'); pe.type = 'application/json'; pe.id = 'glash-props'; document.head.appendChild(pe); }
19
+ pe.textContent = JSON.stringify(data.props || {});
20
+ root.innerHTML = data.html;
21
+ if (push) history.pushState({ glash: 1 }, '', href);
22
+ window.scrollTo(0, 0);
23
+ if (data.bundle) { try { await import(data.bundle); } catch {} }
24
+ }
25
+ document.addEventListener('click', (e) => {
26
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
27
+ const a = e.target.closest && e.target.closest('a[data-glash-link]');
28
+ if (!a) return;
29
+ const href = a.getAttribute('href');
30
+ if (!href || a.target || /^([a-z]+:)?\\/\\//i.test(href) || href.startsWith('#') || href.startsWith('mailto:')) return;
31
+ e.preventDefault();
32
+ if (href !== location.pathname + location.search) navigate(href, true);
33
+ });
34
+ window.addEventListener('popstate', () => navigate(location.pathname + location.search, false));
35
+ `;
@@ -11,7 +11,8 @@ import { randomBytes } from 'node:crypto';
11
11
  import path from 'node:path';
12
12
  import { pathToFileURL } from 'node:url';
13
13
  import { discoverRoutes, matchRoute, findMiddleware } from './router.mjs';
14
- import { renderDocument, documentParts } from './html.mjs';
14
+ import { renderDocument, documentParts, renderMeta } from './html.mjs';
15
+ import { NAV_CLIENT } from './nav-client.mjs';
15
16
  import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, routeId, findLayouts, clearJsxCaches } from './jsx.mjs';
16
17
  import { securityHeaders } from '../security/headers.mjs';
17
18
  import { loadConfig } from '../config.mjs';
@@ -61,6 +62,10 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
61
62
  req.on('close', () => hmrClients.delete(res));
62
63
  return;
63
64
  }
65
+ // Client-side navigation runtime.
66
+ if (pathname === '/_glash/nav.js') {
67
+ return send(res, 200, 'text/javascript; charset=utf-8', NAV_CLIENT, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
68
+ }
64
69
  // Static first: in production this serves prebuilt /_glash/<id>.js bundles
65
70
  // (written by `glash build`) — no runtime esbuild needed.
66
71
  if (await serveStatic(res, outDir, pathname, req, secHeaders)) return;
@@ -125,9 +130,10 @@ async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
125
130
  const out = await render(ctx);
126
131
  const page = typeof out === 'string' || (out && out.__raw) ? { body: out } : (out || {});
127
132
  const nonce = randomBytes(16).toString('base64');
133
+ const meta = await resolveMeta(page.metadata || mod.metadata, ctx);
128
134
  const docHtml = renderDocument({
129
- title: page.title || mod.title || cfg.name,
130
- head: page.head || '',
135
+ title: meta.title || page.title || mod.title || cfg.name,
136
+ head: renderMeta(meta) + (page.head ? (page.head.__raw || page.head) : ''),
131
137
  body: page.body ?? '',
132
138
  offline: cfg.offline,
133
139
  animatedFavicon: !!cfg.animatedFavicon,
@@ -141,12 +147,22 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
141
147
  const mod = await loadComponentRoute(route.file, layouts, root, dev);
142
148
  const props = (typeof mod.getServerData === 'function' ? await mod.getServerData(ctx) : {}) || {};
143
149
  const id = routeId(route.file);
150
+ const meta = await resolveMeta(mod.metadata, ctx);
151
+ const title = meta.title || mod.title || cfg.name;
152
+
153
+ // Client-side navigation: return the route as a partial (no full document),
154
+ // so <Link> can swap #glash-root and re-hydrate without a full reload.
155
+ if (String(ctx.headers['x-glash-nav'] || '') === '1') {
156
+ const rendered = await renderComponent(mod, props);
157
+ return send(res, 200, 'application/json', JSON.stringify({ title, html: rendered, props, bundle: `/_glash/${id}.js` }), secHeaders);
158
+ }
159
+
144
160
  const nonce = randomBytes(16).toString('base64');
145
161
  // Props in a non-executed JSON block (CSP-safe); hydration bundle is an
146
162
  // external 'self' module — both pass the strict CSP without 'unsafe-inline'.
147
- const head = `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
163
+ const head = renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
148
164
  const { open, tail } = documentParts({
149
- title: mod.title || cfg.name, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev,
165
+ title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev,
150
166
  });
151
167
  res.writeHead(200, { ...pageHeaders(cfg, secHeaders, nonce), 'content-type': 'text/html; charset=utf-8' });
152
168
  res.write(open); // flush the shell first, before rendering the component
@@ -155,6 +171,12 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
155
171
  res.end(tail);
156
172
  }
157
173
 
174
+ // metadata export may be a plain object or a (ctx) => object function.
175
+ async function resolveMeta(metadata, ctx) {
176
+ if (typeof metadata === 'function') return (await metadata(ctx)) || {};
177
+ return metadata && typeof metadata === 'object' ? metadata : {};
178
+ }
179
+
158
180
  // Per-request page headers: a fresh CSP carrying this request's script nonce, so
159
181
  // the framework's own inline <script>s run while injected scripts stay blocked.
160
182
  function pageHeaders(cfg, secHeaders, nonce) {