@rigel-cms/rigel 1.0.0 → 1.2.0

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.
Files changed (159) hide show
  1. package/README.md +8 -0
  2. package/docs/content-locales.md +53 -0
  3. package/docs/extensions.md +194 -0
  4. package/package.json +16 -3
  5. package/src/article-body.ts +49 -37
  6. package/src/article-render.ts +25 -2
  7. package/src/article.ts +44 -0
  8. package/src/config.ts +26 -2
  9. package/src/editor/App.svelte +149 -107
  10. package/src/editor/blocks/extension.ts +29 -0
  11. package/src/editor/blocks/index.ts +17 -15
  12. package/src/editor/blocks/video.ts +2 -1
  13. package/src/editor/i18n/README.md +23 -0
  14. package/src/editor/i18n/de/connection.json +136 -0
  15. package/src/editor/i18n/de/defaults.json +6 -0
  16. package/src/editor/i18n/de/fields.json +130 -0
  17. package/src/editor/i18n/de/local.json +49 -0
  18. package/src/editor/i18n/de/media.json +166 -0
  19. package/src/editor/i18n/de/richtext.json +116 -0
  20. package/src/editor/i18n/de/shell.json +182 -0
  21. package/src/editor/i18n/de/system.json +14 -0
  22. package/src/editor/i18n/de/validation.json +82 -0
  23. package/src/editor/i18n/en/connection.json +136 -0
  24. package/src/editor/i18n/en/defaults.json +6 -0
  25. package/src/editor/i18n/en/fields.json +130 -0
  26. package/src/editor/i18n/en/local.json +49 -0
  27. package/src/editor/i18n/en/media.json +166 -0
  28. package/src/editor/i18n/en/richtext.json +116 -0
  29. package/src/editor/i18n/en/shell.json +182 -0
  30. package/src/editor/i18n/en/system.json +14 -0
  31. package/src/editor/i18n/en/validation.json +82 -0
  32. package/src/editor/i18n/es/connection.json +136 -0
  33. package/src/editor/i18n/es/defaults.json +6 -0
  34. package/src/editor/i18n/es/fields.json +130 -0
  35. package/src/editor/i18n/es/local.json +49 -0
  36. package/src/editor/i18n/es/media.json +166 -0
  37. package/src/editor/i18n/es/richtext.json +116 -0
  38. package/src/editor/i18n/es/shell.json +182 -0
  39. package/src/editor/i18n/es/system.json +14 -0
  40. package/src/editor/i18n/es/validation.json +82 -0
  41. package/src/editor/i18n/format.ts +26 -0
  42. package/src/editor/i18n/fr/connection.json +136 -0
  43. package/src/editor/i18n/fr/defaults.json +6 -0
  44. package/src/editor/i18n/fr/fields.json +130 -0
  45. package/src/editor/i18n/fr/local.json +49 -0
  46. package/src/editor/i18n/fr/media.json +166 -0
  47. package/src/editor/i18n/fr/richtext.json +116 -0
  48. package/src/editor/i18n/fr/shell.json +182 -0
  49. package/src/editor/i18n/fr/system.json +14 -0
  50. package/src/editor/i18n/fr/validation.json +82 -0
  51. package/src/editor/i18n/index.ts +18 -0
  52. package/src/editor/i18n/it/connection.json +136 -0
  53. package/src/editor/i18n/it/defaults.json +6 -0
  54. package/src/editor/i18n/it/fields.json +130 -0
  55. package/src/editor/i18n/it/local.json +49 -0
  56. package/src/editor/i18n/it/media.json +166 -0
  57. package/src/editor/i18n/it/richtext.json +116 -0
  58. package/src/editor/i18n/it/shell.json +182 -0
  59. package/src/editor/i18n/it/system.json +14 -0
  60. package/src/editor/i18n/it/validation.json +82 -0
  61. package/src/editor/i18n/ja/connection.json +136 -0
  62. package/src/editor/i18n/ja/defaults.json +6 -0
  63. package/src/editor/i18n/ja/fields.json +130 -0
  64. package/src/editor/i18n/ja/local.json +49 -0
  65. package/src/editor/i18n/ja/media.json +166 -0
  66. package/src/editor/i18n/ja/richtext.json +116 -0
  67. package/src/editor/i18n/ja/shell.json +182 -0
  68. package/src/editor/i18n/ja/system.json +14 -0
  69. package/src/editor/i18n/ja/validation.json +82 -0
  70. package/src/editor/i18n/locales.ts +13 -0
  71. package/src/editor/i18n/nl/connection.json +136 -0
  72. package/src/editor/i18n/nl/defaults.json +6 -0
  73. package/src/editor/i18n/nl/fields.json +130 -0
  74. package/src/editor/i18n/nl/local.json +49 -0
  75. package/src/editor/i18n/nl/media.json +166 -0
  76. package/src/editor/i18n/nl/richtext.json +116 -0
  77. package/src/editor/i18n/nl/shell.json +182 -0
  78. package/src/editor/i18n/nl/system.json +14 -0
  79. package/src/editor/i18n/nl/validation.json +82 -0
  80. package/src/editor/i18n/ru/connection.json +138 -0
  81. package/src/editor/i18n/ru/defaults.json +6 -0
  82. package/src/editor/i18n/ru/fields.json +132 -0
  83. package/src/editor/i18n/ru/local.json +49 -0
  84. package/src/editor/i18n/ru/media.json +166 -0
  85. package/src/editor/i18n/ru/richtext.json +116 -0
  86. package/src/editor/i18n/ru/shell.json +182 -0
  87. package/src/editor/i18n/ru/system.json +14 -0
  88. package/src/editor/i18n/ru/validation.json +82 -0
  89. package/src/editor/i18n/server.ts +13 -0
  90. package/src/editor/i18n/zh/connection.json +136 -0
  91. package/src/editor/i18n/zh/defaults.json +6 -0
  92. package/src/editor/i18n/zh/fields.json +130 -0
  93. package/src/editor/i18n/zh/local.json +49 -0
  94. package/src/editor/i18n/zh/media.json +166 -0
  95. package/src/editor/i18n/zh/richtext.json +116 -0
  96. package/src/editor/i18n/zh/shell.json +182 -0
  97. package/src/editor/i18n/zh/system.json +14 -0
  98. package/src/editor/i18n/zh/validation.json +82 -0
  99. package/src/editor/lib/ActionDialog.svelte +5 -4
  100. package/src/editor/lib/BlockEditor.svelte +305 -88
  101. package/src/editor/lib/CollectionPanel.svelte +17 -15
  102. package/src/editor/lib/DatePicker.svelte +15 -12
  103. package/src/editor/lib/ExtensionMount.svelte +12 -0
  104. package/src/editor/lib/Field.svelte +3 -2
  105. package/src/editor/lib/ImagePicker.svelte +82 -81
  106. package/src/editor/lib/MarkdownEditor.svelte +25 -24
  107. package/src/editor/lib/MediaPicker.svelte +6 -5
  108. package/src/editor/lib/MenuEditor.svelte +31 -30
  109. package/src/editor/lib/PreviewFrame.svelte +11 -6
  110. package/src/editor/lib/PropertiesTabs.svelte +3 -2
  111. package/src/editor/lib/PublishDialog.svelte +15 -14
  112. package/src/editor/lib/ReferenceSelect.svelte +3 -2
  113. package/src/editor/lib/SchemaField.svelte +15 -13
  114. package/src/editor/lib/SchemaPanel.svelte +20 -19
  115. package/src/editor/lib/Select.svelte +5 -4
  116. package/src/editor/lib/SeoGuidance.svelte +6 -5
  117. package/src/editor/lib/Settings.svelte +54 -54
  118. package/src/editor/lib/Shortcuts.svelte +7 -6
  119. package/src/editor/lib/SitePreview.svelte +10 -9
  120. package/src/editor/lib/TopBar.svelte +18 -17
  121. package/src/editor/lib/UnsavedChanges.svelte +7 -6
  122. package/src/editor/lib/VisualEditor.svelte +19 -18
  123. package/src/editor/lib/calendar.ts +10 -4
  124. package/src/editor/lib/embed-preview.ts +23 -5
  125. package/src/editor/lib/extension-preview.ts +121 -0
  126. package/src/editor/lib/github-auth.ts +23 -22
  127. package/src/editor/lib/github-backend.ts +96 -91
  128. package/src/editor/lib/images.ts +33 -31
  129. package/src/editor/lib/menu-tree.ts +5 -4
  130. package/src/editor/lib/preview-frame.ts +4 -3
  131. package/src/editor/lib/schema-edit.ts +3 -2
  132. package/src/editor/lib/settings.ts +5 -3
  133. package/src/editor/lib/slash-command.ts +1 -0
  134. package/src/editor/lib/tooltip.ts +1 -1
  135. package/src/editor/main.ts +9 -0
  136. package/src/editor/styles.css +15 -3
  137. package/src/env.d.ts +11 -1
  138. package/src/extensions.ts +160 -0
  139. package/src/index.ts +1 -1
  140. package/src/manifest/collections.ts +6 -5
  141. package/src/manifest/content-locales.ts +45 -0
  142. package/src/manifest/menu.ts +12 -10
  143. package/src/manifest/navigation.ts +13 -8
  144. package/src/manifest/references.ts +8 -7
  145. package/src/manifest/seo.ts +4 -3
  146. package/src/manifest/types.ts +4 -0
  147. package/src/preview.ts +36 -2
  148. package/src/routes/local-api.ts +5 -3
  149. package/src/routes/local-documents.ts +20 -16
  150. package/src/routes/local-media.ts +21 -16
  151. package/src/routes/local-menu.ts +9 -7
  152. package/src/routes/local-post.ts +18 -12
  153. package/src/routes/media-files.ts +10 -8
  154. package/src/routes/rigel-page.astro +2 -1
  155. package/src/schema/store.ts +22 -20
  156. package/src/schema/types.ts +2 -0
  157. package/src/schema/validate.ts +73 -69
  158. package/src/slugs.ts +11 -10
  159. package/src/vite/manifest-plugin.ts +38 -2
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { t, locale } from "./i18n";
2
3
  import { onMount, tick } from "svelte";
