@uniweb/runtime 0.23.0 → 0.25.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,4 +1,4 @@
1
- import { isRichSchema, deriveCacheKey, resolveDefaultLocale, hasDarkScheme, createUniweb, substitutePlaceholders, resolveServiceUrl, applyScope, matchWhere, sortRecords, sortToWire } from "@uniweb/core";
1
+ import { isRichSchema, deriveCacheKey, resolveDefaultLocale, hasDarkScheme, createUniweb, substitutePlaceholders, resolveServiceUrl, evaluateQuery, 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";
@@ -9,7 +9,7 @@ import { DEFAULT_ICON_BASE, iconUrl } from "@uniweb/core/icon-corpus";
9
9
  import { buildTheme, FONT_LINKS_MARKER, buildSectionOverrides } from "@uniweb/theming";
10
10
  import "@uniweb/core/services";
11
11
  import "@uniweb/core/tracker";
12
- import { pageRouteQuery, siteReaches, resolveFetchConfigs, currentOf, othersView, routeSelection } from "@uniweb/core/fetch-config";
12
+ import { pageRouteQuery, siteReaches, fetchEntries, resolveFetchConfigs, currentFor, othersView, routeSelection } from "@uniweb/core/fetch-config";
13
13
  import { deriveCacheKey as deriveCacheKey$1 } from "@uniweb/core/datastore";
14
14
  import { buildDetailConfig } from "@uniweb/core/detail-url";
15
15
  import { resolveDefaultLocale as resolveDefaultLocale$1 } from "@uniweb/core/locale-config";
@@ -171,19 +171,30 @@ function applyDefaults(params, defaults) {
171
171
  ...params || {}
172
172
  };
173
173
  }
