@opentf/web 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.
@@ -0,0 +1,121 @@
1
+ // Hydration primitives (Phase 2 — see docs/HYDRATION.md). The client *adopts* the
2
+ // server-rendered DOM instead of rebuilding it: it claims existing nodes and wires
3
+ // reactivity (bindText/bindAttr/events from dom.js) onto them. There is no separate
4
+ // "hydrate" binding logic — those helpers already operate on existing nodes; the only
5
+ // new work is *node acquisition*, which is what this module provides.
6
+ //
7
+ // Acquisition is a **cursor walk** (the decided approach, like Solid): the Hydrate
8
+ // codegen emits claim calls in the exact order the SSG backend emitted nodes, so a
9
+ // cursor over a parent's childNodes lines up 1:1 with the template. The server output
10
+ // carries no inter-element whitespace (ssg.rs concatenates with no padding), so the
11
+ // re-parsed DOM has text nodes only where the template does — the walk stays aligned.
12
+ //
13
+ // Markers (only where structure is variable): a dynamic text hole is delimited by
14
+ // `<!--$-->value<!--/-->` so it is findable even when the value is empty or adjacent
15
+ // to static text (which the HTML parser would otherwise merge into one text node).
16
+ // Static structure carries no markers and is claimed by position.
17
+
18
+ /** Comment markers bounding a dynamic text hole in server HTML. */
19
+ export const HOLE_START = "$";
20
+ export const HOLE_END = "/";
21
+
22
+ const ELEMENT = 1;
23
+ const TEXT = 3;
24
+ const COMMENT = 8;
25
+
26
+ /** Thrown when the server DOM doesn't match the template at a claim site. The
27
+ * component boundary catches this and recovers by rebuilding via CSR (never silent;
28
+ * see docs/HYDRATION.md §3.5). */
29
+ export class HydrationMismatch extends Error {
30
+ constructor(message) {
31
+ super(message);
32
+ this.name = "HydrationMismatch";
33
+ }
34
+ }
35
+
36
+ // ── the hydration flag ────────────────────────────────────────────────────────
37
+ // True during the initial hydration pass. A component's connectedCallback reads it
38
+ // to decide whether to *adopt* its server children or *build* a fresh subtree — the
39
+ // custom-element-as-island coordination (docs/HYDRATION.md §3.4).
40
+ let _hydrating = false;
41
+
42
+ /** Is the client mid-hydration right now? */
43
+ export function isHydrating() {
44
+ return _hydrating;
45
+ }
46
+
47
+ /** Run `fn` with the hydration flag set, restoring the prior value (nesting-safe). */
48
+ export function runHydration(fn) {
49
+ const prev = _hydrating;
50
+ _hydrating = true;
51
+ try {
52
+ return fn();
53
+ } finally {
54
+ _hydrating = prev;
55
+ }
56
+ }
57
+
58
+ // ── the cursor ────────────────────────────────────────────────────────────────
59
+
60
+ /** A walker over `parent`'s children, positioned at the next node to claim. */
61
+ export function cursor(parent) {
62
+ return { node: parent.firstChild };
63
+ }
64
+
65
+ /** Advance past the next node without claiming it (a static text node between
66
+ * dynamic siblings), returning it. */
67
+ export function skipNode(cur) {
68
+ const n = cur.node;
69
+ cur.node = n ? n.nextSibling : null;
70
+ return n;
71
+ }
72
+
73
+ /**
74
+ * Claim the next node as an element, optionally asserting its tag, and advance the
75
+ * cursor past it. Returns the element so the caller can claim its children with a
76
+ * fresh `cursor(el)`. A wrong/absent node throws {@link HydrationMismatch}.
77
+ */
78
+ export function claimElement(cur, tag) {
79
+ const el = cur.node;
80
+ if (!el || el.nodeType !== ELEMENT || (tag && el.tagName.toLowerCase() !== tag.toLowerCase())) {
81
+ throw new HydrationMismatch(`expected <${tag ?? "element"}>, found ${describe(el)}`);
82
+ }
83
+ cur.node = el.nextSibling;
84
+ return el;
85
+ }
86
+
87
+ /**
88
+ * Claim a dynamic text hole delimited by `<!--$-->…<!--/-->`, returning the text node
89
+ * to bind onto (created empty when the server rendered no value), and advance the
90
+ * cursor past the closing marker. Wire reactivity with `bindText(node, fn)` from
91
+ * dom.js — the binding logic is shared with CSR; only the node is adopted, not built.
92
+ */
93
+ export function claimText(cur) {
94
+ const start = cur.node;
95
+ if (!start || start.nodeType !== COMMENT || start.data !== HOLE_START) {
96
+ throw new HydrationMismatch(`expected a text-hole marker, found ${describe(start)}`);
97
+ }
98
+ let node = start.nextSibling;
99
+ let textNode = null;
100
+ while (node && !(node.nodeType === COMMENT && node.data === HOLE_END)) {
101
+ if (node.nodeType === TEXT && !textNode) textNode = node;
102
+ node = node.nextSibling;
103
+ }
104
+ const end = node; // the `<!--/-->` marker (or null if the markup is malformed)
105
+ if (!textNode) {
106
+ // Empty hole (`<!--$--><!--/-->`): synthesize the anchor bindText will write into.
107
+ textNode = document.createTextNode("");
108
+ (start.parentNode || document).insertBefore(textNode, end);
109
+ }
110
+ cur.node = end ? end.nextSibling : null;
111
+ return textNode;
112
+ }
113
+
114
+ /** A short human description of a node for mismatch messages. */
115
+ function describe(node) {
116
+ if (!node) return "nothing";
117
+ if (node.nodeType === ELEMENT) return `<${node.tagName.toLowerCase()}>`;
118
+ if (node.nodeType === TEXT) return `text "${(node.data || "").slice(0, 20)}"`;
119
+ if (node.nodeType === COMMENT) return `comment <!--${node.data}-->`;
120
+ return `node(${node.nodeType})`;
121
+ }
package/runtime/index.js CHANGED
@@ -1,16 +1,22 @@
1
1
  // Built-in custom elements self-register on import. They're used in JSX as *tags*
