@uniweb/runtime 0.14.0 → 0.14.2

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/dist/ssr.js CHANGED
@@ -1,8 +1,15 @@
1
- import { isRichSchema, deriveCacheKey, resolveDefaultLocale, hasDarkScheme, createUniweb } from "@uniweb/core";
1
+ import { isRichSchema, deriveCacheKey, resolveDefaultLocale, hasDarkScheme, createUniweb, resolveRequestStyle, resolveServiceUrl, substitutePlaceholders, matchWhere } from "@uniweb/core";
2
2
  import React from "react";
3
3
  import { renderToString } from "react-dom/server";
4
4
  import { sectionDomId } from "@uniweb/core/section-id";
5
+ import { routePatternToRegex } from "@uniweb/core/route-match";
6
+ import { DEFAULT_ICON_BASE, iconUrl } from "@uniweb/core/icon-corpus";
5
7
  import { buildTheme, FONT_LINKS_MARKER, buildSectionOverrides } from "@uniweb/theming";
8
+ import "@uniweb/core/services";
9
+ import "@uniweb/core/tracker";
10
+ import { resolveFetchConfigs } from "@uniweb/core/fetch-config";
11
+ import { deriveCacheKey as deriveCacheKey$1 } from "@uniweb/core/datastore";
12
+ import { resolveDefaultLocale as resolveDefaultLocale$1 } from "@uniweb/core/locale-config";
6
13
  function guaranteeItemStructure(item) {
7
14
  return {
8
15
  title: item.title || "",
@@ -240,27 +247,6 @@ function getComponentMeta(componentName) {
240
247
  function getComponentDefaults(componentName) {
241
248
  return globalThis.uniweb?.getComponentDefaults?.(componentName) || {};
242
249
  }
243
- const PARAM_NAME = "[A-Za-z0-9_-]+";
244
- const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g;
245
- function normalizeRoute(route) {
246
- if (typeof route !== "string" || route === "") return "/";
247
- return route === "/" ? "/" : route.replace(/\/+$/, "") || "/";
248
- }
249
- function routePatternToRegex(pattern) {
250
- const paramNames = [];
251
- const source = normalizeRoute(pattern).replace(REGEX_SPECIALS, "\\$&").replace(new RegExp(`:(${PARAM_NAME})`, "g"), (_, name) => {
252
- paramNames.push(name);
253
- return "([^/]+)";
254
- });
255
- return { regex: new RegExp(`^${source}$`), paramNames };
256
- }
257
- const DEFAULT_ICON_BASE = "https://uniweb.github.io/icons";
258
- function iconPath(family, name) {
259
- return `${family}/${family}-${name}.svg`;
260
- }
261
- function iconUrl(family, name, base = DEFAULT_ICON_BASE) {
262
- return `${String(base).replace(/\/+$/, "")}/${iconPath(family, name)}`;
263
- }
264
250
  function default404Html(basePath = "") {
265
251
  const homeHref = basePath ? `${basePath}/` : "/";
266
252
  return `<div class="page-not-found" style="min-height:80vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:2rem;text-align:center"><h1 style="font-size:3rem;font-weight:bold;color:#1f2937;margin-bottom:1rem">404</h1><p style="color:#64748b;margin-bottom:2rem">Page not found</p><a href="${homeHref}" style="color:#3b82f6;text-decoration:underline">Go to homepage</a></div>`;
@@ -298,6 +284,7 @@ function sliceContentForLocale(content, locale) {
298
284
  function hydrateDataStore(website, fetchedData) {
299
285
  if (!website?.dataStore || !fetchedData?.length) return;
300
286
  for (const entry of fetchedData) {
287
+ if (entry.outcome && entry.outcome !== "fetched") continue;
301
288
  website.dataStore.set(deriveCacheKey(entry.config), { data: entry.data });
302
289
  }
303
290
  }
@@ -851,11 +838,376 @@ function generate404Html({ baseHtml, website, siteContent }) {
851
838
  }
852
839
  return { html, hasNotFoundPage: !!notFoundPage };
853
840
  }
841
+ const KNOWN_OPERATORS = /* @__PURE__ */ new Set(["where", "limit", "sort"]);
842
+ function createDefaultFetcher({ basePath = "", config = {}, dev = false, records = null, fetch: fetchImpl = null } = {}) {
843
+ const doFetch = (input, init) => (fetchImpl || globalThis.fetch)(input, init);
844
+ const pathPrefix = basePath && basePath !== "/" ? basePath.replace(/\/$/, "") : "";
845
+ const baseUrl = typeof config?.baseUrl === "string" ? config.baseUrl.replace(/\/$/, "") : "";
846
+ const staticHeaders = buildStaticHeaders(config?.headers);
847
+ const supports = normalizeSupports(config?.supports);
848
+ const requestConfig = config?.request && typeof config.request === "object" ? config.request : {};
849
+ const styleName = typeof requestConfig.style === "string" ? requestConfig.style : null;
850
+ const style = resolveRequestStyle(styleName, { dev });
851
+ const rename = normalizeRename(requestConfig.rename, style, { dev });
852
+ const siteEnvelope = config?.envelope && typeof config.envelope === "object" ? config.envelope : null;
853
+ const envelope = { ...style.defaultEnvelope || {}, ...siteEnvelope || {} };
854
+ const stampedArrayKey = records?.envelope && typeof records.envelope === "object" && typeof records.envelope.records === "string" && records.envelope.records.length ? records.envelope.records : null;
855
+ const laneEnvelope = stampedArrayKey ? { list: stampedArrayKey } : null;
856
+ return {
857
+ /**
858
+ * Cache-key function. The default-fetcher's cache key includes only
859
+ * the operators it pushes down (because they affect what the source
860
+ * sees). Operators applied as runtime fallback operate on a shared
861
+ * cached value and therefore must NOT split the cache.
862
+ *
863
+ * Example: with `supports: []`, two pages declaring different
864
+ * `where:` clauses against the same path share one cache entry —
865
+ * the file is fetched once and each page filters its own copy. With
866
+ * `supports: [where]`, the same two pages fire two requests because
867
+ * the predicate travels in the request.
868
+ */
869
+ cacheKey(request) {
870
+ const base = deriveCacheKey(request);
871
+ const projected = {};
872
+ for (const op of supports) {
873
+ if (!style.canPush.has(op)) continue;
874
+ if (request[op] !== void 0) projected[op] = request[op];
875
+ }
876
+ if (Object.keys(projected).length === 0 && style.name === "json-body") {
877
+ return base;
878
+ }
879
+ return base + "::style=" + style.name + "::" + JSON.stringify(projected);
880
+ },
881
+ async resolve(request, ctx = {}) {
882
+ if (!request) return { data: null };
883
+ const { path, url, endpoint, transform, body: rawBody } = request;
884
+ let method = (request.method || "GET").toUpperCase();
885
+ if (method !== "GET" && method !== "POST") {
886
+ console.warn(`[default-fetcher] method "${request.method}" is not supported — falling back to GET.`);
887
+ method = "GET";
888
+ }
889
+ let target;
890
+ let isRemote;
891
+ if (endpoint) {
892
+ target = resolveServiceUrl(endpoint, pathPrefix);
893
+ isRemote = true;
894
+ } else if (path) {
895
+ target = pathPrefix && path.startsWith("/") && !path.startsWith("//") ? pathPrefix + path : path;
896
+ isRemote = false;
897
+ } else if (url) {
898
+ target = isAbsoluteUrl(url) ? url : joinUrl(baseUrl, url);
899
+ isRemote = true;
900
+ } else {
901
+ return { data: [], error: "No path, url or endpoint specified" };
902
+ }
903
+ const init = { signal: ctx.signal, method };
904
+ const headers = {};
905
+ if (isRemote && staticHeaders) Object.assign(headers, staticHeaders);
906
+ const pushCandidates = /* @__PURE__ */ new Set();
907
+ if (isRemote) {
908
+ for (const op of KNOWN_OPERATORS) {
909
+ if (supports.has(op) && style.canPush.has(op) && request[op] !== void 0 && request[op] !== null) {
910
+ pushCandidates.add(op);
911
+ }
912
+ }
913
+ }
914
+ const encoded = pushCandidates.size > 0 ? style.encode(request, { method, pushCandidates, rename }) : { queryParams: [], bodyMerge: null, pushed: /* @__PURE__ */ new Set() };
915
+ const pushedOperators = encoded.pushed;
916
+ if (encoded.queryParams.length > 0 && method === "GET") {
917
+ target = appendStyleQueryParams(target, encoded.queryParams);
918
+ }
919
+ if (method === "POST") {
920
+ const dc = request.dynamicContext;
921
+ const resolvedBody = rawBody !== void 0 && rawBody !== null && dc && dc.paramName ? substitutePlaceholders(rawBody, { [dc.paramName]: dc.paramValue }, { encode: false }) : rawBody;
922
+ const finalBody = composePostBody(resolvedBody, encoded.bodyMerge);
923
+ if (finalBody !== null) {
924
+ if (!hasHeader(headers, "Content-Type")) {
925
+ headers["Content-Type"] = "application/json";
926
+ }
927
+ init.body = typeof finalBody === "string" ? finalBody : JSON.stringify(finalBody);
928
+ }
929
+ }
930
+ if (Object.keys(headers).length) init.headers = headers;
931
+ try {
932
+ const response = await doFetch(target, init);
933
+ const requestEnvelope = request.envelope && typeof request.envelope === "object" ? request.envelope : null;
934
+ const effectiveEnvelope = requestEnvelope ?? (endpoint && laneEnvelope ? { ...envelope, ...laneEnvelope } : envelope);
935
+ if (!response.ok) {
936
+ let extracted;
937
+ if (effectiveEnvelope.error) {
938
+ try {
939
+ const text = await response.text();
940
+ const body = safeParseJSON(text);
941
+ if (body !== void 0) {
942
+ const candidate = getNestedValue(body, effectiveEnvelope.error);
943
+ if (typeof candidate === "string" && candidate.length) {
944
+ extracted = candidate;
945
+ }
946
+ }
947
+ } catch {
948
+ }
949
+ }
950
+ return {
951
+ data: [],
952
+ error: extracted ?? `HTTP ${response.status}: ${response.statusText}`
953
+ };
954
+ }
955
+ const contentType = response.headers.get("content-type") || "";
956
+ let data;
957
+ if (contentType.includes("application/json")) {
958
+ data = await response.json();
959
+ } else {
960
+ const text = await response.text();
961
+ try {
962
+ data = JSON.parse(text);
963
+ } catch {
964
+ data = text;
965
+ }
966
+ }
967
+ const isDetailRequest = !!request.dynamicContext;
968
+ const effectiveTransform = transform || (isDetailRequest ? effectiveEnvelope.item : effectiveEnvelope.list);
969
+ if (effectiveTransform && data !== null && data !== void 0) {
970
+ data = getNestedValue(data, effectiveTransform);
971
+ }
972
+ data = applyFallbackOperators(data, request, pushedOperators);
973
+ return { data: data ?? [] };
974
+ } catch (error) {
975
+ if (error?.name === "AbortError") {
976
+ return { data: [], error: "aborted" };
977
+ }
978
+ return { data: [], error: error?.message || String(error) };
979
+ }
980
+ }
981
+ };
982
+ }
983
+ function normalizeRename(raw, style, { dev }) {
984
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
985
+ const out = {};
986
+ for (const [op, wireName] of Object.entries(raw)) {
987
+ if (typeof wireName !== "string" || wireName.length === 0) continue;
988
+ if (dev && !style.canPush.has(op) && !warnedRenameTargets.has(op)) {
989
+ warnedRenameTargets.add(op);
990
+ console.warn(
991
+ `[default-fetcher] request.rename: operator "${op}" is not pushed by style "${style.name}" — rename has no effect. Known operators for this style: ${[...style.canPush].join(", ") || "(none)"}.`
992
+ );
993
+ }
994
+ out[op] = wireName;
995
+ }
996
+ return Object.keys(out).length ? out : null;
997
+ }
998
+ const warnedRenameTargets = /* @__PURE__ */ new Set();
999
+ function normalizeSupports(raw) {
1000
+ const out = /* @__PURE__ */ new Set();
1001
+ if (!Array.isArray(raw)) return out;
1002
+ for (const op of raw) {
1003
+ if (typeof op !== "string") continue;
1004
+ if (KNOWN_OPERATORS.has(op)) out.add(op);
1005
+ else if (!warnedUnknownOperators.has(op)) {
1006
+ warnedUnknownOperators.add(op);
1007
+ console.warn(`[default-fetcher] supports: unknown operator "${op}" — ignored.`);
1008
+ }
1009
+ }
1010
+ return out;
1011
+ }
1012
+ const warnedUnknownOperators = /* @__PURE__ */ new Set();
1013
+ function appendStyleQueryParams(url, pairs) {
1014
+ if (!pairs || pairs.length === 0) return url;
1015
+ const params = pairs.map(
1016
+ ([k, v]) => encodeURIComponent(k) + "=" + encodeURIComponent(v)
1017
+ );
1018
+ const sep = url.includes("?") ? "&" : "?";
1019
+ return url + sep + params.join("&");
1020
+ }
1021
+ function composePostBody(authorBody, bodyMerge) {
1022
+ if (!bodyMerge) {
1023
+ return authorBody === void 0 ? null : authorBody;
1024
+ }
1025
+ if (typeof authorBody === "string") {
1026
+ return authorBody;
1027
+ }
1028
+ const base = authorBody && typeof authorBody === "object" ? authorBody : {};
1029
+ return { ...base, ...bodyMerge };
1030
+ }
1031
+ function applyFallbackOperators(data, request, pushedOperators) {
1032
+ if (!Array.isArray(data)) return data;
1033
+ let result = data;
1034
+ if (request.where && !pushedOperators.has("where")) {
1035
+ result = matchWhere(request.where, result);
1036
+ }
1037
+ if (request.sort && !pushedOperators.has("sort")) {
1038
+ result = applySortFallback(result, request.sort);
1039
+ }
1040
+ if (typeof request.limit === "number" && request.limit > 0 && !pushedOperators.has("limit")) {
1041
+ result = result.slice(0, request.limit);
1042
+ }
1043
+ return result;
1044
+ }
1045
+ function applySortFallback(items, sortExpr) {
1046
+ const sorts = String(sortExpr).split(",").map((s) => {
1047
+ const [field, dir = "asc"] = s.trim().split(/\s+/);
1048
+ return { field, desc: dir.toLowerCase() === "desc" };
1049
+ });
1050
+ return [...items].sort((a, b) => {
1051
+ for (const { field, desc } of sorts) {
1052
+ const av = getNestedValue(a, field) ?? "";
1053
+ const bv = getNestedValue(b, field) ?? "";
1054
+ if (av < bv) return desc ? 1 : -1;
1055
+ if (av > bv) return desc ? -1 : 1;
1056
+ }
1057
+ return 0;
1058
+ });
1059
+ }
1060
+ function buildStaticHeaders(headers) {
1061
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) return null;
1062
+ const out = {};
1063
+ for (const [k, v] of Object.entries(headers)) {
1064
+ if (v === null || v === void 0) continue;
1065
+ out[k] = String(v);
1066
+ }
1067
+ return Object.keys(out).length ? out : null;
1068
+ }
1069
+ function hasHeader(headers, name) {
1070
+ const lower = name.toLowerCase();
1071
+ return Object.keys(headers).some((k) => k.toLowerCase() === lower);
1072
+ }
1073
+ function isAbsoluteUrl(url) {
1074
+ if (typeof url !== "string") return false;
1075
+ if (url.startsWith("//")) return true;
1076
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(url);
1077
+ }
1078
+ function joinUrl(baseUrl, url) {
1079
+ if (!baseUrl) return url;
1080
+ if (url.startsWith("/")) return baseUrl + url;
1081
+ return baseUrl + "/" + url;
1082
+ }
1083
+ function getNestedValue(obj, path) {
1084
+ if (!obj || !path) return obj;
1085
+ let current = obj;
1086
+ for (const part of path.split(".")) {
1087
+ if (current === null || current === void 0) return void 0;
1088
+ current = current[part];
1089
+ }
1090
+ return current;
1091
+ }
1092
+ function safeParseJSON(text) {
1093
+ try {
1094
+ return JSON.parse(text);
1095
+ } catch {
1096
+ return void 0;
1097
+ }
1098
+ }
1099
+ const isRefinement = (f) => f && typeof f === "object" && f.refine === true;
1100
+ function findPageForRoute(content, route) {
1101
+ const pages = content?.pages || [];
1102
+ const exact = pages.find((p) => p.route === route);
1103
+ if (exact) return { page: exact, params: {} };
1104
+ for (const page of pages) {
1105
+ if (!page.isDynamic || !page.route) continue;
1106
+ const compiled = routePatternToRegex(page.route);
1107
+ const m = compiled?.regex ? compiled.regex.exec(route) : null;
1108
+ if (m) return { page, params: Object.fromEntries((compiled.paramNames || []).map((n, i) => [n, m[i + 1]])) };
1109
+ }
1110
+ return { page: null, params: {} };
1111
+ }
1112
+ function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
1113
+ const { page } = findPageForRoute(content, route);
1114
+ if (!page) return [];
1115
+ const pages = content?.pages || [];
1116
+ const parent = page.parent ? pages.find((p) => p.route === page.parent) : null;
1117
+ const options = {
1118
+ locale,
1119
+ defaultLocale: resolveDefaultLocale$1(content?.config) ?? null,
1120
+ queries: content?.config?.queries ?? null,
1121
+ records: content?.config?.records ?? null
1122
+ };
1123
+ const out = /* @__PURE__ */ new Map();
1124
+ const add = (sources) => {
1125
+ for (const cfg of resolveFetchConfigs(sources, options).values()) {
1126
+ const key = deriveCacheKey$1(cfg);
1127
+ if (!out.has(key)) out.set(key, cfg);
1128
+ }
1129
+ };
1130
+ add([page.fetch ?? null, parent?.fetch ?? null, content?.config?.fetch ?? null]);
1131
+ const walk = (sections) => {
1132
+ for (const s of sections || []) {
1133
+ if (s?.fetch && !isRefinement(s.fetch)) add([s.fetch, page.fetch ?? null, parent?.fetch ?? null, content?.config?.fetch ?? null]);
1134
+ if (s?.subsections) walk(s.subsections);
1135
+ }
1136
+ };
1137
+ walk(page.sections);
1138
+ return [...out.values()];
1139
+ }
1140
+ async function executeFetchConfigs(configs, { content, fetch: fetch2 = null, dev = false, prerender = "always" } = {}) {
1141
+ if (prerender !== "author" && prerender !== "always") {
1142
+ throw new Error(`executeFetchConfigs: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`);
1143
+ }
1144
+ const fetcher = createDefaultFetcher({
1145
+ basePath: content?.config?.base || "",
1146
+ config: content?.config?.fetcher ?? {},
1147
+ records: content?.config?.records ?? null,
1148
+ dev,
1149
+ fetch: fetch2
1150
+ });
1151
+ const ctx = { website: null };
1152
+ const out = [];
1153
+ for (const config of configs || []) {
1154
+ if (!config) continue;
1155
+ if (prerender === "author" && config.prerender === false) {
1156
+ out.push({ config, outcome: "skipped", data: null });
1157
+ continue;
1158
+ }
1159
+ const result = await fetcher.resolve(config, ctx);
1160
+ if (result?.error) out.push({ config, outcome: "failed", data: null, error: result.error });
1161
+ else out.push({ config, outcome: "fetched", data: result?.data ?? null });
1162
+ }
1163
+ return out;
1164
+ }
1165
+ async function prefetchPageData({ content, route, locale = null, fetch: fetch2 = null, dev = false, prerender = "always" }) {
1166
+ const configs = resolvePageFetchConfigs(content, route, { locale });
1167
+ return executeFetchConfigs(configs, { content, fetch: fetch2, dev, prerender });
1168
+ }
1169
+ function createPageRenderer({ website, shell }) {
1170
+ if (!website) throw new Error("createPageRenderer: `website` is required");
1171
+ if (typeof shell !== "string") throw new Error("createPageRenderer: `shell` must be an HTML string");
1172
+ function render(target, { inject = {} } = {}) {
1173
+ const page = typeof target === "string" ? resolvePage(website, target) : target;
1174
+ if (!page) return { outcome: "notFound", html: null, page: null, error: null };
1175
+ let result;
1176
+ try {
1177
+ result = renderPage(page, website);
1178
+ } catch (err) {
1179
+ return { outcome: "failed", html: null, page, error: classifyRenderError(err) };
1180
+ }
1181
+ if (result.error) return { outcome: "failed", html: null, page, error: result.error };
1182
+ const html = injectPageContent(shell, result.renderedContent, page, {
1183
+ ...inject,
1184
+ sectionOverrideCSS: result.sectionOverrideCSS
1185
+ });
1186
+ return { outcome: "rendered", html, page, error: null };
1187
+ }
1188
+ return { website, render };
1189
+ }
1190
+ async function prefetchAndHydrate({ website, content, route, locale = null, fetch: fetch2 = null, dev = false, prerender = "always" }) {
1191
+ if (!website?.dataStore) {
1192
+ throw new Error("prefetchAndHydrate: `website` must be an initialized Website with a dataStore");
1193
+ }
1194
+ if (typeof fetch2 !== "function") {
1195
+ throw new Error(
1196
+ "prefetchAndHydrate: `fetch` must be a function. A transport does not survive a JSON-serialized isolate boundary — pass it as an RPC method argument. To use the ambient fetch deliberately, pass `fetch: globalThis.fetch`."
1197
+ );
1198
+ }
1199
+ const fetched = await prefetchPageData({ content, route, locale, fetch: fetch2, dev, prerender });
1200
+ hydrateDataStore(website, fetched);
1201
+ return fetched;
1202
+ }
854
1203
  export {
855
1204
  applyDefaults,
856
1205
  applySchemas,
857
1206
  classifyRenderError,
1207
+ createPageRenderer,
858
1208
  escapeHtml,
1209
+ executeFetchConfigs,
1210
+ findPageForRoute,
859
1211
  generate404Html,
860
1212
  getComponentDefaults,
861
1213
  getComponentMeta,
@@ -865,7 +1217,9 @@ export {
865
1217
  initPrerender,
866
1218
  initPrerenderForLocale,
867
1219
  injectPageContent,
1220
+ prefetchAndHydrate,
868
1221
  prefetchIcons,
1222
+ prefetchPageData,
869
1223
  prepareProps,
870
1224
  renderAppearanceBootScript,
871
1225
  renderBackground,
@@ -874,6 +1228,7 @@ export {
874
1228
  renderLayout,
875
1229
  renderPage,
876
1230
  resolvePage,
1231
+ resolvePageFetchConfigs,
877
1232
  sliceContentForLocale
878
1233
  };
879
1234
  //# sourceMappingURL=ssr.js.map