3
4
  import { tooltipRoot } from "./lib/tooltip";
4
5
  onMount(() => tooltipRoot(document.body).destroy);
@@ -12,6 +13,9 @@
12
13
  import ImagePicker from "./lib/ImagePicker.svelte";
13
14
  import Settings from "./lib/Settings.svelte";
14
15
  import TopBar from "./lib/TopBar.svelte";
16
+ import Select from "./lib/Select.svelte";
17
+ import { contentForLocale, selectionInContent } from "../manifest/content-locales";
18
+ import { validateDocumentSlugs } from "../slugs";
15
19
  import Shortcuts from "./lib/Shortcuts.svelte";
16
20
  import CollectionPanel from "./lib/CollectionPanel.svelte";
17
21
  import { emptyBody, type ArticleBody } from "../article-body";
@@ -37,11 +41,15 @@
37
41
  import type { ContentDocument, Schema } from "../schema/types";
38
42
  import { buildSidebar, navigationKey, type Selection } from "../manifest/navigation";
39
43
  import { startGitHubLogin, checkGitHubAccess, type GitHubAccess } from "./lib/github-auth";
40
- import { loadManifestRemote, saveRemoteSelection, deleteRemotePost, reviewRemotePublish, confirmRemotePublish, type GitContext, type PublishReview } from "./lib/github-backend";
44
+ import { loadManifestRemote, saveRemoteSelection, deleteRemotePost, reviewRemotePublish, confirmRemotePublish, PublishReviewChangedError, type GitContext, type PublishReview } from "./lib/github-backend";
41
45
 
42
46
  type Sel = Selection;
43
47
 
44
48
  let manifest = $state<Manifest>({ pages: [] });
49
+ let selectedContentLocale = $state<string | undefined>(undefined);
50
+ let localeControlRevision = $state(0);
51
+ const contentLocale = $derived(selectedContentLocale ?? manifest.contentLocales?.defaultLocale);
52
+ const visibleContent = $derived(contentForLocale(manifest, contentLocale));
45
53
  let ready = $state(false);
46
54
  let blueprint: Manifest | null = null;
47
55
  let gitContext: GitContext | null = null;
@@ -79,9 +87,9 @@
79
87
  }
80
88
 
81
89
  function publishGuard(context: GitContext, revision: number, auth: number) {
82
- if (!publishOpen || revision !== publishRevision || auth !== authRevision || gitContext !== context) throw new Error("Publish review is no longer active. Reconnect and review again.");
90
+ if (!publishOpen || revision !== publishRevision || auth !== authRevision || gitContext !== context) throw new Error($t("shell.publishReviewInactive"));
83
91
  requireGitContext();
84
- if (dirty || saveTask || saveState === "saving") throw new Error("Save your unsaved edits first, then review publishing. Your edits are still here.");
92
+ if (dirty || saveTask || saveState === "saving") throw new Error($t("shell.saveBeforeReview"));
85
93
  }
86
94
 
87
95
  async function reviewPublish() {
@@ -101,7 +109,7 @@
101
109
  publishReview = review;
102
110
  } catch (error) {
103
111
  handleAuthError(error);
104
- if (revision === publishRevision) publishError = error instanceof Error ? error.message : "Could not review saved changes.";
112
+ if (revision === publishRevision) publishError = error instanceof Error ? error.message : $t("shell.reviewError");
105
113
  } finally { publishLoading = false; }
106
114
  }
107
115
 
@@ -122,17 +130,18 @@
122
130
  guard();
123
131
  manifest = remote;
124
132
  openSelection(sel);
125
- publishMessage = "Drafts synced. Nothing was published. Close this dialog and review again before publishing.";
126
- } else publishMessage = `Published to ${review.publishBranch}. Your configured deployment can now start; the site is not instantly live. The drafts branch is preserved.`;
133
+ publishMessage = $t("shell.draftsSynced");
134
+ } else publishMessage = $t("shell.publishedToBranch", { name: review.publishBranch });
127
135
  } catch (error) {
128
136
  handleAuthError(error);
129
137
  if (revision === publishRevision) {
130
- publishError = error instanceof Error ? error.message : "Could not verify publishing. Check GitHub before retrying.";
131
- if (/changed|no longer matches/.test(publishError)) publishReview = null;
138
+ publishError = error instanceof Error ? error.message : $t("shell.publishVerificationError");
139
+ if (error instanceof PublishReviewChangedError) publishReview = null;
132
140
  }
133
141
  } finally { publishBusy = false; }
134
142
  }
135
143
  let pendingNavigation = $state<(() => void) | null>(null);
144
+ let pendingNavigationCancel: (() => void) | null = null;
136
145
  let actionDialog = $state<{ title: string; description: string; confirmLabel: string; fieldLabel?: string; danger?: boolean; onconfirm: (value: string) => void | Promise<void> } | null>(null);
137
146
  let newEntry = $state<{ collection: string; post: CollectionPost } | null>(null);
138
147
  let newDocument = $state<{ type: string; id: string; slug: string } | null>(null);
@@ -145,9 +154,9 @@
145
154
  ready = false;
146
155
  try {
147
156
  const response = await fetch("/rigel/manifest.json", { cache: "no-store" });
148
- if (!response.ok) throw new Error("Could not load the editor configuration. Rebuild the site and try again.");
157
+ if (!response.ok) throw new Error($t("shell.configurationLoadError"));
149
158
  const data: Manifest = await response.json();
150
- if (!Array.isArray(data.pages) || (data.backend === "github" && !data.github)) throw new Error("The editor configuration is incomplete. Rebuild the site and try again.");
159
+ if (!Array.isArray(data.pages) || (data.backend === "github" && !data.github)) throw new Error($t("shell.configurationIncomplete"));
151
160
  blueprint = data;
152
161
  manifest = data;
153
162
  ready = true;
@@ -160,16 +169,16 @@
160
169
  }
161
170
  }
162
171
  } catch (error) {
163
- loadError = error instanceof Error ? error.message : "Could not load the editor configuration.";
172
+ loadError = error instanceof Error ? error.message : $t("shell.configurationLoadFailed");
164
173
  }
165
174
  }
166
175
 
167
176
  function requireGitContext() {
168
- if (connectionBusy) throw new Error("Wait for GitHub access verification before saving.");
169
- if (!gitContext || !connected || githubAccess?.status !== "ready") throw new Error("Connect GitHub and verify repository access before saving.");
177
+ if (connectionBusy) throw new Error($t("shell.waitForGithubVerification"));
178
+ if (!gitContext || !connected || githubAccess?.status !== "ready") throw new Error($t("shell.connectBeforeSaving"));
170
179
  if (Date.now() >= sessionExpiresAt - 30_000) {
171
180
  invalidateSession();
172
- throw new Error("Your GitHub session expired. Reconnect GitHub, then retry. Your unsaved edits are still here.");
181
+ throw new Error($t("shell.sessionExpiredRetry"));
173
182
  }
174
183
  return gitContext;
175
184
  }
