@acms-subir/core 0.3.23 → 0.3.25
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 +12 -0
- package/cli/preview.mjs +3 -1
- package/package.json +1 -1
- package/src/components/auth/ClerkSession.astro +16 -0
- package/src/components/editor/PostEditor.tsx +20 -9
- package/src/lib/auth/request.ts +5 -2
- package/src/middleware.ts +15 -1
- package/src/pages/admin/c/[collection]/[id].astro +2 -0
- package/src/pages/admin/calendar/events/[id].astro +8 -1
- package/src/pages/admin/pages/[id].astro +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.25
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c6a9129: With Clerk sign-in, saving in the full-screen editors no longer fails a minute after the page loads. Clerk's sign-in cookie lasts a minute, and the post, page and event editors never renewed it, so a change sent after that arrived signed out. Saving an event's hosts sent you through sign-in and dropped the change without a message, and autosave could show "Not saved — Not signed in." The editors now keep the sign-in fresh in the background like the other admin screens, and renew it right before saving hosts. If a save is still turned away because the sign-in lapsed, autosave renews it and tries once more, and a form brings you back to the same page with a note that the change wasn't saved, rather than to the dashboard.
|
|
8
|
+
|
|
9
|
+
## 0.3.24
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- fdab131: `acms check --build`, `acms theme check --build` and `acms theme dev` no longer break a running `npm run dev`. The isolated test project links the project's `node_modules`, so it used to share Vite's dependency cache (`node_modules/.vite`) and Astro's build cache (`node_modules/.astro`) with the dev server. Pre-bundling in the test project replaced the files the dev server was serving, and admin pages went blank with 504 "Outdated Optimize Dep" errors until the dev server restarted. The test project now keeps both caches in its own temporary folder under `.acms/previews/`.
|
|
14
|
+
|
|
3
15
|
## 0.3.23
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/cli/preview.mjs
CHANGED
|
@@ -15,7 +15,9 @@ export async function prepareFixture(p,dir,slug){
|
|
|
15
15
|
await mkdir(join(root,'src/pages/acms-theme-preview'),{recursive:true});await mkdir(join(root,'src/actions'),{recursive:true});
|
|
16
16
|
await writeFile(join(root,'src/actions/index.ts'),"export { server } from '@acms-subir/core/actions';\n");
|
|
17
17
|
await writeFile(join(root,'package.json'),JSON.stringify({name:'acms-isolated-preview',type:'module',private:true}));
|
|
18
|
-
|
|
18
|
+
// node_modules is the project's own, so the default caches (node_modules/.vite, node_modules/.astro) would be too:
|
|
19
|
+
// the fixture's dependency pre-bundling would replace the files a running `npm run dev` serves. Keep them in the fixture.
|
|
20
|
+
await writeFile(join(root,'astro.config.mjs'),`import {defineConfig,passthroughImageService} from 'astro/config';\nimport react from '@astrojs/react';\nimport node from '@astrojs/node';\nimport cloudflare from '@astrojs/cloudflare';\nimport acms from '@acms-subir/core';\nexport default defineConfig({output:'server',cacheDir:${JSON.stringify(join(root,'.astro-cache'))},vite:{cacheDir:${JSON.stringify(join(root,'.vite'))},resolve:{preserveSymlinks:true}},integrations:[react(),acms({seed:{siteTitle:'Theme preview — synthetic content'}})],adapter:process.env.DEPLOY_TARGET==='cloudflare'?cloudflare({platformProxy:{enabled:false},imageService:'passthrough'}):node({mode:'standalone'}),image:{service:passthroughImageService()}});\n`);
|
|
19
21
|
await writeFile(join(root,'src/pages/acms-theme-preview/setup.astro'),`---
|
|
20
22
|
import {getRepo} from ${JSON.stringify(join(CORE,'src/lib/repo/index.ts'))};
|
|
21
23
|
const repo=await getRepo(Astro.locals);
|
package/package.json
CHANGED
|
@@ -29,6 +29,22 @@ if (AUTH.provider === 'clerk') {
|
|
|
29
29
|
|
|
30
30
|
const meta = document.querySelector<HTMLMetaElement>('meta[name="acms-clerk"][data-keep-alive]');
|
|
31
31
|
if (meta) {
|
|
32
|
+
// a form that reached the server after the cookie lapsed comes back here
|
|
33
|
+
// (middleware.ts expiredPostReturn): say so, once, instead of losing it quietly
|
|
34
|
+
const here = new URL(location.href);
|
|
35
|
+
if (here.searchParams.get('signin') === 'expired') {
|
|
36
|
+
here.searchParams.delete('signin');
|
|
37
|
+
history.replaceState(history.state, '', here);
|
|
38
|
+
const note = document.createElement('p');
|
|
39
|
+
note.className = 'flash error';
|
|
40
|
+
note.setAttribute('role', 'alert');
|
|
41
|
+
note.title = 'Dismiss';
|
|
42
|
+
note.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:1000;width:max-content;max-width:calc(100vw - 24px);margin:0;cursor:pointer;box-shadow:0 6px 24px rgb(0 0 0 / 14%)';
|
|
43
|
+
note.textContent = 'Your sign-in had expired, so that change wasn’t saved. Please make it again.';
|
|
44
|
+
note.addEventListener('click', () => note.remove());
|
|
45
|
+
document.body.append(note);
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
const signedOutUrl = meta.dataset.signedOutUrl ?? '/admin/login';
|
|
33
49
|
const start = () => loadClerk().then((clerk) => {
|
|
34
50
|
// ended elsewhere (signed out in another tab, revoked by an owner):
|
|
@@ -36,6 +36,7 @@ import { EventSheet } from './event/EventSheet';
|
|
|
36
36
|
import { focusEventCard } from './event/Section';
|
|
37
37
|
import type { MapsStatus } from '../../lib/maps/types';
|
|
38
38
|
import { fetchRoomStatus, NO_HOLD, RoomConflictDialog } from '../calendar/RoomHolds';
|
|
39
|
+
import { refreshSession } from '../auth/clerk';
|
|
39
40
|
|
|
40
41
|
// The Ghost-style writing surface, shared by posts AND pages: distraction-free
|
|
41
42
|
// column, cards via "/", autosave, a collapsible settings sidebar, and an
|
|
@@ -47,6 +48,16 @@ import { fetchRoomStatus, NO_HOLD, RoomConflictDialog } from '../calendar/RoomHo
|
|
|
47
48
|
|
|
48
49
|
type SaveState = 'saved' | 'dirty' | 'saving' | 'error';
|
|
49
50
|
|
|
51
|
+
// Clerk's sign-in cookie lasts a minute and is renewed in the background
|
|
52
|
+
// (ClerkSession on the editor page). A write that still lands after it lapsed
|
|
53
|
+
// (a laptop waking up before the renewal ran) renews it and goes once more.
|
|
54
|
+
async function signedIn<T extends { error?: { code: string } }>(send: () => Promise<T>): Promise<T> {
|
|
55
|
+
const r = await send();
|
|
56
|
+
if (r.error?.code !== 'UNAUTHORIZED') return r;
|
|
57
|
+
await refreshSession();
|
|
58
|
+
return send();
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
interface Props {
|
|
51
62
|
kind?: 'post' | 'page' | 'event';
|
|
52
63
|
post?: Post;
|
|
@@ -395,7 +406,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
395
406
|
const bodyHtml = langRef.current && mainBody.current ? mainBody.current.html : ed.getHTML();
|
|
396
407
|
const translations = languages.length ? trsRef.current : undefined;
|
|
397
408
|
if (kind === 'event') {
|
|
398
|
-
const r = await actions.events.save({
|
|
409
|
+
const r = await signedIn(() => actions.events.save({
|
|
399
410
|
id: docId,
|
|
400
411
|
title: m.title,
|
|
401
412
|
slug: m.slug,
|
|
@@ -425,7 +436,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
425
436
|
roomHold: m.roomHold,
|
|
426
437
|
scheduledAt: m.scheduledAt,
|
|
427
438
|
translations,
|
|
428
|
-
});
|
|
439
|
+
}));
|
|
429
440
|
if (r.error) {
|
|
430
441
|
setSave('error');
|
|
431
442
|
setSaveError(r.error.message);
|
|
@@ -455,7 +466,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
455
466
|
return next;
|
|
456
467
|
});
|
|
457
468
|
} else if (kind === 'page') {
|
|
458
|
-
const r = await actions.pages.save({
|
|
469
|
+
const r = await signedIn(() => actions.pages.save({
|
|
459
470
|
id: docId,
|
|
460
471
|
title: m.title,
|
|
461
472
|
slug: m.slug,
|
|
@@ -468,7 +479,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
468
479
|
isHome: m.isHome,
|
|
469
480
|
locale: m.locale,
|
|
470
481
|
translations,
|
|
471
|
-
});
|
|
482
|
+
}));
|
|
472
483
|
if (r.error) {
|
|
473
484
|
setSave('error');
|
|
474
485
|
return;
|
|
@@ -483,7 +494,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
483
494
|
return next;
|
|
484
495
|
});
|
|
485
496
|
} else {
|
|
486
|
-
const r = await actions.posts.save({
|
|
497
|
+
const r = await signedIn(() => actions.posts.save({
|
|
487
498
|
id: docId,
|
|
488
499
|
title: m.title,
|
|
489
500
|
slug: m.slug,
|
|
@@ -503,7 +514,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
503
514
|
publishedAt: m.publishedAt,
|
|
504
515
|
scheduledAt: m.scheduledAt,
|
|
505
516
|
translations,
|
|
506
|
-
});
|
|
517
|
+
}));
|
|
507
518
|
if (r.error) {
|
|
508
519
|
setSave('error');
|
|
509
520
|
return;
|
|
@@ -808,10 +819,10 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
808
819
|
if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null; }
|
|
809
820
|
setSave('saved');
|
|
810
821
|
const r = kind === 'page'
|
|
811
|
-
? await actions.pages.discardChanges({ id: docId })
|
|
822
|
+
? await signedIn(() => actions.pages.discardChanges({ id: docId }))
|
|
812
823
|
: kind === 'event'
|
|
813
|
-
? await actions.events.discardChanges({ id: docId })
|
|
814
|
-
: await actions.posts.discardChanges({ id: docId });
|
|
824
|
+
? await signedIn(() => actions.events.discardChanges({ id: docId }))
|
|
825
|
+
: await signedIn(() => actions.posts.discardChanges({ id: docId }));
|
|
815
826
|
if (r.error) {
|
|
816
827
|
setSave('error');
|
|
817
828
|
return false;
|
package/src/lib/auth/request.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface RequestAuth {
|
|
|
16
16
|
headers: Headers;
|
|
17
17
|
error: 'configuration' | 'unavailable' | 'access_denied' | null;
|
|
18
18
|
reverified: boolean;
|
|
19
|
+
/** a Clerk session cookie that lapsed: Clerk renews it on a page GET, never on a POST */
|
|
20
|
+
expired: boolean;
|
|
19
21
|
}
|
|
20
22
|
const requests = new WeakMap<Request, Promise<RequestAuth>>();
|
|
21
23
|
const environments = new WeakMap<Request, unknown>();
|
|
@@ -81,7 +83,7 @@ export async function activeClerkSession(client: ClerkClient, binding: ClerkBind
|
|
|
81
83
|
return active;
|
|
82
84
|
}
|
|
83
85
|
export function getRequestAuth(request: Request, cookies: Cookies, localsOrRepo?: unknown): Promise<RequestAuth> {
|
|
84
|
-
if (AUTH.provider === 'builtin') return validatePerson(cookies.get(SESSION_COOKIE)?.value, localsOrRepo).then(person => ({ userId: person && isStaffRole(person.role) ? person.id : null, person, provider: 'builtin', clerk: null, headers: new Headers(), error: null, reverified: false }));
|
|
86
|
+
if (AUTH.provider === 'builtin') return validatePerson(cookies.get(SESSION_COOKIE)?.value, localsOrRepo).then(person => ({ userId: person && isStaffRole(person.role) ? person.id : null, person, provider: 'builtin', clerk: null, headers: new Headers(), error: null, reverified: false, expired: false }));
|
|
85
87
|
let cached = requests.get(request);
|
|
86
88
|
if (!cached) {
|
|
87
89
|
if (!(localsOrRepo && typeof localsOrRepo === 'object' && 'email' in localsOrRepo)) environments.set(request, localsOrRepo);
|
|
@@ -96,7 +98,7 @@ export async function validatePreviewSession(cookies: Cookies, locals?: unknown)
|
|
|
96
98
|
return locals && typeof locals === 'object' ? (await contexts.get(locals))?.userId ?? null : null;
|
|
97
99
|
}
|
|
98
100
|
async function resolveClerk(request: Request, localsOrRepo?: unknown): Promise<RequestAuth> {
|
|
99
|
-
const result: RequestAuth = { userId: null, person: null, provider: 'clerk', clerk: null, headers: new Headers(), error: null, reverified: false };
|
|
101
|
+
const result: RequestAuth = { userId: null, person: null, provider: 'clerk', clerk: null, headers: new Headers(), error: null, reverified: false, expired: false };
|
|
100
102
|
if (!shouldResolveClerk(request)) return result;
|
|
101
103
|
let config: ReturnType<typeof clerkConfig>;
|
|
102
104
|
try { config = clerkConfig(authEnvironment(request, localsOrRepo)); } catch { return { ...result, error: 'configuration' }; }
|
|
@@ -112,6 +114,7 @@ async function resolveClerk(request: Request, localsOrRepo?: unknown): Promise<R
|
|
|
112
114
|
if (authUrl.pathname.startsWith('/api/')) authUrl.search = '';
|
|
113
115
|
const state = await clerkOperation(() => client.authenticateRequest(new Request(authUrl, { method: request.method, headers }), { authorizedParties: config.authorizedParties, acceptsToken: 'session_token' }));
|
|
114
116
|
result.headers = state.headers;
|
|
117
|
+
result.expired = !state.isAuthenticated && String(state.reason ?? '').startsWith('session-token-expired');
|
|
115
118
|
const auth = state.toAuth();
|
|
116
119
|
if (!auth || !auth.userId || !auth.sessionId || !state.isAuthenticated) return result;
|
|
117
120
|
if (auth.sessionClaims.iss !== config.issuer || typeof auth.sessionClaims.azp !== 'string' || !config.authorizedParties.includes(auth.sessionClaims.azp)) return result;
|
package/src/middleware.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineMiddleware } from 'astro:middleware';
|
|
2
2
|
import type { APIContext, MiddlewareNext } from 'astro';
|
|
3
3
|
import { I18N } from './lib/i18n';
|
|
4
|
-
import { bindAuthEnvironment, getRequestAuth, requestPerson, shouldResolveClerk, validateRequestSession, withClerkHeaders } from './lib/auth/request';
|
|
4
|
+
import { bindAuthEnvironment, getRequestAuth, requestPerson, shouldResolveClerk, validateRequestSession, withClerkHeaders, type RequestAuth } from './lib/auth/request';
|
|
5
5
|
import { roleHome } from './lib/roles';
|
|
6
6
|
import { AUTH } from './lib/auth/config';
|
|
7
7
|
import { getRepo } from './lib/repo';
|
|
@@ -47,6 +47,18 @@ export function legacyContentRedirect(pathname: string): string | null {
|
|
|
47
47
|
return null;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// An admin form sent after the minute-long Clerk sign-in cookie lapsed (a page
|
|
51
|
+
// whose script couldn't renew it in time): Clerk renews it only on a page GET,
|
|
52
|
+
// so the POST arrives signed out and what it carried is gone. Back to the same
|
|
53
|
+
// page, whose GET renews the cookie, flagged so the page can say nothing was
|
|
54
|
+
// saved (ClerkSession.astro), rather than through sign-in to the dashboard.
|
|
55
|
+
export function expiredPostReturn(auth: Pick<RequestAuth, 'expired'> | null, method: string, url: URL): string | null {
|
|
56
|
+
if (!auth?.expired || method !== 'POST') return null;
|
|
57
|
+
const back = new URL(url.pathname + url.search, url.origin);
|
|
58
|
+
back.searchParams.set('signin', 'expired');
|
|
59
|
+
return back.pathname + back.search;
|
|
60
|
+
}
|
|
61
|
+
|
|
50
62
|
// Scheduled publishing fallback: on normal page traffic, at most once a
|
|
51
63
|
// minute per isolate, flip due scheduled posts to published. On Workers the
|
|
52
64
|
// cron trigger in src/worker.ts is the primary path; this sweep covers the
|
|
@@ -110,6 +122,8 @@ async function handle(context: APIContext, next: MiddlewareNext): Promise<Respon
|
|
|
110
122
|
if (pathname.startsWith('/admin') && pathname !== '/admin/login' && pathname !== '/admin/logout') {
|
|
111
123
|
if (auth?.error === 'configuration' || auth?.error === 'unavailable') return new Response('Sign-in is temporarily unavailable. Check the authentication configuration or try again later.', { status: 503, headers: { 'Cache-Control': 'no-store' } });
|
|
112
124
|
if (!(await validateRequestSession(context.request, context.cookies, context.locals))) {
|
|
125
|
+
const expired = expiredPostReturn(auth, context.request.method, context.url);
|
|
126
|
+
if (expired) return withClerkHeaders(context.redirect(expired, 303), auth, context.request);
|
|
113
127
|
// signed in, but in a site role without admin access: their own home
|
|
114
128
|
// page, never the admin (the editor-app sign-in explains on the login page)
|
|
115
129
|
const person = await requestPerson(context.request, context.cookies, context.locals);
|
|
@@ -10,6 +10,7 @@ import { BRAND } from '../../../../lib/brand';
|
|
|
10
10
|
import { CONTENT, collectionUrl } from '../../../../lib/content';
|
|
11
11
|
import { mergePostDraft } from '../../../../lib/drafts';
|
|
12
12
|
import ThemeScript from '../../../../components/ThemeScript.astro';
|
|
13
|
+
import ClerkSession from '../../../../components/auth/ClerkSession.astro';
|
|
13
14
|
import PostEditor from '../../../../components/editor/PostEditor';
|
|
14
15
|
import '../../../../styles/admin.css';
|
|
15
16
|
import '../../../../styles/editor.css';
|
|
@@ -51,6 +52,7 @@ const trDrafts = trLanguages.length > 0 && translationEngine((await repo.getSett
|
|
|
51
52
|
<meta charset="utf-8" />
|
|
52
53
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual" />
|
|
53
54
|
<ThemeScript />
|
|
55
|
+
<ClerkSession keepAlive />
|
|
54
56
|
<meta name="robots" content="noindex" />
|
|
55
57
|
<title>{post.title || 'Untitled'} · {BRAND.name} editor</title>
|
|
56
58
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
@@ -15,6 +15,7 @@ import type { Permission } from '../../../../lib/permissions/catalog';
|
|
|
15
15
|
import { ApiError } from '../../../../lib/api';
|
|
16
16
|
import { roleLabel } from '../../../../lib/roles';
|
|
17
17
|
import ThemeScript from '../../../../components/ThemeScript.astro';
|
|
18
|
+
import ClerkSession from '../../../../components/auth/ClerkSession.astro';
|
|
18
19
|
import Modal from '../../../../components/Modal.astro';
|
|
19
20
|
import PostEditor from '../../../../components/editor/PostEditor';
|
|
20
21
|
import '../../../../styles/admin.css';
|
|
@@ -108,6 +109,7 @@ const approveLabel = pending?.kind === 'changes' ? 'Approve changes' : isLive ?
|
|
|
108
109
|
<meta charset="utf-8" />
|
|
109
110
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual" />
|
|
110
111
|
<ThemeScript />
|
|
112
|
+
<ClerkSession keepAlive />
|
|
111
113
|
<meta name="robots" content="noindex" />
|
|
112
114
|
<title>{event.title || 'Untitled event'} · {BRAND.name} editor</title>
|
|
113
115
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
@@ -230,6 +232,8 @@ const approveLabel = pending?.kind === 'changes' ? 'Approve changes' : isLive ?
|
|
|
230
232
|
)}
|
|
231
233
|
|
|
232
234
|
<script>
|
|
235
|
+
import { refreshSession } from '../../../../components/auth/clerk';
|
|
236
|
+
|
|
233
237
|
const bar = document.getElementById('evreq');
|
|
234
238
|
// the editor's settings sidebar is fixed under its own top bar: move it below this bar too
|
|
235
239
|
if (bar) new ResizeObserver(() => document.body.style.setProperty('--evreq-h', `${bar.offsetHeight}px`)).observe(bar);
|
|
@@ -242,7 +246,9 @@ const approveLabel = pending?.kind === 'changes' ? 'Approve changes' : isLive ?
|
|
|
242
246
|
history.replaceState(history.state, '', url);
|
|
243
247
|
}
|
|
244
248
|
|
|
245
|
-
// let autosave finish before leaving, then skip the editor's leave prompt
|
|
249
|
+
// let autosave finish before leaving, then skip the editor's leave prompt.
|
|
250
|
+
// A straight submit renews the sign-in cookie first (ClerkSession); after
|
|
251
|
+
// waiting, form.submit() skips that listener, so renew it here.
|
|
246
252
|
const busy = () => document.querySelector('.pe-savestate.dirty, .pe-savestate.saving');
|
|
247
253
|
const w = window as unknown as { __evreqLeaving?: boolean };
|
|
248
254
|
document.addEventListener('submit', async (e) => {
|
|
@@ -253,6 +259,7 @@ const approveLabel = pending?.kind === 'changes' ? 'Approve changes' : isLive ?
|
|
|
253
259
|
e.preventDefault();
|
|
254
260
|
for (let i = 0; i < 50 && busy(); i++) await new Promise((r) => setTimeout(r, 120));
|
|
255
261
|
if (busy()) { alert('Your latest edits haven’t saved yet. Check the editor, then try again.'); return; }
|
|
262
|
+
await refreshSession();
|
|
256
263
|
w.__evreqLeaving = true;
|
|
257
264
|
form.submit();
|
|
258
265
|
});
|
|
@@ -11,6 +11,7 @@ import { mergePageDraft } from '../../../lib/drafts';
|
|
|
11
11
|
import { localeHomeUrl } from '../../../lib/i18n';
|
|
12
12
|
import { homeSource } from '../../../lib/home-source';
|
|
13
13
|
import ThemeScript from '../../../components/ThemeScript.astro';
|
|
14
|
+
import ClerkSession from '../../../components/auth/ClerkSession.astro';
|
|
14
15
|
import PostEditor from '../../../components/editor/PostEditor';
|
|
15
16
|
import '../../../styles/admin.css';
|
|
16
17
|
import '../../../styles/editor.css';
|
|
@@ -47,6 +48,7 @@ const trDrafts = trLanguages.length > 0 && translationEngine((await repo.getSett
|
|
|
47
48
|
<meta charset="utf-8" />
|
|
48
49
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual" />
|
|
49
50
|
<ThemeScript />
|
|
51
|
+
<ClerkSession keepAlive />
|
|
50
52
|
<meta name="robots" content="noindex" />
|
|
51
53
|
<title>{page.title || 'Untitled'} · {BRAND.name} editor</title>
|
|
52
54
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|