@opentf/web 0.5.0 → 0.7.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/runtime/router.js CHANGED
@@ -15,19 +15,107 @@
15
15
 
16
16
  import { clearError, reportError } from "../core/errors.js";
17
17
  import { signal } from "../core/signals.js";
18
+ import { beginHydration, cursor, endHydration } from "./hydrate.js";
18
19
  import { runCleanup, runMount } from "./mount.js";
19
20
 
20
21
  const isBrowser = typeof window !== "undefined";
21
22
 
23
+ /**
24
+ * Drop a trailing slash (except for the root "/") so `router.pathname` matches the
25
+ * no-trailing-slash route table and nav paths regardless of how the URL was entered —
26
+ * a static host serves `/docs/x/`, a Pagefind result links to `/docs/x/`, etc. Without
27
+ * this, `/docs/x/` wouldn't match the `/docs/x` nav entry and the breadcrumb / active
28
+ * sidebar link / TOC would silently blank out.
29
+ */
30
+ const normalizePath = (p) => (p || "/").replace(/(.)\/+$/, "$1");
31
+
22
32
  const state = {
23
- pathname: signal(isBrowser ? window.location.pathname : "/"),
33
+ pathname: signal(isBrowser ? normalizePath(window.location.pathname) : "/"),
24
34
  searchParams: signal(new URLSearchParams(isBrowser ? window.location.search : "")),
25
35
  params: signal({}),
36
+ locale: signal(null),
26
37
  };
27
38
 
28
39
  export const routes = { pages: {}, layouts: {}, notFound: null };
29
40
  let guard = null;
30
41
  let rootEl = null;
42
+
43
+ // Navigation mode (docs/HYDRATION.md §7): "spa" (default) lets the client router
44
+ // intercept same-origin `<Link>` clicks for reload-free nav; "mpa" leaves every
45
+ // navigation to the browser (full page load), with each page hydrating its own first
46
+ // paint. MPA is always the substrate — this only toggles the SPA enhancement on top.
47
+ let navMode = "spa";
48
+
49
+ /** Whether the client router should intercept link clicks (SPA) vs. let the browser
50
+ * do a full navigation (MPA). Read by `<Link>`. */
51
+ export function shouldInterceptNav() {
52
+ return navMode === "spa";
53
+ }
54
+
55
+ // i18n: path-prefix locale routing (docs/I18N.md). The route table stays
56
+ // locale-agnostic; a leading non-default locale segment is stripped before
57
+ // matching and recorded as `router.locale`. `prefix_except_default`: the default
58
+ // locale is served at the bare path, others are prefixed (`/fr/about`).
59
+ let i18nConfig = null;
60
+
61
+ /** Register the app's locales (called by `mountApp({ i18n })`). */
62
+ export function configureI18n(cfg) {
63
+ if (!cfg || !Array.isArray(cfg.locales) || cfg.locales.length === 0) {
64
+ i18nConfig = null;
65
+ return;
66
+ }
67
+ const defaultLocale = cfg.defaultLocale ?? cfg.locales[0];
68
+ i18nConfig = {
69
+ locales: cfg.locales,
70
+ defaultLocale,
71
+ nonDefault: new Set(cfg.locales.filter((l) => l !== defaultLocale)),
72
+ };
73
+ state.locale.value = defaultLocale;
74
+ }
75
+
76
+ /** The configured locales + default, or null when i18n isn't enabled. */
77
+ export function i18nLocales() {
78
+ return i18nConfig && { locales: i18nConfig.locales, defaultLocale: i18nConfig.defaultLocale };
79
+ }
80
+
81
+ /**
82
+ * Split a leading non-default locale segment off `pathname`, returning the active
83
+ * `locale` and the locale-agnostic `path` to match against the route table. When
84
+ * i18n is off, or the first segment isn't a configured non-default locale, the
85
+ * path passes through unchanged with the default (or null) locale.
86
+ */
87
+ export function resolveLocale(pathname) {
88
+ const def = i18nConfig ? i18nConfig.defaultLocale : null;
89
+ if (!i18nConfig) return { locale: def, path: pathname };
90
+ const m = (pathname || "/").match(/^\/([^/]+)(\/.*|)$/);
91
+ if (m && i18nConfig.nonDefault.has(m[1])) return { locale: m[1], path: m[2] || "/" };
92
+ return { locale: def, path: pathname };
93
+ }
94
+
95
+ /**
96
+ * Prefix `path` with `locale` (bare for the default locale). Any locale already on
97
+ * `path` is replaced. Used by `<Link>` and programmatic navigation to keep links
98
+ * in the active locale. Pass-through when i18n is off.
99
+ */
100
+ export function localizePath(path, locale = state.locale.value) {
101
+ if (!i18nConfig) return path;
102
+ // Strip ANY existing locale prefix (default included, unlike `resolveLocale`
103
+ // which keeps the canonical default bare) so a link can be re-pointed cleanly.
104
+ const m = (path || "/").match(/^\/([^/]+)(\/.*|)$/);
105
+ const bare = m && i18nConfig.locales.includes(m[1]) ? m[2] || "/" : path;
106
+ if (!locale || locale === i18nConfig.defaultLocale) return bare;
107
+ return bare === "/" ? `/${locale}` : `/${locale}${bare}`;
108
+ }
109
+
110
+ /**
111
+ * Set the active locale directly, without navigating. The reactive `router.locale`
112
+ * updates and any `t()`/formatter bindings re-render fine-grained. Intended for
113
+ * previews, tests, and programmatic control — in a routed app the locale is derived
114
+ * from the URL prefix (docs/I18N.md §2), so navigation is the normal path.
115
+ */
116
+ export function setLocale(locale) {
117
+ state.locale.value = locale;
118
+ }
31
119
  let currentNodes = [];