174
- function mergeEntityData(block, entityData) {
175
- if (!entityData) return;
176
- const current = block.parsedContent.data || {};
177
- let changed = false;
178
- const merged = { ...current };
179
- for (const key of Object.keys(entityData)) {
180
- if (merged[key] === void 0) {
181
- merged[key] = entityData[key];
182
- changed = true;
183
- }
184
- }
185
- if (changed) {
186
- block.parsedContent.data = merged;
174
+ function assembleData(block, meta, entityData) {
175
+ const declared = block.website?.declaredKeys ? block.website.declaredKeys(meta) : [];
176
+ const held = block.heldData || {};
177
+ const data = {};
178
+ for (const [key] of declared) {
179
+ if (held[key] !== void 0) data[key] = held[key];
180
+ else if (entityData && entityData[key] !== void 0) data[key] = entityData[key];
181
+ else data[key] = null;
182
+ }
183
+ warnUndeclaredBlocks(block, declared);
184
+ block.parsedContent.data = data;
185
+ }
186
+ const warnedBlocks = /* @__PURE__ */ new Set();
187
+ function warnUndeclaredBlocks(block, declared) {
188
+ if (!block.website?.entityStore?.dev) return;
189
+ const keys = new Set(declared.map(([key]) => key));
190
+ for (const item of block.parsedContent?.sequence || []) {
191
+ if (item?.type !== "dataBlock" || typeof item.tag !== "string" || keys.has(item.tag)) continue;
192
+ const memo = `${block.type}::${item.tag}`;
193
+ if (warnedBlocks.has(memo)) continue;
194
+ warnedBlocks.add(memo);
195
+ console.warn(
196
+ `[uniweb] ${block.type}: the \`${item.tag}\` data block is not in content.data — ${block.type} does not declare \`${item.tag}\` in its meta.js \`data:\`. It stays in content.sequence.`
197
+ );
187
198
  }
188
199
  }
189
200
  function runDataHandler(block) {
@@ -227,7 +238,8 @@ function runPropsHandler(content, params, block) {
227
238
  return null;
228
239
  }
229
240
  function prepareProps(block, meta, entityData = null) {
230
- mergeEntityData(block, entityData);
241
+ assembleData(block, meta, entityData);
242
+ block.website?.entityStore?.linkOwnRecords?.(block);
231
243
  runDataHandler(block);
232
244
  runContentHandler(block);
233
245
  const defaults = meta?.defaults || {};
@@ -919,7 +931,7 @@ function createDefaultFetcher({ basePath = "", dev = false, fetch: fetchImpl = n
919
931
  if (transform && data !== null && data !== void 0) {
920
932
  data = getNestedValue(data, transform);
921
933
  }
922
- data = applyOperators(data, request, { dev });
934
+ data = applyOperators(data, request, { dev, locale: sortLocale(request, ctx) });
923
935
  const whole = typeof request.whole === "boolean" ? request.whole : void 0;
924
936
  return whole === void 0 ? { data: data ?? [] } : { data: data ?? [], meta: { whole } };
925
937
  } catch (error) {
@@ -937,14 +949,32 @@ function toQuestion(request) {
937
949
  const scope = typeof request.scope === "string" && request.scope ? request.scope : null;
938
950
  if (scope) q.scope = scope;
939
951
  if (where) q.where = where;
940
- if (request.match && typeof request.match === "object") q.match = request.match;
941
- const sort = sortToWire(request.sort);
952
+ const sort = wireSort(request.sort);
942
953
  if (sort) q.sort = sort;
943
954
  if (typeof request.limit === "number" && request.limit > 0) q.limit = request.limit;
944
955
  if (request.whole === true) q.whole = true;
945
- if (typeof request.cursor === "string" && request.cursor) q.cursor = request.cursor;
956
+ const narrow = toNarrow(request.narrow);
957
+ if (narrow) q.narrow = narrow;
946
958
  return q;
947
959
  }
960
+ function toNarrow(narrow) {
961
+ if (!narrow || typeof narrow !== "object") return null;
962
+ const n = {};
963
+ if (narrow.where && typeof narrow.where === "object") n.where = narrow.where;
964
+ if (narrow.match && typeof narrow.match === "object") n.match = narrow.match;
965
+ const sort = wireSort(narrow.sort);
966
+ if (sort) n.sort = sort;
967
+ if (typeof narrow.cursor === "string" && narrow.cursor) n.cursor = narrow.cursor;
968
+ if (typeof narrow.limit === "number" && narrow.limit > 0) n.limit = narrow.limit;
969
+ return Object.keys(n).length > 0 ? n : null;
970
+ }
971
+ function wireSort(sort) {
972
+ try {
973
+ return sortToWire(sort);
974
+ } catch {
975
+ return typeof sort === "string" && sort.trim() ? sort : null;
976
+ }
977
+ }
948
978
  const DEFAULT_MAX_PAGES = 20;
949
979
  async function flushAsked(url, queue, doFetch) {
950
980
  const pending = /* @__PURE__ */ new Map();
@@ -985,58 +1015,47 @@ async function flushAsked(url, queue, doFetch) {
985
1015
  return;
986
1016
  }
987
1017
  const data = parsed && typeof parsed.data === "object" && parsed.data ? parsed.data : {};
988
- const errors = parsed && typeof parsed.errors === "object" && parsed.errors ? parsed.errors : {};
989
1018
  const wholes = parsed && typeof parsed.whole === "object" && parsed.whole ? parsed.whole : {};
990
1019
  const cursors = parsed && typeof parsed.cursors === "object" && parsed.cursors ? parsed.cursors : {};
991
- const limits = parsed && typeof parsed.limits === "object" && parsed.limits ? parsed.limits : {};
1020
+ const degraded = parsed && typeof parsed.partial === "object" && parsed.partial ? parsed.partial : {};
992
1021
  queue.forEach((entry, i) => {
993
1022
  const key = keys[i];
994
1023
  const held = Array.isArray(entry.collected) && entry.collected.length ? entry.collected : null;
995
- if (key in errors) {
996
- const e = errors[key];
997
- const detail = typeof e === "string" ? e : e?.detail || e?.message || JSON.stringify(e);
998
- const out = held ? { data: held, error: detail, meta: withMeta({ partial: true, pages: entry.page }) } : { data: null, error: detail };
999
- if (e && typeof e === "object" && typeof e.code === "string") out.code = e.code;
1000
- entry.resolve(out);
1001
- return;
1002
- }
1003
1024
  if (!(key in data)) {
1004
- entry.resolve({ data: null, error: `the records service answered without the key "${key}"` });
1025
+ const error = `the records service answered without the key "${key}"`;
1026
+ entry.resolve(held ? { data: held, error, meta: withMeta({ partial: true, pages: entry.page }) } : { data: null, error });
1005
1027
  return;
1006
1028
  }
1007
1029
  const whole = typeof wholes[key] === "boolean" ? wholes[key] : typeof entry.request.whole === "boolean" ? entry.request.whole : void 0;
1008
1030
  const cursor = typeof cursors[key] === "string" && cursors[key] ? cursors[key] : null;
1009
- const bound = typeof limits[key] === "number" ? limits[key] : void 0;
1010
1031
  const rows = Array.isArray(data[key]) ? data[key] : data[key];
1011
1032
  if (cursor && entry.request.exhaustive && Array.isArray(rows)) {
1012
1033
  const acc = entry.collected ? entry.collected.concat(rows) : rows.slice();
1013
1034
  const page = (entry.page || 1) + 1;
1014
1035
  const cap = typeof entry.request.maxPages === "number" && entry.request.maxPages > 0 ? entry.request.maxPages : DEFAULT_MAX_PAGES;
1015
1036
  if (page <= cap) {
1016
- pending.set(entry, { cursor, collected: acc, page, whole, bound });
1037
+ pending.set(entry, { cursor, collected: acc, page, whole });
1017
1038
  return;
1018
1039
  }
1019
- entry.resolve({ data: acc, meta: withMeta({ whole, bound, partial: true, pages: cap }) });
1040
+ entry.resolve({ data: acc, meta: withMeta({ whole, partial: true, pages: cap }) });
1020
1041
  return;
1021
1042
  }
1022
1043
  const collected = entry.collected ? entry.collected.concat(Array.isArray(rows) ? rows : []) : rows;
1023
1044
  const meta = withMeta({
1024
1045
  whole,
1025
- bound,
1026
1046
  // ⭐ ONE FLAG, MEANING **NOT THE WHOLE POPULATION** — asked for by name, so
1027
- // a caller has one boolean to branch on rather than three signals to
1047
+ // a caller has one boolean to branch on rather than several signals to
1028
1048
  // combine. It is set in every case that means it:
1029
1049
  // · a cursor came back and this caller does not page (a page render);
1030
1050
  // · a cursor came back and the caller's `maxPages` stopped the walk;
1031
1051
  // · the walk failed or aborted with pages already in hand;
1032
- // · the service reported it BOUNDED the answer and offered no cursor.
1033
- // ⚠️ The last is why `limits` is read at all: a cursor is the signal for a
1034
- // query that declared no `limit`, and `limits` is the signal for one whose
1035
- // author limit was clamped (the records contract §5). Neither alone covers
1036
- // both. ⛔ Named `truncated` when it shipped in 0.17.0 this morning; renamed
1037
- // the same day, before any consumer adopted it, because "truncated" says
1038
- // something was cut and this also means "there is more you did not ask for".
1039
- partial: cursor || bound !== void 0 ? true : void 0,
1052
+ // · a source of the key did not answer (`partial[key]`), and `unavailable`
1053
+ // carries the service's own account of which one.
1054
+ // ⛔ Named `truncated` when it shipped in 0.17.0; renamed the same day, before
1055
+ // any consumer adopted it, because "truncated" says something was cut and this
1056
+ // also means "there is more you did not ask for".
1057
+ partial: cursor || key in degraded ? true : void 0,
1058
+ unavailable: key in degraded ? degraded[key] : void 0,
1040
1059
  pages: entry.page && entry.page > 1 ? entry.page : void 0
1041
1060
  });
1042
1061
  entry.resolve(meta ? { data: collected, meta } : { data: collected });
@@ -1045,7 +1064,7 @@ async function flushAsked(url, queue, doFetch) {
1045
1064
  await Promise.all([...pending].map(([entry, state]) => {
1046
1065
  const next = {
1047
1066
  ...entry,
1048
- request: { ...entry.request, cursor: state.cursor },
1067
+ request: { ...entry.request, narrow: { ...entry.request.narrow || {}, cursor: state.cursor } },
1049
1068
  collected: state.collected,
1050
1069
  page: state.page
1051
1070
  };
@@ -1076,19 +1095,22 @@ function withMeta(fields) {
1076
1095
  for (const [k, v] of Object.entries(fields)) if (v !== void 0) out[k] = v;
1077
1096
  return Object.keys(out).length ? out : void 0;
1078
1097
  }
1079
- function applyOperators(data, request, { dev = false } = {}) {
1080
- if (!Array.isArray(data)) return data;
1081
- let result = data;
1082
- if (typeof request.scope === "string" && request.scope) result = applyScope(result, request.scope);
1083
- if (request.where) result = matchWhere(request.where, result);
1084
- if (request.sort) result = applySort(result, request.sort, dev);
1085
- if (typeof request.limit === "number" && request.limit > 0) result = result.slice(0, request.limit);
1086
- return result;
1098
+ function sortLocale(request, ctx) {
1099
+ if (typeof request?.locale === "string" && request.locale) return request.locale;
1100
+ if (typeof ctx?.locale === "string" && ctx.locale) return ctx.locale;
1101
+ const active = ctx?.website?.getActiveLocale?.();
1102
+ return typeof active === "string" && active ? active : null;
1103
+ }
1104
+ function applyOperators(data, request, { dev = false, locale = null } = {}) {
1105
+ return evaluateQuery(data, request, {
1106
+ locale,
1107
+ sort: (items, sortExpr, options) => applySort(items, sortExpr, dev, options.locale)
1108
+ });
1087
1109
  }
1088
1110
  const warnedBadSorts = /* @__PURE__ */ new Set();
1089
- function applySort(items, sortExpr, dev) {
1111
+ function applySort(items, sortExpr, dev, locale) {
1090
1112
  try {
1091
- return sortRecords(items, sortExpr);
1113
+ return sortRecords(items, sortExpr, { locale });
1092
1114
  } catch (err) {
1093
1115
  if (dev) throw err;
1094
1116
  const key = String(sortExpr);
@@ -1157,7 +1179,6 @@ function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
1157
1179
  sectionsOf: (p) => p.sections,
1158
1180
  site: siteFetch
1159
1181
  }) : null;
1160
- const routeKey = chosen?.key ?? null;
1161
1182
  const routeSource = chosen?.nested ? chosen.config : null;
1162
1183
  const out = /* @__PURE__ */ new Map();
1163
1184
  const put = (cfg) => {
@@ -1165,8 +1186,9 @@ function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
1165
1186
  if (!out.has(key)) out.set(key, cfg);
1166
1187
  };
1167
1188
  const add = (sources) => {
1168
- for (const cfg of resolveFetchConfigs(sources, options).values()) {
1169
- const current = routeKey && cfg.as === routeKey ? currentOf(cfg) : null;
1189
+ const resolved = sources.flatMap((source) => fetchEntries(source).map((entry) => resolveFetchConfigs([entry], options).get(entry.as)));
1190
+ for (const cfg of resolved.filter(Boolean)) {
1191
+ const current = currentFor(cfg, chosen);
1170
1192
  if (current === "exclude") {
1171
1193
  put(othersView(cfg));
1172
1194
  continue;
@@ -1179,7 +1201,7 @@ function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
1179
1201
  const detailCfg = buildDetailConfig(cfg, { paramName: binding.paramName, paramValue: String(binding.paramValue) });
1180
1202
  if (detailCfg) put(detailCfg);
1181
1203
  }
1182
- put(cfg.ask ? cfg : routeSelection(cfg));
1204
+ if (!cfg.ask) put(routeSelection(cfg));
1183
1205
  }
1184
1206
  };
1185
1207
  const siteForPage = siteReaches(parent) ? siteFetch : null;
@@ -1194,7 +1216,7 @@ function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
1194
1216
  for (const s of layoutSectionsFor(content, page)) add([s.fetch ?? null, siteFetch]);
1195
1217
  return [...out.values()];
1196
1218
  }
1197
- async function executeFetchConfigs(configs, { content, fetch: fetch2 = null, dev = false, prerender = "always" } = {}) {
1219
+ async function executeFetchConfigs(configs, { content, fetch: fetch2 = null, dev = false, prerender = "always", locale = null } = {}) {
1198
1220
  if (prerender !== "author" && prerender !== "always") {
1199
1221
  throw new Error(`executeFetchConfigs: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`);
1200
1222
  }
@@ -1203,7 +1225,7 @@ async function executeFetchConfigs(configs, { content, fetch: fetch2 = null, dev
1203
1225
  dev,
1204
1226
  fetch: fetch2
1205
1227
  });
1206
- const ctx = { website: null };
1228
+ const ctx = { website: null, locale: locale ?? content?.config?.activeLocale ?? resolveDefaultLocale$1(content?.config) ?? null };
1207
1229
  return Promise.all((configs || []).filter(Boolean).map(async (config) => {
1208
1230
  if (prerender === "author" && config.prerender === false) {
1209
1231
  return { config, outcome: "skipped", data: null };
@@ -1215,7 +1237,7 @@ async function executeFetchConfigs(configs, { content, fetch: fetch2 = null, dev
1215
1237
  }
1216
1238
  async function prefetchPageData({ content, route, locale = null, fetch: fetch2 = null, dev = false, prerender = "always" }) {
1217
1239
  const configs = resolvePageFetchConfigs(content, route, { locale });
1218
- return executeFetchConfigs(configs, { content, fetch: fetch2, dev, prerender });
1240
+ return executeFetchConfigs(configs, { content, fetch: fetch2, dev, prerender, locale });
1219
1241
  }
1220
1242
  function createPageRenderer({ website, shell }) {
1221
1243
  if (!website) throw new Error("createPageRenderer: `website` is required");
@@ -1270,8 +1292,7 @@ async function collectSiteRecords(content, { locale, fetch: fetch2, signal, only
1270
1292
  const meta = {};
1271
1293
  await Promise.all([...configs].map(async ([name, cfg]) => {
1272
1294
  if (!cfg.ask) return;
1273
- const { limit, ...population } = cfg;
1274
- const asked = { ...population, whole, exhaustive: true };
1295
+ const asked = { ...cfg, whole, exhaustive: true };
1275
1296
  if (typeof maxPages === "number" && maxPages > 0) asked.maxPages = maxPages;
1276
1297
  const result = await fetcher.resolve(asked, { signal });
1277
1298
  if (result?.error) errors[name] = result.error;