glashjs 0.5.0 → 0.6.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
@@ -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)
@@ -165,6 +172,8 @@ 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`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glashjs",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
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.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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": [
package/src/build.mjs CHANGED
@@ -63,8 +63,15 @@ export async function build({ root = process.cwd(), log = console.log } = {}) {
63
63
  log(`\nglashjs build — "${cfg.name}"`);
64
64
  log(` public: ${path.relative(root, publicDir)} -> out: ${path.relative(root, outDir)}\n`);
65
65
 
66
- log('Optimizing assets:');
67
- const manifest = await optimizeAssets(publicDir, { log });
66
+ // A routes-only app may have no public/ dir — that's fine, just skip assets.
67
+ let manifest;
68
+ if (existsSync(publicDir)) {
69
+ log('Optimizing assets:');
70
+ manifest = await optimizeAssets(publicDir, { log });
71
+ } else {
72
+ log(`(no ${cfg.publicDir}/ dir — skipping asset optimization)`);
73
+ manifest = { totals: { assets: 0, originalBytes: 0, optimizedBytes: 0, savedPercent: 0 }, assets: {} };
74
+ }
68
75
  const version = deriveVersion(manifest);
69
76
  manifest.version = version;
70
77
  manifest.generatedAt = new Date().toISOString();
@@ -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/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) {