@raketa-cloud/admin 1.10.7 → 1.10.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/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@raketa-cloud/admin",
3
- "version": "1.10.7",
3
+ "version": "1.10.9",
4
4
  "main": "src/index.js",
5
+ "engines": {
6
+ "node": ">=22"
7
+ },
5
8
  "files": [
6
9
  "src/",
7
10
  "dist/"
@@ -26,7 +29,7 @@
26
29
  "@tailwindcss/cli": "4.3.3",
27
30
  "@tailwindcss/forms": "0.5.11",
28
31
  "@tailwindcss/typography": "0.5.20",
29
- "@types/node": "24.10.1",
32
+ "@types/node": "22.20.5",
30
33
  "babel-jest": "30.5.2",
31
34
  "jest": "30.5.2",
32
35
  "jest-environment-node": "30.5.2",
@@ -37,8 +40,9 @@
37
40
  "@hono/node-server": "2.0.12",
38
41
  "chart.js": "4.5.1",
39
42
  "ejs": "6.0.1",
43
+ "google-auth-library": "11.3.1",
40
44
  "graphql-request": "7.4.0",
41
- "hono": "4.13.7",
45
+ "hono": "4.13.13",
42
46
  "lru-cache": "11.5.2"
43
47
  },
44
48
  "overrides": {
@@ -22,6 +22,39 @@ const targetTranslationLocales = (resource, sourceLocale) =>
22
22
  )
23
23
  .map(([locale]) => locale);
24
24
 
