@orieljs/live 0.1.0-alpha.3 → 0.1.0-alpha.5

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/index.js CHANGED
@@ -219,8 +219,24 @@ import { inject as inject3 } from "@orieljs/container";
219
219
  // Testable.ts
220
220
  import { AssertionError } from "assert";
221
221
  import { isDeepStrictEqual } from "util";
222
- import { LiveManager as LiveManager2 } from "@orieljs/contracts";
223
- import { inject as inject2 } from "@orieljs/container";
222
+ import { LiveManager as LiveManager2, Session, token } from "@orieljs/contracts";
223
+ import { Container, currentContainer, inject as inject2 } from "@orieljs/container";
224
+
225
+ // flash.ts
226
+ function trackFlash(session, { saved = true } = {}) {
227
+ if (session === void 0) return () => void 0;
228
+ const before = new Set(session.get("_flash.new", []));
229
+ return (redirected) => {
230
+ if (redirected) return;
231
+ const flashed = session.get("_flash.new", []);
232
+ const fresh = flashed.filter((key) => !before.has(key));
233
+ if (fresh.length === 0) return;
234
+ session.put("_flash.new", flashed.filter((key) => before.has(key)));
235
+ if (!saved) return session.forget(...fresh);
236
+ const old = session.get("_flash.old", []);
237
+ session.put("_flash.old", [...old, ...fresh.filter((key) => !old.includes(key))]);
238
+ };
239
+ }
224
240
 
225
241
  // errors.ts
