@acms-subir/core 0.3.24 → 0.3.26

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.26
4
+
5
+ ### Patch Changes
6
+
7
+ - 89ecf3c: Apps can now follow a collection's old addresses. Each `features.blog` entry in the app API's `/api/v1/site` (and the client's `Site` type) gains `redirectFrom`: the absolute addresses the website 301s to that collection's `url`. It lists the collection's `redirectFrom` bases, plus `/blog` for `posts` once its base moved away from that default, so on a site whose posts live at `/sermon`, an app can open a `/blog` link as the sermons list instead of looking for a page called "blog". The list comes from the same place as the website's redirects, so the two can't drift apart. An address another collection lives at is left out, so a site with sermons at `/sermon` and its own blog at `/blog` keeps `/blog` for the blog.
8
+
9
+ ## 0.3.25
10
+
11
+ ### Patch Changes
12
+
13
+ - 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.
14
+
3
15
  ## 0.3.24
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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;
@@ -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;
@@ -33,6 +33,12 @@ export const collectionUrl = (key: string, slug?: string) => {
33
33
  return slug ? `${base}/${slug}` : base;
34
34
  };
35
35
 
36
+ // Old bases a collection 301s from (middleware.ts), also listed in the app
37
+ // API's site features so apps can follow them: its redirectFrom, plus '/blog'
38
+ // for the built-in collection (its historic default) once it moved away.
39
+ export const legacyBases = (key: string, c: CollectionConfig) =>
40
+ [...new Set([...(key === 'posts' ? ['/blog'] : []), ...c.redirectFrom])].filter((from) => from !== c.base);
41
+
36
42
  // Which collection a public URL belongs to — injected routes share one page
37
43
  // file per shape (blog/index.astro, blog/[slug].astro), and Astro's
38
44
  // injectRoute can't attach props, so pages resolve their collection from the
@@ -8,7 +8,7 @@ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str
8
8
  export const Site = z.object({
9
9
  name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
10
10
  languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
11
- features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool }), apiVersion: z.literal(1),
11
+ features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool }), apiVersion: z.literal(1),
12
12
  });
13
13
  export const Theme = z.object({
14
14
  id: str, name: str, scheme: z.enum(['auto', 'light', 'dark']), colors: z.object({ light: Palette, dark: Palette }),
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { ContentRepository } from '../repo/types';
3
- import { CONTENT } from '../content';
3
+ import { CONTENT, legacyBases } from '../content';
4
4
  import { I18N, localeName } from '../i18n';
5
5
  import { CALENDAR, bookingsEnabled } from '../calendar/config';
6
6
  import { SEARCH } from '../search-config';
@@ -15,6 +15,7 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
15
15
  z.object({}).strict().parse(queryParams(url));
16
16
  const [settings, nav, pages] = await Promise.all([repo.getSettings(), repo.listNavItems(), repo.listPages()]);
17
17
  const origin = siteOrigin(settings, url);
18
+ const bases = new Set(Object.values(CONTENT.collections).map(c => c.base));
18
19
  const header = nav.filter(n => n.area === 'header').sort((a, b) => a.order - b.order);
19
20
  const menu = header.length ? header : [
20
21
  { label: 'Home', url: '/' },
@@ -28,7 +29,9 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
28
29
  menu: menu.map(n => ({ label: n.label, href: safeUrl(n.url, origin, true) })),
29
30
  languages: { default: I18N.defaultLocale || null, locales: I18N.locales.map(code => ({ code, name: localeName(code, true) })) },
30
31
  features: {
31
- blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(), search: SEARCH.enabled,
32
+ // redirectFrom: old addresses the website 301s to url, so apps can follow them too,
33
+ // minus any another collection now lives at (the website serves that one first)
34
+ blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin), redirectFrom: legacyBases(key, c).filter(from => !bases.has(from)).map(from => safeUrl(from, origin)) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(), search: SEARCH.enabled,
32
35
  // Admin → Mobile app → App access
33
36
  bookingChanges: CALENDAR.enabled && settings.mobileAccess.bookingChanges, guestSignIn: await guestSignInReady(repo, locals),
34
37
  forms: settings.mobileAccess.forms, links: settings.mobileAccess.links,
package/src/middleware.ts CHANGED
@@ -1,11 +1,11 @@
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';
8
- import { CONTENT } from './lib/content';
8
+ import { CONTENT, legacyBases } from './lib/content';
9
9
  import { processDueDeliveries, waitUntilFrom } from './lib/webhooks';
10
10
  import { dispatchEmail } from './lib/email/service';
11
11
  import { decideOverdue, sendDueReminders } from './lib/booking/service';
@@ -28,10 +28,7 @@ const TAG_SOURCES = [
28
28
  ]),
29
29
  ];
30
30
  const COLLECTION_SOURCES: Array<{ from: string; to: string }> = Object.entries(CONTENT.collections).flatMap(
31
- ([key, c]) =>
32
- [...new Set([...(key === 'posts' ? ['/blog'] : []), ...c.redirectFrom])]
33
- .filter((from) => from !== c.base)
34
- .map((from) => ({ from, to: c.base }))
31
+ ([key, c]) => legacyBases(key, c).map((from) => ({ from, to: c.base }))
35
32
  );
36
33
 
37
34
  export function legacyContentRedirect(pathname: string): string | null {
@@ -47,6 +44,18 @@ export function legacyContentRedirect(pathname: string): string | null {
47
44
  return null;
48
45
  }
49
46
 
47
+ // An admin form sent after the minute-long Clerk sign-in cookie lapsed (a page
48
+ // whose script couldn't renew it in time): Clerk renews it only on a page GET,
49
+ // so the POST arrives signed out and what it carried is gone. Back to the same
50
+ // page, whose GET renews the cookie, flagged so the page can say nothing was
51
+ // saved (ClerkSession.astro), rather than through sign-in to the dashboard.
52
+ export function expiredPostReturn(auth: Pick<RequestAuth, 'expired'> | null, method: string, url: URL): string | null {
53
+ if (!auth?.expired || method !== 'POST') return null;
54
+ const back = new URL(url.pathname + url.search, url.origin);
55
+ back.searchParams.set('signin', 'expired');
56
+ return back.pathname + back.search;
57
+ }
58
+
50
59
  // Scheduled publishing fallback: on normal page traffic, at most once a
51
60
  // minute per isolate, flip due scheduled posts to published. On Workers the
52
61
  // cron trigger in src/worker.ts is the primary path; this sweep covers the
@@ -110,6 +119,8 @@ async function handle(context: APIContext, next: MiddlewareNext): Promise<Respon
110
119
  if (pathname.startsWith('/admin') && pathname !== '/admin/login' && pathname !== '/admin/logout') {
111
120
  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
121
  if (!(await validateRequestSession(context.request, context.cookies, context.locals))) {
122
+ const expired = expiredPostReturn(auth, context.request.method, context.url);
123
+ if (expired) return withClerkHeaders(context.redirect(expired, 303), auth, context.request);
113
124
  // signed in, but in a site role without admin access: their own home
114
125
  // page, never the admin (the editor-app sign-in explains on the login page)
115
126
  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" />