glashjs 0.5.1 → 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 +9 -0
- package/package.json +2 -1
- package/src/components/link.mjs +13 -0
- package/src/index.mjs +2 -0
- package/src/server/html.mjs +31 -1
- package/src/server/jsx.mjs +1 -1
- package/src/server/nav-client.mjs +35 -0
- package/src/server/server.mjs +27 -5
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.
|
|
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": [
|
|
@@ -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';
|
package/src/server/html.mjs
CHANGED
|
@@ -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
|
-
|
|
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). */
|
package/src/server/jsx.mjs
CHANGED
|
@@ -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
|
+
`;
|
package/src/server/server.mjs
CHANGED
|
@@ -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
|
|
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) {
|