25
+ const localesAtOpenRevisionLimit = (resource, sourceLocale, maxOpenRevisions) =>
26
+ Object.entries(resource.translations || {})
27
+ .filter(
28
+ ([locale, translation]) =>
29
+ locale !== sourceLocale &&
30
+ translation?.id &&
31
+ translation.revisionsCount >= maxOpenRevisions,
32
+ )
33
+ .map(([locale]) => locale);
34
+
35
+ const withTranslationRevisionCounts = async (ctx, resource, resourceType) => {
36
+ const revisionCounts = await Promise.all(
37
+ Object.entries(resource.translations || {}).map(
38
+ async ([locale, translation]) => {
39
+ if (locale === ctx.locale || !translation?.id) {
40
+ return [locale, translation];
41
+ }
42
+
43
+ const target = await ctx.client.resources.find(
44
+ translation.id,
45
+ locale,
46
+ resourceType,
47
+ );
48
+ return [
49
+ locale,
50
+ { ...translation, revisionsCount: target.revisionsCount },
51
+ ];
52
+ },
53
+ ),
54
+ );
55
+
56
+ return { ...resource, translations: Object.fromEntries(revisionCounts) };
57
+ };
25
58
  const selectedWidgetIds = (resource, query = {}) => {
26
59
  const requested = Array.isArray(query.widget_ids)
27
60
  ? query.widget_ids
@@ -73,6 +106,7 @@ const validatePartialTranslation = ({
73
106
  workflow,
74
107
  unknownWidgetIds,
75
108
  unknownLocales,
109
+ localesAtRevisionLimit,
76
110
  }) => {
77
111
  const errors = [];
78
112
 
@@ -91,6 +125,12 @@ const validatePartialTranslation = ({
91
125
  message: `not found in the resource translations: ${unknownLocales.join(", ")}`,
92
126
  });
93
127
  }
128
+ if (localesAtRevisionLimit.length > 0) {
129
+ errors.push({
130
+ key: "locales",
131
+ message: `open revision limit reached for: ${localesAtRevisionLimit.join(", ")}`,
132
+ });
133
+ }
94
134
  if (!workflow.type) {
95
135
  errors.push({ key: "workflow", message: "can't be blank" });
96
136
  }
@@ -131,14 +171,19 @@ const create_form = async (ctx) => {
131
171
  throw new Error(`Cannot find ${resourceType} with id ${parent_id}`);
132
172
  }
133
173
 
134
- const widgetIds = selectedWidgetIds(resource, ctx.query);
174
+ const resourceWithTranslationRevisionCounts =
175
+ await withTranslationRevisionCounts(ctx, resource, resourceType);
176
+ const widgetIds = selectedWidgetIds(
177
+ resourceWithTranslationRevisionCounts,
178
+ ctx.query,
179
+ );
135
180
  const { validWidgetIds, unknownWidgetIds } = partitionWidgetIds(
136
- resource,
181
+ resourceWithTranslationRevisionCounts,
137
182
  widgetIds,
138
183
  );
139
184
 
140
185
  const previewUrl = ctx.client.resources.previewUrl(
141
- { ...resource, resourceType },
186
+ { ...resourceWithTranslationRevisionCounts, resourceType },
142
187
  ctx.locale,
143
188
  );
144
189
  const validationErrors = [];
@@ -170,7 +215,7 @@ const create_form = async (ctx) => {
170
215
  if (validationErrors.length > 0) {
171
216
  return {
172
217
  assigns: formAssigns(
173
- resource,
218
+ resourceWithTranslationRevisionCounts,
174
219
  ctx.query,
175
220
  previewUrl,
176
221
  { widgetIds: validWidgetIds },
@@ -182,7 +227,13 @@ const create_form = async (ctx) => {
182
227
  };
183
228
  }
184
229
 
185
- return { assigns: formAssigns(resource, ctx.query, previewUrl) };
230
+ return {
231
+ assigns: formAssigns(
232
+ resourceWithTranslationRevisionCounts,
233
+ ctx.query,
234
+ previewUrl,
235
+ ),
236
+ };
186
237
  };
187
238
 
188
239
  const create = async (ctx) => {
@@ -198,8 +249,10 @@ const create = async (ctx) => {
198
249
  throw new Error(`Cannot find ${resourceType} with id ${parent_id}`);
199
250
  }
200
251
 
252
+ const resourceWithTranslationRevisionCounts =
253
+ await withTranslationRevisionCounts(ctx, resource, resourceType);
201
254
  const previewUrl = ctx.client.resources.previewUrl(
202
- { ...resource, resourceType },
255
+ { ...resourceWithTranslationRevisionCounts, resourceType },
203
256
  ctx.locale,
204
257
  );
205
258
  const values = ctx.resourceParams?.partial_translation || {};
@@ -210,30 +263,42 @@ const create = async (ctx) => {
210
263
  : toArray(values.widget_ids);
211
264
  const workflow = values.workflow || {};
212
265
  const { validWidgetIds, unknownWidgetIds } = partitionWidgetIds(
213
- resource,
266
+ resourceWithTranslationRevisionCounts,
214
267
  widgetIds,
215
268
  );
216
- const validLocales = targetTranslationLocales(resource, ctx.locale);
269
+ const validLocales = targetTranslationLocales(
270
+ resourceWithTranslationRevisionCounts,
271
+ ctx.locale,
272
+ );
217
273
  const unknownLocales = locales.filter(
218
274
  (locale) => !validLocales.includes(locale),
219
275
  );
220
- const selectedValidLocales = locales.filter((locale) =>
221
- validLocales.includes(locale),
276
+ const localesAtRevisionLimit = localesAtOpenRevisionLimit(
277
+ resourceWithTranslationRevisionCounts,
278
+ ctx.locale,
279
+ ctx.maxOpenRevisions,
280
+ );
281
+ const selectedValidLocales = locales.filter(
282
+ (locale) =>
283
+ validLocales.includes(locale) && !localesAtRevisionLimit.includes(locale),
222
284
  );
223
285
 
224
286
  const validationErrors = validatePartialTranslation({
225
- resource,
287
+ resource: resourceWithTranslationRevisionCounts,
226
288
  locales,
227
289
  widgetIds,
228
290
  workflow,
229
291
  unknownWidgetIds,
230
292
  unknownLocales,
293
+ localesAtRevisionLimit: locales.filter((locale) =>
294
+ localesAtRevisionLimit.includes(locale),
295
+ ),
231
296
  });
232
297
 
233
298
  if (validationErrors.length > 0) {
234
299
  return {
235
300
  assigns: formAssigns(
236
- resource,
301
+ resourceWithTranslationRevisionCounts,
237
302
  ctx.query,
238
303
  previewUrl,
239
304
  {
@@ -250,21 +315,25 @@ const create = async (ctx) => {
250
315
  };
251
316
  }
252
317
 
253
- const widgets = (resource.widgets || []).filter((widget) =>
254
- validWidgetIds.includes(widget.widgetId),
318
+ const widgets = (resourceWithTranslationRevisionCounts.widgets || []).filter(
319
+ (widget) => validWidgetIds.includes(widget.widgetId),
255
320
  );
256
321
  const { errors } = await ctx.client.resources.multiPartialTranslate(
257
322
  parent_id,
258
323
  locales,
259
324
  widgets,
260
325
  workflow,
261
- { ...resource, locale: ctx.locale, resourceType },
326
+ {
327
+ ...resourceWithTranslationRevisionCounts,
328
+ locale: ctx.locale,
329
+ resourceType,
330
+ },
262
331
  );
263
332
 
264
333
  if (errors) {
265
334
  return {
266
335
  assigns: formAssigns(
267
- resource,
336
+ resourceWithTranslationRevisionCounts,
268
337
  ctx.query,
269
338
  previewUrl,
270
339
  { ...values, locales, widgetIds: validWidgetIds, workflow },
@@ -5,9 +5,15 @@
5
5
  // with no human in the loop — sign a JWT with it, swap that for an access token.
6
6
  // Nothing is persisted, so a restart costs one token exchange.
7
7
  //
8
- // The key's client_email needs Viewer access on the GA property.
8
+ // Without a key, config.ga.useAdc falls back to Application Default Credentials
9
+ // via google-auth-library — on Cloud Run / GCE / GKE that is the attached
10
+ // service account through the metadata server. A key, when set, always wins.
11
+ //
12
+ // The key's client_email (or the ADC principal) needs Viewer access on the GA
13
+ // property.
9
14
  import { createSign } from "node:crypto";
10
15
  import { readFileSync } from "node:fs";
16
+ import { GoogleAuth } from "google-auth-library";
11
17
 
12
18
  const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
13
19
  const JWT_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
@@ -24,12 +30,16 @@ const keyCache = new Map();
24
30
  const tokenCache = new Map();
25
31
  const pendingTokens = new Map();
26
32
 
33
+ // Single ADC client: it owns its own token cache and refresh.
34
+ let adcAuth = null;
35
+
27
36
  const credentialSource = (ga = {}) => ga.serviceAccount;
37
+ const adcEnabled = (ga = {}) => Boolean(ga.useAdc);
28
38
 
29
39
  // True when a credential is configured — gates the Insights tab, so an
30
40
  // unconfigured admin sees no GA UI at all.
31
41
  export const serviceAccountConfigured = (ga = {}) =>
32
- Boolean(credentialSource(ga));
42
+ Boolean(credentialSource(ga)) || adcEnabled(ga);
33
43
 
34
44
  // GOOGLE_APPLICATION_CREDENTIALS now holds the whole key JSON rather than a path
35
45
  // to it, so a source can be: an already-parsed object, the JSON itself, that
@@ -88,7 +98,7 @@ function serviceAccountKey(ga = {}) {
88
98
 
89
99
  if (!source) {
90
100
  throw new Error(
91
- "Google service-account key is not set. Put the key JSON in GOOGLE_APPLICATION_CREDENTIALS (surfaced via config.ga.serviceAccount).",
101
+ "Google service-account key is not set. Put the key JSON in GOOGLE_APPLICATION_CREDENTIALS (surfaced via config.ga.serviceAccount), or enable Application Default Credentials via config.ga.useAdc.",
92
102
  );
93
103
  }
94
104
 
@@ -139,6 +149,8 @@ function signAssertion(key) {
139
149
  // they would each mint their own token. The in-flight exchange is shared: the
140
150
  // first caller starts it, the rest await the same promise.
141
151
  export async function accessToken(ga) {
152
+ if (!credentialSource(ga) && adcEnabled(ga)) return adcAccessToken();
153
+
142
154
  const key = serviceAccountKey(ga);
143
155
 
144
156
  const cached = tokenCache.get(key.client_email);
@@ -190,9 +202,27 @@ async function requestToken(key) {
190
202
  return json.access_token;
191
203
  }
192
204
 
205
+ async function adcAccessToken() {
206
+ adcAuth ??= new GoogleAuth({ scopes: [SCOPE] });
207
+
208
+ let token;
209
+ try {
210
+ token = await adcAuth.getAccessToken();
211
+ } catch (err) {
212
+ throw new Error(`Google ADC token request failed: ${err.message}`);
213
+ }
214
+
215
+ if (!token) {
216
+ throw new Error("Google ADC token response contained no access_token.");
217
+ }
218
+
219
+ return token;
220
+ }
221
+
193
222
  // Test / credential-rotation helper.
194
223
  export function clearAuthCache() {
195
224
  keyCache.clear();
196
225
  tokenCache.clear();
197
226
  pendingTokens.clear();
227
+ adcAuth = null;
198
228
  }
@@ -2,7 +2,7 @@
2
2
  // Quick Insights for a single page path: summary KPIs with period-over-period
3
3
  // trend plus a daily page-views series, both over the last 30 days.
4
4
  //
5
- // `ga` below is the whole config.ga object — property id plus the key path the
5
+ // `ga` below is the whole config.ga object — property id plus the credentials the
6
6
  // bearer token is minted from (see ./googleAuth).
7
7
  import localCache from "../../localCache";
8
8
  import { accessToken } from "./googleAuth";
@@ -135,7 +135,7 @@ async function fetchQuickInsights(ctx, resource, resourceType) {
135
135
  try {
136
136
  if (!gaEnabled(ctx)) {
137
137
  throw new Error(
138
- "Google Analytics is not configured. Set config.ga.propertyId and config.ga.serviceAccount (GOOGLE_APPLICATION_CREDENTIALS) to enable reports.",
138
+ "Google Analytics is not configured. Set config.ga.propertyId plus config.ga.serviceAccount (GOOGLE_APPLICATION_CREDENTIALS) or config.ga.useAdc to enable reports.",
139
139
  );
140
140
  }
141
141
  const pagePath = gaPagePath(ctx, resource, resourceType);
@@ -24,6 +24,7 @@ const buildCtx = (params = {}) => ({
24
24
  params: { parent_id: "42" },
25
25
  query: {},
26
26
  locale: "en",
27
+ maxOpenRevisions: 1,
27
28
  resourceConfig: { resourceType: "page", resourceTypePlural: "pages" },
28
29
  resourceParams: params,
29
30
  });
@@ -194,6 +195,30 @@ describe("PartialTranslationsHandler", () => {
194
195
  });
195
196
  });
196
197
 
198
+ test("does not submit to locales at the open revision limit", async () => {
199
+ const ctx = buildCtx({
200
+ partial_translation: {
201
+ locales: ["fr"],
202
+ widget_ids: ["hero-1"],
203
+ workflow: { type: "LOKALISE_TT_AI" },
204
+ },
205
+ });
206
+ ctx.client.resources.find
207
+ .mockResolvedValueOnce({
208
+ ...resource,
209
+ translations: { fr: { id: "43" } },
210
+ })
211
+ .mockResolvedValueOnce({ id: "43", revisionsCount: 1 });
212
+
213
+ const result = await PartialTranslationsHandler.create(ctx);
214
+
215
+ expect(ctx.client.resources.multiPartialTranslate).not.toHaveBeenCalled();
216
+ expect(result.assigns.partialTranslation.locales).toEqual([]);
217
+ expect(result.assigns.errors.partialTranslation).toEqual({
218
+ locales: "open revision limit reached for: fr",
219
+ });
220
+ });
221
+
197
222
  test("does not submit when a translation has an active content task", async () => {
198
223
  const ctx = buildCtx({
199
224
  partial_translation: {
@@ -3,6 +3,7 @@ import { mkdtempSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
+ import { GoogleAuth } from "google-auth-library";
6
7
  import {
7
8
  clearAuthCache,
8
9
  serviceAccountConfigured,
@@ -14,6 +15,14 @@ import {
14
15
  } from "../../../helpers/reports/quick-insights";
15
16
  import localCache from "../../../localCache";
16
17
 
18
+ // Only the ADC path goes through google-auth-library; the key path signs its
19
+ // own JWT against the mocked fetch below, so it never touches this.
20
+ const mockGetAccessToken = jest.fn();
21
+
22
+ jest.mock("google-auth-library", () => ({
23
+ GoogleAuth: jest.fn(() => ({ getAccessToken: mockGetAccessToken })),
24
+ }));
25
+
17
26
  // One throwaway RSA key. The app passes the whole key JSON through
18
27
  // ga.serviceAccount, so that is the form used here; the same key is also written
19
28
  // to a temp file, since that config value also accepts a path.
@@ -280,6 +289,98 @@ describe("quick-insights auth", () => {
280
289
  });
281
290
  });
282
291
 
292
+ describe("quick-insights ADC auth", () => {
293
+ const ADC_GA = { propertyId: "1234", useAdc: true };
294
+
295
+ const ARGS = {
296
+ pagePath: "/pricing",
297
+ startDate: "2026-07-01",
298
+ endDate: "2026-07-01",
299
+ ga: ADC_GA,
300
+ };
301
+
302
+ beforeEach(() => {
303
+ clearAuthCache();
304
+ jest.clearAllMocks();
305
+ mockGetAccessToken.mockResolvedValue("adc-token");
306
+ });
307
+
308
+ afterEach(() => {
309
+ delete global.fetch;
310
+ });
311
+
312
+ test("serviceAccountConfigured accepts ADC in place of a key", () => {
313
+ expect(serviceAccountConfigured({ useAdc: true })).toBe(true);
314
+ expect(serviceAccountConfigured({ useAdc: false })).toBe(false);
315
+ });
316
+
317
+ test("calls the Data API with the ADC token, no JWT exchange", async () => {
318
+ mockGoogle();
319
+
320
+ const result = await runPageReport(ARGS);
321
+
322
+ expect(GoogleAuth).toHaveBeenCalledWith({
323
+ scopes: ["https://www.googleapis.com/auth/analytics.readonly"],
324
+ });
325
+ expect(global.fetch).toHaveBeenCalledTimes(1);
326
+
327
+ const [reportUrl, reportOptions] = global.fetch.mock.calls[0];
328
+ expect(reportUrl).toBe(
329
+ "https://analyticsdata.googleapis.com/v1beta/properties/1234:runReport",
330
+ );
331
+ expect(reportOptions.headers.authorization).toBe("Bearer adc-token");
332
+ expect(result.rows).toEqual([{ date: "2026-07-01", views: 4 }]);
333
+ });
334
+
335
+ test("a configured key wins over ADC", async () => {
336
+ mockGoogle();
337
+
338
+ await runPageReport({
339
+ ...ARGS,
340
+ ga: { ...ADC_GA, serviceAccount: keyJson },
341
+ });
342
+
343
+ expect(GoogleAuth).not.toHaveBeenCalled();
344
+ expect(global.fetch.mock.calls[0][0]).toBe(
345
+ "https://oauth2.googleapis.com/token",
346
+ );
347
+ expect(global.fetch.mock.calls[1][1].headers.authorization).toBe(
348
+ "Bearer at-1",
349
+ );
350
+ });
351
+
352
+ test("reuses one ADC client until the cache is cleared", async () => {
353
+ mockGoogle();
354
+
355
+ await runPageReport(ARGS);
356
+ await runPageReport(ARGS);
357
+ expect(GoogleAuth).toHaveBeenCalledTimes(1);
358
+ expect(mockGetAccessToken).toHaveBeenCalledTimes(2);
359
+
360
+ clearAuthCache();
361
+ await runPageReport(ARGS);
362
+ expect(GoogleAuth).toHaveBeenCalledTimes(2);
363
+ });
364
+
365
+ test("surfaces ADC token failures", async () => {
366
+ mockGoogle();
367
+
368
+ mockGetAccessToken.mockRejectedValueOnce(
369
+ new Error("Could not load the default credentials"),
370
+ );
371
+ await expect(runPageReport(ARGS)).rejects.toThrow(
372
+ /Google ADC token request failed: Could not load the default credentials/,
373
+ );
374
+
375
+ mockGetAccessToken.mockResolvedValueOnce(null);
376
+ await expect(runPageReport(ARGS)).rejects.toThrow(
377
+ /Google ADC token response contained no access_token/,
378
+ );
379
+
380
+ expect(global.fetch).not.toHaveBeenCalled();
381
+ });
382
+ });
383
+
283
384
  describe("runSummary", () => {
284
385
  const metrics = (views, users, engagementSeconds, rate, sessions) => ({
285
386
  metricValues: [views, users, engagementSeconds, rate, sessions].map(
@@ -10,6 +10,7 @@ function renderPartialTranslations(translations) {
10
10
  return ejs.render(template, {
11
11
  _context: {
12
12
  locale: "en",
13
+ maxOpenRevisions: 1,
13
14
  locales: { en: { name: "English" }, fr: { name: "French" } },
14
15
  resourceConfig: { resourceTypePlural: "pages" },
15
16
  },
@@ -47,4 +48,14 @@ describe("partial translations form", () => {
47
48
  expect(rendered).not.toContain('value="de"');
48
49
  expect(rendered).not.toContain("This resource has no translations.");
49
50
  });
51
+
52
+ test("disables translations at the open revision limit", () => {
53
+ const rendered = renderPartialTranslations({
54
+ fr: { id: "43", revisionsCount: 1 },
55
+ });
56
+
57
+ expect(rendered).toContain('value="fr"');
58
+ expect(rendered).toContain('value="fr" disabled');
59
+ expect(rendered).toContain("open revision limit reached");
60
+ });
50
61
  });
@@ -9,7 +9,7 @@
9
9
 
10
10
  <div class="flex gap-4 w-full pb-4">
11
11
  <div class="flex items-center justify-between mb-4">
12
- <div class="flex items-center border-b-2">
12
+ <div class="flex items-center border-b-2 border-neutral-200 dark:border-neutral-700">
13
13
  <div class=" <%= active === 'general' ? activeClass : inactiveClass %>">
14
14
  <a href="<%= `/${_context.locale}/${_context.resourceConfig.resourceType}_revisions/${resource.id}/general/edit` %>" class="text-raketa-primary font-semibold">
15
15
  General
@@ -1,4 +1,4 @@
1
- <div class="actions-toolbar justify-end">
1
+ <div class="justify-end actions-toolbar">
2
2
  <form method="POST" action="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/${_context.resourceConfig.resourceType}_changes/reverts` %>" class="inline-block">
3
3
  <input type="hidden" name="version" value="<%= change.version %>">
4
4
 
@@ -7,7 +7,7 @@
7
7
  Revert
8
8
  </button>
9
9
  <% } else { %>
10
- <button type="submit" disabled class="btn-text opacity-50 cursor-not-allowed">
10
+ <button type="submit" disabled class="opacity-50 cursor-not-allowed btn-text">
11
11
  Revert
12
12
  </button>
13
13
  <% } %>
@@ -11,10 +11,11 @@
11
11
  <div class="mb-4">
12
12
  <h6>Current translations</h6>
13
13
  <div class="checkbox-group !max-h-fit">
14
- <% targetTranslations.forEach(([locale]) => { %>
15
- <label>
16
- <input type="checkbox" name="partial_translation[locales][]" value="<%= locale %>" <%= partialTranslation.locales.includes(locale) ? 'checked' : '' %>>
14
+ <% targetTranslations.forEach(([locale, translation]) => { const atRevisionLimit = translation.revisionsCount >= _context.maxOpenRevisions; %>
15
+ <label class="<%= atRevisionLimit ? 'opacity-50' : '' %>">
16
+ <input type="checkbox" name="partial_translation[locales][]" value="<%= locale %>" <%= partialTranslation.locales.includes(locale) ? 'checked' : '' %> <%= atRevisionLimit ? 'disabled' : '' %>>
17
17
  <%= _context.locales[locale]?.name || locale %>
18
+ <% if (atRevisionLimit) { %><span class="text-sm text-raketa-foreground/50">(open revision limit reached)</span><% } %>
18
19
  </label>
19
20
  <% }) %>
20
21
  </div>
@@ -5,7 +5,7 @@
5
5
 
6
6
  <div class="flex w-full pb-4">
7
7
  <div class="flex items-center justify-between mb-4">
8
- <div class="flex items-center border-b-2">
8
+ <div class="flex items-center border-b-2 border-neutral-200 dark:border-neutral-700">
9
9
  <div class="<%= active === 'meta' ? activeClass : inactiveClass %>">
10
10
  <a href="<%= `/${_context.locale}/settings/meta/edit` %>" class="text-raketa-primary font-semibold">
11
11
  Meta and OpenGraph