@capacms/sdk 1.0.0-next.7 → 1.0.0-next.9
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/CHANGELOG.md +64 -1
- package/README.md +292 -29
- package/dist/esm/nextjs/overlay.d.ts +30 -0
- package/dist/esm/nextjs/overlay.js +75 -0
- package/dist/esm/overlay/index.d.ts +32 -0
- package/dist/esm/overlay/index.js +510 -0
- package/dist/esm/overlay/protocol.d.ts +134 -0
- package/dist/esm/overlay/protocol.js +173 -0
- package/dist/esm/package.json +4 -0
- package/dist/next/client.d.ts +6 -6
- package/dist/next/client.js +2 -1
- package/dist/next/field-names.js +1 -1
- package/dist/next/graphql/introspection.d.ts +1 -1
- package/dist/next/graphql/introspection.js +1 -1
- package/dist/next/graphql/plan.js +2 -2
- package/dist/next/key-family.d.ts +9 -12
- package/dist/next/key-family.js +11 -19
- package/dist/nextjs/index.d.ts +149 -9
- package/dist/nextjs/index.js +207 -15
- package/dist/nextjs/overlay.d.ts +26 -1
- package/dist/nextjs/overlay.js +49 -6
- package/dist/overlay/index.d.ts +14 -2
- package/dist/overlay/index.js +168 -45
- package/package.json +12 -4
package/dist/nextjs/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DEFAULT_API_VERSION = exports.CAPA_ENV_ALIASES = exports.CAPA_ENV = exports.EDIT_CACHE_CONTROL = exports.DRAFT_COOKIE = exports.EDIT_HEADER = exports.EDIT_PARAM = exports.LAYOUT_PAGE = exports.gql = exports.MEDIA_TAG = exports.GRAPHQL_TAG = void 0;
|
|
3
|
+
exports.DRAFT_COOKIE_MAX_AGE = exports.DRAFT_ROBOTS_TAG = exports.CAPA_ADMIN_ORIGIN = exports.DEFAULT_API_VERSION = exports.CAPA_ENV_ALIASES = exports.CAPA_ENV = exports.EDIT_CACHE_CONTROL = exports.DRAFT_COOKIE = exports.EDIT_HEADER = exports.VIEW_PARAM = exports.PREVIEW_PARAM = exports.EDIT_PARAM = exports.LAYOUT_PAGE = exports.gql = exports.MEDIA_TAG = exports.GRAPHQL_TAG = void 0;
|
|
4
4
|
exports.withCache = withCache;
|
|
5
5
|
exports.modelTag = modelTag;
|
|
6
6
|
exports.tagsFor = tagsFor;
|
|
@@ -15,6 +15,11 @@ exports.getPublishedClient = getPublishedClient;
|
|
|
15
15
|
exports.getCapaClient = getCapaClient;
|
|
16
16
|
exports.graphql = graphql;
|
|
17
17
|
exports.safeSitePath = safeSitePath;
|
|
18
|
+
exports.frameAncestors = frameAncestors;
|
|
19
|
+
exports.draftHeaders = draftHeaders;
|
|
20
|
+
exports.capaHeaders = capaHeaders;
|
|
21
|
+
exports.frameDraftCookie = frameDraftCookie;
|
|
22
|
+
exports.clearDraftCookie = clearDraftCookie;
|
|
18
23
|
exports.createPreviewRoute = createPreviewRoute;
|
|
19
24
|
exports.exitPreviewRoute = exitPreviewRoute;
|
|
20
25
|
exports.capaMiddleware = capaMiddleware;
|
|
@@ -284,6 +289,10 @@ function pagesFor(client) {
|
|
|
284
289
|
* clickable. It never switches the site to draft data.
|
|
285
290
|
*/
|
|
286
291
|
exports.EDIT_PARAM = "capa-edit";
|
|
292
|
+
/** The query parameter a Capa preview link carries: `?capa-preview=<token>`. */
|
|
293
|
+
exports.PREVIEW_PARAM = "capa-preview";
|
|
294
|
+
/** The query parameter of the editor's Published view: `?capa-view=published`. */
|
|
295
|
+
exports.VIEW_PARAM = "capa-view";
|
|
287
296
|
/**
|
|
288
297
|
* The request header `resolveEditRequest` sets once a `capa-edit` token has
|
|
289
298
|
* been verified, for `editMode()` to read. Any copy a browser sent is removed
|
|
@@ -322,6 +331,7 @@ function verifiedEdit(headers) {
|
|
|
322
331
|
* const edit = await resolveEditRequest(request, publishedClient());
|
|
323
332
|
* const response = NextResponse.next({ request: { headers: edit.headers } });
|
|
324
333
|
* if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
|
|
334
|
+
* if (edit.robotsTag) response.headers.set("X-Robots-Tag", edit.robotsTag);
|
|
325
335
|
* return response;
|
|
326
336
|
* }
|
|
327
337
|
*
|
|
@@ -348,7 +358,13 @@ async function resolveEditRequest(request, client) {
|
|
|
348
358
|
const draft = request.cookies?.has(exports.DRAFT_COOKIE) ??
|
|
349
359
|
(headers.get("cookie") ?? "").split(/;\s*/).some((c) => c.startsWith(`${exports.DRAFT_COOKIE}=`));
|
|
350
360
|
const edit = verified || draft;
|
|
351
|
-
return {
|
|
361
|
+
return {
|
|
362
|
+
edit,
|
|
363
|
+
verified,
|
|
364
|
+
headers,
|
|
365
|
+
cacheControl: edit ? exports.EDIT_CACHE_CONTROL : null,
|
|
366
|
+
robotsTag: edit ? exports.DRAFT_ROBOTS_TAG : null,
|
|
367
|
+
};
|
|
352
368
|
}
|
|
353
369
|
// ------------------------------------------------- five-minute integration ---
|
|
354
370
|
/**
|
|
@@ -570,26 +586,166 @@ function safeSitePath(value) {
|
|
|
570
586
|
return "/";
|
|
571
587
|
return value;
|
|
572
588
|
}
|
|
589
|
+
// -------------------------------------------------------- draft responses ---
|
|
590
|
+
/** The Capa admin's origin: what frames a draft in the editor, unless a site names another. */
|
|
591
|
+
exports.CAPA_ADMIN_ORIGIN = "https://app.capacms.com";
|
|
592
|
+
/** `X-Robots-Tag` on every draft and edit-mode response: a draft is never indexed, nor its links followed. */
|
|
593
|
+
exports.DRAFT_ROBOTS_TAG = "noindex, nofollow";
|
|
594
|
+
/** How long the draft cookie lasts, in seconds: one hour, as a preview token does. */
|
|
595
|
+
exports.DRAFT_COOKIE_MAX_AGE = 3600;
|
|
596
|
+
/**
|
|
597
|
+
* `frame-ancestors 'self' <origins>`, the CSP directive that lets the Capa
|
|
598
|
+
* editor frame a draft and nothing else frame it. Each origin is a scheme and
|
|
599
|
+
* a host, with a port when it has one, and is written as the URL parser
|
|
600
|
+
* normalises it, so no `;` or quote can reach the policy. Anything else, a
|
|
601
|
+
* path or a query included, throws a `TypeError`.
|
|
602
|
+
*/
|
|
603
|
+
function frameAncestors(adminOrigins = [exports.CAPA_ADMIN_ORIGIN]) {
|
|
604
|
+
const origins = new Set();
|
|
605
|
+
for (const value of adminOrigins)
|
|
606
|
+
origins.add(originOf(value));
|
|
607
|
+
return ["frame-ancestors 'self'", ...origins].join(" ");
|
|
608
|
+
}
|
|
609
|
+
function originOf(value) {
|
|
610
|
+
let url = null;
|
|
611
|
+
try {
|
|
612
|
+
url = typeof value === "string" ? new URL(value) : null;
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
url = null;
|
|
616
|
+
}
|
|
617
|
+
const bare = url !== null &&
|
|
618
|
+
(url.protocol === "https:" || url.protocol === "http:") &&
|
|
619
|
+
url.pathname === "/" &&
|
|
620
|
+
url.search === "" &&
|
|
621
|
+
url.hash === "" &&
|
|
622
|
+
url.username === "" &&
|
|
623
|
+
url.password === "";
|
|
624
|
+
if (!bare) {
|
|
625
|
+
throw new TypeError(`@capacms/sdk/nextjs: ${JSON.stringify(value)} is not an origin. Pass a scheme and a host, such as ${exports.CAPA_ADMIN_ORIGIN}.`);
|
|
626
|
+
}
|
|
627
|
+
return url.origin;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* The headers every draft response carries: `X-Robots-Tag: noindex, nofollow`,
|
|
631
|
+
* and `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com`
|
|
632
|
+
* (or the `adminOrigins` given). Throws for an origin that is not one.
|
|
633
|
+
*/
|
|
634
|
+
function draftHeaders(options = {}) {
|
|
635
|
+
return {
|
|
636
|
+
"X-Robots-Tag": exports.DRAFT_ROBOTS_TAG,
|
|
637
|
+
"Content-Security-Policy": frameAncestors(options.adminOrigins),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Rules for `next.config`'s `headers()` that send `draftHeaders` on draft
|
|
642
|
+
* responses only:
|
|
643
|
+
*
|
|
644
|
+
* // next.config.mjs
|
|
645
|
+
* import { capaHeaders } from "@capacms/sdk/nextjs";
|
|
646
|
+
* export default { async headers() { return [...capaHeaders()]; } };
|
|
647
|
+
*
|
|
648
|
+
* A rule matches a request carrying the draft cookie, or a `capa-preview`,
|
|
649
|
+
* `capa-edit` or `capa-view` query. A visitor's request carries none of
|
|
650
|
+
* them, so its response, cached or not, is exactly what it was. Next checks
|
|
651
|
+
* the cookie by name, not by value, so a forged cookie only adds these
|
|
652
|
+
* headers to the forger's own response.
|
|
653
|
+
*
|
|
654
|
+
* A site that sends its own `frame-ancestors` or `X-Frame-Options` must leave
|
|
655
|
+
* them off draft responses (`missing: [{ type: "cookie", key: DRAFT_COOKIE }]`
|
|
656
|
+
* on its rule): a browser applies every CSP it is sent, so the strictest wins.
|
|
657
|
+
*/
|
|
658
|
+
function capaHeaders(options = {}) {
|
|
659
|
+
const headers = () => Object.entries(draftHeaders(options)).map(([key, value]) => ({ key, value }));
|
|
660
|
+
const conditions = [
|
|
661
|
+
{ type: "cookie", key: exports.DRAFT_COOKIE },
|
|
662
|
+
{ type: "query", key: exports.PREVIEW_PARAM },
|
|
663
|
+
{ type: "query", key: exports.EDIT_PARAM },
|
|
664
|
+
{ type: "query", key: exports.VIEW_PARAM },
|
|
665
|
+
];
|
|
666
|
+
return conditions.map((condition) => ({ source: "/:path*", has: [condition], headers: headers() }));
|
|
667
|
+
}
|
|
668
|
+
/** The attributes a draft cookie needs to be sent inside the Capa editor's cross-site frame. */
|
|
669
|
+
const FRAMED = { path: "/", httpOnly: true, secure: true, sameSite: "none", partitioned: true };
|
|
670
|
+
function draftCookieMaxAge(maxAge = exports.DRAFT_COOKIE_MAX_AGE) {
|
|
671
|
+
if (typeof maxAge !== "number" || !Number.isInteger(maxAge) || maxAge <= 0) {
|
|
672
|
+
throw new TypeError(`@capacms/sdk/nextjs: maxAge must be a whole number of seconds above 0, and got ${String(maxAge)}.`);
|
|
673
|
+
}
|
|
674
|
+
return maxAge;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Re-set the cookie `draftMode().enable()` just set, so the Capa editor can
|
|
678
|
+
* use it: `HttpOnly; Secure; SameSite=None; Partitioned; Path=/` and
|
|
679
|
+
* `Max-Age` one hour (`maxAge` seconds). Next sets it with no `Max-Age`, so
|
|
680
|
+
* it unlocks every draft on the site until the browser closes, and without
|
|
681
|
+
* `Partitioned`, which Safari 26.2 and later need to send a cookie into a
|
|
682
|
+
* cross-site frame. Call it after `enable()`, in the same route handler.
|
|
683
|
+
* Resolves false, and sets nothing, when there is no draft cookie to re-set.
|
|
684
|
+
*/
|
|
685
|
+
async function frameDraftCookie(cookies, options = {}) {
|
|
686
|
+
const maxAge = draftCookieMaxAge(options.maxAge);
|
|
687
|
+
const jar = await cookies();
|
|
688
|
+
const current = jar.get(exports.DRAFT_COOKIE);
|
|
689
|
+
if (!current?.value)
|
|
690
|
+
return false;
|
|
691
|
+
jar.set({ name: exports.DRAFT_COOKIE, value: current.value, ...FRAMED, maxAge });
|
|
692
|
+
return true;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Delete the draft cookie `frameDraftCookie` set. A partitioned cookie is
|
|
696
|
+
* deleted only by a `Set-Cookie` that is partitioned too, which
|
|
697
|
+
* `draftMode().disable()` is not. Call it after `disable()`; it replaces
|
|
698
|
+
* `disable()`'s own deletion, since a response sets one cookie per name. A
|
|
699
|
+
* draft cookie set without `Partitioned`, before a site used
|
|
700
|
+
* `frameDraftCookie`, ends when the browser closes, as it always did.
|
|
701
|
+
*/
|
|
702
|
+
async function clearDraftCookie(cookies) {
|
|
703
|
+
(await cookies()).set({ name: exports.DRAFT_COOKIE, value: "", ...FRAMED, expires: new Date(0) });
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* The preview and exit routes' own redirect. Next's `redirect()` throws and
|
|
707
|
+
* answers for the route, so it cannot carry headers; this one is a plain
|
|
708
|
+
* `Response`, to which Next appends every cookie the route set. It sets no
|
|
709
|
+
* cookie itself: Next keeps one per name, and the response's own would win.
|
|
710
|
+
*/
|
|
711
|
+
function routeRedirect(location, headers) {
|
|
712
|
+
return new Response(null, {
|
|
713
|
+
status: 307,
|
|
714
|
+
headers: { Location: location, "Cache-Control": exports.EDIT_CACHE_CONTROL, "Referrer-Policy": "no-referrer", ...headers },
|
|
715
|
+
});
|
|
716
|
+
}
|
|
573
717
|
/**
|
|
574
718
|
* `app/api/capa/preview/route.ts`:
|
|
575
719
|
*
|
|
576
|
-
* import { draftMode } from "next/headers";
|
|
577
|
-
*
|
|
578
|
-
* export const GET = createPreviewRoute({ draftMode, redirect });
|
|
720
|
+
* import { cookies, draftMode } from "next/headers";
|
|
721
|
+
* export const GET = createPreviewRoute({ draftMode, cookies });
|
|
579
722
|
*
|
|
580
723
|
* Checks the token with Capa (the site never holds the signing key), turns
|
|
581
724
|
* draft mode on and lands on the entry's page. A bad or expired token lands on
|
|
582
725
|
* the page without draft mode and `?preview=expired`; Capa unreachable gives
|
|
583
|
-
* `?preview=unavailable`.
|
|
726
|
+
* `?preview=unavailable`. The token is checked with any key the site holds,
|
|
727
|
+
* its legacy key included (`getPublishedClient`, from `CAPA_KEY`).
|
|
728
|
+
*
|
|
729
|
+
* Given `cookies`, the draft cookie is re-set by `frameDraftCookie`: one hour,
|
|
730
|
+
* and sent inside the editor's frame. With no `redirect`, the route answers
|
|
731
|
+
* with its own 307, marked `X-Robots-Tag: noindex, nofollow`,
|
|
732
|
+
* `Referrer-Policy: no-referrer` (the token is in the URL),
|
|
733
|
+
* `Cache-Control: private, no-store` and the editor's `frame-ancestors`.
|
|
734
|
+
* Given Next's `redirect`, it is called instead, as before, and the redirect
|
|
735
|
+
* carries none of those.
|
|
584
736
|
*/
|
|
585
737
|
function createPreviewRoute(input) {
|
|
738
|
+
// Checked here, so a bad origin or maxAge fails where the route is built.
|
|
739
|
+
const headers = draftHeaders({ adminOrigins: input.adminOrigins });
|
|
740
|
+
const maxAge = draftCookieMaxAge(input.maxAge);
|
|
741
|
+
const go = (location) => input.redirect ? input.redirect(location) : routeRedirect(location, headers);
|
|
586
742
|
return async (request) => {
|
|
587
743
|
const url = new URL(request.url);
|
|
588
|
-
const token = url.searchParams.get("token") ?? url.searchParams.get(
|
|
744
|
+
const token = url.searchParams.get("token") ?? url.searchParams.get(exports.PREVIEW_PARAM) ?? "";
|
|
589
745
|
// Reached two ways: directly, or through the middleware's rewrite of a page
|
|
590
746
|
// URL carrying `?capa-preview=`, where Next may hand over the ORIGINAL URL.
|
|
591
747
|
// Then the page itself is the path.
|
|
592
|
-
const path = safeSitePath(url.searchParams.get("path") ?? (url.searchParams.has(
|
|
748
|
+
const path = safeSitePath(url.searchParams.get("path") ?? (url.searchParams.has(exports.PREVIEW_PARAM) ? url.pathname : null));
|
|
593
749
|
let claim = null;
|
|
594
750
|
let failed = false;
|
|
595
751
|
try {
|
|
@@ -601,18 +757,36 @@ function createPreviewRoute(input) {
|
|
|
601
757
|
const draft = await input.draftMode();
|
|
602
758
|
if (!claim) {
|
|
603
759
|
draft.disable?.();
|
|
604
|
-
|
|
760
|
+
if (input.cookies)
|
|
761
|
+
await clearDraftCookie(input.cookies);
|
|
762
|
+
return go(`${path}?preview=${failed ? "unavailable" : "expired"}`);
|
|
605
763
|
}
|
|
606
764
|
draft.enable?.();
|
|
765
|
+
if (input.cookies)
|
|
766
|
+
await frameDraftCookie(input.cookies, { maxAge });
|
|
607
767
|
await input.onEnable?.();
|
|
608
|
-
return
|
|
768
|
+
return go(safeSitePath(claim.path ?? path));
|
|
609
769
|
};
|
|
610
770
|
}
|
|
611
|
-
/**
|
|
771
|
+
/**
|
|
772
|
+
* `app/api/capa/exit/route.ts`:
|
|
773
|
+
*
|
|
774
|
+
* import { cookies, draftMode } from "next/headers";
|
|
775
|
+
* export const GET = exitPreviewRoute({ draftMode, cookies });
|
|
776
|
+
*
|
|
777
|
+
* Turns draft mode off and lands on `?path=`. Given `cookies`, the framed
|
|
778
|
+
* cookie `createPreviewRoute` set is deleted too. With no `redirect`, the route
|
|
779
|
+
* answers with its own 307, marked noindex and never cached.
|
|
780
|
+
*/
|
|
612
781
|
function exitPreviewRoute(input) {
|
|
613
782
|
return async (request) => {
|
|
614
783
|
(await input.draftMode()).disable?.();
|
|
615
|
-
|
|
784
|
+
if (input.cookies)
|
|
785
|
+
await clearDraftCookie(input.cookies);
|
|
786
|
+
const location = safeSitePath(new URL(request.url).searchParams.get("path"));
|
|
787
|
+
if (input.redirect)
|
|
788
|
+
return input.redirect(location);
|
|
789
|
+
return routeRedirect(location, { "X-Robots-Tag": exports.DRAFT_ROBOTS_TAG });
|
|
616
790
|
};
|
|
617
791
|
}
|
|
618
792
|
/**
|
|
@@ -626,12 +800,25 @@ function exitPreviewRoute(input) {
|
|
|
626
800
|
* - `?capa-view=published` renders without the draft cookie, for the editor's
|
|
627
801
|
* Published view;
|
|
628
802
|
* - `?capa-edit=<token>` turns edit mode on (ids and overlay, published data);
|
|
629
|
-
* - every edit-mode response is `private, no-store
|
|
803
|
+
* - every edit-mode response is `private, no-store`, and it and every request
|
|
804
|
+
* carrying `capa-edit` or `capa-view` is `X-Robots-Tag: noindex, nofollow`.
|
|
805
|
+
*
|
|
806
|
+
* Give it a `matcher` so a visitor's request never runs it (Next reads
|
|
807
|
+
* `config` from the file itself, so it is written out there):
|
|
808
|
+
*
|
|
809
|
+
* export const config = {
|
|
810
|
+
* matcher: [
|
|
811
|
+
* { source: "/:path*", has: [{ type: "query", key: "capa-preview" }] },
|
|
812
|
+
* { source: "/:path*", has: [{ type: "query", key: "capa-edit" }] },
|
|
813
|
+
* { source: "/:path*", has: [{ type: "query", key: "capa-view" }] },
|
|
814
|
+
* { source: "/:path*", has: [{ type: "cookie", key: "__prerender_bypass" }] },
|
|
815
|
+
* ],
|
|
816
|
+
* };
|
|
630
817
|
*/
|
|
631
818
|
function capaMiddleware(input) {
|
|
632
819
|
const previewRoute = input.previewRoute ?? "/api/capa/preview";
|
|
633
820
|
return async (request) => {
|
|
634
|
-
const token = request.nextUrl.searchParams.get(
|
|
821
|
+
const token = request.nextUrl.searchParams.get(exports.PREVIEW_PARAM);
|
|
635
822
|
if (token) {
|
|
636
823
|
const target = request.nextUrl.clone();
|
|
637
824
|
target.pathname = previewRoute;
|
|
@@ -641,7 +828,7 @@ function capaMiddleware(input) {
|
|
|
641
828
|
return input.NextResponse.rewrite(target);
|
|
642
829
|
}
|
|
643
830
|
const headers = new Headers(request.headers);
|
|
644
|
-
if (request.nextUrl.searchParams.get(
|
|
831
|
+
if (request.nextUrl.searchParams.get(exports.VIEW_PARAM) === "published") {
|
|
645
832
|
const cookies = request.cookies
|
|
646
833
|
.getAll()
|
|
647
834
|
.filter((cookie) => cookie.name !== exports.DRAFT_COOKIE)
|
|
@@ -658,6 +845,11 @@ function capaMiddleware(input) {
|
|
|
658
845
|
const response = input.NextResponse.next({ request: { headers: edit.headers } });
|
|
659
846
|
if (edit.cacheControl)
|
|
660
847
|
response.headers.set("Cache-Control", edit.cacheControl);
|
|
848
|
+
// A capa- link is the editor's, verified or not, and never a page to index.
|
|
849
|
+
const capaLink = request.nextUrl.searchParams.has(exports.EDIT_PARAM) || request.nextUrl.searchParams.has(exports.VIEW_PARAM);
|
|
850
|
+
const robots = edit.robotsTag ?? (capaLink ? exports.DRAFT_ROBOTS_TAG : null);
|
|
851
|
+
if (robots)
|
|
852
|
+
response.headers.set("X-Robots-Tag", robots);
|
|
661
853
|
return response;
|
|
662
854
|
};
|
|
663
855
|
}
|
package/dist/nextjs/overlay.d.ts
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
export interface CapaOverlayProps {
|
|
2
2
|
/** The Capa admin origins allowed to drive the overlay. */
|
|
3
3
|
adminOrigins: string[];
|
|
4
|
+
/**
|
|
5
|
+
* How a save shows. `"in-place"` (the default) re-renders the draft with
|
|
6
|
+
* `router.refresh()`, and reloads the page only if that has not landed
|
|
7
|
+
* within `refreshTimeoutMs`. `"reload"` reloads the page on every save.
|
|
8
|
+
* The scroll position is kept either way.
|
|
9
|
+
*/
|
|
10
|
+
refresh?: "in-place" | "reload";
|
|
11
|
+
/** How long an in-place refresh may take before the page reloads instead. 10 seconds. */
|
|
12
|
+
refreshTimeoutMs?: number;
|
|
4
13
|
}
|
|
5
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The refresh runs in a transition, so `isPending` says when the new draft
|
|
16
|
+
* has been committed to the screen, and the overlay settles the refresh
|
|
17
|
+
* then: it puts the scroll position back, and a refresh that has not
|
|
18
|
+
* committed within `refreshTimeoutMs` reloads.
|
|
19
|
+
*
|
|
20
|
+
* While the transition is pending the overlay makes a no-op state update
|
|
21
|
+
* every `REFRESH_NUDGE_MS`. React 19.2 under Next 15.5 can leave a draft
|
|
22
|
+
* refresh suspended after every part of its streamed response has arrived:
|
|
23
|
+
* the page suspends inside an already visible Suspense boundary (a root
|
|
24
|
+
* `loading.tsx` makes one around every page), and the signal that its data
|
|
25
|
+
* is ready is lost, so nothing commits until some other state update. Any
|
|
26
|
+
* state update makes React retry the suspended render, which then completes.
|
|
27
|
+
* A refresh that commits on its own stops the nudges at once. Draft mode
|
|
28
|
+
* only: a visitor never runs this component.
|
|
29
|
+
*/
|
|
30
|
+
export declare function CapaOverlay({ adminOrigins, refresh, refreshTimeoutMs }: CapaOverlayProps): null;
|
package/dist/nextjs/overlay.js
CHANGED
|
@@ -18,18 +18,61 @@ exports.CapaOverlay = CapaOverlay;
|
|
|
18
18
|
*
|
|
19
19
|
* Its own entry point, apart from `@capacms/sdk/nextjs`, because it imports
|
|
20
20
|
* `react` and `next/navigation` and is a client module: the server helpers must
|
|
21
|
-
* stay free of both.
|
|
21
|
+
* stay free of both. A bundler that imports it gets an ES module, so it sees
|
|
22
|
+
* that only `useRouter` is used from `next/navigation` and leaves the chunks a
|
|
23
|
+
* visitor loads as they were.
|
|
22
24
|
*/
|
|
23
25
|
const react_1 = require("react");
|
|
24
26
|
const navigation_1 = require("next/navigation");
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
+
const index_js_1 = require("../overlay/index.js");
|
|
28
|
+
/**
|
|
29
|
+
* While a refresh is pending, the overlay updates its own unused state this
|
|
30
|
+
* often. See `CapaOverlay` for why.
|
|
31
|
+
*/
|
|
32
|
+
const REFRESH_NUDGE_MS = 300;
|
|
33
|
+
/**
|
|
34
|
+
* The refresh runs in a transition, so `isPending` says when the new draft
|
|
35
|
+
* has been committed to the screen, and the overlay settles the refresh
|
|
36
|
+
* then: it puts the scroll position back, and a refresh that has not
|
|
37
|
+
* committed within `refreshTimeoutMs` reloads.
|
|
38
|
+
*
|
|
39
|
+
* While the transition is pending the overlay makes a no-op state update
|
|
40
|
+
* every `REFRESH_NUDGE_MS`. React 19.2 under Next 15.5 can leave a draft
|
|
41
|
+
* refresh suspended after every part of its streamed response has arrived:
|
|
42
|
+
* the page suspends inside an already visible Suspense boundary (a root
|
|
43
|
+
* `loading.tsx` makes one around every page), and the signal that its data
|
|
44
|
+
* is ready is lost, so nothing commits until some other state update. Any
|
|
45
|
+
* state update makes React retry the suspended render, which then completes.
|
|
46
|
+
* A refresh that commits on its own stops the nudges at once. Draft mode
|
|
47
|
+
* only: a visitor never runs this component.
|
|
48
|
+
*/
|
|
49
|
+
function CapaOverlay({ adminOrigins, refresh = "in-place", refreshTimeoutMs }) {
|
|
27
50
|
const router = (0, navigation_1.useRouter)();
|
|
51
|
+
const [refreshing, startRefresh] = (0, react_1.useTransition)();
|
|
52
|
+
const [, nudge] = (0, react_1.useState)(0);
|
|
53
|
+
/** One resolver per refresh waiting for its transition to commit. */
|
|
54
|
+
const waiting = (0, react_1.useRef)([]);
|
|
28
55
|
// A string, so a new array with the same origins does not restart it.
|
|
29
56
|
const origins = adminOrigins.join(",");
|
|
30
|
-
(0, react_1.useEffect)(() => (0,
|
|
57
|
+
(0, react_1.useEffect)(() => (0, index_js_1.startOverlay)({
|
|
31
58
|
adminOrigins: origins.split(",").filter(Boolean),
|
|
32
|
-
onRefresh:
|
|
33
|
-
|
|
59
|
+
onRefresh: refresh === "reload"
|
|
60
|
+
? undefined
|
|
61
|
+
: () => new Promise((resolve) => {
|
|
62
|
+
waiting.current.push(resolve);
|
|
63
|
+
startRefresh(() => router.refresh());
|
|
64
|
+
}),
|
|
65
|
+
refreshTimeoutMs,
|
|
66
|
+
}), [origins, router, refresh, refreshTimeoutMs]);
|
|
67
|
+
(0, react_1.useEffect)(() => {
|
|
68
|
+
if (!refreshing) {
|
|
69
|
+
// Committed: every refresh started before now is on screen.
|
|
70
|
+
for (const settle of waiting.current.splice(0))
|
|
71
|
+
settle();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const timer = window.setInterval(() => nudge((n) => n + 1), REFRESH_NUDGE_MS);
|
|
75
|
+
return () => window.clearInterval(timer);
|
|
76
|
+
}, [refreshing]);
|
|
34
77
|
return null;
|
|
35
78
|
}
|
package/dist/overlay/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { acceptMessage, hoverMessage, normaliseOrigins, pickCentred, readyMessage, scrollEdge, selectMessage, visibleMessage, ADMIN_SOURCE, PROTOCOL_VERSION, SITE_SOURCE, } from "./protocol";
|
|
2
|
-
export type { AdminMessage, MessageLike, SiteMessage, TaggedBox } from "./protocol";
|
|
1
|
+
export { acceptMessage, hoverMessage, normaliseOrigins, pickCentred, readyMessage, scrollEdge, selectMessage, visibleMessage, ADMIN_SOURCE, PROTOCOL_VERSION, SITE_SOURCE, } from "./protocol.js";
|
|
2
|
+
export type { AdminMessage, MessageLike, SiteMessage, TaggedBox } from "./protocol.js";
|
|
3
3
|
export interface OverlayOptions {
|
|
4
4
|
/**
|
|
5
5
|
* The Capa admin origins allowed to drive this page, for example
|
|
@@ -9,9 +9,21 @@ export interface OverlayOptions {
|
|
|
9
9
|
/**
|
|
10
10
|
* How to re-render the draft after the editor saves. `router.refresh()` in a
|
|
11
11
|
* Next app. Without it the page reloads. Scroll position is kept either way.
|
|
12
|
+
*
|
|
13
|
+
* Return a promise that settles once the new draft is on screen, and the
|
|
14
|
+
* overlay waits for it: a promise that rejects, or is still pending after
|
|
15
|
+
* `refreshTimeoutMs`, falls back to a reload, which keeps the scroll
|
|
16
|
+
* position too. A function that returns nothing counts as done at once.
|
|
12
17
|
*/
|
|
13
18
|
onRefresh?: () => void | Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* How long `onRefresh`'s promise may stay pending before the page reloads
|
|
21
|
+
* instead. 10 seconds.
|
|
22
|
+
*/
|
|
23
|
+
refreshTimeoutMs?: number;
|
|
14
24
|
}
|
|
25
|
+
/** How long an in-place refresh may take before the page reloads instead. */
|
|
26
|
+
export declare const REFRESH_TIMEOUT_MS = 10000;
|
|
15
27
|
/**
|
|
16
28
|
* Start the overlay. Returns a disposer that removes every listener and the
|
|
17
29
|
* drawing layer. Calling it again while it runs updates the options and returns
|