@@ -181,7 +190,7 @@
181
190
  try {
182
191
  if (Date.now() >= sessionExpiresAt - 30_000) {
183
192
  invalidateSession();
184
- throw new Error("Your GitHub session expired. Sign in again to check repository access.");
193
+ throw new Error($t("shell.sessionExpiredCheckAccess"));
185
194
  }
186
195
  const access = await checkGitHubAccess(context.token, config.repo, config.appSlug);
187
196
  if (gitContext !== context) return;
@@ -228,7 +237,7 @@
228
237
  writeSession(manifest.github, session);
229
238
  await verifyGitHub();
230
239
  } catch (error) {
231
- connectionError = error instanceof Error ? error.message : "Could not connect GitHub. Try signing in again.";
240
+ connectionError = error instanceof Error ? error.message : $t("shell.githubConnectionError");
232
241
  } finally {
233
242
  connectionBusy = false;
234
243
  }
@@ -239,7 +248,7 @@
239
248
  connectionBusy = true;
240
249
  connectionError = "";
241
250
  try { await verifyGitHub(); }
242
- catch (error) { connectionError = error instanceof Error ? error.message : "Could not check GitHub access. Try again."; }
251
+ catch (error) { connectionError = error instanceof Error ? error.message : $t("shell.githubAccessError"); }
243
252
  finally { connectionBusy = false; }
244
253
  }
245
254
 
@@ -292,19 +301,19 @@
292
301
  const creatingDoc = docSel !== null && newDocument?.id === docSel.id;
293
302
  try {
294
303
  if ((selection?.kind === "post" || (docSel && !activeDocType?.shared)) && Object.hasOwn(values, "slug")) {
295
- values.slug = normalizeUrlSlug(values.slug);
304
+ values.slug = normalizeUrlSlug(values.slug, $t);
296
305
  draft.slug = values.slug;
297
306
  }
298
307
  if (githubMode) {
299
- if (!selection) throw new Error("Select content before saving.");
308
+ if (!selection) throw new Error($t("shell.selectBeforeSaving"));
300
309
  await saveRemoteSelection(requireGitContext(), manifest, selection, values, body, !!creating || creatingDoc);
301
310
  } else {
302
311
  const url = docSel
303
312
  ? `/rigel/api/local/documents/${encodeURIComponent(docSel.id)}`
304
313
  : m ? `/rigel/api/local/menus/${m.slug}` : po && c ? `/rigel/api/local/${c.name}/${encodeURIComponent(po.slug)}` : "/rigel/api/local/save";
305
314
  const payload = docSel ? { data: values } : m ? { slug: m.slug, title: values.title, items: values.items } : po ? { frontmatter: values, body } : { slug: p?.slug, values };
306
- const res = await fetch(url, { method: docSel ? (creatingDoc ? "POST" : "PUT") : m ? "PUT" : "POST", headers: { "Content-Type": "application/json", ...(po && creating ? { "If-None-Match": "*" } : {}) }, body: JSON.stringify(payload) });
307
- if (!res.ok) throw new Error((await res.text()) || `Save failed (${res.status})`);
315
+ const res = await fetch(url, { method: docSel ? (creatingDoc ? "POST" : "PUT") : m ? "PUT" : "POST", headers: { "Content-Type": "application/json", "Accept-Language": $locale, ...(po && creating ? { "If-None-Match": "*" } : {}) }, body: JSON.stringify(payload) });
316
+ if (!res.ok) throw new Error((await res.text()) || $t("shell.saveFailedStatus", { status: res.status }));
308
317
  }
309
318
  if (docSel) {
310
319
  const list = manifest.documents ?? (manifest.documents = []);
@@ -324,7 +333,7 @@
324
333
  } catch (error) {
325
334
  handleAuthError(error);
326
335
  saveState = "error";
327
- saveError = error instanceof Error ? error.message : "Could not save changes";
336
+ saveError = error instanceof Error ? error.message : $t("shell.saveChangesError");
328
337
  return false;
329
338
  }
330
339
  }
@@ -338,10 +347,10 @@
338
347
 
339
348
  let settings = $state(getSettings());
340
349
  let settingsError = $state("");
341
- const userName = $derived(githubMode ? githubAccess?.user.name ?? githubAccess?.user.login ?? "GitHub user" : "Local user");
350
+ const userName = $derived(githubMode ? githubAccess?.user.name ?? githubAccess?.user.login ?? $t("shell.githubUser") : $t("shell.localUser"));
342
351
  const userAvatar = $derived(githubMode && /^https:\/\/avatars\.githubusercontent\.com\//.test(githubAccess?.user.avatar_url ?? "") ? githubAccess!.user.avatar_url : "");
343
352
  let failedAvatar = $state("");
344
- const userLogin = $derived(githubMode && githubAccess ? `@${githubAccess.user.login}` : "Local mode");
353
+ const userLogin = $derived(githubMode && githubAccess ? `@${githubAccess.user.login}` : $t("shell.localMode"));
345
354
  let content: HTMLDivElement;
346
355
  let contentWidth = $state(0);
347
356
  let resizing = $state(false);
@@ -390,10 +399,10 @@
390
399
 
391
400
  function eraseBrowserData() {
392
401
  actionDialog = {
393
- title: "Erase browser data?", confirmLabel: "Erase data", danger: true,
394
- description: "This removes Rigel settings, API keys, GitHub login and unsaved drafts from this browser. Saved repository files will not change. The editor will reload.",
402
+ title: $t("shell.eraseBrowserDataTitle"), confirmLabel: $t("shell.eraseData"), danger: true,
403
+ description: $t("shell.eraseBrowserDataDescription"),
395
404
  onconfirm: () => {
396
- if (saveState === "saving") throw new Error("Wait for the current save to finish.");
405
+ if (saveState === "saving") throw new Error($t("shell.waitForSave"));
397
406
  eraseData();
398
407
  dirty = false;
399
408
  location.reload();
@@ -413,7 +422,7 @@
413
422
  });
414
423
 
415
424
  function findColl(name: string): ManifestCollection | undefined {
416
- return manifest.collections?.find((x) => x.name === name);
425
+ return visibleContent.collections?.find((x) => x.name === name);
417
426
  }
418
427
 
419
428
  function collName(s: Sel | null): string | null {
@@ -439,12 +448,12 @@
439
448
  });
440
449
 
441
450
  const columns = $derived.by((): { key: string; label: string; image?: boolean }[] => {
442
- if (sel?.kind === "menus") return [{ key: "title", label: "Name" }, { key: "slug", label: "Slug" }];
443
- if (sel?.kind === "pages") return [{ key: "label", label: "Title" }, { key: "slug", label: "Slug" }, { key: "category", label: "Category" }];
451
+ if (sel?.kind === "menus") return [{ key: "title", label: $t("shell.name") }, { key: "slug", label: $t("shell.slug") }];
452
+ if (sel?.kind === "pages") return [{ key: "label", label: $t("shell.title") }, { key: "slug", label: $t("shell.slug") }, { key: "category", label: $t("shell.category") }];
444
453
  const source = sel?.kind === "documents" ? activeDocType : coll;
445
454
  if (!source) return [];
446
455
  const keys = ["title", "slug", ...(source.columns ?? (sel?.kind === "documents" ? [] : source.fields.filter(field => !isSeoField(field.key)).map(field => field.key)))];
447
- const titleLabel = sel?.kind === "documents" && !source.fields.some(field => field.key === "title") ? source.fields.find(field => field.key === "name")?.label ?? "Title" : "Title";
456
+ const titleLabel = sel?.kind === "documents" && !source.fields.some(field => field.key === "title") ? source.fields.find(field => field.key === "name")?.label ?? $t("shell.title") : $t("shell.title");
448
457
  const seen = new Set<string>();
449
458
  const out: { key: string; label: string; image?: boolean }[] = [];
450
459
 
@@ -452,7 +461,7 @@
452
461
  if (seen.has(k)) continue;
453
462
  seen.add(k);
454
463
  const f = source.fields.find((x) => x.key === k);
455
- out.push({ key: k, label: k === "slug" ? "Slug" : k === "title" ? titleLabel : f?.label ?? k, image: f?.type === "image" });
464
+ out.push({ key: k, label: k === "slug" ? $t("shell.slug") : k === "title" ? titleLabel : f?.labelKey ? $t(f.labelKey) : f?.label ?? k, image: f?.type === "image" });
456
465
  }
457
466
  return sel?.kind === "documents" ? [...out.filter(column => column.image), ...out.filter(column => !column.image && column.key !== "slug"), ...out.filter(column => column.key === "slug")] : out;
458
467
  });