32
120
 
33
121
  /** Reactive router facade — getters read signal values (tracked in effects). */
@@ -44,6 +132,9 @@ export const router = {
44
132
  get params() {
45
133
  return state.params.value;
46
134
  },
135
+ get locale() {
136
+ return state.locale.value;
137
+ },
47
138
  push: (path) => navigate(path),
48
139
  replace: (path) => navigate(path, true),
49
140
  };
@@ -52,7 +143,7 @@ export const router = {
52
143
  function routeFromPath(filePath) {
53
144
  const r = filePath
54
145
  .replace(/^.*\/app/, "")
55
- .replace(/\/(page|layout|404)\.(jsx|tsx)$/, "");
146
+ .replace(/\/(page|layout|404)\.(jsx|tsx|mdx|md)$/, "");
56
147
  return r === "" ? "/" : r;
57
148
  }
58
149
 
@@ -110,19 +201,74 @@ export async function buildRouteNode(match, query = {}) {
110
201
  return { node, nodes };
111
202
  }
112
203
 
204
+ /**
205
+ * Adopt a matched route's server-rendered DOM (docs/HYDRATION.md §3.4, 2.1c) — the hydrate
206
+ * analogue of {@link buildRouteNode}. One cursor threads the whole layout chain: the
207
+ * outermost layout adopts at the container, and each layout hands that cursor to the next at
208
+ * its `{children}` slot (`props.children` is a thunk that claims the nested route's subtree
209
+ * and advances the cursor), down to the page. Returns `{ nodes }` (page → … → root) for
210
+ * lifecycle, or `null` if the page or any layout isn't adoptable (no `hydrateAt` export) — the
211
+ * caller then falls back to a clean CSR build. A thrown `HydrationMismatch` propagates up,
212
+ * disposing each layer's partial wiring on the way (each `hydrateAt` has its own guard).
213
+ */
214
+ export async function hydrateRouteNode(match, query, rootEl) {
215
+ const props = { params: match.params, query };
216
+ const pageMod = await resolveModule(match.entry);
217
+ if (!pageMod || typeof pageMod.hydrateAt !== "function") return null;
218
+ const chain = layoutChain(match.route);
219
+ const layoutMods = [];
220
+ for (const entry of chain) {
221
+ const m = await resolveModule(entry);
222
+ if (!m || typeof m.hydrateAt !== "function") return null; // whole chain must be adoptable
223
+ layoutMods.push(m);
224
+ }
225
+
226
+ // Compose the adopt thunks innermost-first: the page, then each layout wrapping it. Each
227
+ // thunk pushes its claimed root node, so `nodes` ends up page → … → outermost (the inner
228
+ // thunk runs — and pushes — during the outer layout's walk, before the outer pushes).
229
+ const nodes = [];
230
+ let thunk = (c) => {
231
+ const n = pageMod.hydrateAt(c, props);
232
+ nodes.push(n);
233
+ return n;
234
+ };
235
+ for (let i = chain.length - 1; i >= 0; i--) {
236
+ const layout = layoutMods[i];
237
+ const inner = thunk;
238
+ thunk = (c) => {
239
+ const n = layout.hydrateAt(c, { ...props, children: inner });
240
+ nodes.push(n);
241
+ return n;
242
+ };
243
+ }
244
+ thunk(cursor(rootEl)); // outermost adopts at the container; the chain threads inward
245
+ return { nodes };
246
+ }
247
+
248
+ /** Resolve a route entry (lazy loader or module namespace) to its module. */
249
+ async function resolveModule(entry) {
250
+ return typeof entry === "function" ? await entry() : entry;
251
+ }
252
+
113
253
  /**
114
254
  * Set the reactive route state directly (no history/render). Used by server render
115
255
  * so a page reading `router.pathname`/`params`/`query` resolves to the route being
116
256
  * pre-rendered. The client uses `navigate` instead.
117
257
  */
118
- export function setRouteState({ pathname = "/", search = "", params = {} } = {}) {
119
- state.pathname.value = pathname;
258
+ export function setRouteState({ pathname = "/", search = "", params = {}, locale } = {}) {
259
+ state.pathname.value = normalizePath(pathname);
120
260
  state.searchParams.value = new URLSearchParams(search);
121
261
  state.params.value = params;
262
+ state.locale.value = locale !== undefined ? locale : resolveLocale(pathname).locale;
122
263
  }
123
264
 
124
- /** Match `pathname` against the registered routes, resolving `[param]` segments. */
265
+ /**
266
+ * Match `pathname` against the registered routes, resolving `[param]` segments. A
267
+ * leading non-default locale segment is stripped first (the route table is
268
+ * locale-agnostic; see `resolveLocale`).
269
+ */
125
270
  export function matchRoute(pathname) {
271
+ pathname = resolveLocale(pathname).path;
126
272
  for (const route in routes.pages) {
127
273
  const pattern = route
128
274
  .replace(/\[\.\.\.([^\]]+)\]/g, "(?<$1>.+)")
@@ -143,7 +289,7 @@ export function matchRoute(pathname) {
143
289
  * Navigate to `path`. Runs an optional route guard, swaps the rendered page
144
290
  * (tearing down the previous one's lifecycle), and updates window.history.
145
291
  */
146
- export async function navigate(path, replace = false, isPop = false) {
292
+ export async function navigate(path, replace = false, isPop = false, hydrate = false) {
147
293
  if (!path || !rootEl) return;
148
294
  const url = new URL(path, window.location.origin);
149
295
 
@@ -177,15 +323,48 @@ export async function navigate(path, replace = false, isPop = false) {
177
323
  matchRoute(url.pathname) ||
178
324
  (routes.notFound ? { entry: routes.notFound, params: {}, route: null } : null);
179
325
 
180
- state.pathname.value = url.pathname;
326
+ state.pathname.value = normalizePath(url.pathname);
181
327
  state.searchParams.value = url.searchParams;
182
328
  state.params.value = match ? match.params : {};
329
+ state.locale.value = resolveLocale(url.pathname).locale;
183
330
 
184
331
  if (!isPop) {
185
332
  if (replace) window.history.replaceState({}, "", path);
186
333
  else window.history.pushState({}, "", path);
187
334
  }
188
335
 
336
+ // First paint over server-rendered DOM: *adopt* it (hydrate) instead of rebuilding,
337
+ // when the route module exposes a `hydrateAt` adopt factory (compiled with the hydrate
338
+ // target). `hydrateRouteNode` threads one cursor through the whole layout chain (2.1c);
339
+ // a route whose page or any layout isn't adoptable returns null → clean CSR build. A
340
+ // hydration mismatch is reported (never silent) and also falls through to a rebuild.
341
+ //
342
+ // The hydration flag must be live *before* the route modules are imported: route chunks
343
+ // are code-split, so `customElements.define` — and the synchronous upgrade of every
344
+ // server-rendered `<web-*>` host — happens during those imports, before the factories
345
+ // run. Those upgrading components read `isHydrating()` to adopt their server DOM rather
346
+ // than build (docs/HYDRATION.md §3.4). `endHydration()` in `finally` makes every
347
+ // subsequent client navigation build fresh; the CSR fallback below then runs with the
348
+ // flag cleared, so a rebuilt host builds instead of trying to re-adopt.
349
+ if (hydrate && match) {
350
+ beginHydration();
351
+ try {
352
+ const query = Object.fromEntries(url.searchParams);
353
+ const adopted = await hydrateRouteNode(match, query, rootEl);
354
+ if (adopted) {
355
+ currentNodes = adopted.nodes;
356
+ for (const n of adopted.nodes) runMount(n); // onMount for page + every layout
357
+ clearError({ phase: "route" });
358
+ return;
359
+ }
360
+ } catch (e) {
361
+ reportError(e, { phase: "hydrate", path: url.pathname });
362
+ // fall through to a clean CSR build below
363
+ } finally {
364
+ endHydration();
365
+ }
366
+ }
367
+
189
368
  // Resolve (and lazily load) the page + its layout chain before tearing down the
190
369
  // current view, so a slow/failed import doesn't leave a blank page.
191
370
  let nodes = null;
@@ -210,6 +389,13 @@ export async function navigate(path, replace = false, isPop = false) {
210
389
  for (const n of nodes) runMount(n); // run onMount for page + every layout
211
390
  currentNodes = nodes;
212
391
  clearError({ phase: "route" }); // a good render dismisses a prior error overlay
392
+ // A forward navigation lands at the top of the new page (or at the targeted
393
+ // anchor); back/forward (`isPop`) keeps the browser's restored scroll position.
394
+ if (!isPop && isBrowser) {
395
+ const anchor = url.hash ? document.getElementById(decodeURIComponent(url.hash.slice(1))) : null;
396
+ if (anchor) anchor.scrollIntoView();
397
+ else window.scrollTo(0, 0);
398
+ }
213
399
  } else {
214
400
  rootEl.innerHTML = "<h1>404 — Not Found</h1>";
215
401
  currentNodes = [];
@@ -224,12 +410,18 @@ export async function navigate(path, replace = false, isPop = false) {
224
410
  * @param {Object} opts.pages `{ path: module }` route map (from the dev server).
225
411
  * @param {Element} [opts.target] the app root (defaults to `#app`).
226
412
  * @param {Function} [opts.guard] optional `(to, tools) => …` route guard.
413
+ * @param {"spa"|"mpa"} [opts.nav] navigation mode (default "spa"); "mpa" disables
414
+ * client-side link interception so every navigation is a full page load.
227
415
  */
228
- export function mountApp({ pages, target, guard: g } = {}) {
416
+ export function mountApp({ pages, target, guard: g, i18n, nav } = {}) {
229
417
  rootEl = target || (isBrowser ? document.getElementById("app") : null);
418
+ navMode = nav === "mpa" ? "mpa" : "spa";
419
+ if (i18n) configureI18n(i18n);
230
420
  if (pages) registerRoutes(pages);
231
421
  guard = g || null;
232
- if (isBrowser) {
422
+ // In MPA mode the browser owns navigation (full loads push real history entries),
423
+ // so there is no client-side history to react to — only wire popstate for SPA.
424
+ if (isBrowser && navMode === "spa") {
233
425
  window.addEventListener("popstate", () =>
234
426
  navigate(
235
427
  window.location.pathname + window.location.search + window.location.hash,
@@ -238,7 +430,21 @@ export function mountApp({ pages, target, guard: g } = {}) {
238
430
  ),
239
431
  );
240
432
  }
241
- navigate(window.location.pathname + window.location.search + window.location.hash, true, true);
433
+ // Hydrate the first paint when the server stamped `data-otfw-hydrate` on the root and
434
+ // left rendered markup in it; otherwise this is a plain CSR mount (build into #app).
435
+ const hydrate = !!(
436
+ isBrowser &&
437
+ rootEl &&
438
+ rootEl.firstChild &&
439
+ typeof rootEl.hasAttribute === "function" &&
440
+ rootEl.hasAttribute("data-otfw-hydrate")
441
+ );
442
+ return navigate(
443
+ window.location.pathname + window.location.search + window.location.hash,
444
+ true,
445
+ true,
446
+ hydrate,
447
+ );
242
448
  }
243
449
 
244
450
  // `<Link>` is a pure JSX component (packages/web/components/Link.jsx), compiled by
@@ -6,6 +6,11 @@ import { defineSSG } from "./ssg-runtime.js";
6
6
 
7
7
  // (web-link is provided by the compiled components/Link.jsx, not hand-written.)
8
8
  // Passthrough: their effect is structural/client-side; SSG renders children inline.
9
- defineSSG("web-context-provider", (_props, children) => children);
10
- defineSSG("web-portal", (_props, children) => children);
11
- defineSSG("web-error-boundary", (_props, children) => children);
9
+ defineSSG("web-internal-context-provider", (_props, children) => children);
10
+ defineSSG("web-internal-portal", (_props, children) => children);
11
+ defineSSG("web-internal-error-boundary", (_props, children) => children);
12
+ // RawHtml: emit the trusted HTML string inline (MDX highlighted code blocks).
13
+ defineSSG("web-internal-raw-html", (props) => (props && props.html != null ? String(props.html) : ""));
14
+ // CodeFence: same inline HTML as RawHtml; the copy button wires up on the client
15
+ // when the element upgrades (SSG output is static, the behavior is CSR-only).
16
+ defineSSG("web-internal-code-block", (props) => (props && props.html != null ? String(props.html) : ""));
package/server/head.js ADDED
@@ -0,0 +1,191 @@
1
+ //! SEO head resolution + rendering for SSG (ARCHITECTURE.md §6, SPEC §9).
2
+ //! Runs in plain Bun/Node at build time and returns the inner HTML of `<head>`.
3
+ //!
4
+ //! Pages/layouts declare metadata with a Next-style plain-data API:
5
+ //! export const metadata = { title, description, canonical, openGraph, twitter,
6
+ //! robots, jsonLd, meta: [...], links: [...] };
7
+ //! export function generateMetadata({ params, query }) { return { ... }; }
8
+ //! Both are read off the eager-imported module namespace (the SSG route entries
9
+ //! ARE the namespaces — see runtime/router.js), so no compiler change is needed.
10
+
11
+ import { layoutChain } from "../runtime/router.js";
12
+ import { escapeAttr, escapeHtml } from "./ssg-runtime.js";
13
+
14
+ // Sub-objects that merge one level deep (rather than wholesale replace) so a page
15
+ // can override `openGraph.image` without dropping a layout's `openGraph.siteName`.
16
+ const SUBOBJECTS = ["openGraph", "twitter", "robots"];
17
+
18
+ /** Read a route entry's static `metadata` (namespace or its default export). */
19
+ function staticMeta(entry) {
20
+ if (!entry || typeof entry === "function") return null;
21
+ return entry.metadata || (entry.default && entry.default.metadata) || null;
22
+ }
23
+
24
+ /** Invoke a route entry's `generateMetadata({ params, query })` if it exports one. */
25
+ async function dynamicMeta(entry, params, query) {
26
+ if (!entry || typeof entry === "function") return null;
27
+ const gen = entry.generateMetadata || (entry.default && entry.default.generateMetadata);
28
+ return typeof gen === "function" ? (await gen({ params, query })) || null : null;
29
+ }
30
+
31
+ /** Merge `add` over `base`, deep-merging the known sub-objects. */
32
+ function mergeMeta(base, add) {
33
+ if (!add) return base;
34
+ const out = { ...base };
35
+ for (const k in add) {
36
+ const v = add[k];
37
+ if (v == null) continue;
38
+ if (SUBOBJECTS.includes(k) && isPlainObject(v) && isPlainObject(base[k])) {
39
+ out[k] = { ...base[k], ...v };
40
+ } else {
41
+ out[k] = v;
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ function isPlainObject(v) {
48
+ return v != null && typeof v === "object" && !Array.isArray(v);
49
+ }
50
+
51
+ /**
52
+ * Resolve the effective metadata for a route by merging, least- to most-specific:
53
+ * each layout's static then dynamic metadata (root → leaf), then the page's static
54
+ * then dynamic metadata. The most specific (page `generateMetadata`) wins.
55
+ */
56
+ export async function resolveMetadata({ route, entry, params = {}, query = {} } = {}) {
57
+ let meta = {};
58
+ for (const layout of layoutChain(route)) {
59
+ meta = mergeMeta(meta, staticMeta(layout));
60
+ meta = mergeMeta(meta, await dynamicMeta(layout, params, query));
61
+ }
62
+ meta = mergeMeta(meta, staticMeta(entry));
63
+ meta = mergeMeta(meta, await dynamicMeta(entry, params, query));
64
+ return meta;
65
+ }
66
+
67
+ /** Resolve a possibly-relative URL against `baseUrl` (absolute URLs pass through). */
68
+ function absUrl(baseUrl, p) {
69
+ if (!p) return "";
70
+ if (/^https?:\/\//i.test(p)) return p;
71
+ if (!baseUrl) return p; // no site origin configured → emit relative
72
+ return baseUrl.replace(/\/+$/, "") + (p.startsWith("/") ? p : `/${p}`);
73
+ }
74
+
75
+ /**
76
+ * Resolve a `title` (string or `{ absolute }`) against an optional `template`
77
+ * containing `%s`. A plain string is wrapped by the template; `{ absolute }` (or a
78
+ * template without `%s`) is used verbatim. Returns null when there is no title.
79
+ */
80
+ function resolveTitle(title, template) {
81
+ if (title != null && typeof title === "object") {
82
+ return title.absolute != null ? String(title.absolute) : null;
83
+ }
84
+ if (title == null) return null;
85
+ const s = String(title);
86
+ return template && template.includes("%s") ? template.replace("%s", s) : s;
87
+ }
88
+
89
+ /** `<meta name|property="key" content="value">` (omitted when value is null). */
90
+ function metaTag(kind, key, content) {
91
+ if (content == null || content === "") return "";
92
+ return `<meta ${kind}="${escapeAttr(key)}" content="${escapeAttr(content)}">`;
93
+ }
94
+
95
+ /** Normalize `robots` (string passthrough, or `{ index, follow, ... }` flags). */
96
+ function robotsContent(r) {
97
+ if (r == null) return "";
98
+ if (typeof r === "string") return r;
99
+ const parts = [];
100
+ if (r.index === false) parts.push("noindex");
101
+ else if (r.index === true) parts.push("index");
102
+ if (r.follow === false) parts.push("nofollow");
103
+ else if (r.follow === true) parts.push("follow");
104
+ for (const flag of ["noarchive", "nosnippet", "noimageindex", "notranslate"]) {
105
+ if (r[flag]) parts.push(flag);
106
+ }
107
+ return parts.join(", ");
108
+ }
109
+
110
+ // Escape `</script>` / `<` so a JSON-LD payload can't break out of the script tag.
111
+ function jsonLdSafe(s) {
112
+ return String(s).replace(/</g, "\\u003c");
113
+ }
114
+
115
+ /**
116
+ * Render the resolved `meta` to the inner HTML of `<head>` for the route at `path`.
117
+ * `baseUrl` (site origin) makes canonical / og:url / image URLs absolute when set.
118
+ */
119
+ export function renderHead(meta = {}, { path = "/", baseUrl = "" } = {}) {
120
+ const tags = [];
121
+ const push = (s) => s && tags.push(s);
122
+
123
+ // Resolve the display title. `title` is a string (wrapped by an inherited
124
+ // `titleTemplate` like "%s — OTF Web") or `{ absolute }` to bypass the template —
125
+ // so a layout can brand every child page while the homepage keeps a bespoke title.
126
+ const title = resolveTitle(meta.title, meta.titleTemplate);
127
+ const description = meta.description;
128
+ if (title != null) push(`<title>${escapeHtml(title)}</title>`);
129
+ push(metaTag("name", "description", description));
130
+
131
+ const canonical = absUrl(baseUrl, meta.canonical ?? path);
132
+ if (canonical) push(`<link rel="canonical" href="${escapeAttr(canonical)}">`);
133
+
134
+ push(metaTag("name", "robots", robotsContent(meta.robots)));
135
+
136
+ // Open Graph (falls back to the top-level title/description).
137
+ const og = isPlainObject(meta.openGraph) ? meta.openGraph : {};
138
+ const ogImage = og.image ? absUrl(baseUrl, og.image) : "";
139
+ push(metaTag("property", "og:title", og.title ?? title));
140
+ push(metaTag("property", "og:description", og.description ?? description));
141
+ push(metaTag("property", "og:type", og.type ?? "website"));
142
+ push(metaTag("property", "og:url", og.url ? absUrl(baseUrl, og.url) : canonical));
143
+ push(metaTag("property", "og:image", ogImage));
144
+ push(metaTag("property", "og:site_name", og.siteName));
145
+
146
+ // Twitter Card (falls back to Open Graph / top-level values).
147
+ const tw = isPlainObject(meta.twitter) ? meta.twitter : {};
148
+ const twImage = tw.image ? absUrl(baseUrl, tw.image) : ogImage;
149
+ push(metaTag("name", "twitter:card", tw.card ?? (twImage ? "summary_large_image" : "summary")));
150
+ push(metaTag("name", "twitter:title", tw.title ?? og.title ?? title));
151
+ push(metaTag("name", "twitter:description", tw.description ?? og.description ?? description));
152
+ push(metaTag("name", "twitter:image", twImage));
153
+
154
+ // Arbitrary extra tags (escape hatch for anything not modeled above).
155
+ for (const m of Array.isArray(meta.meta) ? meta.meta : []) {
156
+ const kind = m.name ? "name" : m.property ? "property" : m.httpEquiv ? "http-equiv" : null;
157
+ if (kind) push(metaTag(kind, m.name ?? m.property ?? m.httpEquiv, m.content));
158
+ }
159
+ for (const l of Array.isArray(meta.links) ? meta.links : []) {
160
+ const rel = l.rel ? ` rel="${escapeAttr(l.rel)}"` : "";
161
+ const href = l.href ? ` href="${escapeAttr(absUrl(baseUrl, l.href))}"` : "";
162
+ const extra = l.hreflang ? ` hreflang="${escapeAttr(l.hreflang)}"` : "";
163
+ if (rel || href) push(`<link${rel}${href}${extra}>`);
164
+ }
165
+
166
+ // Structured data (JSON-LD) for rich results.
167
+ if (meta.jsonLd != null) {
168
+ const json = typeof meta.jsonLd === "string" ? meta.jsonLd : JSON.stringify(meta.jsonLd);
169
+ push(`<script type="application/ld+json">${jsonLdSafe(json)}</script>`);
170
+ }
171
+
172
+ return tags.join("\n");
173
+ }
174
+
175
+ /**
176
+ * Build `rel="alternate" hreflang` link descriptors for a locale-agnostic
177
+ * `routePath` (e.g. `/about`) across all configured locales — for the toolchain to
178
+ * pass through `metadata.links` so `renderHead` emits them (docs/I18N.md §6). The
179
+ * default locale also gets `x-default`. `localize(path, locale)` is the router's
180
+ * `localizePath` (kept as a param so this stays free of a router import here).
181
+ */
182
+ export function localeAlternateLinks(routePath, { locales, defaultLocale } = {}, localize) {
183
+ if (!Array.isArray(locales) || locales.length === 0 || typeof localize !== "function") return [];
184
+ const links = locales.map((locale) => ({
185
+ rel: "alternate",
186
+ hreflang: locale,
187
+ href: localize(routePath, locale),
188
+ }));
189
+ links.push({ rel: "alternate", hreflang: "x-default", href: localize(routePath, defaultLocale) });
190
+ return links;
191
+ }
package/server/index.js CHANGED
@@ -3,5 +3,6 @@
3
3
  // import). Imported as "@opentf/web/server".
4
4
 
5
5
  export * from "./ssg-runtime.js";
6
+ export * from "./head.js";
6
7
  export * from "./render.js";
7
8
  import "./builtins.js";
package/server/render.js CHANGED
@@ -14,22 +14,34 @@ import {
14
14
  routes,
15
15
  setRouteState,
16
16
  } from "../runtime/router.js";
17
+ import { resolveMetadata } from "./head.js";
18
+ import { beginHydrationCollect, endHydrationCollect } from "./ssg-runtime.js";
17
19
 
18
20
  /**
19
- * Render `pathname` to an HTML string (the markup for inside `#app`). Falls back
20
- * to the registered `404` page for an unmatched path; returns `null` if there's
21
- * no match and no 404.
21
+ * Render `pathname` to `{ html, metadata, status, hydration }`: the markup for inside
22
+ * `#app`, the resolved SEO metadata (for the `<head>`), an HTTP `status` (200 when the
23
+ * path matched a real route, 404 when it fell back to the registered 404 page — the SSR
24
+ * server uses it; SSG ignores it), and `hydration` — the JSON island-props payload the
25
+ * toolchain embeds so the client resumes from rich data (`""` when nothing needs it).
26
+ * `params` (from `getStaticPaths`) override the matched route's params for dynamic
27
+ * routes. Returns `null` if there's no match and no 404 page.
22
28
  */
23
- export async function renderToString(pathname, search = "") {
29
+ export async function renderRoute(pathname, params = null, search = "") {
30
+ const real = matchRoute(pathname);
24
31
  const match =
25
- matchRoute(pathname) ||
26
- (routes.notFound ? { entry: routes.notFound, params: {}, route: null } : null);
32
+ real || (routes.notFound ? { entry: routes.notFound, params: {}, route: null } : null);
27
33
  if (!match) return null;
34
+ if (params) match.params = params;
28
35
 
29
36
  // Let a page reading `router.params`/`pathname`/`query` resolve to this route.
30
37
  setRouteState({ pathname, search, params: match.params });
31
38
 
32
- const props = { params: match.params, query: Object.fromEntries(new URLSearchParams(search)) };
39
+ const query = Object.fromEntries(new URLSearchParams(search));
40
+ const props = { params: match.params, query };
41
+
42
+ // Collect each island's rich props while the tree renders (ssgComponent records them
43
+ // and stamps `data-h` ids), then serialize the payload for the shell.
44
+ beginHydrationCollect();
33
45
  let html = (await resolveFactory(match.entry))(props);
34
46
 
35
47
  // Wrap with layouts, most-specific inward to root outermost.
@@ -38,7 +50,21 @@ export async function renderToString(pathname, search = "") {
38
50
  const layout = await resolveFactory(chain[i]);
39
51
  html = layout({ ...props, children: html });
40
52
  }
41
- return html;
53
+ const hydration = endHydrationCollect();
54
+
55
+ const metadata = await resolveMetadata({
56
+ route: match.route,
57
+ entry: match.entry,
58
+ params: match.params,
59
+ query,
60
+ });
61
+ return { html, metadata, status: real ? 200 : 404, hydration };
62
+ }
63
+
64
+ /** Back-compat / convenience: render just the `#app` markup for `pathname`. */
65
+ export async function renderToString(pathname, search = "") {
66
+ const result = await renderRoute(pathname, null, search);
67
+ return result ? result.html : null;
42
68
  }
43
69
 
44
70
  /** Substitute `[param]` / `[...rest]` in a route with concrete values. */
@@ -49,16 +75,18 @@ function fillRoute(route, params) {
49
75
  }
50
76
 
51
77
  /**
52
- * Enumerate the concrete paths to pre-render. Static routes are taken as-is;
53
- * dynamic routes (`[param]`) are expanded via the page module's optional
54
- * `getStaticPaths()` (returning `[{ params }]`), else collected as `skipped`.
78
+ * Enumerate the concrete paths to pre-render as `{ path, params }`. Static routes
79
+ * are taken as-is (`params: {}`); dynamic routes (`[param]`) are expanded via the
80
+ * page module's optional `getStaticPaths()` (returning `[{ params }]`), carrying the
81
+ * params forward so the renderer/`generateMetadata` see them. Dynamic routes without
82
+ * `getStaticPaths` are collected as `skipped`.
55
83
  */
56
84
  export async function collectRoutePaths() {
57
85
  const paths = [];
58
86
  const skipped = [];
59
87
  for (const route in routes.pages) {
60
88
  if (!route.includes("[")) {
61
- paths.push(route);
89
+ paths.push({ path: route, params: {} });
62
90
  continue;
63
91
  }
64
92
  const ns = routes.pages[route];
@@ -66,7 +94,8 @@ export async function collectRoutePaths() {
66
94
  ns && (ns.getStaticPaths || (ns.default && ns.default.getStaticPaths));
67
95
  if (typeof getStaticPaths === "function") {
68
96
  for (const entry of (await getStaticPaths()) || []) {
69
- paths.push(fillRoute(route, entry.params || entry));
97
+ const params = entry.params || entry;
98
+ paths.push({ path: fillRoute(route, params), params });
70
99
  }
71
100
  } else {
72
101
  skipped.push(route);