@opentf/web 0.4.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.
Files changed (187) hide show
  1. package/README.md +30 -28
  2. package/components/Link.jsx +43 -0
  3. package/core/errors.js +48 -0
  4. package/core/reactive.js +190 -0
  5. package/core/signals.js +207 -0
  6. package/index.js +13 -0
  7. package/package.json +27 -18
  8. package/runtime/clipboard.js +47 -0
  9. package/runtime/code-block.js +47 -0
  10. package/runtime/context.js +99 -0
  11. package/runtime/dom.js +368 -0
  12. package/runtime/error-boundary.js +109 -0
  13. package/runtime/events.js +17 -0
  14. package/runtime/hydrate.js +121 -0
  15. package/runtime/index.js +22 -0
  16. package/runtime/lifecycle.js +9 -0
  17. package/runtime/mount.js +49 -0
  18. package/runtime/portal.js +59 -0
  19. package/runtime/raw-html.js +33 -0
  20. package/runtime/router.js +392 -0
  21. package/server/builtins.js +16 -0
  22. package/server/head.js +191 -0
  23. package/server/index.js +8 -0
  24. package/server/render.js +98 -0
  25. package/server/ssg-runtime.js +112 -0
  26. package/dist/CHANGELOG.md +0 -49
  27. package/dist/README.md +0 -41
  28. package/dist/babel.config.cjs +0 -6
  29. package/dist/compiler/babel-plugin.cjs +0 -798
  30. package/dist/core/mount.js +0 -32
  31. package/dist/index.js +0 -7
  32. package/dist/node_modules/@babel/core/LICENSE +0 -22
  33. package/dist/node_modules/@babel/core/README.md +0 -19
  34. package/dist/node_modules/@babel/core/lib/config/cache-contexts.js +0 -5
  35. package/dist/node_modules/@babel/core/lib/config/cache-contexts.js.map +0 -1
  36. package/dist/node_modules/@babel/core/lib/config/caching.js +0 -261
  37. package/dist/node_modules/@babel/core/lib/config/caching.js.map +0 -1
  38. package/dist/node_modules/@babel/core/lib/config/config-chain.js +0 -469
  39. package/dist/node_modules/@babel/core/lib/config/config-chain.js.map +0 -1
  40. package/dist/node_modules/@babel/core/lib/config/config-descriptors.js +0 -190
  41. package/dist/node_modules/@babel/core/lib/config/config-descriptors.js.map +0 -1
  42. package/dist/node_modules/@babel/core/lib/config/files/configuration.js +0 -290
  43. package/dist/node_modules/@babel/core/lib/config/files/configuration.js.map +0 -1
  44. package/dist/node_modules/@babel/core/lib/config/files/import.cjs +0 -6
  45. package/dist/node_modules/@babel/core/lib/config/files/import.cjs.map +0 -1
  46. package/dist/node_modules/@babel/core/lib/config/files/index-browser.js +0 -58
  47. package/dist/node_modules/@babel/core/lib/config/files/index-browser.js.map +0 -1
  48. package/dist/node_modules/@babel/core/lib/config/files/index.js +0 -78
  49. package/dist/node_modules/@babel/core/lib/config/files/index.js.map +0 -1
  50. package/dist/node_modules/@babel/core/lib/config/files/module-types.js +0 -203
  51. package/dist/node_modules/@babel/core/lib/config/files/module-types.js.map +0 -1
  52. package/dist/node_modules/@babel/core/lib/config/files/package.js +0 -61
  53. package/dist/node_modules/@babel/core/lib/config/files/package.js.map +0 -1
  54. package/dist/node_modules/@babel/core/lib/config/files/plugins.js +0 -220
  55. package/dist/node_modules/@babel/core/lib/config/files/plugins.js.map +0 -1
  56. package/dist/node_modules/@babel/core/lib/config/files/types.js +0 -5
  57. package/dist/node_modules/@babel/core/lib/config/files/types.js.map +0 -1
  58. package/dist/node_modules/@babel/core/lib/config/files/utils.js +0 -36
  59. package/dist/node_modules/@babel/core/lib/config/files/utils.js.map +0 -1
  60. package/dist/node_modules/@babel/core/lib/config/full.js +0 -312
  61. package/dist/node_modules/@babel/core/lib/config/full.js.map +0 -1
  62. package/dist/node_modules/@babel/core/lib/config/helpers/config-api.js +0 -85
  63. package/dist/node_modules/@babel/core/lib/config/helpers/config-api.js.map +0 -1
  64. package/dist/node_modules/@babel/core/lib/config/helpers/deep-array.js +0 -23
  65. package/dist/node_modules/@babel/core/lib/config/helpers/deep-array.js.map +0 -1
  66. package/dist/node_modules/@babel/core/lib/config/helpers/environment.js +0 -12
  67. package/dist/node_modules/@babel/core/lib/config/helpers/environment.js.map +0 -1
  68. package/dist/node_modules/@babel/core/lib/config/index.js +0 -87
  69. package/dist/node_modules/@babel/core/lib/config/index.js.map +0 -1
  70. package/dist/node_modules/@babel/core/lib/config/item.js +0 -67
  71. package/dist/node_modules/@babel/core/lib/config/item.js.map +0 -1
  72. package/dist/node_modules/@babel/core/lib/config/partial.js +0 -158
  73. package/dist/node_modules/@babel/core/lib/config/partial.js.map +0 -1
  74. package/dist/node_modules/@babel/core/lib/config/pattern-to-regex.js +0 -38
  75. package/dist/node_modules/@babel/core/lib/config/pattern-to-regex.js.map +0 -1
  76. package/dist/node_modules/@babel/core/lib/config/plugin.js +0 -33
  77. package/dist/node_modules/@babel/core/lib/config/plugin.js.map +0 -1
  78. package/dist/node_modules/@babel/core/lib/config/printer.js +0 -113
  79. package/dist/node_modules/@babel/core/lib/config/printer.js.map +0 -1
  80. package/dist/node_modules/@babel/core/lib/config/resolve-targets-browser.js +0 -41
  81. package/dist/node_modules/@babel/core/lib/config/resolve-targets-browser.js.map +0 -1
  82. package/dist/node_modules/@babel/core/lib/config/resolve-targets.js +0 -61
  83. package/dist/node_modules/@babel/core/lib/config/resolve-targets.js.map +0 -1
  84. package/dist/node_modules/@babel/core/lib/config/util.js +0 -31
  85. package/dist/node_modules/@babel/core/lib/config/util.js.map +0 -1
  86. package/dist/node_modules/@babel/core/lib/config/validation/option-assertions.js +0 -277
  87. package/dist/node_modules/@babel/core/lib/config/validation/option-assertions.js.map +0 -1
  88. package/dist/node_modules/@babel/core/lib/config/validation/options.js +0 -187
  89. package/dist/node_modules/@babel/core/lib/config/validation/options.js.map +0 -1
  90. package/dist/node_modules/@babel/core/lib/config/validation/plugins.js +0 -67
  91. package/dist/node_modules/@babel/core/lib/config/validation/plugins.js.map +0 -1
  92. package/dist/node_modules/@babel/core/lib/config/validation/removed.js +0 -68
  93. package/dist/node_modules/@babel/core/lib/config/validation/removed.js.map +0 -1
  94. package/dist/node_modules/@babel/core/lib/errors/config-error.js +0 -18
  95. package/dist/node_modules/@babel/core/lib/errors/config-error.js.map +0 -1
  96. package/dist/node_modules/@babel/core/lib/errors/rewrite-stack-trace.js +0 -98
  97. package/dist/node_modules/@babel/core/lib/errors/rewrite-stack-trace.js.map +0 -1
  98. package/dist/node_modules/@babel/core/lib/gensync-utils/async.js +0 -90
  99. package/dist/node_modules/@babel/core/lib/gensync-utils/async.js.map +0 -1
  100. package/dist/node_modules/@babel/core/lib/gensync-utils/fs.js +0 -31
  101. package/dist/node_modules/@babel/core/lib/gensync-utils/fs.js.map +0 -1
  102. package/dist/node_modules/@babel/core/lib/gensync-utils/functional.js +0 -58
  103. package/dist/node_modules/@babel/core/lib/gensync-utils/functional.js.map +0 -1
  104. package/dist/node_modules/@babel/core/lib/index.js +0 -230
  105. package/dist/node_modules/@babel/core/lib/index.js.map +0 -1
  106. package/dist/node_modules/@babel/core/lib/parse.js +0 -45
  107. package/dist/node_modules/@babel/core/lib/parse.js.map +0 -1
  108. package/dist/node_modules/@babel/core/lib/parser/index.js +0 -85
  109. package/dist/node_modules/@babel/core/lib/parser/index.js.map +0 -1
  110. package/dist/node_modules/@babel/core/lib/parser/util/missing-plugin-helper.js +0 -337
  111. package/dist/node_modules/@babel/core/lib/parser/util/missing-plugin-helper.js.map +0 -1
  112. package/dist/node_modules/@babel/core/lib/tools/build-external-helpers.js +0 -144
  113. package/dist/node_modules/@babel/core/lib/tools/build-external-helpers.js.map +0 -1
  114. package/dist/node_modules/@babel/core/lib/transform-ast.js +0 -48
  115. package/dist/node_modules/@babel/core/lib/transform-ast.js.map +0 -1
  116. package/dist/node_modules/@babel/core/lib/transform-file-browser.js +0 -23
  117. package/dist/node_modules/@babel/core/lib/transform-file-browser.js.map +0 -1
  118. package/dist/node_modules/@babel/core/lib/transform-file.js +0 -40
  119. package/dist/node_modules/@babel/core/lib/transform-file.js.map +0 -1
  120. package/dist/node_modules/@babel/core/lib/transform.js +0 -47
  121. package/dist/node_modules/@babel/core/lib/transform.js.map +0 -1
  122. package/dist/node_modules/@babel/core/lib/transformation/block-hoist-plugin.js +0 -84
  123. package/dist/node_modules/@babel/core/lib/transformation/block-hoist-plugin.js.map +0 -1
  124. package/dist/node_modules/@babel/core/lib/transformation/file/babel-7-helpers.cjs +0 -4
  125. package/dist/node_modules/@babel/core/lib/transformation/file/babel-7-helpers.cjs.map +0 -1
  126. package/dist/node_modules/@babel/core/lib/transformation/file/file.js +0 -204
  127. package/dist/node_modules/@babel/core/lib/transformation/file/file.js.map +0 -1
  128. package/dist/node_modules/@babel/core/lib/transformation/file/generate.js +0 -84
  129. package/dist/node_modules/@babel/core/lib/transformation/file/generate.js.map +0 -1
  130. package/dist/node_modules/@babel/core/lib/transformation/file/merge-map.js +0 -37
  131. package/dist/node_modules/@babel/core/lib/transformation/file/merge-map.js.map +0 -1
  132. package/dist/node_modules/@babel/core/lib/transformation/index.js +0 -90
  133. package/dist/node_modules/@babel/core/lib/transformation/index.js.map +0 -1
  134. package/dist/node_modules/@babel/core/lib/transformation/normalize-file.js +0 -127
  135. package/dist/node_modules/@babel/core/lib/transformation/normalize-file.js.map +0 -1
  136. package/dist/node_modules/@babel/core/lib/transformation/normalize-opts.js +0 -59
  137. package/dist/node_modules/@babel/core/lib/transformation/normalize-opts.js.map +0 -1
  138. package/dist/node_modules/@babel/core/lib/transformation/plugin-pass.js +0 -48
  139. package/dist/node_modules/@babel/core/lib/transformation/plugin-pass.js.map +0 -1
  140. package/dist/node_modules/@babel/core/lib/transformation/util/clone-deep.js +0 -54
  141. package/dist/node_modules/@babel/core/lib/transformation/util/clone-deep.js.map +0 -1
  142. package/dist/node_modules/@babel/core/lib/vendor/import-meta-resolve.js +0 -1042
  143. package/dist/node_modules/@babel/core/lib/vendor/import-meta-resolve.js.map +0 -1
  144. package/dist/node_modules/@babel/core/package.json +0 -82
  145. package/dist/node_modules/@babel/core/src/config/files/index-browser.ts +0 -115
  146. package/dist/node_modules/@babel/core/src/config/files/index.ts +0 -30
  147. package/dist/node_modules/@babel/core/src/config/resolve-targets-browser.ts +0 -42
  148. package/dist/node_modules/@babel/core/src/config/resolve-targets.ts +0 -54
  149. package/dist/node_modules/@babel/core/src/transform-file-browser.ts +0 -33
  150. package/dist/node_modules/@babel/core/src/transform-file.ts +0 -56
  151. package/dist/node_modules/@babel/helper-module-imports/LICENSE +0 -22
  152. package/dist/node_modules/@babel/helper-module-imports/README.md +0 -19
  153. package/dist/node_modules/@babel/helper-module-imports/lib/import-builder.js +0 -122
  154. package/dist/node_modules/@babel/helper-module-imports/lib/import-builder.js.map +0 -1
  155. package/dist/node_modules/@babel/helper-module-imports/lib/import-injector.js +0 -304
  156. package/dist/node_modules/@babel/helper-module-imports/lib/import-injector.js.map +0 -1
  157. package/dist/node_modules/@babel/helper-module-imports/lib/index.js +0 -37
  158. package/dist/node_modules/@babel/helper-module-imports/lib/index.js.map +0 -1
  159. package/dist/node_modules/@babel/helper-module-imports/lib/is-module.js +0 -11
  160. package/dist/node_modules/@babel/helper-module-imports/lib/is-module.js.map +0 -1
  161. package/dist/node_modules/@babel/helper-module-imports/package.json +0 -28
  162. package/dist/node_modules/@babel/plugin-syntax-jsx/LICENSE +0 -22
  163. package/dist/node_modules/@babel/plugin-syntax-jsx/README.md +0 -19
  164. package/dist/node_modules/@babel/plugin-syntax-jsx/lib/index.js +0 -21
  165. package/dist/node_modules/@babel/plugin-syntax-jsx/lib/index.js.map +0 -1
  166. package/dist/node_modules/@babel/plugin-syntax-jsx/package.json +0 -33
  167. package/dist/node_modules/@preact/signals-core/CHANGELOG.md +0 -331
  168. package/dist/node_modules/@preact/signals-core/LICENSE +0 -21
  169. package/dist/node_modules/@preact/signals-core/README.md +0 -258
  170. package/dist/node_modules/@preact/signals-core/dist/signals-core.d.ts +0 -149
  171. package/dist/node_modules/@preact/signals-core/dist/signals-core.js +0 -1
  172. package/dist/node_modules/@preact/signals-core/dist/signals-core.js.map +0 -1
  173. package/dist/node_modules/@preact/signals-core/dist/signals-core.min.js +0 -1
  174. package/dist/node_modules/@preact/signals-core/dist/signals-core.min.js.map +0 -1
  175. package/dist/node_modules/@preact/signals-core/dist/signals-core.mjs +0 -1
  176. package/dist/node_modules/@preact/signals-core/dist/signals-core.mjs.map +0 -1
  177. package/dist/node_modules/@preact/signals-core/dist/signals-core.module.js +0 -1
  178. package/dist/node_modules/@preact/signals-core/dist/signals-core.module.js.map +0 -1
  179. package/dist/node_modules/@preact/signals-core/package.json +0 -48
  180. package/dist/node_modules/@preact/signals-core/src/index.ts +0 -1121
  181. package/dist/package.json +0 -30
  182. package/dist/router/Link.js +0 -73
  183. package/dist/router/index.js +0 -216
  184. package/dist/runtime/For.js +0 -16
  185. package/dist/runtime/dom.js +0 -73
  186. package/dist/runtime/lifecycle.js +0 -25
  187. package/dist/runtime/props.js +0 -103