@@ -468,13 +477,13 @@
468
477
  });
469
478
  });
470
479
 
471
- const sidebar = $derived(buildSidebar(manifest));
480
+ const sidebar = $derived(buildSidebar(visibleContent, $t));
472
481
  const pageCategory = $derived(sel?.kind === "pages" ? sel.category : undefined);
473
482
  const schema = $derived<Schema | undefined>(manifest.schema);
474
- const docTypes = $derived(schema?.documents ?? []);
483
+ const docTypes = $derived(visibleContent.schema?.documents ?? []);
475
484
 
476
485
  const pageDocType = $derived(docTypes.find((type) => !type.shared && !type.singleton));
477
- const pageDocuments = $derived((manifest.documents ?? []).filter((doc) => docTypes.some(type => type.name === doc.type && !type.shared)));
486
+ const pageDocuments = $derived((visibleContent.documents ?? []).filter((doc) => docTypes.some(type => type.name === doc.type && !type.shared)));
478
487
  function docTypeName(s: Sel | null): string | null {
479
488
  return s && (s.kind === "document" || s.kind === "documents") ? s.type : null;
480
489
  }
@@ -492,14 +501,15 @@
492
501
  });
493
502
  function documentPreview(mode: "draft" | "website") {
494
503
  if (sel?.kind !== "document" || (mode === "draft" && newDocument?.id === sel.id)) return null;
495
- return previewUrl(manifest.preview ?? {}, { kind: "document", type: sel.type, slug: sel.id.slice(sel.type.length + 1), urlSlug: Object.hasOwn(draft, "slug") ? String(draft.slug) : undefined }, mode);
504
+ const storedSlug = activeDocument ? effectiveSlug(activeDocument.data, activeDocument.slug) : sel.id.slice(sel.type.length + 1);
505
+ return previewUrl(manifest.preview ?? {}, { kind: "document", type: sel.type, slug: storedSlug, urlSlug: Object.hasOwn(draft, "slug") ? String(draft.slug) : undefined }, mode);
496
506
  }
497
507
  const standaloneDocument = $derived(sel?.kind === "document" && activeDocType?.shared === true);
498
508
  const visualSelection = $derived(sel?.kind === "post" || (sel?.kind === "document" && !standaloneDocument));
499
509
 
500
510
  const sortedPages = $derived.by(() => {
501
511
  const rows = [
502
- ...manifest.pages.filter((p) => !p.shared && (pageCategory === undefined || p.category === pageCategory))
512
+ ...visibleContent.pages.filter((p) => !p.shared && (pageCategory === undefined || p.category === pageCategory))
503
513
  .map((p) => ({ slug: p.slug, title: p.label, category: p.category, draft: false, target: { kind: "page", slug: p.slug } as Sel })),
504
514
  ...(pageCategory === undefined ? pageDocuments
505
515
  .map((doc) => ({ slug: effectiveSlug(doc.data, doc.slug), title: String(doc.data.title ?? doc.slug), category: undefined, draft: doc.data.draft === true, target: { kind: "document", type: doc.type, id: doc.id } as Sel })) : []),
@@ -510,12 +520,12 @@
510
520
  function documentCell(doc: ContentDocument, key: string): string {
511
521
  return String(key === "title" ? doc.data.title ?? doc.data.name ?? doc.slug : key === "slug" ? activeDocType?.shared ? doc.slug : effectiveSlug(doc.data, doc.slug) : doc.data[key] ?? "");
512
522
  }
513
- const typeDocuments = $derived((manifest.documents ?? []).filter((doc) => doc.type === docTypeName(sel)).sort((a, b) => documentCell(a, sortBy).localeCompare(documentCell(b, sortBy)) * sortDir));
523
+ const typeDocuments = $derived((visibleContent.documents ?? []).filter((doc) => doc.type === docTypeName(sel)).sort((a, b) => documentCell(a, sortBy).localeCompare(documentCell(b, sortBy)) * sortDir));
514
524
 
515
525
 
516
526
  const menu = $derived.by(() => { const slug = sel?.kind === "menu" ? sel.slug : null; return manifest.menus?.entries.find((m) => m.slug === slug); });
517
527
  const sortedMenus = $derived([...(manifest.menus?.entries ?? [])].sort((a, b) => (sortBy === "slug" ? a.slug.localeCompare(b.slug) : a.title.localeCompare(b.title)) * sortDir));
518
- const listTitle = $derived(sel?.kind === "menus" ? "All Menus" : sel?.kind === "documents" ? activeDocType?.title ?? "Documents" : sel?.kind === "pages" ? `All ${pageCategory ?? "pages"}` : coll?.listTitle?.trim() || `All ${coll?.label ?? "entries"}`);
528
+ const listTitle = $derived(sel?.kind === "menus" ? $t("shell.allMenus") : sel?.kind === "documents" ? activeDocType?.title ?? $t("shell.documents") : sel?.kind === "pages" ? pageCategory !== undefined ? $t("shell.allNamedEntries", { name: pageCategory }) : $t("shell.allPages") : coll?.listTitle?.trim() || (coll?.label !== undefined ? $t("shell.allNamedEntries", { name: coll.label }) : $t("shell.allEntries")));
519
529
  const listRows = $derived(sel?.kind === "menus"
520
530
  ? sortedMenus.map((m) => ({ slug: m.slug, cells: { slug: m.slug, title: m.title } as Record<string, string>, draft: false, target: { kind: "menu", slug: m.slug } as Sel }))
521
531
  : sel?.kind === "documents"
@@ -538,7 +548,7 @@
538
548
  setSettings(settings);
539
549
  settingsError = "";
540
550
  } catch {
541
- settingsError = "Settings could not be saved in this browser. Allow browser storage and try again.";
551
+ settingsError = $t("shell.settingsStorageError");
542
552
  }
543
553
  });
544
554
 
@@ -586,22 +596,31 @@
586
596
  return false;
587
597
  }
588
598
 
589
- function requestNavigation(action: () => void) {
599
+ function requestNavigation(action: () => void, oncancel: (() => void) | null = null) {
590
600
  if (publishOpen || publishBusy) return;
601
+ pendingNavigationCancel = oncancel;
591
602
  if (dirty || saveState === "saving") pendingNavigation = action;
592
603
  else action();
593
604
  }
594
605
 
606
+ function cancelNavigation() {
607
+ pendingNavigation = null;
608
+ pendingNavigationCancel?.();
609
+ pendingNavigationCancel = null;
610
+ }
611
+
595
612
  async function saveAndLeave() {
596
613
  if (!(await save())) return;
597
614
  const action = pendingNavigation;
598
615
  pendingNavigation = null;
616
+ pendingNavigationCancel = null;
599
617
  action?.();
600
618
  }
601
619
 
602
620
  function discardAndLeave() {
603
621
  const action = pendingNavigation;
604
622
  pendingNavigation = null;
623
+ pendingNavigationCancel = null;
605
624
  const fallback: Sel | null = newEntry ? { kind: "collection", name: newEntry.collection } : newDocument ? { kind: "pages" } : sel;
606
625
  newEntry = null;
607
626
  newDocument = null;
@@ -610,6 +629,7 @@
610
629
  }
611
630
 
612
631
  function select(next: Sel | null) {
632
+ if (manifest.contentLocales && !selectionInContent(visibleContent, next)) return;
613
633
  if (sel && next && sameSel(sel, next)) return;
614
634
  requestNavigation(() => openSelection(next));
615
635
  }
@@ -654,6 +674,21 @@
654
674
 
655
675
  }
656
676
 