226
242
  var LiveError = class extends Error {
@@ -284,14 +300,14 @@ function rootTag(html, component) {
284
300
  let rootEnd = -1;
285
301
  TOKEN.lastIndex = 0;
286
302
  for (let match = TOKEN.exec(source); match !== null; match = TOKEN.exec(source)) {
287
- const [token, closing, opening, selfClosing] = match;
303
+ const [token2, closing, opening, selfClosing] = match;
288
304
  if (root === void 0 && match.index !== 0) fail2();
289
305
  if (depth === 0 && root !== void 0) fail2();
290
306
  if (opening !== void 0) {
291
307
  const tag = opening.toLowerCase();
292
308
  root ??= tag;
293
309
  if (selfClosing === "/" || VOID.has(tag)) {
294
- if (depth === 0) rootEnd = match.index + token.length;
310
+ if (depth === 0) rootEnd = match.index + token2.length;
295
311
  continue;
296
312
  }
297
313
  if (RAW_TEXT.has(tag)) {
@@ -303,7 +319,7 @@ function rootTag(html, component) {
303
319
  depth++;
304
320
  } else if (closing !== void 0) {
305
321
  if (--depth < 0) fail2();
306
- if (depth === 0) rootEnd = match.index + token.length;
322
+ if (depth === 0) rootEnd = match.index + token2.length;
307
323
  }
308
324
  }
309
325
  if (root === void 0 || depth !== 0 || source.slice(rootEnd).trim() !== "") fail2();
@@ -322,23 +338,40 @@ var fail = (message, actual, expected) => {
322
338
  throw new AssertionError({ message, actual, expected, operator: "live" });
323
339
  };
324
340
  var getPath = (target, path) => path.split(".").reduce((node, segment) => typeof node === "object" && node !== null ? node[segment] : void 0, target);
341
+ var TestRequestScope = token("Testing.RequestScope");
342
+ function requestScope() {
343
+ const container = currentContainer() ?? Container.getInstance();
344
+ return container?.bound(TestRequestScope) === true ? container.make(TestRequestScope) : (run) => run();
345
+ }
346
+ function currentSession() {
347
+ const container = currentContainer();
348
+ if (container?.bound(Session) !== true) return void 0;
349
+ try {
350
+ return container.make(Session);
351
+ } catch {
352
+ return void 0;
353
+ }
354
+ }
325
355
  var Testable = class _Testable {
326
- constructor(manager, result) {
356
+ constructor(manager, scope, result) {
327
357
  this.manager = manager;
358
+ this.scope = scope;
328
359
  this.#snapshot = JSON.stringify(result.snapshot);
329
360
  this.#html = result.html ?? "";
330
361
  this.#effects = result.effects;
331
362
  this.#instance = result.instance;
332
363
  }
333
364
  manager;
365
+ scope;
334
366
  #snapshot;
335
367
  #html;
336
368
  #effects;
337
369
  #instance;
338
370
  /** Mounts `component` with `params`, as `<Live component={…} {...params} />` would. */
339
371
  static async create(component, params = {}) {
372
+ const scope = requestScope();
340
373
  const manager = inject2(LiveManager2);
341
- return new _Testable(manager, await manager.mountComponent(component, params));
374
+ return new _Testable(manager, scope, await scope(() => manager.mountComponent(component, params)));
342
375
  }
343
376
  /** The component instance after the last request. */
344
377
  instance() {
@@ -373,7 +406,12 @@ var Testable = class _Testable {
373
406
  return this.#update({}, [{ method: "$refresh", params: [] }]);
374
407
  }
375
408
  async #update(updates, calls) {
376
- const result = await this.manager.updateComponent(this.#snapshot, updates, calls);
409
+ const result = await this.scope(async () => {
410
+ const flashed = trackFlash(currentSession(), { saved: false });
411
+ const updated = await this.manager.updateComponent(this.#snapshot, updates, calls);
412
+ flashed(updated.effects.redirect !== void 0);
413
+ return updated;
414
+ });
377
415
  this.#snapshot = JSON.stringify(result.snapshot);
378
416
  if (result.html !== void 0) this.#html = result.html;
379
417
  this.#effects = result.effects;
@@ -469,18 +507,20 @@ var Live = Object.assign(LiveComponent, {
469
507
  });
470
508
 
471
509
  // LiveComponentManager.ts
472
- import { AsyncLocalStorage } from "async_hooks";
510
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
473
511
  import { randomBytes } from "crypto";
474
512
  import {
475
513
  ClassRegistry,
476
514
  ConfigRepository,
477
- CurrentRoute,
515
+ CurrentRoute as CurrentRoute2,
478
516
  Encrypter,
479
517
  HttpRequest,
480
518
  Redirector,
519
+ Router,
520
+ Session as Session2,
481
521
  ViewFactory
482
522
  } from "@orieljs/contracts";
483
- import { currentContainer } from "@orieljs/container";
523
+ import { currentContainer as currentContainer2 } from "@orieljs/container";
484
524
  import { Str as Str2 } from "@orieljs/support";
485
525
 
486
526
  // checksum.ts
@@ -531,6 +571,125 @@ async function discover(directory) {
531
571
  return found;
532
572
  }
533
573
 
574
+ // persistent.ts
575
+ import { CurrentRoute } from "@orieljs/contracts";
576
+ var PERSISTENT_MIDDLEWARE = ["auth", "verified", "guest", "can"];
577
+ var ROUTER_METHODS = ["getRoutesByMatchOrder", "getPatterns", "getPatternVersion", "getMiddleware", "getMiddlewareGroups", "substituteBindings"];
578
+ function canMatch(router) {
579
+ const methods = router;
580
+ return ROUTER_METHODS.every((name) => typeof methods[name] === "function");
581
+ }
582
+ function isMatchingRoute(route) {
583
+ const candidate = route;
584
+ return typeof candidate === "object" && candidate !== null && Array.isArray(candidate.methods) && ["match", "gatherMiddleware", "excludedMiddleware"].every((name) => typeof candidate[name] === "function");
585
+ }
586
+ function routePath(path) {
587
+ const trimmed = path.replace(/^\/+|\/+$/g, "").replace(/\/{2,}/g, "/");
588
+ return trimmed === "" ? "" : `/${trimmed}`;
589
+ }
590
+ function expand(references, aliases, groups, out = [], visiting = /* @__PURE__ */ new Set()) {
591
+ for (const reference of references) {
592
+ if (typeof reference === "function") {
593
+ out.push({ cls: reference, parameters: [] });
594
+ continue;
595
+ }
596
+ const index = reference.indexOf(":");
597
+ const name = index === -1 ? reference : reference.slice(0, index);
598
+ if (index === -1 && Object.hasOwn(groups, name)) {
599
+ if (visiting.has(name)) continue;
600
+ visiting.add(name);
601
+ expand(groups[name], aliases, groups, out, visiting);
602
+ visiting.delete(name);
603
+ } else if (Object.hasOwn(aliases, name)) {
604
+ out.push({ cls: aliases[name], parameters: index === -1 ? [] : reference.slice(index + 1).split(",") });
605
+ }
606
+ }
607
+ return out;
608
+ }
609
+ function persistentPass(router, persistent, method, path) {
610
+ if (!canMatch(router)) throw new LiveError("Live re-applies the page route's middleware to updates, which needs Oriel's router.");
611
+ const routes = router.getRoutesByMatchOrder();
612
+ if (!Array.isArray(routes) || !routes.every(isMatchingRoute)) throw new LiveError("Live re-applies the page route's middleware to updates, which needs Oriel's routes.");
613
+ const verb = method.toUpperCase();
614
+ const target = routePath(path);
615
+ const patterns = router.getPatterns();
616
+ const version = router.getPatternVersion();
617
+ let matched;
618
+ for (const candidate of routes) {
619
+ const values2 = candidate.methods.includes(verb) ? candidate.match(target, patterns, version) : void 0;
620
+ if (values2 !== void 0) {
621
+ matched = { route: candidate, values: values2 };
622
+ break;
623
+ }
624
+ }
625
+ if (matched === void 0) return void 0;
626
+ const { route, values } = matched;
627
+ const aliases = router.getMiddleware();
628
+ const groups = router.getMiddlewareGroups();
629
+ const wanted = new Set(persistent.flatMap((name) => Object.hasOwn(aliases, name) ? [aliases[name]] : []));
630
+ const excluded = new Set(expand(route.excludedMiddleware(), aliases, groups).map((entry) => entry.cls));
631
+ const ids = /* @__PURE__ */ new Map();
632
+ const seen = /* @__PURE__ */ new Set();
633
+ const middleware = expand(route.gatherMiddleware(), aliases, groups).filter((entry) => {
634
+ if (!ids.has(entry.cls)) ids.set(entry.cls, ids.size);
635
+ const key = `${ids.get(entry.cls)}:${entry.parameters.join(",")}`;
636
+ if (!wanted.has(entry.cls) || excluded.has(entry.cls) || seen.has(key)) return false;
637
+ seen.add(key);
638
+ return true;
639
+ });
640
+ const early = new Set(expand(["web", "api"], aliases, groups).map((entry) => entry.cls));
641
+ for (const name of ["auth", "guest"]) if (Object.hasOwn(aliases, name)) early.add(aliases[name]);
642
+ const before = middleware.filter((entry) => early.has(entry.cls));
643
+ const after = middleware.filter((entry) => !early.has(entry.cls));
644
+ return { router, route, values, middleware: [...before, ...after], at: before.length };
645
+ }
646
+ function persistentMiddleware(router, persistent, method, path) {
647
+ return [...persistentPass(router, persistent, method, path)?.middleware ?? []];
648
+ }
649
+ function currentRoute(container) {
650
+ if (!container.bound(CurrentRoute)) return void 0;
651
+ try {
652
+ return container.make(CurrentRoute);
653
+ } catch {
654
+ return void 0;
655
+ }
656
+ }
657
+ async function throughMiddleware(container, request, pass, destination) {
658
+ const original = currentRoute(container);
659
+ const page = { route: pass.route, parameters: { ...pass.values }, middleware: [] };
660
+ let swapped = false;
661
+ const show = () => {
662
+ container.instance(CurrentRoute, page);
663
+ swapped = true;
664
+ };
665
+ const restore = () => {
666
+ if (!swapped) return;
667
+ if (original === void 0) container.forgetInstance(CurrentRoute);
668
+ else container.instance(CurrentRoute, original);
669
+ swapped = false;
670
+ };
671
+ let ran;
672
+ const run = async (index) => {
673
+ if (index === pass.at) Object.assign(page.parameters, await pass.router.substituteBindings(pass.route, pass.values, container));
674
+ if (index === pass.middleware.length) {
675
+ restore();
676
+ ran = { result: await destination() };
677
+ return new Response(null, { status: 204 });
678
+ }
679
+ const { cls, parameters } = pass.middleware[index];
680
+ const instance = container.make(cls);
681
+ const next = () => run(index + 1);
682
+ return instance.handle(request, next, ...parameters);
683
+ };
684
+ show();
685
+ try {
686
+ const response = await run(0);
687
+ return ran ? ran.result : response;
688
+ } finally {
689
+ restore();
690
+ }
691
+ }
692
+
534
693
  // security.ts
535
694
  import { Collection as Collection2 } from "@orieljs/collections";
536
695
 
@@ -677,12 +836,72 @@ function isUpdatable(instance, path) {
677
836
  return !isModel(node) && !(node instanceof Collection2);
678
837
  }
679
838
 
839
+ // WithPagination.ts
840
+ import { AsyncLocalStorage } from "async_hooks";
841
+ var PAGINATED = /* @__PURE__ */ Symbol.for("oriel:live.pagination");
842
+ function toPage(value) {
843
+ const page = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN;
844
+ return Number.isFinite(page) && page >= 1 ? Math.min(Math.floor(page), Number.MAX_SAFE_INTEGER) : 1;
845
+ }
846
+ async function hook(instance, name, ...args) {
847
+ const method = instance[name];
848
+ if (typeof method === "function") await method.apply(instance, args);
849
+ }
850
+ function WithPagination(Base) {
851
+ class WithPaginationMixin extends Base {
852
+ page = 1;
853
+ nextPage() {
854
+ return this.setPage(toPage(this.page) + 1);
855
+ }
856
+ previousPage() {
857
+ return this.setPage(Math.max(toPage(this.page) - 1, 1));
858
+ }
859
+ gotoPage(page) {
860
+ return this.setPage(page);
861
+ }
862
+ resetPage() {
863
+ return this.setPage(1);
864
+ }
865
+ async setPage(page) {
866
+ const next = toPage(page);
867
+ await hook(this, "updatingPage", next);
868
+ this.page = next;
869
+ await hook(this, "updatedPage", next);
870
+ }
871
+ }
872
+ Object.defineProperty(WithPaginationMixin.prototype, PAGINATED, { value: true });
873
+ return WithPaginationMixin;
874
+ }
875
+ function isPaginated(component) {
876
+ return component[PAGINATED] === true;
877
+ }
878
+ var rendering = new AsyncLocalStorage();
879
+ function renderPaginated(component, url, run) {
880
+ return rendering.run(isPaginated(component) ? { component, url } : void 0, run);
881
+ }
882
+ function outsidePagination(run) {
883
+ return rendering.run(void 0, run);
884
+ }
885
+ var livePaginationState = {
886
+ currentPage(pageName) {
887
+ const current = rendering.getStore();
888
+ if (current === void 0 || pageName !== "page") return void 0;
889
+ const page = toPage(current.component.page);
890
+ current.component.page = page;
891
+ return page;
892
+ },
893
+ currentPath() {
894
+ return rendering.getStore()?.url;
895
+ }
896
+ };
897
+
680
898
  // LiveComponentManager.ts
681
- var renderContext = new AsyncLocalStorage();
682
- function optional(container, token) {
683
- if (!container.bound(token)) return void 0;
899
+ var renderContext = new AsyncLocalStorage2();
900
+ var pageRequest = new AsyncLocalStorage2();
901
+ function optional(container, token2) {
902
+ if (!container.bound(token2)) return void 0;
684
903
  try {
685
- return container.make(token);
904
+ return container.make(token2);
686
905
  } catch {
687
906
  return void 0;
688
907
  }
@@ -691,7 +910,7 @@ var isRecord = (value) => typeof value === "object" && value !== null && !Array.
691
910
  function setOwn(object, key, value) {
692
911
  Object.defineProperty(object, key, { value, enumerable: true, writable: true, configurable: true });
693
912
  }
694
- async function hook(instance, name, ...args) {
913
+ async function hook2(instance, name, ...args) {
695
914
  const method = instance[name];
696
915
  if (typeof method === "function") await method.apply(instance, args);
697
916
  }
@@ -702,7 +921,7 @@ var LiveComponentManager = class {
702
921
  app;
703
922
  /** The request scope when there is one (sessions, the current route), else the application. */
704
923
  container() {
705
- return currentContainer() ?? this.app;
924
+ return currentContainer2() ?? this.app;
706
925
  }
707
926
  registry() {
708
927
  return this.container().make(ClassRegistry);
@@ -742,7 +961,7 @@ var LiveComponentManager = class {
742
961
  setOwn(context.children, childKey, existing);
743
962
  return `<${existing[0]} o:id="${escapeAttribute(existing[1])}"></${existing[0]}>`;
744
963
  }
745
- const result = await this.mountComponent(component, params);
964
+ const result = await outsidePagination(() => this.mountComponent(component, params));
746
965
  setOwn(context.children, childKey, [result.tag, result.snapshot.memo.id]);
747
966
  return result.html;
748
967
  }
@@ -754,14 +973,15 @@ var LiveComponentManager = class {
754
973
  const state = stateOf(instance);
755
974
  state.id = randomBytes(15).toString("base64url");
756
975
  state.name = name;
757
- await hook(instance, "boot");
976
+ await hook2(instance, "boot");
758
977
  for (const [property, value] of Object.entries(params)) {
759
978
  if (Object.hasOwn(instance, property)) instance[property] = value;
760
979
  }
761
980
  this.prepareComputed(instance);
762
- await hook(instance, "mount", params);
763
981
  const request = optional(this.container(), HttpRequest);
764
- const memo = { id: state.id, name, path: request?.path() ?? "", method: request?.method() ?? "GET", children: {}, errors: {} };
982
+ const page = pageRequest.getStore() ?? { path: request?.path() ?? "", method: request?.method() ?? "GET" };
983
+ await renderPaginated(instance, this.pageUrl(page.path), () => hook2(instance, "mount", params));
984
+ const memo = { id: state.id, name, path: page.path, method: page.method, children: {}, errors: {} };
765
985
  const { html, tag } = await this.render(instance, memo, {});
766
986
  const effects = this.effects(instance);
767
987
  const listeners = Object.keys(listenersOf(cls));
@@ -774,7 +994,8 @@ var LiveComponentManager = class {
774
994
  async update(request) {
775
995
  const components = [];
776
996
  for (const component of request.components) {
777
- const result = await this.updateComponent(component.snapshot, component.updates, component.calls);
997
+ const { memo } = this.verify(component.snapshot);
998
+ const result = await pageRequest.run({ path: memo.path, method: memo.method }, () => this.updateComponent(component.snapshot, component.updates, component.calls));
778
999
  components.push({ snapshot: JSON.stringify(result.snapshot), effects: result.effects });
779
1000
  }
780
1001
  return { components };
@@ -787,22 +1008,24 @@ var LiveComponentManager = class {
787
1008
  state.id = snapshot.memo.id;
788
1009
  state.name = snapshot.memo.name;
789
1010
  state.errors = { ...snapshot.memo.errors };
790
- await hook(instance, "boot");
1011
+ await hook2(instance, "boot");
791
1012
  const registry = this.registry();
792
1013
  for (const [property, value] of Object.entries(snapshot.data)) {
793
1014
  Object.defineProperty(instance, property, { value: await hydrate(value, registry), enumerable: true, writable: true, configurable: true });
794
1015
  }
795
1016
  this.prepareComputed(instance);
796
- await hook(instance, "hydrate");
797
1017
  const returns = [];
798
- try {
799
- for (const [path, value] of Object.entries(updates)) await this.applyUpdate(instance, path, value);
800
- for (const call of calls) returns.push(await this.call(instance, call.method, call.params));
801
- } catch (error) {
802
- const errors = error.errors;
803
- if (error.kind !== "Validation" || !isRecord(errors)) throw error;
804
- state.errors = { ...errors };
805
- }
1018
+ await renderPaginated(instance, this.pageUrl(snapshot.memo.path), async () => {
1019
+ await hook2(instance, "hydrate");
1020
+ try {
1021
+ for (const [path, value] of Object.entries(updates)) await this.applyUpdate(instance, path, value);
1022
+ for (const call of calls) returns.push(await this.call(instance, call.method, call.params));
1023
+ } catch (error) {
1024
+ const errors = error.errors;
1025
+ if (error.kind !== "Validation" || !isRecord(errors)) throw error;
1026
+ state.errors = { ...errors };
1027
+ }
1028
+ });
806
1029
  const memo = { ...snapshot.memo, children: { ...snapshot.memo.children } };
807
1030
  const effects = this.effects(instance);
808
1031
  let html;
@@ -816,15 +1039,37 @@ var LiveComponentManager = class {
816
1039
  if (calls.length > 0) effects.returns = returns;
817
1040
  return { instance, snapshot: await this.snapshot(instance, memo), effects, html, tag };
818
1041
  }
819
- /** The route action of POST /oriel/live/update. */
1042
+ /**
1043
+ * The route action of POST /oriel/live/update. Each component's update runs behind the persistent middleware of the
1044
+ * route that rendered its page (Livewire's): `auth`, `verified`, `guest` and `can`, with the page route's parameters
1045
+ * bound again. A signed-out tab gets a 401, a revoked ability a 403 and a deleted model a 404, before the component
1046
+ * hydrates. When a middleware answers instead (a redirect), that is the response. A key first flashed during the
1047
+ * update shows in this response only, unless the update redirects.
1048
+ */
820
1049
  async handle(request) {
821
1050
  if (!this.isLiveRequest(request)) throw new InvalidLiveRequestError("Live update requests need the X-Oriel-Live header.");
822
- return this.update(this.payload(request));
1051
+ const container = this.container();
1052
+ const persistent = optional(container, ConfigRepository)?.get("live.persistent_middleware", PERSISTENT_MIDDLEWARE) ?? PERSISTENT_MIDDLEWARE;
1053
+ const components = [];
1054
+ const flashed = trackFlash(optional(container, Session2));
1055
+ for (const component of this.payload(request).components) {
1056
+ const { memo } = this.verify(component.snapshot);
1057
+ const pass = persistentPass(container.make(Router), persistent, memo.method, memo.path);
1058
+ const update = () => this.updateComponent(component.snapshot, component.updates, component.calls);
1059
+ const result = await pageRequest.run(
1060
+ { path: memo.path, method: memo.method },
1061
+ () => pass === void 0 || pass.middleware.length === 0 ? update() : throughMiddleware(container, request, pass, update)
1062
+ );
1063
+ if (result instanceof Response) return result;
1064
+ components.push({ snapshot: JSON.stringify(result.snapshot), effects: result.effects });
1065
+ }
1066
+ flashed(components.some((component) => component.effects.redirect !== void 0));
1067
+ return { components };
823
1068
  }
824
1069
  /** A full-page component (Route.get('/counter', Counter)): mounted with the route parameters, inside the layout. */
825
1070
  async page(component) {
826
1071
  const container = this.container();
827
- const params = { ...optional(container, CurrentRoute)?.parameters ?? {} };
1072
+ const params = { ...optional(container, CurrentRoute2)?.parameters ?? {} };
828
1073
  const cls = component.constructor;
829
1074
  const result = await this.mountComponent(cls, params, component);
830
1075
  const redirect = result.effects.redirect;
@@ -893,8 +1138,8 @@ var LiveComponentManager = class {
893
1138
  const [root, ...rest] = path.split(".");
894
1139
  const key = rest.length > 0 ? rest.join(".") : void 0;
895
1140
  const studly = Str2.studly(root);
896
- await hook(instance, "updating", path, value);
897
- await hook(instance, `updating${studly}`, value, key);
1141
+ await hook2(instance, "updating", path, value);
1142
+ await hook2(instance, `updating${studly}`, value, key);
898
1143
  const target = instance;
899
1144
  if (rest.length === 0) target[root] = value;
900
1145
  else {
@@ -912,8 +1157,8 @@ var LiveComponentManager = class {
912
1157
  assertSlot(node, last);
913
1158
  Object.defineProperty(node, last, { value, enumerable: true, writable: true, configurable: true });
914
1159
  }
915
- await hook(instance, "updated", path, value);
916
- await hook(instance, `updated${studly}`, value, key);
1160
+ await hook2(instance, "updated", path, value);
1161
+ await hook2(instance, `updated${studly}`, value, key);
917
1162
  }
918
1163
  /** Runs one call from the browser: a magic action, an event, or a public method. Returns its return value. */
919
1164
  async call(instance, method, params) {
@@ -941,18 +1186,35 @@ var LiveComponentManager = class {
941
1186
  if (!isCallable(instance, method)) throw new MethodNotCallableError(method, name);
942
1187
  return instance[method](...params);
943
1188
  }
944
- /** Renders the component (rendering/rendered hooks, nested children tracked) and checks for a single root. */
1189
+ /**
1190
+ * Renders the component (rendering/rendered hooks, nested children tracked) and checks for a single root. A
1191
+ * WithPagination component's page and page URL are what paginate() reads while it renders, as in its mount() and in
1192
+ * its update's hooks and actions.
1193
+ */
945
1194
  async render(instance, memo, previous) {
946
- await hook(instance, "rendering");
1195
+ await hook2(instance, "rendering");
947
1196
  const context = { previous, children: {}, index: 0 };
948
- const html = await renderContext.run(context, async () => {
949
- const output = await instance.render();
950
- return typeof output === "string" ? output : this.container().make(ViewFactory).renderToString(output);
951
- });
952
- await hook(instance, "rendered", html);
1197
+ const html = await renderContext.run(
1198
+ context,
1199
+ () => renderPaginated(instance, this.pageUrl(memo.path), async () => {
1200
+ const output = await instance.render();
1201
+ return typeof output === "string" ? output : this.container().make(ViewFactory).renderToString(output);
1202
+ })
1203
+ );
1204
+ await hook2(instance, "rendered", html);
953
1205
  memo.children = context.children;
954
1206
  return { html: html.trim(), tag: rootTag(html, memo.name) };
955
1207
  }
1208
+ /** The absolute URL of the page a memo path belongs to, on the current request's origin ('/' + path without one). */
1209
+ pageUrl(path) {
1210
+ const relative3 = `/${path.replace(/^\/+/, "")}`;
1211
+ const url = optional(this.container(), HttpRequest)?.url();
1212
+ try {
1213
+ return url === void 0 ? relative3 : new URL(relative3, url).href;
1214
+ } catch {
1215
+ return relative3;
1216
+ }
1217
+ }
956
1218
  /** Dispatches, redirects: the effects every response carries. */
957
1219
  effects(instance) {
958
1220
  const state = stateOf(instance);
@@ -971,7 +1233,7 @@ var LiveComponentManager = class {
971
1233
  }
972
1234
  /** dehydrate() hook, then the state as snapshot data, the error bag, and the checksum over both. */
973
1235
  async snapshot(instance, memo) {
974
- await hook(instance, "dehydrate");
1236
+ await hook2(instance, "dehydrate");
975
1237
  const registry = this.registry();
976
1238
  const data = {};
977
1239
  for (const [property, value] of Object.entries(instance)) data[property] = dehydrate(value, registry, `${memo.name}.${property}`);
@@ -981,7 +1243,7 @@ var LiveComponentManager = class {
981
1243
  };
982
1244
 
983
1245
  // LiveServiceProvider.ts
984
- import { ConfigRepository as ConfigRepository3, ConsoleKernel, HttpRequest as HttpRequest2, LiveManager as LiveManager4, Router } from "@orieljs/contracts";
1246
+ import { ConfigRepository as ConfigRepository3, ConsoleKernel, HttpRequest as HttpRequest2, LiveManager as LiveManager4, PaginationState, Router as Router2 } from "@orieljs/contracts";
985
1247
  import { inject as inject4 } from "@orieljs/container";
986
1248
  import { ServiceProvider } from "@orieljs/support";
987
1249
 
@@ -1055,14 +1317,15 @@ function registerLiveCommands(kernel, app) {
1055
1317
  var UPDATE_URI = "/oriel/live/update";
1056
1318
  var LiveServiceProvider = class extends ServiceProvider {
1057
1319
  register() {
1058
- this.mergeConfigFrom({ class_path: this.app.appPath("Live"), layout: "components.layouts.app" }, "live");
1320
+ this.mergeConfigFrom({ class_path: this.app.appPath("Live"), layout: "components.layouts.app", persistent_middleware: [...PERSISTENT_MIDDLEWARE] }, "live");
1059
1321
  this.app.singleton(LiveComponentManager, () => new LiveComponentManager(this.app));
1060
1322
  this.app.singleton(LiveManager4, (container) => container.make(LiveComponentManager));
1323
+ this.app.singleton(PaginationState, () => livePaginationState);
1061
1324
  }
1062
1325
  async boot() {
1063
1326
  const manager = this.app.make(LiveComponentManager);
1064
- if (this.app.bound(Router)) {
1065
- this.app.make(Router).post(UPDATE_URI, () => manager.handle(inject4(HttpRequest2))).middleware("web").name("live.update");
1327
+ if (this.app.bound(Router2)) {
1328
+ this.app.make(Router2).post(UPDATE_URI, () => manager.handle(inject4(HttpRequest2))).middleware("web").name("live.update");
1066
1329
  }
1067
1330
  if (this.app.bound(ConsoleKernel)) registerLiveCommands(this.app.make(ConsoleKernel), this.app);
1068
1331
  const directory = this.app.make(ConfigRepository3).get("live.class_path", this.app.appPath("Live"));
@@ -1071,6 +1334,42 @@ var LiveServiceProvider = class extends ServiceProvider {
1071
1334
  }
1072
1335
  }
1073
1336
  };
1337
+
1338
+ // Pagination.ts
1339
+ function urlWindow(current, last, onEachSide) {
1340
+ const range = (from, to) => Array.from({ length: Math.max(to - from + 1, 0) }, (_, index) => from + index);
1341
+ if (last < onEachSide * 2 + 8) return range(1, last);
1342
+ const size = onEachSide + 4;
1343
+ if (current <= size) return [...range(1, size + onEachSide), "...", ...range(last - 1, last)];
1344
+ if (current > last - size) return [...range(1, 2), "...", ...range(last - (size + (onEachSide - 1)), last)];
1345
+ return [...range(1, 2), "...", ...range(current - onEachSide, current + onEachSide), "...", ...range(last - 1, last)];
1346
+ }
1347
+ function button(label, action, attributes = "") {
1348
+ const live2 = action === void 0 ? " disabled" : ` o:click="${escapeAttribute(action)}"`;
1349
+ return `<button type="button" class="btn btn-secondary"${attributes}${live2}>${escapeAttribute(label)}</button>`;
1350
+ }
1351
+ function Pagination({ paginator, class: className }) {
1352
+ if (typeof paginator.nextCursor === "function") {
1353
+ throw new LiveError("<Pagination> needs paginate() or simplePaginate(): a cursor paginator has no page numbers.");
1354
+ }
1355
+ const current = paginator.currentPage();
1356
+ const onFirst = paginator.onFirstPage();
1357
+ const hasMore = paginator.hasMorePages();
1358
+ if (onFirst && !hasMore) return { toHtml: () => "" };
1359
+ const parts = [button("\xAB Previous", onFirst ? void 0 : "previousPage")];
1360
+ if (typeof paginator.lastPage === "function") {
1361
+ const onEachSide = Number.isInteger(paginator.onEachSideCount) && paginator.onEachSideCount >= 0 ? paginator.onEachSideCount : 3;
1362
+ for (const page of urlWindow(current, paginator.lastPage(), onEachSide)) {
1363
+ if (page === "...") parts.push('<span aria-hidden="true">...</span>');
1364
+ else if (page === current) parts.push(`<button type="button" class="btn" aria-current="page" disabled>${page}</button>`);
1365
+ else parts.push(button(String(page), `gotoPage(${page})`, ` aria-label="Go to page ${page}"`));
1366
+ }
1367
+ }
1368
+ parts.push(button("Next \xBB", hasMore ? "nextPage" : void 0));
1369
+ const classes = className === void 0 || className === "" ? "" : ` class="${escapeAttribute(className)}"`;
1370
+ const html = `<nav aria-label="Pagination"${classes}>${parts.join("")}</nav>`;
1371
+ return { toHtml: () => html };
1372
+ }
1074
1373
  export {
1075
1374
  Component,
1076
1375
  Computed,
@@ -1085,11 +1384,16 @@ export {
1085
1384
  Locked,
1086
1385
  MethodNotCallableError,
1087
1386
  On,
1387
+ PERSISTENT_MIDDLEWARE,
1388
+ Pagination,
1088
1389
  PendingEvent,
1089
1390
  PropertyNotUpdatableError,
1391
+ TestRequestScope,
1090
1392
  Testable,
1091
1393
  UPDATE_URI,
1394
+ WithPagination,
1092
1395
  componentName,
1093
- live
1396
+ live,
1397
+ persistentMiddleware
1094
1398
  };
1095
1399
  //# sourceMappingURL=index.js.map