@uniweb/runtime 0.16.0 → 0.17.1

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
@@ -2,6 +2,7 @@ import { isRichSchema, deriveCacheKey, resolveDefaultLocale, hasDarkScheme, crea
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 { applyBasePath } from "@uniweb/core/base-path";
5
6
  import { routePatternToRegex, decodeRouteValue, splitPathCapture } from "@uniweb/core/route-match";
6
7
  import { DEFAULT_ICON_BASE, iconUrl } from "@uniweb/core/icon-corpus";
7
8
  import { buildTheme, FONT_LINKS_MARKER, buildSectionOverrides } from "@uniweb/theming";
@@ -11,6 +12,7 @@ import { resolveFetchConfigs } from "@uniweb/core/fetch-config";
11
12
  import { deriveCacheKey as deriveCacheKey$1 } from "@uniweb/core/datastore";
12
13
  import { buildDetailConfig } from "@uniweb/core/detail-url";
13
14
  import { resolveDefaultLocale as resolveDefaultLocale$1 } from "@uniweb/core/locale-config";
15
+ import { resolveRecordsService } from "@uniweb/core/records-service";
14
16
  function guaranteeItemStructure(item) {
15
17
  return {
16
18
  title: item.title || "",
@@ -248,6 +250,23 @@ function getComponentMeta(componentName) {
248
250
  function getComponentDefaults(componentName) {
249
251
  return globalThis.uniweb?.getComponentDefaults?.(componentName) || {};
250
252
  }
253
+ function siteUrl(url) {
254
+ return applyBasePath(url, globalThis.uniweb?.activeWebsite?.basePath || "");
255
+ }
256
+ function withOpacity(color, opacity) {
257
+ if (typeof color !== "string" || !color) return color;
258
+ if (color.startsWith("#")) {
259
+ const r = parseInt(color.slice(1, 3), 16);
260
+ const g = parseInt(color.slice(3, 5), 16);
261
+ const b = parseInt(color.slice(5, 7), 16);
262
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
263
+ }
264
+ if (color.startsWith("rgb")) {
265
+ const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
266
+ if (match) return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${opacity})`;
267
+ }
268
+ return color;
269
+ }
251
270
  function default404Html(basePath = "") {
252
271
  const homeHref = basePath ? `${basePath}/` : "/";
253
272
  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>`;
@@ -394,28 +413,6 @@ function getWrapperProps(block) {
394
413
  }
395
414
  return { id: sectionDomId(block), style, className, background };
396
415
  }
397
- function withOpacity(color, opacity) {
398
- if (color.startsWith("#")) {
399
- const r = parseInt(color.slice(1, 3), 16);
400
- const g = parseInt(color.slice(3, 5), 16);
401
- const b = parseInt(color.slice(5, 7), 16);
402
- return `rgba(${r}, ${g}, ${b}, ${opacity})`;
403
- }
404
- if (color.startsWith("rgb")) {
405
- const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
406
- if (match) {
407
- return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${opacity})`;
408
- }
409
- }
410
- return color;
411
- }
412
- function resolveUrl(url) {
413
- if (!url || !url.startsWith("/")) return url;
414
- const basePath = globalThis.uniweb?.activeWebsite?.basePath || "";
415
- if (!basePath) return url;
416
- if (url.startsWith(basePath + "/") || url === basePath) return url;
417
- return basePath + url;
418
- }
419
416
  function renderBackground(background) {
420
417
  if (!background?.mode) return null;
421
418
  const containerStyle = {
@@ -472,7 +469,7 @@ function renderBackground(background) {
472
469
  style: {
473
470
  position: "absolute",
474
471
  inset: "0",
475
- backgroundImage: `url(${resolveUrl(img.src)})`,
472
+ backgroundImage: `url(${siteUrl(img.src)})`,
476
473
  backgroundPosition: img.position || "center",
477
474
  backgroundSize: img.size || "cover",
478
475
  backgroundRepeat: "no-repeat"
@@ -842,23 +839,23 @@ function generate404Html({ baseHtml, website, siteContent }) {
842
839
  function createDefaultFetcher({ basePath = "", dev = false, fetch: fetchImpl = null } = {}) {
843
840
  const doFetch = (input, init) => (fetchImpl || globalThis.fetch)(input, init);
844
841
  const pathPrefix = basePath && basePath !== "/" ? basePath.replace(/\/$/, "") : "";
845
- const doorQueues = /* @__PURE__ */ new Map();
846
- const askDoor = (request, ctx) => {
842
+ const askQueues = /* @__PURE__ */ new Map();
843
+ const askRecords = (request, ctx) => {
847
844
  if (typeof request.schema !== "string" || !request.schema) {
848
845
  return Promise.resolve({
849
846
  data: null,
850
- error: `the payload stamps a records door but carries no Model ref for query "${request.query ?? request.as}" (config.queries) — the door cannot be asked`
847
+ error: `the payload offers the records service but carries no Model ref for query "${request.query ?? request.as}" (config.queries) — it cannot be asked`
851
848
  });
852
849
  }
853
850
  return new Promise((resolve) => {
854
- const url = resolveServiceUrl(request.door, pathPrefix);
855
- let queue = doorQueues.get(url);
851
+ const url = resolveServiceUrl(request.ask, pathPrefix);
852
+ let queue = askQueues.get(url);
856
853
  if (!queue) {
857
854
  queue = [];
858
- doorQueues.set(url, queue);
855
+ askQueues.set(url, queue);
859
856
  queueMicrotask(() => {
860
- doorQueues.delete(url);
861
- flushDoor(url, queue, doFetch);
857
+ askQueues.delete(url);
858
+ flushAsked(url, queue, doFetch);
862
859
  });
863
860
  }
864
861
  queue.push({ request, ctx, resolve });
@@ -866,8 +863,8 @@ function createDefaultFetcher({ basePath = "", dev = false, fetch: fetchImpl = n
866
863
  };
867
864
  return {
868
865
  /**
869
- * The cache identity is the request's ADDRESS — or, on a question door,
870
- * the QUESTION (`deriveCacheKey` hashes every operator of an address-less
866
+ * The cache identity is the request's ADDRESS — or, when asked of the
867
+ * records service, the QUESTION (`deriveCacheKey` hashes every operator of an address-less
871
868
  * request). Operators evaluated here run over a shared cached value and
872
869
  * must NOT split the cache: two pages declaring different `where:` clauses
873
870
  * against the same path share one entry — the file is fetched once and
@@ -878,7 +875,7 @@ function createDefaultFetcher({ basePath = "", dev = false, fetch: fetchImpl = n
878
875
  },
879
876
  async resolve(request, ctx = {}) {
880
877
  if (!request) return { data: null };
881
- if (request.door) return askDoor(request, ctx);
878
+ if (request.ask) return askRecords(request, ctx);
882
879
  const { path, url, transform, body: rawBody } = request;
883
880
  let method = (request.method || "GET").toUpperCase();
884
881
  if (method !== "GET" && method !== "POST") {
@@ -891,7 +888,7 @@ function createDefaultFetcher({ basePath = "", dev = false, fetch: fetchImpl = n
891
888
  } else if (url) {
892
889
  target = url;
893
890
  } else {
894
- return { data: [], error: "No path, url or door specified" };
891
+ return { data: [], error: "No path, url or ask specified" };
895
892
  }
896
893
  const init = { signal: ctx.signal, method };
897
894
  if (method === "POST") {
@@ -954,7 +951,7 @@ function createDefaultFetcher({ basePath = "", dev = false, fetch: fetchImpl = n
954
951
  }
955
952
  };
956
953
  }
957
- function doorQuestion(request) {
954
+ function toQuestion(request) {
958
955
  const q = { schema: request.schema };
959
956
  let where = request.where && typeof request.where === "object" ? request.where : null;
960
957
  let scope = typeof request.scope === "string" && request.scope ? request.scope : null;
@@ -969,19 +966,22 @@ function doorQuestion(request) {
969
966
  if (sort) q.sort = sort;
970
967
  if (typeof request.limit === "number" && request.limit > 0) q.limit = request.limit;
971
968
  if (request.depth === "brief" || request.depth === "full") q.depth = request.depth;
969
+ if (typeof request.cursor === "string" && request.cursor) q.cursor = request.cursor;
972
970
  return q;
973
971
  }
974
- const DOOR_OPERATOR = { nin: "not_in" };
972
+ const OPERATOR_ALIAS = { nin: "not_in" };
975
973
  function renameOperators(where) {
976
974
  if (Array.isArray(where)) return where.map(renameOperators);
977
975
  if (!where || typeof where !== "object") return where;
978
976
  const out = {};
979
977
  for (const [key, value] of Object.entries(where)) {
980
- out[DOOR_OPERATOR[key] ?? key] = value && typeof value === "object" ? renameOperators(value) : value;
978
+ out[OPERATOR_ALIAS[key] ?? key] = value && typeof value === "object" ? renameOperators(value) : value;
981
979
  }
982
980
  return out;
983
981
  }
984
- async function flushDoor(url, queue, doFetch) {
982
+ const MAX_PAGES = 50;
983
+ async function flushAsked(url, queue, doFetch) {
984
+ const pending = /* @__PURE__ */ new Map();
985
985
  const body = {};
986
986
  const keys = [];
987
987
  for (const entry of queue) {
@@ -989,7 +989,7 @@ async function flushDoor(url, queue, doFetch) {
989
989
  let key = base;
990
990
  for (let n = 2; key in body; n += 1) key = `${base}#${n}`;
991
991
  keys.push(key);
992
- body[key] = doorQuestion(entry.request);
992
+ body[key] = toQuestion(entry.request);
993
993
  }
994
994
  let parsed;
995
995
  try {
@@ -1018,6 +1018,8 @@ async function flushDoor(url, queue, doFetch) {
1018
1018
  const data = parsed && typeof parsed.data === "object" && parsed.data ? parsed.data : {};
1019
1019
  const errors = parsed && typeof parsed.errors === "object" && parsed.errors ? parsed.errors : {};
1020
1020
  const depths = parsed && typeof parsed.depths === "object" && parsed.depths ? parsed.depths : {};
1021
+ const cursors = parsed && typeof parsed.cursors === "object" && parsed.cursors ? parsed.cursors : {};
1022
+ const limits = parsed && typeof parsed.limits === "object" && parsed.limits ? parsed.limits : {};
1021
1023
  queue.forEach((entry, i) => {
1022
1024
  const key = keys[i];
1023
1025
  if (key in errors) {
@@ -1029,12 +1031,50 @@ async function flushDoor(url, queue, doFetch) {
1029
1031
  return;
1030
1032
  }
1031
1033
  if (!(key in data)) {
1032
- entry.resolve({ data: null, error: `the records door answered without the key "${key}"` });
1034
+ entry.resolve({ data: null, error: `the records service answered without the key "${key}"` });
1033
1035
  return;
1034
1036
  }
1035
1037
  const depth = depths[key] === "brief" || depths[key] === "full" ? depths[key] : entry.request.depth === "brief" || entry.request.depth === "full" ? entry.request.depth : void 0;
1036
- entry.resolve(depth ? { data: data[key], meta: { depth } } : { data: data[key] });
1038
+ const cursor = typeof cursors[key] === "string" && cursors[key] ? cursors[key] : null;
1039
+ const bound = typeof limits[key] === "number" ? limits[key] : void 0;
1040
+ const rows = Array.isArray(data[key]) ? data[key] : data[key];
1041
+ if (cursor && entry.request.exhaustive && Array.isArray(rows)) {
1042
+ const acc = entry.collected ? entry.collected.concat(rows) : rows.slice();
1043
+ const page = (entry.page || 1) + 1;
1044
+ if (page <= MAX_PAGES) {
1045
+ pending.set(entry, { cursor, collected: acc, page, depth, bound });
1046
+ return;
1047
+ }
1048
+ entry.resolve({ data: acc, meta: withMeta({ depth, bound, truncated: true, pages: MAX_PAGES }) });
1049
+ return;
1050
+ }
1051
+ const collected = entry.collected ? entry.collected.concat(Array.isArray(rows) ? rows : []) : rows;
1052
+ const meta = withMeta({
1053
+ depth,
1054
+ bound,
1055
+ // ⭐ A cursor IS the truncation signal, and it is the only one for a query
1056
+ // that declared no `limit`: `limits` is reported only when an author's own
1057
+ // limit was clamped (the records contract §5).
1058
+ truncated: cursor ? true : void 0,
1059
+ pages: entry.page && entry.page > 1 ? entry.page : void 0
1060
+ });
1061
+ entry.resolve(meta ? { data: collected, meta } : { data: collected });
1037
1062
  });
1063
+ if (pending.size === 0) return;
1064
+ await Promise.all([...pending].map(([entry, state]) => {
1065
+ const next = {
1066
+ ...entry,
1067
+ request: { ...entry.request, cursor: state.cursor },
1068
+ collected: state.collected,
1069
+ page: state.page
1070
+ };
1071
+ return flushAsked(url, [next], doFetch);
1072
+ }));
1073
+ }
1074
+ function withMeta(fields) {
1075
+ const out = {};
1076
+ for (const [k, v] of Object.entries(fields)) if (v !== void 0) out[k] = v;
1077
+ return Object.keys(out).length ? out : void 0;
1038
1078
  }
1039
1079
  function applyOperators(data, request, { dev = false } = {}) {
1040
1080
  if (!Array.isArray(data)) return data;
@@ -1114,7 +1154,7 @@ function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
1114
1154
  locale,
1115
1155
  defaultLocale: resolveDefaultLocale$1(content?.config) ?? null,
1116
1156
  queries: content?.config?.queries ?? null,
1117
- records: content?.config?.records ?? null,
1157
+ services: content?.config?.services ?? null,
1118
1158
  variables: binding?.variables ?? null
1119
1159
  };
1120
1160
  const out = /* @__PURE__ */ new Map();
@@ -1199,10 +1239,42 @@ async function prefetchAndHydrate({ website, content, route, locale = null, fetc
1199
1239
  hydrateDataStore(website, fetched);
1200
1240
  return fetched;
1201
1241
  }
1242
+ async function collectSiteRecords(content, { locale, fetch: fetch2, signal, only = null } = {}) {
1243
+ const config = content?.config;
1244
+ const services = config?.services ?? null;
1245
+ const queries = config?.queries ?? null;
1246
+ if (!queries || typeof queries !== "object") return empty();
1247
+ if (!resolveRecordsService(services, locale)) return empty();
1248
+ const names = Object.keys(queries).filter((n) => only ? only.includes(n) : true);
1249
+ if (names.length === 0) return empty();
1250
+ const configs = resolveFetchConfigs(
1251
+ names.map((name) => ({ query: name, as: name })),
1252
+ { services, queries, locale, defaultLocale: config?.defaultLanguage ?? locale }
1253
+ );
1254
+ const fetcher = createDefaultFetcher({ fetch: fetch2, basePath: config?.base ?? "" });
1255
+ const records = {};
1256
+ const errors = {};
1257
+ const meta = {};
1258
+ await Promise.all([...configs].map(async ([name, cfg]) => {
1259
+ if (!cfg.ask) return;
1260
+ const result = await fetcher.resolve({ ...cfg, depth: "brief", exhaustive: true }, { signal });
1261
+ if (result?.error) {
1262
+ errors[name] = result.error;
1263
+ return;
1264
+ }
1265
+ records[name] = Array.isArray(result?.data) ? result.data : [];
1266
+ if (result?.meta) meta[name] = result.meta;
1267
+ }));
1268
+ return { records, errors: Object.keys(errors).length ? errors : null, meta };
1269
+ }
1270
+ function empty() {
1271
+ return { records: {}, errors: null, meta: {} };
1272
+ }
1202
1273
  export {
1203
1274
  applyDefaults,
1204
1275
  applySchemas,
1205
1276
  classifyRenderError,
1277
+ collectSiteRecords,
1206
1278
  createPageRenderer,
1207
1279
  escapeHtml,
1208
1280
  executeFetchConfigs,