@@ -0,0 +1,392 @@
1
+ // File-based client router for the OTF Web runtime.
2
+ //
3
+ // Pages/layouts compile to factory functions (default export) that return a DOM
4
+ // node; `mountApp` registers a route table discovered from the filesystem (by the
5
+ // dev server / build) and renders the matching page into the app root, swapping
6
+ // it on navigation. Route state (pathname/params/query) is exposed reactively via
7
+ // the `router` facade so views can read `router.pathname` etc. inside bindings.
8
+ //
9
+ // Dynamic segments use the `[param]` / `[...rest]` folder convention (SPEC §6).
10
+ //
11
+ // NOTE: passing route params to a page via its `props` argument
12
+ // (`function Page(props) { props.params.id }`) and layout `props.children`
13
+ // composition both require signal-free page props, which the compiler does not
14
+ // emit yet — for now pages read params via the reactive `router.params`.
15
+
16
+ import { clearError, reportError } from "../core/errors.js";
17
+ import { signal } from "../core/signals.js";
18
+ import { runCleanup, runMount } from "./mount.js";
19
+
20
+ const isBrowser = typeof window !== "undefined";
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
+
31
+ const state = {
32
+ pathname: signal(isBrowser ? normalizePath(window.location.pathname) : "/"),
33
+ searchParams: signal(new URLSearchParams(isBrowser ? window.location.search : "")),
34
+ params: signal({}),
35
+ locale: signal(null),
36
+ };
37
+
38
+ export const routes = { pages: {}, layouts: {}, notFound: null };
39
+ let guard = null;
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
+ }
118
+ let currentNodes = [];
119
+
120
+ /** Reactive router facade — getters read signal values (tracked in effects). */
121
+ export const router = {
122
+ get pathname() {
123
+ return state.pathname.value;
124
+ },
125
+ get searchParams() {
126
+ return state.searchParams.value;
127
+ },
128
+ get query() {
129
+ return Object.fromEntries(state.searchParams.value);
130
+ },
131
+ get params() {
132
+ return state.params.value;
133
+ },
134
+ get locale() {
135
+ return state.locale.value;
136
+ },
137
+ push: (path) => navigate(path),
138
+ replace: (path) => navigate(path, true),
139
+ };
140
+
141
+ /** Derive the route ("/counter", "/") from a `.../app/<route>/page.jsx` path. */
142
+ function routeFromPath(filePath) {
143
+ const r = filePath
144
+ .replace(/^.*\/app/, "")
145
+ .replace(/\/(page|layout|404)\.(jsx|tsx|mdx|md)$/, "");
146
+ return r === "" ? "/" : r;
147
+ }
148
+
149
+ /**
150
+ * Register routes from a `{ path: entry }` map. Each `entry` is either a module
151
+ * namespace (eager) or a `() => import(...)` loader (lazy, code-split) — both
152
+ * resolve to a default-export factory at navigation time. `layout.jsx` files
153
+ * register as layouts (wrapping nested pages), `404.jsx` as the fallback.
154
+ */
155
+ export function registerRoutes(modules) {
156
+ for (const file in modules) {
157
+ const entry = modules[file];
158
+ if (/\/404\.(jsx|tsx)$/.test(file)) routes.notFound = entry;
159
+ else if (/\/layout\.(jsx|tsx)$/.test(file)) routes.layouts[routeFromPath(file)] = entry;
160
+ else routes.pages[routeFromPath(file)] = entry;
161
+ }
162
+ }
163
+
164
+ /** Layout entries that wrap `route`, outermost (root) first. */
165
+ export function layoutChain(route) {
166
+ const chain = [];
167
+ if (!route) return chain;
168
+ let p = route;
169
+ while (true) {
170
+ if (routes.layouts[p]) chain.unshift(routes.layouts[p]);
171
+ if (p === "/") break;
172
+ p = p.slice(0, p.lastIndexOf("/")) || "/";
173
+ }
174
+ return chain;
175
+ }
176
+
177
+ /** Resolve a route entry (module namespace or lazy loader) to its factory. */
178
+ export async function resolveFactory(entry) {
179
+ const mod = typeof entry === "function" ? await entry() : entry;
180
+ return mod && mod.default ? mod.default : mod;
181
+ }
182
+
183
+ /**
184
+ * Build the DOM node for a matched route: the page factory wrapped by its layout
185
+ * chain (most-specific inward, root outermost). Returns the outermost `node` plus
186
+ * the ordered `nodes` list (page → … → root) so callers can run lifecycle on each.
187
+ * Shared by the client router (`navigate`) and server render (`renderToString`).
188
+ */
189
+ export async function buildRouteNode(match, query = {}) {
190
+ const props = { params: match.params, query };
191
+ const pageFactory = await resolveFactory(match.entry);
192
+ let node = pageFactory(props);
193
+ const nodes = [node];
194
+ const chain = layoutChain(match.route);
195
+ for (let i = chain.length - 1; i >= 0; i--) {
196
+ const layout = await resolveFactory(chain[i]);
197
+ node = layout({ ...props, children: node });
198
+ nodes.push(node);
199
+ }
200
+ return { node, nodes };
201
+ }
202
+
203
+ /**
204
+ * Set the reactive route state directly (no history/render). Used by server render
205
+ * so a page reading `router.pathname`/`params`/`query` resolves to the route being
206
+ * pre-rendered. The client uses `navigate` instead.
207
+ */
208
+ export function setRouteState({ pathname = "/", search = "", params = {}, locale } = {}) {
209
+ state.pathname.value = normalizePath(pathname);
210
+ state.searchParams.value = new URLSearchParams(search);
211
+ state.params.value = params;
212
+ state.locale.value = locale !== undefined ? locale : resolveLocale(pathname).locale;
213
+ }
214
+
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
+ */
220
+ export function matchRoute(pathname) {
221
+ pathname = resolveLocale(pathname).path;
222
+ for (const route in routes.pages) {
223
+ const pattern = route
224
+ .replace(/\[\.\.\.([^\]]+)\]/g, "(?<$1>.+)")
225
+ .replace(/\[([^\]]+)\]/g, "(?<$1>[^/]+)");
226
+ const m = pathname.match(new RegExp(`^${pattern}/?$`));
227
+ if (m) {
228
+ const params = { ...(m.groups || {}) };
229
+ for (const k in params) {
230
+ if (route.includes(`[...${k}]`)) params[k] = params[k].split("/");
231
+ }
232
+ return { entry: routes.pages[route], params, route };
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+
238
+ /**
239
+ * Navigate to `path`. Runs an optional route guard, swaps the rendered page
240
+ * (tearing down the previous one's lifecycle), and updates window.history.
241
+ */
242
+ export async function navigate(path, replace = false, isPop = false, hydrate = false) {
243
+ if (!path || !rootEl) return;
244
+ const url = new URL(path, window.location.origin);
245
+
246
+ if (guard) {
247
+ let redirected = false;
248
+ const to = {
249
+ path: url.pathname,
250
+ params: matchRoute(url.pathname)?.params || {},
251
+ query: Object.fromEntries(url.searchParams),
252
+ };
253
+ await new Promise((resolve) => {
254
+ const tools = {
255
+ next: () => resolve(),
256
+ redirect: (p) => {
257
+ redirected = true;
258
+ navigate(p, true);
259
+ resolve();
260
+ },
261
+ replace: (p) => {
262
+ redirected = true;
263
+ navigate(p, true);
264
+ resolve();
265
+ },
266
+ };
267
+ Promise.resolve(guard(to, tools)).catch(() => resolve());
268
+ });
269
+ if (redirected) return;
270
+ }
271
+
272
+ const match =
273
+ matchRoute(url.pathname) ||
274
+ (routes.notFound ? { entry: routes.notFound, params: {}, route: null } : null);
275
+
276
+ state.pathname.value = normalizePath(url.pathname);
277
+ state.searchParams.value = url.searchParams;
278
+ state.params.value = match ? match.params : {};
279
+ state.locale.value = resolveLocale(url.pathname).locale;
280
+
281
+ if (!isPop) {
282
+ if (replace) window.history.replaceState({}, "", path);
283
+ else window.history.pushState({}, "", path);
284
+ }
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
+
308
+ // Resolve (and lazily load) the page + its layout chain before tearing down the
309
+ // current view, so a slow/failed import doesn't leave a blank page.
310
+ let nodes = null;
311
+ if (match) {
312
+ try {
313
+ const built = await buildRouteNode(match, Object.fromEntries(url.searchParams));
314
+ nodes = built.nodes;
315
+ } catch (e) {
316
+ reportError(e, { phase: "route", path: url.pathname });
317
+ rootEl.replaceChildren();
318
+ rootEl.innerHTML = `<pre style="color:#f87171;padding:1rem">Failed to load ${url.pathname}\n${e?.message ?? e}</pre>`;
319
+ currentNodes = [];
320
+ return;
321
+ }
322
+ }
323
+
324
+ for (const n of currentNodes) runCleanup(n);
325
+ rootEl.replaceChildren();
326
+
327
+ if (nodes) {
328
+ rootEl.appendChild(nodes[nodes.length - 1]); // outermost node
329
+ for (const n of nodes) runMount(n); // run onMount for page + every layout
330
+ currentNodes = nodes;
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
+ }
339
+ } else {
340
+ rootEl.innerHTML = "<h1>404 — Not Found</h1>";
341
+ currentNodes = [];
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Bootstrap the application: register routes, wire history, and render the page
347
+ * for the current URL.
348
+ *
349
+ * @param {Object} opts
350
+ * @param {Object} opts.pages `{ path: module }` route map (from the dev server).
351
+ * @param {Element} [opts.target] the app root (defaults to `#app`).
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.
355
+ */
356
+ export function mountApp({ pages, target, guard: g, i18n, nav } = {}) {
357
+ rootEl = target || (isBrowser ? document.getElementById("app") : null);
358
+ navMode = nav === "mpa" ? "mpa" : "spa";
359
+ if (i18n) configureI18n(i18n);
360
+ if (pages) registerRoutes(pages);
361
+ guard = g || null;
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") {
365
+ window.addEventListener("popstate", () =>
366
+ navigate(
367
+ window.location.pathname + window.location.search + window.location.hash,
368
+ false,
369
+ true,
370
+ ),
371
+ );
372
+ }
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
+ );
388
+ }
389
+
390
+ // `<Link>` is a pure JSX component (packages/web/components/Link.jsx), compiled by
391
+ // the app's pipeline like any UI component — it lives there, not as a hand-written
392
+ // Custom Element here. It imports `navigate` from this module.
@@ -0,0 +1,16 @@
1
+ //! SSG renderers for the built-in runtime components (the ones that are
2
+ //! hand-written Custom Elements, not compiled). Registered on import so the SSG
3
+ //! bundle can render `<Link>`, `<ContextProvider>`, `<Portal>`, `<ErrorBoundary>`.
4
+
5
+ import { defineSSG } from "./ssg-runtime.js";
6
+
7
+ // (web-link is provided by the compiled components/Link.jsx, not hand-written.)
8
+ // Passthrough: their effect is structural/client-side; SSG renders children inline.
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
+ }
@@ -0,0 +1,8 @@
1
+ // Server entry for SSG: the string-building helpers the compiler emits, the
2
+ // render/route API, and the built-in component SSG renderers (registered on
3
+ // import). Imported as "@opentf/web/server".
4
+
5
+ export * from "./ssg-runtime.js";
6
+ export * from "./head.js";
7
+ export * from "./render.js";
8
+ import "./builtins.js";