@uniweb/runtime 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ssr.js CHANGED
@@ -1,8 +1,16 @@
1
- import { isRichSchema, deriveCacheKey as deriveCacheKey$1, resolveDefaultLocale as resolveDefaultLocale$1, hasDarkScheme, createUniweb, resolveRequestStyle, resolveServiceUrl, substitutePlaceholders as substitutePlaceholders$1, matchWhere } from "@uniweb/core";
1
+ import { isRichSchema, deriveCacheKey, resolveDefaultLocale, hasDarkScheme, createUniweb, resolveServiceUrl, substitutePlaceholders, matchWhere, sortRecords, sortToWire } 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, decodeRouteValue, splitPathCapture } 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 { buildDetailConfig } from "@uniweb/core/detail-url";
13
+ import { resolveDefaultLocale as resolveDefaultLocale$1 } from "@uniweb/core/locale-config";
6
14
  function guaranteeItemStructure(item) {
7
15
  return {
8
16
  title: item.title || "",
@@ -240,27 +248,6 @@ function getComponentMeta(componentName) {
240
248
  function getComponentDefaults(componentName) {
241
249
  return globalThis.uniweb?.getComponentDefaults?.(componentName) || {};
242
250
  }
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
251
  function default404Html(basePath = "") {
265
252
  const homeHref = basePath ? `${basePath}/` : "/";
266
253
  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>`;
@@ -282,7 +269,7 @@ function wireFoundationCapabilities(uniweb, foundation) {
282
269
  }
283
270
  }
284
271
  function sliceContentForLocale(content, locale) {
285
- const defaultLang = resolveDefaultLocale$1(content?.config);
272
+ const defaultLang = resolveDefaultLocale(content?.config);
286
273
  const locData = content?.locales?.[locale];
287
274
  if (!locale || locale === defaultLang || !locData) return content;
288
275
  return {
@@ -299,7 +286,7 @@ function hydrateDataStore(website, fetchedData) {
299
286
  if (!website?.dataStore || !fetchedData?.length) return;
300
287
  for (const entry of fetchedData) {
301
288
  if (entry.outcome && entry.outcome !== "fetched") continue;
302
- website.dataStore.set(deriveCacheKey$1(entry.config), { data: entry.data });
289
+ website.dataStore.set(deriveCacheKey(entry.config), entry.meta ? { data: entry.data, meta: entry.meta } : { data: entry.data });
303
290
  }
304
291
  }
305
292
  function ensureThemeCss(uniweb, foundation) {
@@ -628,7 +615,7 @@ function renderLayout(page, website) {
628
615
  function initPrerenderForLocale(content, foundation, locale, extensionsOrOptions, maybeOptions) {
629
616
  const localeContent = sliceContentForLocale(content, locale);
630
617
  const uniweb = initPrerender(localeContent, foundation, extensionsOrOptions, maybeOptions);
631
- const defaultLang = resolveDefaultLocale$1(content?.config);
618
+ const defaultLang = resolveDefaultLocale(content?.config);
632
619
  if (locale && locale !== defaultLang && uniweb.activeWebsite?.setActiveLocale) {
633
620
  uniweb.activeWebsite.setActiveLocale(locale);
634
621
  }
@@ -852,211 +839,40 @@ function generate404Html({ baseHtml, website, siteContent }) {
852
839
  }
853
840
  return { html, hasNotFoundPage: !!notFoundPage };
854
841
  }
855
- const DATA_DIR = "data";
856
- const DATA_URL_PREFIX = `/${DATA_DIR}/`;
857
- function queryDataUrl(name) {
858
- return `${DATA_URL_PREFIX}${name}.json`;
859
- }
860
- function recordDataUrl(query, slug) {
861
- return `${DATA_URL_PREFIX}${query}/${slug}.json`;
862
- }
863
- function isDataUrl(path) {
864
- return typeof path === "string" && path.startsWith(DATA_URL_PREFIX);
865
- }
866
- const PLACEHOLDER_RE = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
867
- function substitutePlaceholders(value, context, options = {}) {
868
- const { encode = true } = options;
869
- if (typeof value === "string") {
870
- return value.replace(PLACEHOLDER_RE, (literal, key) => {
871
- if (!(key in (context || {}))) return literal;
872
- const raw = context[key];
873
- if (raw === void 0 || raw === null) return literal;
874
- return encode ? encodeURIComponent(String(raw)) : String(raw);
875
- });
876
- }
877
- if (Array.isArray(value)) {
878
- return value.map((item) => substitutePlaceholders(item, context, options));
879
- }
880
- if (value && typeof value === "object") {
881
- const result = {};
882
- for (const key of Object.keys(value)) {
883
- result[key] = substitutePlaceholders(value[key], context, options);
884
- }
885
- return result;
886
- }
887
- return value;
888
- }
889
- const PATH_SLOT = "{path}";
890
- const PARAM_SLOT = "{param}";
891
- const warnedPatterns = /* @__PURE__ */ new Set();
892
- function warnOnce(key, message) {
893
- if (warnedPatterns.has(key)) return;
894
- warnedPatterns.add(key);
895
- console.warn(`[query-address] ${message}`);
896
- }
897
- function readPattern(lane, key) {
898
- if (!lane || typeof lane !== "object" || Array.isArray(lane)) return null;
899
- const pattern = lane[key];
900
- return typeof pattern === "string" && pattern.length > 0 ? pattern : null;
901
- }
902
- function resolveQueryAddress(query, lane) {
903
- if (typeof query !== "string" || query.length === 0) return null;
904
- const pattern = readPattern(lane, "list");
905
- if (!pattern) return null;
906
- if (!pattern.includes(PATH_SLOT)) {
907
- warnOnce(
908
- `list:${pattern}`,
909
- `config.records.list carries no ${PATH_SLOT} placeholder, so every query would resolve to the same address. Ignoring it and reading the compiled file instead.`
910
- );
911
- return null;
912
- }
913
- return substitutePlaceholders(pattern, { path: query });
914
- }
915
- function resolveRecordAddressPattern(query, lane) {
916
- if (typeof query !== "string" || query.length === 0) return null;
917
- const pattern = readPattern(lane, "record");
918
- if (!pattern) return null;
919
- if (!pattern.includes(PARAM_SLOT)) {
920
- warnOnce(
921
- `record:${pattern}`,
922
- `config.records.record carries no ${PARAM_SLOT} placeholder, so every record would resolve to the same address. Ignoring it and reading the per-record file instead.`
923
- );
924
- return null;
925
- }
926
- return substitutePlaceholders(pattern, { path: query });
927
- }
928
- function localizeConfig(cfg, locale, defaultLocale) {
929
- if (!cfg.path) return cfg;
930
- if (!locale || locale === defaultLocale) return cfg;
931
- if (!isDataUrl(cfg.path)) return cfg;
932
- return { ...cfg, path: `/${locale}${cfg.path}` };
933
- }
934
- function applyDeferredDetail(cfg, queries, records) {
935
- if (cfg.detail !== void 0) return cfg;
936
- if (cfg.endpoint) {
937
- const recordPattern = resolveRecordAddressPattern(cfg.query, records);
938
- if (recordPattern) return { ...cfg, detail: recordPattern };
939
- }
940
- const queryName = cfg.query || bindingKey(cfg);
941
- if (!queryName || !queries) return cfg;
942
- const collConfig = queries[queryName];
943
- if (!collConfig || typeof collConfig !== "object") return cfg;
944
- const deferred = Array.isArray(collConfig.deferred) ? collConfig.deferred : null;
945
- if (!deferred || deferred.length === 0) return cfg;
946
- const pattern = typeof collConfig.detailUrl === "string" ? collConfig.detailUrl : recordDataUrl(queryName, "{slug}");
947
- return { ...cfg, detail: pattern };
948
- }
949
- function resolveQuerySource(cfg, records) {
950
- if (typeof cfg.query !== "string" || cfg.query.length === 0) return cfg;
951
- const endpoint = resolveQueryAddress(cfg.query, records);
952
- if (endpoint) {
953
- const { path, url, ...rest } = cfg;
954
- return { ...rest, endpoint };
955
- }
956
- return { ...cfg, path: queryDataUrl(cfg.query) };
957
- }
958
- function bindingKey(cfg) {
959
- return cfg?.as;
960
- }
961
- function resolveFetchConfigs(sources, options = {}) {
962
- const {
963
- schemas = [],
964
- locale = null,
965
- defaultLocale = null,
966
- queries = null,
967
- records = null
968
- } = options;
969
- const configs = /* @__PURE__ */ new Map();
970
- const collectAll = schemas.length === 0;
971
- for (const source of sources) {
972
- if (!source) continue;
973
- const configList = Array.isArray(source) ? source : [source];
974
- for (const cfg of configList) {
975
- const key = bindingKey(cfg);
976
- if (!key) continue;
977
- if (configs.has(key)) continue;
978
- if (!collectAll && !schemas.includes(key)) continue;
979
- const sourced = resolveQuerySource(cfg, records);
980
- const localized = localizeConfig(sourced, locale, defaultLocale);
981
- configs.set(key, applyDeferredDetail(localized, queries, records));
982
- }
983
- }
984
- return configs;
985
- }
986
- function deriveCacheKey(request) {
987
- const { path, url, endpoint, transform } = request || {};
988
- const as = request?.as;
989
- const method = request?.method && request.method.toUpperCase() !== "GET" ? request.method.toUpperCase() : void 0;
990
- const body = method === "POST" ? request?.body : void 0;
991
- return JSON.stringify({ path, url, endpoint, as, transform, method, body });
992
- }
993
- function codeOf(entry) {
994
- if (typeof entry === "string" && entry.trim() && entry.trim() !== "*") return entry.trim();
995
- if (entry && typeof entry === "object" && typeof entry.code === "string" && entry.code.trim()) {
996
- return entry.code.trim();
997
- }
998
- return null;
999
- }
1000
- function normalizeLanguageList(value) {
1001
- if (!Array.isArray(value)) return [];
1002
- const seen = /* @__PURE__ */ new Set();
1003
- const codes = [];
1004
- for (const entry of value) {
1005
- const code = codeOf(entry);
1006
- if (!code || seen.has(code)) continue;
1007
- seen.add(code);
1008
- codes.push(code);
1009
- }
1010
- return codes;
1011
- }
1012
- function resolveDefaultLocale(config = {}) {
1013
- if (typeof config?.defaultLanguage === "string" && config.defaultLanguage.trim()) {
1014
- return config.defaultLanguage.trim();
1015
- }
1016
- return normalizeLanguageList(config?.languages)[0] || "en";
1017
- }
1018
- const KNOWN_OPERATORS = /* @__PURE__ */ new Set(["where", "limit", "sort"]);
1019
- function createDefaultFetcher({ basePath = "", config = {}, dev = false, records = null, fetch: fetchImpl = null } = {}) {
842
+ function createDefaultFetcher({ basePath = "", dev = false, records = null, fetch: fetchImpl = null } = {}) {
1020
843
  const doFetch = (input, init) => (fetchImpl || globalThis.fetch)(input, init);
1021
844
  const pathPrefix = basePath && basePath !== "/" ? basePath.replace(/\/$/, "") : "";
1022
- const baseUrl = typeof config?.baseUrl === "string" ? config.baseUrl.replace(/\/$/, "") : "";
1023
- const staticHeaders = buildStaticHeaders(config?.headers);
1024
- const supports = normalizeSupports(config?.supports);
1025
- const requestConfig = config?.request && typeof config.request === "object" ? config.request : {};
1026
- const styleName = typeof requestConfig.style === "string" ? requestConfig.style : null;
1027
- const style = resolveRequestStyle(styleName, { dev });
1028
- const rename = normalizeRename(requestConfig.rename, style, { dev });
1029
- const siteEnvelope = config?.envelope && typeof config.envelope === "object" ? config.envelope : null;
1030
- const envelope = { ...style.defaultEnvelope || {}, ...siteEnvelope || {} };
1031
845
  const stampedArrayKey = records?.envelope && typeof records.envelope === "object" && typeof records.envelope.records === "string" && records.envelope.records.length ? records.envelope.records : null;
1032
846
  const laneEnvelope = stampedArrayKey ? { list: stampedArrayKey } : null;
847
+ const doorQueues = /* @__PURE__ */ new Map();
848
+ const askDoor = (request, ctx) => new Promise((resolve) => {
849
+ const url = resolveServiceUrl(request.door, pathPrefix);
850
+ let queue = doorQueues.get(url);
851
+ if (!queue) {
852
+ queue = [];
853
+ doorQueues.set(url, queue);
854
+ queueMicrotask(() => {
855
+ doorQueues.delete(url);
856
+ flushDoor(url, queue, doFetch);
857
+ });
858
+ }
859
+ queue.push({ request, ctx, resolve });
860
+ });
1033
861
  return {
1034
862
  /**
1035
- * Cache-key function. The default-fetcher's cache key includes only
1036
- * the operators it pushes down (because they affect what the source
1037
- * sees). Operators applied as runtime fallback operate on a shared
1038
- * cached value and therefore must NOT split the cache.
1039
- *
1040
- * Example: with `supports: []`, two pages declaring different
1041
- * `where:` clauses against the same path share one cache entry —
1042
- * the file is fetched once and each page filters its own copy. With
1043
- * `supports: [where]`, the same two pages fire two requests because
1044
- * the predicate travels in the request.
863
+ * The cache identity is the request's ADDRESS or, on a question door,
864
+ * the QUESTION (`deriveCacheKey` hashes every operator of an address-less
865
+ * request). Operators evaluated here run over a shared cached value and
866
+ * must NOT split the cache: two pages declaring different `where:` clauses
867
+ * against the same path share one entry — the file is fetched once and
868
+ * each page filters its own copy.
1045
869
  */
1046
870
  cacheKey(request) {
1047
- const base = deriveCacheKey$1(request);
1048
- const projected = {};
1049
- for (const op of supports) {
1050
- if (!style.canPush.has(op)) continue;
1051
- if (request[op] !== void 0) projected[op] = request[op];
1052
- }
1053
- if (Object.keys(projected).length === 0 && style.name === "json-body") {
1054
- return base;
1055
- }
1056
- return base + "::style=" + style.name + "::" + JSON.stringify(projected);
871
+ return deriveCacheKey(request);
1057
872
  },
1058
873
  async resolve(request, ctx = {}) {
1059
874
  if (!request) return { data: null };
875
+ if (request.door) return askDoor(request, ctx);
1060
876
  const { path, url, endpoint, transform, body: rawBody } = request;
1061
877
  let method = (request.method || "GET").toUpperCase();
1062
878
  if (method !== "GET" && method !== "POST") {
@@ -1064,59 +880,39 @@ function createDefaultFetcher({ basePath = "", config = {}, dev = false, records
1064
880
  method = "GET";
1065
881
  }
1066
882
  let target;
1067
- let isRemote;
1068
883
  if (endpoint) {
1069
884
  target = resolveServiceUrl(endpoint, pathPrefix);
1070
- isRemote = true;
885
+ if (typeof request.locale === "string" && request.locale) {
886
+ target += (target.includes("?") ? "&" : "?") + "locale=" + encodeURIComponent(request.locale);
887
+ }
1071
888
  } else if (path) {
1072
889
  target = pathPrefix && path.startsWith("/") && !path.startsWith("//") ? pathPrefix + path : path;
1073
- isRemote = false;
1074
890
  } else if (url) {
1075
- target = isAbsoluteUrl(url) ? url : joinUrl(baseUrl, url);
1076
- isRemote = true;
891
+ target = url;
1077
892
  } else {
1078
893
  return { data: [], error: "No path, url or endpoint specified" };
1079
894
  }
1080
895
  const init = { signal: ctx.signal, method };
1081
- const headers = {};
1082
- if (isRemote && staticHeaders) Object.assign(headers, staticHeaders);
1083
- const pushCandidates = /* @__PURE__ */ new Set();
1084
- if (isRemote) {
1085
- for (const op of KNOWN_OPERATORS) {
1086
- if (supports.has(op) && style.canPush.has(op) && request[op] !== void 0 && request[op] !== null) {
1087
- pushCandidates.add(op);
1088
- }
1089
- }
1090
- }
1091
- const encoded = pushCandidates.size > 0 ? style.encode(request, { method, pushCandidates, rename }) : { queryParams: [], bodyMerge: null, pushed: /* @__PURE__ */ new Set() };
1092
- const pushedOperators = encoded.pushed;
1093
- if (encoded.queryParams.length > 0 && method === "GET") {
1094
- target = appendStyleQueryParams(target, encoded.queryParams);
1095
- }
1096
896
  if (method === "POST") {
1097
897
  const dc = request.dynamicContext;
1098
- const resolvedBody = rawBody !== void 0 && rawBody !== null && dc && dc.paramName ? substitutePlaceholders$1(rawBody, { [dc.paramName]: dc.paramValue }, { encode: false }) : rawBody;
1099
- const finalBody = composePostBody(resolvedBody, encoded.bodyMerge);
1100
- if (finalBody !== null) {
1101
- if (!hasHeader(headers, "Content-Type")) {
1102
- headers["Content-Type"] = "application/json";
1103
- }
1104
- init.body = typeof finalBody === "string" ? finalBody : JSON.stringify(finalBody);
898
+ const body = rawBody !== void 0 && rawBody !== null && dc && dc.paramName ? substitutePlaceholders(rawBody, { [dc.paramName]: dc.paramValue }, { encode: false }) : rawBody;
899
+ if (body !== void 0 && body !== null) {
900
+ init.headers = { "Content-Type": "application/json" };
901
+ init.body = typeof body === "string" ? body : JSON.stringify(body);
1105
902
  }
1106
903
  }
1107
- if (Object.keys(headers).length) init.headers = headers;
1108
904
  try {
1109
905
  const response = await doFetch(target, init);
1110
906
  const requestEnvelope = request.envelope && typeof request.envelope === "object" ? request.envelope : null;
1111
- const effectiveEnvelope = requestEnvelope ?? (endpoint && laneEnvelope ? { ...envelope, ...laneEnvelope } : envelope);
907
+ const envelope = requestEnvelope ?? (endpoint && laneEnvelope ? laneEnvelope : {});
1112
908
  if (!response.ok) {
1113
909
  let extracted;
1114
- if (effectiveEnvelope.error) {
910
+ if (envelope.error) {
1115
911
  try {
1116
912
  const text = await response.text();
1117
913
  const body = safeParseJSON(text);
1118
914
  if (body !== void 0) {
1119
- const candidate = getNestedValue(body, effectiveEnvelope.error);
915
+ const candidate = getNestedValue(body, envelope.error);
1120
916
  if (typeof candidate === "string" && candidate.length) {
1121
917
  extracted = candidate;
1122
918
  }
@@ -1142,12 +938,13 @@ function createDefaultFetcher({ basePath = "", config = {}, dev = false, records
1142
938
  }
1143
939
  }
1144
940
  const isDetailRequest = !!request.dynamicContext;
1145
- const effectiveTransform = transform || (isDetailRequest ? effectiveEnvelope.item : effectiveEnvelope.list);
941
+ const effectiveTransform = transform || (isDetailRequest ? envelope.item : envelope.list);
1146
942
  if (effectiveTransform && data !== null && data !== void 0) {
1147
943
  data = getNestedValue(data, effectiveTransform);
1148
944
  }
1149
- data = applyFallbackOperators(data, request, pushedOperators);
1150
- return { data: data ?? [] };
945
+ data = applyOperators(data, request, { dev });
946
+ const depth = request.depth === "brief" || request.depth === "full" ? request.depth : void 0;
947
+ return depth ? { data: data ?? [], meta: { depth } } : { data: data ?? [] };
1151
948
  } catch (error) {
1152
949
  if (error?.name === "AbortError") {
1153
950
  return { data: [], error: "aborted" };
@@ -1157,105 +954,100 @@ function createDefaultFetcher({ basePath = "", config = {}, dev = false, records
1157
954
  }
1158
955
  };
1159
956
  }
1160
- function normalizeRename(raw, style, { dev }) {
1161
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
957
+ function doorQuestion(request) {
958
+ const q = { schema: request.schema };
959
+ let where = request.where && typeof request.where === "object" ? request.where : null;
960
+ let scope = typeof request.scope === "string" && request.scope ? request.scope : null;
961
+ if (where && !scope && where.path && typeof where.path === "object" && typeof where.path.under === "string" && where.path.under) {
962
+ const { path, ...rest } = where;
963
+ scope = path.under;
964
+ where = Object.keys(rest).length ? rest : null;
965
+ }
966
+ if (scope) q.scope = scope;
967
+ if (where) q.where = renameOperators(where);
968
+ const sort = sortToWire(request.sort);
969
+ if (sort) q.sort = sort;
970
+ if (typeof request.limit === "number" && request.limit > 0) q.limit = request.limit;
971
+ if (request.depth === "brief" || request.depth === "full") q.depth = request.depth;
972
+ return q;
973
+ }
974
+ const DOOR_OPERATOR = { nin: "not_in" };
975
+ function renameOperators(where) {
976
+ if (Array.isArray(where)) return where.map(renameOperators);
977
+ if (!where || typeof where !== "object") return where;
1162
978
  const out = {};
1163
- for (const [op, wireName] of Object.entries(raw)) {
1164
- if (typeof wireName !== "string" || wireName.length === 0) continue;
1165
- if (dev && !style.canPush.has(op) && !warnedRenameTargets.has(op)) {
1166
- warnedRenameTargets.add(op);
1167
- console.warn(
1168
- `[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)"}.`
1169
- );
1170
- }
1171
- out[op] = wireName;
1172
- }
1173
- return Object.keys(out).length ? out : null;
1174
- }
1175
- const warnedRenameTargets = /* @__PURE__ */ new Set();
1176
- function normalizeSupports(raw) {
1177
- const out = /* @__PURE__ */ new Set();
1178
- if (!Array.isArray(raw)) return out;
1179
- for (const op of raw) {
1180
- if (typeof op !== "string") continue;
1181
- if (KNOWN_OPERATORS.has(op)) out.add(op);
1182
- else if (!warnedUnknownOperators.has(op)) {
1183
- warnedUnknownOperators.add(op);
1184
- console.warn(`[default-fetcher] supports: unknown operator "${op}" — ignored.`);
1185
- }
979
+ for (const [key, value] of Object.entries(where)) {
980
+ out[DOOR_OPERATOR[key] ?? key] = value && typeof value === "object" ? renameOperators(value) : value;
1186
981
  }
1187
982
  return out;
1188
983
  }
1189
- const warnedUnknownOperators = /* @__PURE__ */ new Set();
1190
- function appendStyleQueryParams(url, pairs) {
1191
- if (!pairs || pairs.length === 0) return url;
1192
- const params = pairs.map(
1193
- ([k, v]) => encodeURIComponent(k) + "=" + encodeURIComponent(v)
1194
- );
1195
- const sep = url.includes("?") ? "&" : "?";
1196
- return url + sep + params.join("&");
1197
- }
1198
- function composePostBody(authorBody, bodyMerge) {
1199
- if (!bodyMerge) {
1200
- return authorBody === void 0 ? null : authorBody;
1201
- }
1202
- if (typeof authorBody === "string") {
1203
- return authorBody;
1204
- }
1205
- const base = authorBody && typeof authorBody === "object" ? authorBody : {};
1206
- return { ...base, ...bodyMerge };
984
+ async function flushDoor(url, queue, doFetch) {
985
+ const body = {};
986
+ const keys = [];
987
+ for (const entry of queue) {
988
+ const base = entry.request.as || "q";
989
+ let key = base;
990
+ for (let n = 2; key in body; n += 1) key = `${base}#${n}`;
991
+ keys.push(key);
992
+ body[key] = doorQuestion(entry.request);
993
+ }
994
+ let parsed;
995
+ try {
996
+ const response = await doFetch(url, {
997
+ method: "POST",
998
+ headers: { "Content-Type": "application/json" },
999
+ body: JSON.stringify(body)
1000
+ });
1001
+ if (!response.ok) {
1002
+ const error = `HTTP ${response.status}: ${response.statusText}`;
1003
+ for (const entry of queue) entry.resolve({ data: null, error });
1004
+ return;
1005
+ }
1006
+ parsed = await response.json();
1007
+ } catch (error) {
1008
+ const message = error?.name === "AbortError" ? "aborted" : error?.message || String(error);
1009
+ for (const entry of queue) entry.resolve({ data: null, error: message });
1010
+ return;
1011
+ }
1012
+ const data = parsed && typeof parsed.data === "object" && parsed.data ? parsed.data : {};
1013
+ const errors = parsed && typeof parsed.errors === "object" && parsed.errors ? parsed.errors : {};
1014
+ const depths = parsed && typeof parsed.depths === "object" && parsed.depths ? parsed.depths : {};
1015
+ queue.forEach((entry, i) => {
1016
+ const key = keys[i];
1017
+ if (key in errors) {
1018
+ const e = errors[key];
1019
+ entry.resolve({ data: null, error: typeof e === "string" ? e : e?.message || JSON.stringify(e) });
1020
+ return;
1021
+ }
1022
+ if (!(key in data)) {
1023
+ entry.resolve({ data: null, error: `the records door answered without the key "${key}"` });
1024
+ return;
1025
+ }
1026
+ const depth = depths[key] === "brief" || depths[key] === "full" ? depths[key] : entry.request.depth === "brief" || entry.request.depth === "full" ? entry.request.depth : void 0;
1027
+ entry.resolve(depth ? { data: data[key], meta: { depth } } : { data: data[key] });
1028
+ });
1207
1029
  }
1208
- function applyFallbackOperators(data, request, pushedOperators) {
1030
+ function applyOperators(data, request, { dev = false } = {}) {
1209
1031
  if (!Array.isArray(data)) return data;
1210
1032
  let result = data;
1211
- if (request.where && !pushedOperators.has("where")) {
1212
- result = matchWhere(request.where, result);
1213
- }
1214
- if (request.sort && !pushedOperators.has("sort")) {
1215
- result = applySortFallback(result, request.sort);
1216
- }
1217
- if (typeof request.limit === "number" && request.limit > 0 && !pushedOperators.has("limit")) {
1218
- result = result.slice(0, request.limit);
1219
- }
1033
+ if (request.where) result = matchWhere(request.where, result);
1034
+ if (request.sort) result = applySort(result, request.sort, dev);
1035
+ if (typeof request.limit === "number" && request.limit > 0) result = result.slice(0, request.limit);
1220
1036
  return result;
1221
1037
  }
1222
- function applySortFallback(items, sortExpr) {
1223
- const sorts = String(sortExpr).split(",").map((s) => {
1224
- const [field, dir = "asc"] = s.trim().split(/\s+/);
1225
- return { field, desc: dir.toLowerCase() === "desc" };
1226
- });
1227
- return [...items].sort((a, b) => {
1228
- for (const { field, desc } of sorts) {
1229
- const av = getNestedValue(a, field) ?? "";
1230
- const bv = getNestedValue(b, field) ?? "";
1231
- if (av < bv) return desc ? 1 : -1;
1232
- if (av > bv) return desc ? -1 : 1;
1038
+ const warnedBadSorts = /* @__PURE__ */ new Set();
1039
+ function applySort(items, sortExpr, dev) {
1040
+ try {
1041
+ return sortRecords(items, sortExpr);
1042
+ } catch (err) {
1043
+ if (dev) throw err;
1044
+ const key = String(sortExpr);
1045
+ if (!warnedBadSorts.has(key)) {
1046
+ warnedBadSorts.add(key);
1047
+ console.error(`[default-fetcher] ${err.message} Records delivered unsorted.`);
1233
1048
  }
1234
- return 0;
1235
- });
1236
- }
1237
- function buildStaticHeaders(headers) {
1238
- if (!headers || typeof headers !== "object" || Array.isArray(headers)) return null;
1239
- const out = {};
1240
- for (const [k, v] of Object.entries(headers)) {
1241
- if (v === null || v === void 0) continue;
1242
- out[k] = String(v);
1049
+ return items;
1243
1050
  }
1244
- return Object.keys(out).length ? out : null;
1245
- }
1246
- function hasHeader(headers, name) {
1247
- const lower = name.toLowerCase();
1248
- return Object.keys(headers).some((k) => k.toLowerCase() === lower);
1249
- }
1250
- function isAbsoluteUrl(url) {
1251
- if (typeof url !== "string") return false;
1252
- if (url.startsWith("//")) return true;
1253
- return /^[a-z][a-z0-9+.-]*:\/\//i.test(url);
1254
- }
1255
- function joinUrl(baseUrl, url) {
1256
- if (!baseUrl) return url;
1257
- if (url.startsWith("/")) return baseUrl + url;
1258
- return baseUrl + "/" + url;
1259
1051
  }
1260
1052
  function getNestedValue(obj, path) {
1261
1053
  if (!obj || !path) return obj;
@@ -1282,25 +1074,44 @@ function findPageForRoute(content, route) {
1282
1074
  if (!page.isDynamic || !page.route) continue;
1283
1075
  const compiled = routePatternToRegex(page.route);
1284
1076
  const m = compiled?.regex ? compiled.regex.exec(route) : null;
1285
- if (m) return { page, params: Object.fromEntries((compiled.paramNames || []).map((n, i) => [n, m[i + 1]])) };
1077
+ if (m) {
1078
+ const params = {};
1079
+ (compiled.paramNames || []).forEach((n, i) => {
1080
+ const raw = m[i + 1];
1081
+ params[n] = n === compiled.catchAll ? raw.split("/").map(decodeRouteValue).join("/") : decodeRouteValue(raw);
1082
+ });
1083
+ return { page, params };
1084
+ }
1286
1085
  }
1287
1086
  return { page: null, params: {} };
1288
1087
  }
1088
+ function routeBinding(page, params) {
1089
+ const { catchAll } = routePatternToRegex(page.route);
1090
+ if (catchAll && params[catchAll] !== void 0) {
1091
+ const parts = splitPathCapture(params[catchAll]);
1092
+ const paramName2 = page.paramName || "slug";
1093
+ return { paramName: paramName2, paramValue: parts.slug, variables: { ...params, ...parts } };
1094
+ }
1095
+ const paramName = page.paramName || Object.keys(params)[0];
1096
+ return { paramName, paramValue: params[paramName], variables: { ...params } };
1097
+ }
1289
1098
  function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
1290
- const { page } = findPageForRoute(content, route);
1099
+ const { page, params } = findPageForRoute(content, route);
1291
1100
  if (!page) return [];
1292
1101
  const pages = content?.pages || [];
1293
1102
  const parent = page.parent ? pages.find((p) => p.route === page.parent) : null;
1103
+ const binding = page.isDynamic && Object.keys(params).length ? routeBinding(page, params) : null;
1294
1104
  const options = {
1295
1105
  locale,
1296
- defaultLocale: resolveDefaultLocale(content?.config) ?? null,
1106
+ defaultLocale: resolveDefaultLocale$1(content?.config) ?? null,
1297
1107
  queries: content?.config?.queries ?? null,
1298
- records: content?.config?.records ?? null
1108
+ records: content?.config?.records ?? null,
1109
+ variables: binding?.variables ?? null
1299
1110
  };
1300
1111
  const out = /* @__PURE__ */ new Map();
1301
1112
  const add = (sources) => {
1302
1113
  for (const cfg of resolveFetchConfigs(sources, options).values()) {
1303
- const key = deriveCacheKey(cfg);
1114
+ const key = deriveCacheKey$1(cfg);
1304
1115
  if (!out.has(key)) out.set(key, cfg);
1305
1116
  }
1306
1117
  };
@@ -1312,6 +1123,14 @@ function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
1312
1123
  }
1313
1124
  };
1314
1125
  walk(page.sections);
1126
+ if (binding && binding.paramValue !== void 0 && page.parentSchema) {
1127
+ const listCfg = [...out.values()].find((cfg) => cfg.as === page.parentSchema && cfg.detail);
1128
+ const detailCfg = listCfg ? buildDetailConfig(listCfg, { paramName: binding.paramName, paramValue: String(binding.paramValue) }) : null;
1129
+ if (detailCfg) {
1130
+ const key = deriveCacheKey$1(detailCfg);
1131
+ if (!out.has(key)) out.set(key, detailCfg);
1132
+ }
1133
+ }
1315
1134
  return [...out.values()];
1316
1135
  }
1317
1136
  async function executeFetchConfigs(configs, { content, fetch: fetch2 = null, dev = false, prerender = "always" } = {}) {
@@ -1320,33 +1139,63 @@ async function executeFetchConfigs(configs, { content, fetch: fetch2 = null, dev
1320
1139
  }
1321
1140
  const fetcher = createDefaultFetcher({
1322
1141
  basePath: content?.config?.base || "",
1323
- config: content?.config?.fetcher ?? {},
1324
1142
  records: content?.config?.records ?? null,
1325
1143
  dev,
1326
1144
  fetch: fetch2
1327
1145
  });
1328
1146
  const ctx = { website: null };
1329
- const out = [];
1330
- for (const config of configs || []) {
1331
- if (!config) continue;
1147
+ return Promise.all((configs || []).filter(Boolean).map(async (config) => {
1332
1148
  if (prerender === "author" && config.prerender === false) {
1333
- out.push({ config, outcome: "skipped", data: null });
1334
- continue;
1149
+ return { config, outcome: "skipped", data: null };
1335
1150
  }
1336
1151
  const result = await fetcher.resolve(config, ctx);
1337
- if (result?.error) out.push({ config, outcome: "failed", data: null, error: result.error });
1338
- else out.push({ config, outcome: "fetched", data: result?.data ?? null });
1339
- }
1340
- return out;
1152
+ if (result?.error) return { config, outcome: "failed", data: null, error: result.error };
1153
+ return { config, outcome: "fetched", data: result?.data ?? null, ...result?.meta ? { meta: result.meta } : {} };
1154
+ }));
1341
1155
  }
1342
1156
  async function prefetchPageData({ content, route, locale = null, fetch: fetch2 = null, dev = false, prerender = "always" }) {
1343
1157
  const configs = resolvePageFetchConfigs(content, route, { locale });
1344
1158
  return executeFetchConfigs(configs, { content, fetch: fetch2, dev, prerender });
1345
1159
  }
1160
+ function createPageRenderer({ website, shell }) {
1161
+ if (!website) throw new Error("createPageRenderer: `website` is required");
1162
+ if (typeof shell !== "string") throw new Error("createPageRenderer: `shell` must be an HTML string");
1163
+ function render(target, { inject = {} } = {}) {
1164
+ const page = typeof target === "string" ? resolvePage(website, target) : target;
1165
+ if (!page) return { outcome: "notFound", html: null, page: null, error: null };
1166
+ let result;
1167
+ try {
1168
+ result = renderPage(page, website);
1169
+ } catch (err) {
1170
+ return { outcome: "failed", html: null, page, error: classifyRenderError(err) };
1171
+ }
1172
+ if (result.error) return { outcome: "failed", html: null, page, error: result.error };
1173
+ const html = injectPageContent(shell, result.renderedContent, page, {
1174
+ ...inject,
1175
+ sectionOverrideCSS: result.sectionOverrideCSS
1176
+ });
1177
+ return { outcome: "rendered", html, page, error: null };
1178
+ }
1179
+ return { website, render };
1180
+ }
1181
+ async function prefetchAndHydrate({ website, content, route, locale = null, fetch: fetch2 = null, dev = false, prerender = "always" }) {
1182
+ if (!website?.dataStore) {
1183
+ throw new Error("prefetchAndHydrate: `website` must be an initialized Website with a dataStore");
1184
+ }
1185
+ if (typeof fetch2 !== "function") {
1186
+ throw new Error(
1187
+ "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`."
1188
+ );
1189
+ }
1190
+ const fetched = await prefetchPageData({ content, route, locale, fetch: fetch2, dev, prerender });
1191
+ hydrateDataStore(website, fetched);
1192
+ return fetched;
1193
+ }
1346
1194
  export {
1347
1195
  applyDefaults,
1348
1196
  applySchemas,
1349
1197
  classifyRenderError,
1198
+ createPageRenderer,
1350
1199
  escapeHtml,
1351
1200
  executeFetchConfigs,
1352
1201
  findPageForRoute,
@@ -1359,6 +1208,7 @@ export {
1359
1208
  initPrerender,
1360
1209
  initPrerenderForLocale,
1361
1210
  injectPageContent,
1211
+ prefetchAndHydrate,
1362
1212
  prefetchIcons,
1363
1213
  prefetchPageData,
1364
1214
  prepareProps,