677
+ function focusContentLocale() {
678
+ void tick().then(() => document.querySelector<HTMLButtonElement>(".content-language button[role=combobox]")?.focus({ preventScroll: true }));
679
+ }
680
+
681
+ function switchContentLocale(value: string | string[]) {
682
+ localeControlRevision += 1;
683
+ if (typeof value !== "string" || value === contentLocale || !manifest.contentLocales?.locales.some(locale => locale.code === value)) return;
684
+ requestNavigation(() => {
685
+ const destination = contentForLocale(manifest, value);
686
+ selectedContentLocale = value;
687
+ if (!selectionInContent(destination, sel)) openSelection(null);
688
+ focusContentLocale();
689
+ }, focusContentLocale);
690
+ }
691
+
657
692
  function sort(key: string) {
658
693
  if (sortBy === key) sortDir = sortDir === 1 ? -1 : 1;
659
694
  else {
@@ -682,7 +717,7 @@
682
717
  function cellValue(p: { slug: string; frontmatter: Record<string, unknown> }, key: string): string {
683
718
  const v = key === "slug" ? effectiveSlug(p.frontmatter, p.slug) : p.frontmatter[key];
684
719
  const field = coll?.fields.find(field => field.key === key);
685
- if (field?.type === "reference") return referenceLabel(v, referenceOptions(field, manifest.documents)) || "—";
720
+ if (field?.type === "reference") return referenceLabel(v, referenceOptions(field, manifest.documents), $t) || "—";
686
721
  if (v === true) return "✓";
687
722
  if (v === false || v == null) return "—";
688
723
  if (Array.isArray(v)) return v.join(", ");
@@ -692,14 +727,14 @@
692
727
  function newPage(type = pageDocType) {
693
728
  if (!type || !schema || type.creatable === false || type.singleton) return;
694
729
  actionDialog = {
695
- title: `Add ${type.title}`, description: "Enter a name to start a draft from the template. Nothing is written until you press Save.", fieldLabel: type.shared ? "Name" : "Page name", confirmLabel: "Create draft",
730
+ title: $t("shell.addDocument", { name: type.title }), description: $t("shell.newDocumentDescription"), fieldLabel: type.shared ? $t("shell.name") : $t("shell.pageName"), confirmLabel: $t("shell.createDraft"),
696
731
  onconfirm: (title) => {
697
732
  const slug = slugify(title);
698
- if (!slug) throw new Error("Enter a title containing letters or numbers.");
699
- const taken = [...(manifest.documents ?? []).filter(doc => !type.shared || doc.type === type.name).flatMap((doc) => [doc.slug, ...(type.shared || docTypes.find(type => type.name === doc.type)?.shared ? [] : [effectiveSlug(doc.data, doc.slug)])]), ...(!type.shared ? manifest.pages.map((page) => page.slug) : [])];
700
- if (taken.includes(slug)) throw new Error(`The slug "${slug}" is already used by another document or page.`);
733
+ if (!slug) throw new Error($t("shell.titleRequired"));
734
+ if (manifest.documents?.some(doc => doc.type === type.name && doc.slug === slug)) throw new Error($t("shell.slugAlreadyUsed", { slug }));
701
735
  const id = `${type.name}:${slug}`;
702
736
  const data = newDocumentFromTemplate(schema, type.name, title, slug);
737
+ validateDocumentSlugs(schema, [...(manifest.documents ?? []), { id, type: type.name, slug, data }], manifest.preview, $t);
703
738
  newDocument = { type: type.name, id, slug };
704
739
  draft = JSON.parse(JSON.stringify(data));
705
740
  docBase = null;
@@ -710,10 +745,10 @@
710
745
 
711
746
  function newPost(c: ManifestCollection) {
712
747
  actionDialog = {
713
- title: `Add to ${c.label}`, description: "Enter a title to start a draft. Nothing is written until you press Save.", fieldLabel: "Title", confirmLabel: "Create draft",
748
+ title: $t("shell.addToCollection", { name: c.label }), description: $t("shell.newPostDescription"), fieldLabel: $t("shell.title"), confirmLabel: $t("shell.createDraft"),
714
749
  onconfirm: (title) => {
715
750
  const slug = slugify(title, c.posts.flatMap((post) => [post.slug, effectiveSlug(post.frontmatter, post.slug)]));
716
- if (!slug) throw new Error("Enter a title containing letters or numbers.");
751
+ if (!slug) throw new Error($t("shell.titleRequired"));
717
752
  newEntry = { collection: c.name, post: { slug, frontmatter: { ...initialCollectionValues(c.fields, title), slug }, body: emptyBody() } };
718
753
  openSelection({ kind: "post", collection: c.name, slug });
719
754
  },
@@ -725,8 +760,8 @@
725
760
  const current = post, collection = coll, unsaved = newEntry !== null;
726
761
  if (!current || !collection) return;
727
762
  actionDialog = {
728
- title: `Delete “${String(current.frontmatter.title ?? current.slug)}”?`, confirmLabel: "Delete", danger: true,
729
- description: unsaved ? "This discards the new post and its unsaved changes." : `This deletes ${current.slug}.json${githubMode ? ` from ${manifest.github?.branch}` : ""} and discards any unsaved changes. This cannot be undone in the editor.`,
763
+ title: $t("shell.deletePostTitle", { name: String(current.frontmatter.title ?? current.slug) }), confirmLabel: $t("shell.delete"), danger: true,
764
+ description: unsaved ? $t("shell.discardNewPostDescription") : githubMode ? $t("shell.deleteRemotePostDescription", { slug: current.slug, branch: String(manifest.github?.branch) }) : $t("shell.deleteLocalPostDescription", { slug: current.slug }),
730
765
  onconfirm: async () => {
731
766
  if (!unsaved) {
732
767
  if (githubMode) {
@@ -734,8 +769,8 @@
734
769
  catch (error) { handleAuthError(error); throw error; }
735
770
  }
736
771
  else {
737
- const res = await fetch(`/rigel/api/local/${collection.name}/${encodeURIComponent(current.slug)}`, { method: "DELETE" });
738
- if (!res.ok) throw new Error((await res.text()) || `Delete failed (${res.status})`);
772
+ const res = await fetch(`/rigel/api/local/${collection.name}/${encodeURIComponent(current.slug)}`, { method: "DELETE", headers: { "Accept-Language": $locale } });
773
+ if (!res.ok) throw new Error((await res.text()) || $t("shell.deleteFailedStatus", { status: res.status }));
739
774
  }
740
775
  collection.posts = collection.posts.filter((p) => p.slug !== current.slug);
741
776
  }
@@ -762,7 +797,7 @@
762
797
  onstorage={(event) => {
763
798
  if ((event.key === "rigelcms.session" || event.key === null) && blueprint?.github && !readSession(blueprint.github)) {
764
799
  invalidateSession();
765
- connectionError = "Signed out in another tab. Reconnect GitHub to save. Your unsaved edits are still here.";
800
+ connectionError = $t("shell.signedOutAnotherTab");
766
801
  }
767
802
  }}
768
803
  onresize={endPreviewResize}
@@ -784,31 +819,31 @@
784
819
  <div class="rigel connection-shell">
785
820
  <main id="github-connection" class="connection-card" aria-busy={connectionBusy}>
786
821
  <div class="connection-brand"><img src={logo} alt="" width="64" height="64" /><span>Rigel <span class="brand-suffix">CMS</span></span></div>
787
- <h1>{!ready ? "Loading editor" : "Connect GitHub"}</h1>
822
+ <h1>{!ready ? $t("shell.loadingEditor") : $t("shell.connectGithub")}</h1>
788
823
  {#if !ready}
789
- {#if loadError}<p class="connection-error" role="alert">{loadError}</p><button class="primary" onclick={loadBlueprint}>Retry loading</button>
790
- {:else}<p role="status">Loading the editor configuration…</p>{/if}
824
+ {#if loadError}<p class="connection-error" role="alert">{loadError}</p><button class="primary" onclick={loadBlueprint}>{$t("shell.retryLoading")}</button>
825
+ {:else}<p role="status">{$t("shell.loadingConfiguration")}</p>{/if}
791
826
  {:else if manifest.github}
792
827
  {#if githubAccess}
793
- <p>Signed in as <strong>{githubAccess.user.login}</strong>.</p>
828
+ <p>{#each $t("shell.signedInAs", { name: "{name}" }).split(/(\{name\})/) as part}{#if part === "{name}"}<strong>{githubAccess.user.login}</strong>{:else}{part}{/if}{/each}</p>
794
829
  {#if githubAccess.status === "read-only" || githubAccess.status === "installation-required"}
795
- <p>You don’t have editing access. Contact the website administrator.</p>
830
+ <p>{$t("shell.noEditingAccess")}</p>
796
831
  {:else}
797
- <p role="status">{connectionBusy ? "Opening the editor…" : "Access confirmed. Try opening the editor again."}</p>
832
+ <p role="status">{connectionBusy ? $t("shell.openingEditor") : $t("shell.accessConfirmed")}</p>
798
833
  {/if}
799
834
  {#if githubAccess.status !== "ready"}
800
- <a id="github-install" class="primary" href={githubAccess.installUrl} target="_blank" rel="noopener noreferrer">Manage GitHub access</a>
835
+ <a id="github-install" class="primary" href={githubAccess.installUrl} target="_blank" rel="noopener noreferrer">{$t("shell.manageGithubAccess")}</a>
801
836
  {/if}
802
837
  {:else}
803
- <p>Sign in to edit this website.</p>
838
+ <p>{$t("shell.signInHint")}</p>
804
839
  {/if}
805
840
  <div class="connection-actions">
806
841
  {#if sessionExpiresAt && !tokenExpired}
807
- <button id="github-check-access" class="primary" disabled={connectionBusy} onclick={recheckGitHub}>{connectionBusy ? "Checking access…" : githubAccess?.status === "ready" ? "Open editor" : "Check access"}</button>
842
+ <button id="github-check-access" class="primary" disabled={connectionBusy} onclick={recheckGitHub}>{connectionBusy ? $t("shell.checkingAccess") : githubAccess?.status === "ready" ? $t("shell.openEditor") : $t("shell.checkAccess")}</button>
808
843
  {/if}
809
- <button id="github-sign-in" class:primary={!sessionExpiresAt || tokenExpired} class:outlined={sessionExpiresAt > 0 && !tokenExpired} disabled={connectionBusy} onclick={connectGitHub}>{connectionBusy && !sessionExpiresAt ? "Signing in…" : sessionExpiresAt ? "Sign in again" : "Sign in with GitHub"}</button>
844
+ <button id="github-sign-in" class:primary={!sessionExpiresAt || tokenExpired} class:outlined={sessionExpiresAt > 0 && !tokenExpired} disabled={connectionBusy} onclick={connectGitHub}>{connectionBusy && !sessionExpiresAt ? $t("shell.signingIn") : sessionExpiresAt ? $t("shell.signInAgain") : $t("shell.signInWithGithub")}</button>
810
845
  </div>
811
- <p class="connection-note">By continuing, you agree to the <a href="https://rigelcms.dev/terms" target="_blank" rel="noopener noreferrer">Terms of Use</a> and acknowledge the <a href="https://rigelcms.dev/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.</p>
846
+ <p class="connection-note">{#each $t("shell.legalNotice", { terms: "{terms}", privacy: "{privacy}" }).split(/(\{terms\}|\{privacy\})/) as part}{#if part === "{terms}"}<a href="https://rigelcms.dev/terms" target="_blank" rel="noopener noreferrer">{$t("shell.termsOfUse")}</a>{:else if part === "{privacy}"}<a href="https://rigelcms.dev/privacy" target="_blank" rel="noopener noreferrer">{$t("shell.privacyPolicy")}</a>{:else}{part}{/if}{/each}</p>
812
847
  {/if}
813
848
  {#if connectionError}<p class="connection-error" role="alert">{connectionError}</p>{/if}
814
849
  </main>
@@ -816,13 +851,20 @@
816
851
  {:else}
817
852
  <div class="rigel" class:sidebar-collapsed={settings.userPref.sidebarCollapsed}>
818
853
  <aside id="rigel-sidebar" data-tooltip-position="right">
819
- <a class="brand" href="/rigel/" role="button" aria-label="Welcome" onclick={(event) => { event.preventDefault(); goWelcome(); }}>
854
+ <a class="brand" href="/rigel/" role="button" aria-label={$t("shell.welcome")} onclick={(event) => { event.preventDefault(); goWelcome(); }}>
820
855
  <img src={logo} alt="" width="32" height="32" />
821
856
  <span>Rigel <span class="brand-suffix">CMS</span></span>
822
857
  </a>
823
- <TopBar {manifest} {sel} onselect={select} />
858
+ <TopBar manifest={visibleContent} {sel} onselect={select} />
859
+ {#if manifest.contentLocales && manifest.contentLocales.locales.length > 1}
860
+ <div class="content-language" data-locale={contentLocale}>
861
+ {#key localeControlRevision}
862
+ <Select label={$t("shell.contentLanguage")} value={contentLocale} options={manifest.contentLocales.locales.map(locale => ({ value: locale.code, label: locale.label }))} disabled={publishOpen || publishBusy} onchange={switchContentLocale} />
863
+ {/key}
864
+ </div>
865
+ {/if}
824
866
 
825
- <nav id="rigel-sidebar-navigation" class="rail-scroll" aria-label="Content">
867
+ <nav id="rigel-sidebar-navigation" class="rail-scroll" aria-label={$t("shell.content")}>
826
868
  {#each sidebar as node}
827
869
  {#if node.kind === "heading"}
828
870
  <h3 class="sidebar-heading">{node.label}</h3>
@@ -850,16 +892,16 @@
850
892
  </nav>
851
893
 
852
894
  {#if githubMode && manifest.github?.publishBranch}
853
- <button id="publish-button" class="sidebar-publish" aria-label="Publish" data-tooltip={dirty ? "Save your edits before publishing" : "Publish saved branch changes"} disabled={publishBusy || publishLoading || saveState === "saving" || connectionBusy || tokenExpired || githubAccess?.status !== "ready"} onclick={reviewPublish}>
854
- <Icon name="rocketLaunch" size={18} /><span class="sidebar-label">Publish</span>
895
+ <button id="publish-button" class="sidebar-publish" aria-label={$t("shell.publish")} data-tooltip={dirty ? $t("shell.saveBeforePublishing") : $t("shell.publishSavedChanges")} disabled={publishBusy || publishLoading || saveState === "saving" || connectionBusy || tokenExpired || githubAccess?.status !== "ready"} onclick={reviewPublish}>
896
+ <Icon name="rocketLaunch" size={18} /><span class="sidebar-label">{$t("shell.publish")}</span>
855
897
  </button>
856
898
  {/if}
857
- <button class="sidebar-toggle" aria-expanded={!settings.userPref.sidebarCollapsed} aria-controls="rigel-sidebar-navigation" aria-label={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} data-tooltip={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} onclick={() => { settings.userPref.sidebarCollapsed = !settings.userPref.sidebarCollapsed; }}>
858
- <SidebarIcon name="sidebar-simple" /><span class="sidebar-label">Collapse sidebar</span>
899
+ <button class="sidebar-toggle" aria-expanded={!settings.userPref.sidebarCollapsed} aria-controls="rigel-sidebar-navigation" aria-label={settings.userPref.sidebarCollapsed ? $t("shell.expandSidebar") : $t("shell.collapseSidebar")} data-tooltip={settings.userPref.sidebarCollapsed ? $t("shell.expandSidebar") : $t("shell.collapseSidebar")} onclick={() => { settings.userPref.sidebarCollapsed = !settings.userPref.sidebarCollapsed; }}>
900
+ <SidebarIcon name="sidebar-simple" /><span class="sidebar-label">{$t("shell.collapseSidebar")}</span>
859
901
  </button>
860
902
  <div class="aside-foot">
861
903
  <div class="user-menu-wrap">
862
- <button class="user-btn" aria-label={githubMode ? "GitHub user menu" : "Local user menu"} data-tooltip={githubMode ? "GitHub user menu" : "Local user menu"} aria-haspopup="menu" aria-expanded={userOpen} onclick={() => (userOpen = !userOpen)}>
904
+ <button class="user-btn" aria-label={githubMode ? $t("shell.githubUserMenu") : $t("shell.localUserMenu")} data-tooltip={githubMode ? $t("shell.githubUserMenu") : $t("shell.localUserMenu")} aria-haspopup="menu" aria-expanded={userOpen} onclick={() => (userOpen = !userOpen)}>
863
905
  {@render avatar()}
864
906
  <span class="user-details"><span class="uname">{userName}</span><small>{userLogin}</small></span>
865
907
  <span class="user-menu-icon"><Icon name="dotsThreeOutlineVertical" size={18} /></span>
@@ -873,26 +915,26 @@
873
915
  <div class="divider"></div>
874
916
  <a role="menuitem" href="/" target="_blank" rel="noopener">
875
917
  <Icon name="external" size={13} />
876
- Live Site
918
+ {$t("shell.liveSite")}
877
919
  </a>
878
920
  <div class="divider"></div>
879
921
  <button role="menuitem" onclick={() => { userOpen = false; settingsOpen = true; }}>
880
922
  <Icon name="gear" size={13} />
881
- Settings
923
+ {$t("shell.settings")}
882
924
  </button>
883
925
  <button role="menuitem" onclick={() => { userOpen = false; shortcutsOpen = true; }}>
884
926
  <Icon name="keyboard" size={13} />
885
- Keyboard Shortcuts
927
+ {$t("shell.keyboardShortcutsMenu")}
886
928
  </button>
887
929
  <div class="divider"></div>
888
930
  {#if githubMode}
889
- <a role="menuitem" href={githubAccess?.installUrl} target="_blank" rel="noopener noreferrer">Configure GitHub access</a>
890
- <button role="menuitem" class="github-reconnect" disabled={connectionBusy || saveState === "saving"} onclick={() => { userOpen = false; void connectGitHub(); }}>Reconnect GitHub</button>
931
+ <a role="menuitem" href={githubAccess?.installUrl} target="_blank" rel="noopener noreferrer">{$t("shell.configureGithubAccess")}</a>
932
+ <button role="menuitem" class="github-reconnect" disabled={connectionBusy || saveState === "saving"} onclick={() => { userOpen = false; void connectGitHub(); }}>{$t("shell.reconnectGithub")}</button>
891
933
  {/if}
892
- <button id={githubMode ? "github-sign-out" : undefined} role="menuitem" class:muted={!githubMode} disabled={!githubMode || saveState === "saving" || connectionBusy} data-tooltip={githubMode ? "Sign out of this editor" : "Local mode"} onclick={signOut}>
934
+ <button id={githubMode ? "github-sign-out" : undefined} role="menuitem" class:muted={!githubMode} disabled={!githubMode || saveState === "saving" || connectionBusy} data-tooltip={githubMode ? $t("shell.signOutTooltip") : $t("shell.localMode")} onclick={signOut}>
893
935
  <Icon name="signOut" size={13} />
894
- Sign Out
895
- {#if !githubMode}<span class="soon">backend: github</span>{/if}
936
+ {$t("shell.signOut")}
937
+ {#if !githubMode}<span class="soon">{$t("shell.githubBackendHint")}</span>{/if}
896
938
  </button>
897
939
  </div>
898
940
  {/if}
@@ -904,8 +946,8 @@
904
946
  <main id="rigel-editor-pane" class:wide={sel?.kind === "collection" || sel?.kind === "pages" || sel?.kind === "documents" || sel?.kind === "document" || sel?.kind === "post" || sel?.kind === "menus" || sel?.kind === "menu" || sel?.kind === "media"} class:visual-pane={visualSelection}>
905
947
  {#if githubMode && (tokenExpired || githubAccess?.status !== "ready" || connectionError)}
906
948
  <div class="github-session" role="status">
907
- <p>{connectionError || (tokenExpired ? "Your GitHub session expired. Your unsaved edits are still here." : "Repository access changed. Reconnect GitHub before saving.")}</p>
908
- <button class="primary" disabled={connectionBusy || saveState === "saving"} onclick={connectGitHub}>{connectionBusy ? "Connecting…" : "Reconnect GitHub"}</button>
949
+ <p>{connectionError || (tokenExpired ? $t("shell.sessionExpired") : $t("shell.repositoryAccessChanged"))}</p>
950
+ <button class="primary" disabled={connectionBusy || saveState === "saving"} onclick={connectGitHub}>{connectionBusy ? $t("shell.connecting") : $t("shell.reconnectGithub")}</button>
909
951
  </div>
910
952
  {/if}
911
953
  {#if settingsError}<p role="alert">{settingsError}</p>{/if}
@@ -913,30 +955,30 @@
913
955
  {#if sel.kind === "page" || sel.kind === "post" || sel.kind === "menu" || sel.kind === "document"}
914
956
  <div class="editor-actions">
915
957
  {#if sel.kind === "page" && page}
916
- <p class="back"><button onclick={() => select(page.shared ? null : { kind: "pages", category: page.category })}>← Back to {page.shared ? "Welcome" : page.category ?? "Pages"}</button></p>
958
+ <p class="back"><button onclick={() => select(page.shared ? null : { kind: "pages", category: page.category })}>{$t("shell.backTo", { name: page.shared ? $t("shell.welcome") : page.category ?? $t("shell.pages") })}</button></p>
917
959
  {:else if sel.kind === "document"}
918
960
  {@const backSel: Sel | null = activeDocType?.singleton ? null : sel.type === pageDocType?.name ? { kind: "pages" } : { kind: "documents", type: sel.type }}
919
- <p class="back"><button onclick={() => select(backSel)}>← Back to {activeDocType?.singleton ? "Welcome" : sel.type === pageDocType?.name ? "Pages" : activeDocType?.title ?? "Documents"}</button></p>
961
+ <p class="back"><button onclick={() => select(backSel)}>{$t("shell.backTo", { name: activeDocType?.singleton ? $t("shell.welcome") : sel.type === pageDocType?.name ? $t("shell.pages") : activeDocType?.title ?? $t("shell.documents") })}</button></p>
920
962
  {:else if sel.kind === "post" && coll}
921
- <p class="back"><button onclick={() => select({ kind: "collection", name: coll.name })}>← Back to {coll.label}</button></p>
963
+ <p class="back"><button onclick={() => select({ kind: "collection", name: coll.name })}>{$t("shell.backTo", { name: coll.label })}</button></p>
922
964
  {:else if sel.kind === "menu"}
923
- <p class="back"><button onclick={() => select({ kind: "menus" })}>← Back to Menus</button></p>
965
+ <p class="back"><button onclick={() => select({ kind: "menus" })}>{$t("shell.backTo", { name: $t("shell.menus") })}</button></p>
924
966
  {/if}
925
967
  <div class="save-actions">
926
968
  <span class="save-status" role="status" class:dirty={dirty && saveState !== "saving"} class:saving={saveState === "saving"} class:saved={!dirty && saveState === "saved"} class:error={saveState === "error"}>
927
- {saveState === "saving" ? "Saving…" : saveState === "error" ? "Save failed" : dirty ? "Unsaved" : saveState === "saved" ? "Saved" : ""}
969
+ {saveState === "saving" ? $t("shell.saving") : saveState === "error" ? $t("shell.saveFailed") : dirty ? $t("shell.unsaved") : saveState === "saved" ? $t("shell.saved") : ""}
928
970
  </span>
929
- <button class="primary save-button" disabled={!dirty || saveState === "saving"} onclick={() => save()}>{saveState === "saving" ? "Saving…" : "Save"}</button>
971
+ <button class="primary save-button" disabled={!dirty || saveState === "saving"} onclick={() => save()}>{saveState === "saving" ? $t("shell.saving") : $t("shell.save")}</button>
930
972
  </div>
931
973
  </div>
932
974
  {/if}
933
975
  <div class="titlebar">
934
976
  <h2>{sel.kind === "page" ? page?.label : sel.kind === "menu" ? String(draft.title || menu?.slug || "") : sel.kind === "post" ? String(draft.title || post?.slug || "") : sel.kind === "document" ? activeDocType?.singleton ? activeDocType.title : String(draft.title || draft.name || activeDocument?.slug || sel.id) : listTitle}</h2>
935
977
  {#if (sel?.kind === "collection" && coll) || (sel?.kind === "pages" && pageDocType?.creatable !== false && pageDocType) || (sel?.kind === "documents" && activeDocType && activeDocType.creatable !== false && !activeDocType.singleton)}
936
- <button class="primary" onclick={() => (sel?.kind === "collection" ? newPost(coll!) : newPage(sel?.kind === "documents" ? activeDocType : pageDocType))}>Add new</button>
978
+ <button class="primary" onclick={() => (sel?.kind === "collection" ? newPost(coll!) : newPage(sel?.kind === "documents" ? activeDocType : pageDocType))}>{$t("shell.addNew")}</button>
937
979
  {/if}
938
980
  {#if sel?.kind === "post" && post}
939
- <button class="icon-btn" aria-label="Delete post" data-tooltip="Delete post" disabled={saveState === "saving"} onclick={deletePost}>
981
+ <button class="icon-btn" aria-label={$t("shell.deletePost")} data-tooltip={$t("shell.deletePost")} disabled={saveState === "saving"} onclick={deletePost}>
940
982
  <Icon name="trash" size={15} />
941
983
  </button>
942
984
  {/if}
@@ -945,16 +987,16 @@
945
987
  {#if saveError}
946
988
  <div class="save-toast" role="alert">
947
989
  <div class="save-toast-content">
948
- <strong>Could not save changes</strong>
990
+ <strong>{$t("shell.saveChangesError")}</strong>
949
991
  <p>{saveError}</p>
950
- <button class="outlined" disabled={saveState === "saving"} onclick={() => save()}>Retry save</button>
951
- {#if githubMode}<button class="outlined github-reconnect" disabled={connectionBusy || saveState === "saving"} onclick={connectGitHub}>Reconnect GitHub</button>{/if}
992
+ <button class="outlined" disabled={saveState === "saving"} onclick={() => save()}>{$t("shell.retrySave")}</button>
993
+ {#if githubMode}<button class="outlined github-reconnect" disabled={connectionBusy || saveState === "saving"} onclick={connectGitHub}>{$t("shell.reconnectGithub")}</button>{/if}
952
994
  </div>
953
- <button type="button" class="icon-btn" aria-label="Dismiss save error" onclick={() => { saveError = ""; }}><Icon name="x" size={18} /></button>
995
+ <button type="button" class="icon-btn" aria-label={$t("shell.dismissSaveError")} onclick={() => { saveError = ""; }}><Icon name="x" size={18} /></button>
954
996
  </div>
955
997
  {/if}
956
998
  {#if !ready}
957
- <p>Loading…</p>
999
+ <p>{$t("shell.loading")}</p>
958
1000
 
959
1001
  {:else if sel?.kind === "media"}
960
1002
  <ImagePicker inline />
@@ -981,7 +1023,7 @@
981
1023
  <td>
982
1024
  {#if col.key === titleColumn}
983
1025
  <button class="table-link" onclick={() => select(row.target)}>{row.cells[col.key]}</button>
984
- {#if row.draft}<span class="badge">draft</span>{/if}
1026
+ {#if row.draft}<span class="badge">{$t("shell.draftBadge")}</span>{/if}
985
1027
  {:else if col.image}
986
1028
  {#if row.cells[col.key]}
987
1029
  <img class="table-thumbnail" src={row.cells[col.key]} alt="" width="32" height="32" loading="lazy" decoding="async" />
@@ -995,7 +1037,7 @@
995
1037
  {/each}
996
1038
  </tr>
997
1039
  {:else}
998
- <tr><td colspan={columns.length}>No entries</td></tr>
1040
+ <tr><td colspan={columns.length}>{$t("shell.noEntries")}</td></tr>
999
1041
  {/each}
1000
1042
  </tbody>
1001
1043
  </table>
@@ -1028,14 +1070,14 @@
1028
1070
 
1029
1071
  {:else if sel?.kind === "menu" && menu && manifest.menus}
1030
1072
  <section class="menu-details">
1031
- <label><span>Slug</span><input type="text" value={menu.slug} readonly /></label>
1032
- <label><span>Name</span><input type="text" value={String(draft.title ?? "")} oninput={(e) => { draft.title = e.currentTarget.value; touch(); }} /></label>
1073
+ <label><span>{$t("shell.slug")}</span><input type="text" value={menu.slug} readonly /></label>
1074
+ <label><span>{$t("shell.name")}</span><input type="text" value={String(draft.title ?? "")} oninput={(e) => { draft.title = e.currentTarget.value; touch(); }} /></label>
1033
1075
  </section>
1034
1076
  <MenuEditor items={draft.items as MenuItem[]} maxDepth={manifest.menus.maxDepth} oninput={(items) => { draft.items = items; touch(); }} />
1035
1077
 
1036
1078
  {:else if sel?.kind === "page" && page}
1037
1079
  {#if !page.shared}
1038
- <section><label><span>Website route</span><input type="text" value={previewPath(manifest.preview ?? {}, { kind: "page", slug: page.slug }) ?? "Not configured"} readonly /></label><p class="hint">This copy page uses a fixed Astro route. Its URL can only be changed in the website code; slug metadata cannot move it.</p></section>
1080
+ <section><label><span>{$t("shell.websiteRoute")}</span><input type="text" value={previewPath(manifest.preview ?? {}, { kind: "page", slug: page.slug }) ?? $t("shell.notConfigured")} readonly /></label><p class="hint">{$t("shell.fixedRouteHint")}</p></section>
1039
1081
  {/if}
1040
1082
  {#each page.groups as group (group.id)}
1041
1083
  <section>
@@ -1060,7 +1102,7 @@
1060
1102
  <VisualEditor
1061
1103
  docId={`${coll.name}:${post.slug}`}
1062
1104
  values={draft}
1063
- previewValues={collectionPreviewValues(coll.fields, draft, manifest.documents ?? [])}
1105
+ previewValues={collectionPreviewValues(coll.fields, draft, manifest.documents ?? [], $t)}
1064
1106
  body={bodyDraft}
1065
1107
  bodyInline={settings.userPref.editorExperience === "inline"}
1066
1108
  onbody={(value) => { bodyDraft = value; touch(); }}
@@ -1079,20 +1121,20 @@
1079
1121
  {:else}
1080
1122
  <div class="welcome">
1081
1123
  <img src={logo} alt="" width="64" height="64" />
1082
- <h2>Welcome to Rigel CMS</h2>
1083
- <p>The best CMS in the whole space.</p>
1084
- <p class="welcome-hint">Select a page or collection from the sidebar to start editing.</p>
1124
+ <h2>{$t("shell.welcomeTitle")}</h2>
1125
+ <p>{$t("shell.welcomeTagline")}</p>
1126
+ <p class="welcome-hint">{$t("shell.welcomeHint")}</p>
1085
1127
  </div>
1086
1128
  {/if}
1087
1129
  {#if settings.devMode && (sel?.kind === "page" || sel?.kind === "post" || sel?.kind === "menu" || sel?.kind === "document")}
1088
1130
  <section class="md-preview" class:article-state={sel.kind === "post"}>
1089
- <h3>Current draft</h3>
1131
+ <h3>{$t("shell.currentDraft")}</h3>
1090
1132
  <pre>{JSON.stringify(sel.kind === "post" ? { frontmatter: draft, body: bodyDraft } : draft, null, 2)}</pre>
1091
1133
  </section>
1092
1134
  {/if}
1093
1135
  </main>
1094
1136
  {#if manifest.preview && previewTarget}
1095
- <div class="preview-divider" role="separator" tabindex="0" aria-label="Editor and preview width" aria-orientation="vertical" aria-controls="rigel-editor-pane" aria-valuemin={Math.round(splitMin * 100)} aria-valuemax={Math.round(splitMax * 100)} aria-valuenow={Math.round(previewSplit * 100)} aria-valuetext={`${Math.round(previewSplit * 100)}% editor width`} onpointerdown={startPreviewResize} onpointermove={movePreviewResize} onpointerup={endPreviewResize} onpointercancel={endPreviewResize} onlostpointercapture={endPreviewResize} onkeydown={keyPreviewResize}></div>
1137
+ <div class="preview-divider" role="separator" tabindex="0" aria-label={$t("shell.editorPreviewWidth")} aria-orientation="vertical" aria-controls="rigel-editor-pane" aria-valuemin={Math.round(splitMin * 100)} aria-valuemax={Math.round(splitMax * 100)} aria-valuenow={Math.round(previewSplit * 100)} aria-valuetext={$t("shell.editorWidthPercent", { percent: Math.round(previewSplit * 100) })} onpointerdown={startPreviewResize} onpointermove={movePreviewResize} onpointerup={endPreviewResize} onpointercancel={endPreviewResize} onlostpointercapture={endPreviewResize} onkeydown={keyPreviewResize}></div>
1096
1138
  <SitePreview target={previewTarget} config={manifest.preview} isNew={!!newEntry} values={draft} body={bodyDraft} devMode={settings.devMode} />
1097
1139
  {/if}
1098
1140
  </div>
@@ -1106,7 +1148,7 @@
1106
1148
  <Shortcuts onclose={() => (shortcutsOpen = false)} />
1107
1149
  {/if}
1108
1150
  {#if pendingNavigation}
1109
- <UnsavedChanges busy={saveState === "saving"} error={saveError} onsave={saveAndLeave} ondiscard={discardAndLeave} oncancel={() => { pendingNavigation = null; }} />
1151
+ <UnsavedChanges busy={saveState === "saving"} error={saveError} onsave={saveAndLeave} ondiscard={discardAndLeave} oncancel={cancelNavigation} />
1110
1152
  {/if}
1111
1153
  {#if actionDialog}
1112
1154
  <ActionDialog {...actionDialog} onclose={() => { actionDialog = null; }} />