2
- // (`<Portal>` → `web-portal`), so their bindings are never referenced and a plain
2
+ // (`<Portal>` → `web-internal-portal`), so their bindings are never referenced and a plain
3
3
  // re-export would be tree-shaken away (the element never registers). Bare imports
4
4
  // force the registration side effect to be retained.
5
5
  import "./context.js";
6
6
  import "./error-boundary.js";
7
7
  import "./portal.js";
8
+ import "./raw-html.js";
9
+ import "./code-block.js";
8
10
 
9
11
  export * from "./dom.js";
10
12
  export * from "./events.js";
11
13
  export * from "./mount.js";
14
+ export * from "./hydrate.js";
12
15
  export * from "./lifecycle.js";
13
16
  export * from "./router.js";
14
17
  export * from "./context.js";
15
18
  export * from "./error-boundary.js";
16
19
  export * from "./portal.js";
20
+ export * from "./raw-html.js";
21
+ export * from "./code-block.js";
22
+ export * from "./clipboard.js";
package/runtime/portal.js CHANGED
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // <Portal to="#toast-root"> … </Portal> // selector, element, or default body
5
5
  //
6
- // `<Portal>` compiles to this built-in `web-portal` element (capitalized name →
6
+ // `<Portal>` compiles to this built-in `web-internal-portal` element (capitalized name →
7
7
  // tag, like <ContextProvider>) — no compiler changes. At connect it moves its
8
8
  // light-DOM children to the target; on disconnect it removes them so their
9
9
  // custom-element disconnect + effect cleanups run (the part userland leaks).
@@ -52,8 +52,8 @@ export class PortalElement extends HTMLElement {
52
52
  }
53
53
 
54
54
  // Exported so `import { Portal } from "@opentf/web"` resolves; the import side
55
- // effect registers the element (JSX uses the `web-portal` tag).
55
+ // effect registers the element (JSX uses the `web-internal-portal` tag).
56
56
  export const Portal = PortalElement;
57
- if (typeof customElements !== "undefined" && !customElements.get("web-portal")) {
58
- customElements.define("web-portal", PortalElement);
57
+ if (typeof customElements !== "undefined" && !customElements.get("web-internal-portal")) {
58
+ customElements.define("web-internal-portal", PortalElement);
59
59
  }
@@ -0,0 +1,33 @@
1
+ //! RawHtml — render a trusted HTML string as light-DOM content.
2
+ //
3
+ // <RawHtml html={"<pre>…</pre>"} /> // compiles to the built-in `web-internal-raw-html`
4
+ //
5
+ // Used by the MDX front-end for build-time syntax-highlighted code blocks: the
6
+ // highlighted markup is static (no reactivity), so the element just assigns its
7
+ // `html` property to `innerHTML`. The string is treated as trusted — it comes from
8
+ // the compiler, not user input. SSG renders the string inline (server/builtins.js).
9
+
10
+ export class RawHtmlElement extends HTMLElement {
11
+ set html(v) {
12
+ this._html = v;
13
+ if (this.isConnected) this.innerHTML = v == null ? "" : String(v);
14
+ }
15
+ get html() {
16
+ return this._html;
17
+ }
18
+
19
+ connectedCallback() {
20
+ // Apply once on connect (the property is set before append during CSR build).
21
+ if (this._html != null && !this._applied) {
22
+ this.innerHTML = String(this._html);
23
+ this._applied = true;
24
+ }
25
+ }
26
+ }
27
+
28
+ // Exported so `import { RawHtml } from "@opentf/web"` resolves; the import side
29
+ // effect registers the element (JSX uses the `web-internal-raw-html` tag).
30
+ export const RawHtml = RawHtmlElement;
31
+ if (typeof customElements !== "undefined" && !customElements.get("web-internal-raw-html")) {
32
+ customElements.define("web-internal-raw-html", RawHtmlElement);
33
+ }
package/runtime/router.js CHANGED
@@ -19,15 +19,102 @@ import { runCleanup, runMount } from "./mount.js";
19
19
 
20
20
  const isBrowser = typeof window !== "undefined";
21
21
 
22
+ /**
23
+ * Drop a trailing slash (except for the root "/") so `router.pathname` matches the
24
+ * no-trailing-slash route table and nav paths regardless of how the URL was entered —
25
+ * a static host serves `/docs/x/`, a Pagefind result links to `/docs/x/`, etc. Without
26
+ * this, `/docs/x/` wouldn't match the `/docs/x` nav entry and the breadcrumb / active
27
+ * sidebar link / TOC would silently blank out.
28
+ */
29
+ const normalizePath = (p) => (p || "/").replace(/(.)\/+$/, "$1");
30
+
22
31
  const state = {
23
- pathname: signal(isBrowser ? window.location.pathname : "/"),
32
+ pathname: signal(isBrowser ? normalizePath(window.location.pathname) : "/"),
24
33
  searchParams: signal(new URLSearchParams(isBrowser ? window.location.search : "")),
25
34
  params: signal({}),
35
+ locale: signal(null),
26
36
  };
27
37
 
28
38
  export const routes = { pages: {}, layouts: {}, notFound: null };
29
39
  let guard = null;
30
40
  let rootEl = null;
41
+
42
+ // Navigation mode (docs/HYDRATION.md §7): "spa" (default) lets the client router
43
+ // intercept same-origin `<Link>` clicks for reload-free nav; "mpa" leaves every
44
+ // navigation to the browser (full page load), with each page hydrating its own first
45
+ // paint. MPA is always the substrate — this only toggles the SPA enhancement on top.
46
+ let navMode = "spa";
47
+
48
+ /** Whether the client router should intercept link clicks (SPA) vs. let the browser
49
+ * do a full navigation (MPA). Read by `<Link>`. */
50
+ export function shouldInterceptNav() {
51
+ return navMode === "spa";
52
+ }
53
+
54
+ // i18n: path-prefix locale routing (docs/I18N.md). The route table stays
55
+ // locale-agnostic; a leading non-default locale segment is stripped before
56
+ // matching and recorded as `router.locale`. `prefix_except_default`: the default
57
+ // locale is served at the bare path, others are prefixed (`/fr/about`).
58
+ let i18nConfig = null;
59
+
60
+ /** Register the app's locales (called by `mountApp({ i18n })`). */
61
+ export function configureI18n(cfg) {
62
+ if (!cfg || !Array.isArray(cfg.locales) || cfg.locales.length === 0) {
63
+ i18nConfig = null;
64
+ return;
65
+ }
66
+ const defaultLocale = cfg.defaultLocale ?? cfg.locales[0];
67
+ i18nConfig = {
68
+ locales: cfg.locales,
69
+ defaultLocale,
70
+ nonDefault: new Set(cfg.locales.filter((l) => l !== defaultLocale)),
71
+ };
72
+ state.locale.value = defaultLocale;
73
+ }
74
+
75
+ /** The configured locales + default, or null when i18n isn't enabled. */
76
+ export function i18nLocales() {
77
+ return i18nConfig && { locales: i18nConfig.locales, defaultLocale: i18nConfig.defaultLocale };
78
+ }
79
+
80
+ /**
81
+ * Split a leading non-default locale segment off `pathname`, returning the active
82
+ * `locale` and the locale-agnostic `path` to match against the route table. When
83
+ * i18n is off, or the first segment isn't a configured non-default locale, the
84
+ * path passes through unchanged with the default (or null) locale.
85
+ */
86
+ export function resolveLocale(pathname) {
87
+ const def = i18nConfig ? i18nConfig.defaultLocale : null;
88
+ if (!i18nConfig) return { locale: def, path: pathname };
89
+ const m = (pathname || "/").match(/^\/([^/]+)(\/.*|)$/);
90
+ if (m && i18nConfig.nonDefault.has(m[1])) return { locale: m[1], path: m[2] || "/" };
91
+ return { locale: def, path: pathname };
92
+ }
93
+
94
+ /**
95
+ * Prefix `path` with `locale` (bare for the default locale). Any locale already on
96
+ * `path` is replaced. Used by `<Link>` and programmatic navigation to keep links
97
+ * in the active locale. Pass-through when i18n is off.
98
+ */
99
+ export function localizePath(path, locale = state.locale.value) {
100
+ if (!i18nConfig) return path;
101
+ // Strip ANY existing locale prefix (default included, unlike `resolveLocale`
102
+ // which keeps the canonical default bare) so a link can be re-pointed cleanly.
103
+ const m = (path || "/").match(/^\/([^/]+)(\/.*|)$/);
104
+ const bare = m && i18nConfig.locales.includes(m[1]) ? m[2] || "/" : path;
105
+ if (!locale || locale === i18nConfig.defaultLocale) return bare;
106
+ return bare === "/" ? `/${locale}` : `/${locale}${bare}`;
107
+ }
108
+
109
+ /**
110
+ * Set the active locale directly, without navigating. The reactive `router.locale`
111
+ * updates and any `t()`/formatter bindings re-render fine-grained. Intended for
112
+ * previews, tests, and programmatic control — in a routed app the locale is derived
113
+ * from the URL prefix (docs/I18N.md §2), so navigation is the normal path.
114
+ */
115
+ export function setLocale(locale) {
116
+ state.locale.value = locale;
117
+ }
31
118
  let currentNodes = [];
32
119
 
33
120
  /** Reactive router facade — getters read signal values (tracked in effects). */
@@ -44,6 +131,9 @@ export const router = {
44
131
  get params() {
45
132
  return state.params.value;
46
133
  },
134
+ get locale() {
135
+ return state.locale.value;
136
+ },
47
137
  push: (path) => navigate(path),
48
138
  replace: (path) => navigate(path, true),
49
139
  };
@@ -52,7 +142,7 @@ export const router = {
52
142
  function routeFromPath(filePath) {
53
143
  const r = filePath
54
144
  .replace(/^.*\/app/, "")
55
- .replace(/\/(page|layout|404)\.(jsx|tsx)$/, "");
145
+ .replace(/\/(page|layout|404)\.(jsx|tsx|mdx|md)$/, "");
56
146
  return r === "" ? "/" : r;
57
147
  }
58
148
 
@@ -115,14 +205,20 @@ export async function buildRouteNode(match, query = {}) {
115
205
  * so a page reading `router.pathname`/`params`/`query` resolves to the route being
116
206
  * pre-rendered. The client uses `navigate` instead.
117
207
  */
118
- export function setRouteState({ pathname = "/", search = "", params = {} } = {}) {
119
- state.pathname.value = pathname;
208
+ export function setRouteState({ pathname = "/", search = "", params = {}, locale } = {}) {
209
+ state.pathname.value = normalizePath(pathname);
120
210
  state.searchParams.value = new URLSearchParams(search);
121
211
  state.params.value = params;
212
+ state.locale.value = locale !== undefined ? locale : resolveLocale(pathname).locale;
122
213
  }
123
214
 
124
- /** Match `pathname` against the registered routes, resolving `[param]` segments. */
215
+ /**
216
+ * Match `pathname` against the registered routes, resolving `[param]` segments. A
217
+ * leading non-default locale segment is stripped first (the route table is
218
+ * locale-agnostic; see `resolveLocale`).
219
+ */
125
220
  export function matchRoute(pathname) {
221
+ pathname = resolveLocale(pathname).path;
126
222
  for (const route in routes.pages) {
127
223
  const pattern = route
128
224
  .replace(/\[\.\.\.([^\]]+)\]/g, "(?<$1>.+)")
@@ -143,7 +239,7 @@ export function matchRoute(pathname) {
143
239
  * Navigate to `path`. Runs an optional route guard, swaps the rendered page
144
240
  * (tearing down the previous one's lifecycle), and updates window.history.
145
241
  */
146
- export async function navigate(path, replace = false, isPop = false) {
242
+ export async function navigate(path, replace = false, isPop = false, hydrate = false) {
147
243
  if (!path || !rootEl) return;
148
244
  const url = new URL(path, window.location.origin);
149
245
 
@@ -177,15 +273,38 @@ export async function navigate(path, replace = false, isPop = false) {
177
273
  matchRoute(url.pathname) ||
178
274
  (routes.notFound ? { entry: routes.notFound, params: {}, route: null } : null);
179
275
 
180
- state.pathname.value = url.pathname;
276
+ state.pathname.value = normalizePath(url.pathname);
181
277
  state.searchParams.value = url.searchParams;
182
278
  state.params.value = match ? match.params : {};
279
+ state.locale.value = resolveLocale(url.pathname).locale;
183
280
 
184
281
  if (!isPop) {
185
282
  if (replace) window.history.replaceState({}, "", path);
186
283
  else window.history.pushState({}, "", path);
187
284
  }
188
285
 
286
+ // First paint over server-rendered DOM: *adopt* it (hydrate) instead of rebuilding,
287
+ // when the route module exposes a `hydrate` adopt factory (compiled with the hydrate
288
+ // target). Only leaf routes (no layout chain) hydrate so far — `{children}`-slot
289
+ // adoption is a later phase — so anything else falls through to a clean CSR build. A
290
+ // hydration mismatch is reported (never silent) and also falls through to a rebuild.
291
+ if (hydrate && match) {
292
+ try {
293
+ const mod = typeof match.entry === "function" ? await match.entry() : match.entry;
294
+ if (mod && typeof mod.hydrate === "function" && layoutChain(match.route).length === 0) {
295
+ const query = Object.fromEntries(url.searchParams);
296
+ const node = mod.hydrate(rootEl, { params: match.params, query });
297
+ currentNodes = [node];
298
+ runMount(node);
299
+ clearError({ phase: "route" });
300
+ return;
301
+ }
302
+ } catch (e) {
303
+ reportError(e, { phase: "hydrate", path: url.pathname });
304
+ // fall through to a clean CSR build below
305
+ }
306
+ }
307
+
189
308
  // Resolve (and lazily load) the page + its layout chain before tearing down the
190
309
  // current view, so a slow/failed import doesn't leave a blank page.
191
310
  let nodes = null;
@@ -210,6 +329,13 @@ export async function navigate(path, replace = false, isPop = false) {
210
329
  for (const n of nodes) runMount(n); // run onMount for page + every layout
211
330
  currentNodes = nodes;
212
331
  clearError({ phase: "route" }); // a good render dismisses a prior error overlay
332
+ // A forward navigation lands at the top of the new page (or at the targeted
333
+ // anchor); back/forward (`isPop`) keeps the browser's restored scroll position.
334
+ if (!isPop && isBrowser) {
335
+ const anchor = url.hash ? document.getElementById(decodeURIComponent(url.hash.slice(1))) : null;
336
+ if (anchor) anchor.scrollIntoView();
337
+ else window.scrollTo(0, 0);
338
+ }
213
339
  } else {
214
340
  rootEl.innerHTML = "<h1>404 — Not Found</h1>";
215
341
  currentNodes = [];
@@ -224,12 +350,18 @@ export async function navigate(path, replace = false, isPop = false) {
224
350
  * @param {Object} opts.pages `{ path: module }` route map (from the dev server).
225
351
  * @param {Element} [opts.target] the app root (defaults to `#app`).
226
352
  * @param {Function} [opts.guard] optional `(to, tools) => …` route guard.
353
+ * @param {"spa"|"mpa"} [opts.nav] navigation mode (default "spa"); "mpa" disables
354
+ * client-side link interception so every navigation is a full page load.
227
355
  */
228
- export function mountApp({ pages, target, guard: g } = {}) {
356
+ export function mountApp({ pages, target, guard: g, i18n, nav } = {}) {
229
357
  rootEl = target || (isBrowser ? document.getElementById("app") : null);
358
+ navMode = nav === "mpa" ? "mpa" : "spa";
359
+ if (i18n) configureI18n(i18n);
230
360
  if (pages) registerRoutes(pages);
231
361
  guard = g || null;
232
- if (isBrowser) {
362
+ // In MPA mode the browser owns navigation (full loads push real history entries),
363
+ // so there is no client-side history to react to — only wire popstate for SPA.
364
+ if (isBrowser && navMode === "spa") {
233
365
  window.addEventListener("popstate", () =>
234
366
  navigate(
235
367
  window.location.pathname + window.location.search + window.location.hash,
@@ -238,7 +370,21 @@ export function mountApp({ pages, target, guard: g } = {}) {
238
370
  ),
239
371
  );
240
372
  }
241
- navigate(window.location.pathname + window.location.search + window.location.hash, true, true);
373
+ // Hydrate the first paint when the server stamped `data-otfw-hydrate` on the root and
374
+ // left rendered markup in it; otherwise this is a plain CSR mount (build into #app).
375
+ const hydrate = !!(
376
+ isBrowser &&
377
+ rootEl &&
378
+ rootEl.firstChild &&
379
+ typeof rootEl.hasAttribute === "function" &&
380
+ rootEl.hasAttribute("data-otfw-hydrate")
381
+ );
382
+ return navigate(
383
+ window.location.pathname + window.location.search + window.location.hash,
384
+ true,
385
+ true,
386
+ hydrate,
387
+ );
242
388
  }
243
389
 
244
390
  // `<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";