@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 } from "../i18n";
2
3
  import { mount, unmount, onDestroy, tick, type Snippet } from "svelte";
3
4
  import BlockEditor from "./BlockEditor.svelte";
4
5
  import { tooltipRoot } from "./tooltip";
@@ -93,7 +94,7 @@
93
94
 
94
95
  function anchorSelector(path: string) {
95
96
  const escaped = CSS.escape(path);
96
- return `[data-rigel-text="${escaped}"],[data-rigel-richtext="${escaped}"],[data-rigel-image="${escaped}"],[data-rigel-alt="${escaped}"],[data-rigel-href="${escaped}"]`;
97
+ return `[data-rigel-tags="${escaped}"],[data-rigel-text="${escaped}"],[data-rigel-richtext="${escaped}"],[data-rigel-image="${escaped}"],[data-rigel-alt="${escaped}"],[data-rigel-href="${escaped}"]`;
97
98
  }
98
99
 
99
100
  function applySelection(scroll = false) {
@@ -165,7 +166,7 @@
165
166
  }
166
167
 
167
168
  function fieldPathFrom(element: HTMLElement): { path: string; doc: string } | null {
168
- for (const attr of ["data-rigel-text", "data-rigel-richtext", "data-rigel-image", "data-rigel-alt", "data-rigel-href"]) {
169
+ for (const attr of ["data-rigel-tags", "data-rigel-text", "data-rigel-richtext", "data-rigel-image", "data-rigel-alt", "data-rigel-href"]) {
169
170
  const value = element.getAttribute(attr);
170
171
  if (value) return { path: value, doc: element.getAttribute("data-rigel-doc") ?? docId };
171
172
  }
@@ -263,7 +264,7 @@
263
264
  if (!target) return;
264
265
  const articleBody = target.closest<HTMLElement>('[data-rigel-richtext="$body"]');
265
266
  if (inlineBodyEnabled && articleBody) { void startBodyEdit(articleBody, event); return; }
266
- const element = target.closest<HTMLElement>("[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
267
+ const element = target.closest<HTMLElement>("[data-rigel-tags],[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
267
268
  if (element) selectFromElement(element);
268
269
  }
269
270
 
@@ -314,7 +315,7 @@
314
315
  const target = eventElement(event.target);
315
316
  if (target && (editingBody?.contains(target) || bodyScope?.contains(target))) return;
316
317
  if (!target) return;
317
- const element = target.closest<HTMLElement>("[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
318
+ const element = target.closest<HTMLElement>("[data-rigel-tags],[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
318
319
  if (!element || element === highlighted) return;
319
320
  if (hovered && hovered !== element) clearHover();
320
321
  hovered = element;
@@ -324,7 +325,7 @@
324
325
  function onOut(event: MouseEvent) {
325
326
  const target = eventElement(event.target);
326
327
  if (!target) return;
327
- const element = target.closest<HTMLElement>("[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
328
+ const element = target.closest<HTMLElement>("[data-rigel-tags],[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
328
329
  if (element && element === hovered) clearHover();
329
330
  }
330
331
 
@@ -337,7 +338,7 @@
337
338
  structureSignature = "";
338
339
  const doc = docElement();
339
340
  if (!doc) {
340
- error = "The page could not be loaded in the preview.";
341
+ error = $t("richtext.pageLoadError");
341
342
  return;
342
343
  }
343
344
  try {
@@ -348,12 +349,12 @@
348
349
  doc.addEventListener("mouseout", onOut, true);
349
350
  doc.addEventListener("submit", (event) => event.preventDefault(), true);
350
351
  doc.defaultView?.addEventListener("scroll", () => closeInline(), { passive: true });
351
- if (!doc.querySelector(schema ? `[data-rigel-doc="${docId}"]` : "[data-rigel-text],[data-rigel-richtext],[data-rigel-image]")) throw new Error("This page isn’t set up for live editing. Changes won’t appear in this preview.");
352
+ if (!doc.querySelector(schema ? `[data-rigel-doc="${docId}"]` : "[data-rigel-tags],[data-rigel-text],[data-rigel-richtext],[data-rigel-image]")) throw new Error($t("richtext.liveEditingUnavailable"));
352
353
  loaded = true;
353
354
  bodyAvailable = !schema && !!body && !!doc.querySelector('[data-rigel-richtext="$body"]');
354
355
  patch();
355
356
  } catch (cause) {
356
- error = cause instanceof Error ? cause.message : "Preview unavailable";
357
+ error = cause instanceof Error ? cause.message : $t("richtext.previewUnavailable");
357
358
  }
358
359
  }
359
360
 
@@ -438,14 +439,14 @@
438
439
  <div class="visual-editor" bind:clientWidth={editorWidth} class:with-panel={panelOpen} class:resizing-properties={resizing} style={`--properties-width: ${panelWidth}px`}>
439
440
  <div class="visual-canvas">
440
441
  <div class="visual-toolbar">
441
- <div class="mode-toggle" role="group" aria-label="Preview mode">
442
- <button type="button" aria-pressed={mode === "browse"} onclick={() => setMode("browse")}>Browse</button>
443
- <button type="button" aria-pressed={mode === "edit"} onclick={() => setMode("edit")}>Edit</button>
442
+ <div class="mode-toggle" role="group" aria-label={$t("richtext.previewMode")}>
443
+ <button type="button" aria-pressed={mode === "browse"} onclick={() => setMode("browse")}>{$t("richtext.browse")}</button>
444
+ <button type="button" aria-pressed={mode === "edit"} onclick={() => setMode("edit")}>{$t("richtext.edit")}</button>
444
445
  </div>
445
- <span class="visual-hint">{mode === "edit" ? "Click a region to edit it. Scripts, forms and links stay disabled." : "Scripts, forms and links are disabled in this preview."}</span>
446
+ <span class="visual-hint">{mode === "edit" ? $t("richtext.editPreviewHint") : $t("richtext.browsePreviewHint")}</span>
446
447
  <div class="visual-actions">
447
- {#if websitePath}<a class="icon-btn visual-live" href={websitePath} target="_blank" rel="noopener noreferrer" aria-label="Preview on website" data-tooltip="Preview on website"><Icon name="external" size={18} /></a>{/if}
448
- <button type="button" class="icon-btn" aria-pressed={panelOpen} aria-label="Toggle properties panel" data-tooltip="Toggle properties panel" onclick={() => (panelOpen = !panelOpen)}>
448
+ {#if websitePath}<a class="icon-btn visual-live" href={websitePath} target="_blank" rel="noopener noreferrer" aria-label={$t("richtext.previewOnWebsite")} data-tooltip={$t("richtext.previewOnWebsite")}><Icon name="external" size={18} /></a>{/if}
449
+ <button type="button" class="icon-btn" aria-pressed={panelOpen} aria-label={$t("richtext.togglePropertiesPanel")} data-tooltip={$t("richtext.togglePropertiesPanel")} onclick={() => (panelOpen = !panelOpen)}>
449
450
  <SidebarIcon name="square-half" />
450
451
  </button>
451
452
  </div>
@@ -455,16 +456,16 @@
455
456
  <p class="visual-error" role="status">{error}</p>
456
457
  {/if}
457
458
  {#if !previewPath}
458
- <p class="visual-error" role="status">{creating ? "New page — Save writes the file. The route renders once the site has it." : "No preview URL is mapped to this document. Use the properties panel to edit."}</p>
459
+ <p class="visual-error" role="status">{creating ? $t("richtext.newPagePreviewHint") : $t("richtext.noDocumentPreview")}</p>
459
460
  {:else}
460
- <PreviewFrame bind:frame url={previewPath} {reloadToken} title="Website page editor" onload={onLoad} onloading={() => { stopBodyEdit(); inline = null; loaded = false; bodyAvailable = false; error = ""; }} onerror={(message) => { loaded = false; error = message; }} />
461
+ <PreviewFrame bind:frame url={previewPath} {reloadToken} title={$t("richtext.websitePageEditor")} onload={onLoad} onloading={() => { stopBodyEdit(); inline = null; loaded = false; bodyAvailable = false; error = ""; }} onerror={(message) => { loaded = false; error = message; }} />
461
462
  {/if}
462
463
  {#if inline}
463
464
  <input
464
465
  class="inline-editor"
465
466
  bind:this={inlineInput}
466
467
  type="text"
467
- aria-label="Edit text"
468
+ aria-label={$t("richtext.editText")}
468
469
  style={`left:${inline.left}px;top:${inline.top}px;width:${inline.width}px;height:${inline.height}px;font-size:${inline.fontSize}`}
469
470
  value={inline.value}
470
471
  oninput={(event) => updateInline(event.currentTarget.value)}
@@ -477,7 +478,7 @@
477
478
  </div>
478
479
  </div>
479
480
  {#if panelOpen}
480
- <div class="properties-divider" role="separator" tabindex="0" aria-label="Properties panel width" aria-orientation="vertical" aria-valuemin="280" aria-valuemax={panelMax} aria-valuenow={panelWidth} aria-valuetext={`${Math.round(panelWidth)} pixels`} onpointerdown={startResize} onpointermove={moveResize} onpointerup={endResize} onpointercancel={endResize} onlostpointercapture={endResize} onkeydown={keyResize}></div>
481
+ <div class="properties-divider" role="separator" tabindex="0" aria-label={$t("richtext.propertiesPanelWidth")} aria-orientation="vertical" aria-valuemin="280" aria-valuemax={panelMax} aria-valuenow={panelWidth} aria-valuetext={$t("richtext.panelPixels", { width: Math.round(panelWidth) })} onpointerdown={startResize} onpointermove={moveResize} onpointerup={endResize} onpointercancel={endResize} onlostpointercapture={endResize} onkeydown={keyResize}></div>
481
482
  {/if}
482
483
  {#if panelOpen && properties}
483
484
  {@render properties(selected, focusToken, inlineBodyEnabled || inlineBodyLoading, focusBody, inlineBodyLoading)}
@@ -1,5 +1,11 @@
1
- const display = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
2
- export const months = Array.from({ length: 12 }, (_, month) => new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" }).format(new Date(Date.UTC(2000, month, 1))));
1
+ import { get } from "svelte/store";
2
+ import { locale, translate } from "../i18n";
3
+
4
+ export function monthLabels(t = translate): string[] {
5
+ return ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"].map(month => t(`fields.month.${month}`));
6
+ }
7
+
8
+ export const months = monthLabels();
3
9
 
4
10
  export function isoDate(date: Date): string {
5
11
  return `${String(date.getUTCFullYear()).padStart(4, "0")}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
@@ -13,9 +19,9 @@ export function parseDate(value: string): Date | null {
13
19
  return isoDate(date) === value ? date : null;
14
20
  }
15
21
 
16
- export function formatDate(value: string): string {
22
+ export function formatDate(value: string, language = get(locale)): string {
17
23
  const date = parseDate(value);
18
- return date ? display.format(date) : value;
24
+ return date ? new Intl.DateTimeFormat(language === "en" ? "en-US" : language, { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }).format(date) : value;
19
25
  }
20
26
 
21
27
  export function todayDate(date = new Date()): string {
@@ -1,13 +1,31 @@
1
1
  import { embedProviderLabel, embedWatchUrl, safeEmbed } from "./embeds";
2
+ import { t, translate } from "../i18n";
2
3
 
3
- export function createVideoOverlay(doc: Document, label: string, url: string, editable = false): HTMLElement {
4
+ const labels = new WeakMap<HTMLElement, () => string>();
5
+
6
+ function mediaLabel(element: HTMLElement, text: () => string) {
7
+ element.dataset.rigelMediaLabel = "";
8
+ labels.set(element, text);
9
+ element.textContent = text();
10
+ }
11
+
12
+ export function subscribeMediaLabels(root: HTMLElement) {
13
+ return t.subscribe(() => {
14
+ for (const element of root.querySelectorAll<HTMLElement>("[data-rigel-media-label]")) {
15
+ const text = labels.get(element)?.();
16
+ if (text !== undefined && element.textContent !== text) element.textContent = text;
17
+ }
18
+ });
19
+ }
20
+
21
+ export function createVideoOverlay(doc: Document, label: () => string, url: string, editable = false): HTMLElement {
4
22
  const overlay = doc.createElement("div");
5
23
  overlay.contentEditable = "false";
6
24
  overlay.style.cssText = "position:absolute;inset:0;display:grid;place-items:center;padding:1rem;background:#0006";
7
25
  const card = doc.createElement("div");
8
26
  card.style.cssText = "box-sizing:border-box;width:100%;max-width:360px;padding:1rem;background:#18181b;color:white;border:1px solid #52525b;font:14px/1.5 system-ui;text-align:left";
9
27
  const heading = doc.createElement("strong");
10
- heading.textContent = label;
28
+ mediaLabel(heading, label);
11
29
  const link = doc.createElement("div");
12
30
  link.textContent = url;
13
31
  link.style.cssText = "margin:0.5rem 0 1rem;overflow-wrap:anywhere;color:#e4e4e7";
@@ -19,7 +37,7 @@ export function createVideoOverlay(doc: Document, label: string, url: string, ed
19
37
  event.stopPropagation();
20
38
  if (event.key === "Enter" || event.key === " ") { event.preventDefault(); edit.click(); }
21
39
  });
22
- edit.textContent = "Edit video";
40
+ mediaLabel(edit, () => translate("media.editVideo"));
23
41
  edit.hidden = !editable;
24
42
  edit.style.cssText = "min-height:44px;padding:0.5rem 0.75rem;border:1px solid #d4d4d8;background:white;color:#18181b;font:inherit;cursor:pointer";
25
43
  card.append(heading, link, edit);
@@ -49,9 +67,9 @@ export function createEmbedPreview(value: unknown, doc: Document, editable = fal
49
67
  poster.append(image);
50
68
  }
51
69
  const caption = doc.createElement("figcaption");
52
- caption.textContent = `${embedProviderLabel(ref)} · ${title || "Video"}`;
70
+ mediaLabel(caption, () => translate("media.embedCaption", { provider: embedProviderLabel(ref), title: title || translate("media.video") }));
53
71
  caption.style.cssText = "margin-top:0.5rem;font-size:0.875rem;line-height:1.5";
54
- poster.append(createVideoOverlay(doc, `${embedProviderLabel(ref)} Video`, embedWatchUrl(ref), editable));
72
+ poster.append(createVideoOverlay(doc, () => translate("media.providerVideo", { provider: embedProviderLabel(ref) }), embedWatchUrl(ref), editable));
55
73
  figure.append(poster, caption);
56
74
  return figure;
57
75
  }
@@ -0,0 +1,121 @@
1
+ import modules from "virtual:rigel-extensions";
2
+ import { mount, unmount } from "svelte";
3
+ import { $getRoot, $getNodeByKey, type LexicalEditor } from "lexical";
4
+ import { resolveExtensions } from "../../extensions";
5
+ import type { BlockData, ExtensionBlock } from "../../extensions";
6
+ import { ExtensionNode, cloneBlockData } from "../blocks/extension";
7
+
8
+
9
+ const resolved = resolveExtensions(modules);
10
+ export const extensionRegistry = {
11
+ commands: Object.fromEntries(resolved.commands.map(command => [command.name, command])),
12
+ blocks: Object.fromEntries(resolved.blocks.map(block => [block.name, block])),
13
+ };
14
+ export function extensionBlock(name: string): ExtensionBlock | undefined {
15
+ return Object.hasOwn(extensionRegistry.blocks, name) ? extensionRegistry.blocks[name] : undefined;
16
+ }
17
+ export function reportExtensionError(target: HTMLElement, cause: unknown) {
18
+ const message = cause instanceof Error ? cause.message : String(cause);
19
+ console.error("Rigel extension:", cause);
20
+ target.dispatchEvent(new target.ownerDocument.defaultView!.CustomEvent("rigel-extension-error", { bubbles: true, detail: message }));
21
+ }
22
+ type ExtensionMountHandle = (() => void) & { update: (data: BlockData) => void };
23
+ export function mountExtensionComponent(target: HTMLElement, name: string, raw: unknown, onchange?: (data: BlockData) => void): ExtensionMountHandle {
24
+ const report = (cause: unknown) => reportExtensionError(target, cause);
25
+ const block = extensionBlock(name);
26
+ const fallback = (data: unknown) => { target.textContent = `Unavailable extension: ${block?.label ?? name}\n${JSON.stringify(data, null, 2)}`; };
27
+ const failed = Object.assign(() => {}, { update: fallback });
28
+ target.replaceChildren();
29
+ if (!block) { fallback(raw); return failed; }
30
+ try {
31
+ let data = cloneBlockData(raw);
32
+ block.validate(cloneBlockData(data));
33
+ let disposed = false;
34
+ let component: { update: (data: BlockData) => void } | undefined;
35
+ void import("./ExtensionMount.svelte").then(({ default: ExtensionMount }) => {
36
+ if (disposed || !target.isConnected) return;
37
+ component = mount(ExtensionMount, { target, props: { component: onchange ? block.editor : block.render, data, onchange, report } }) as unknown as { update: (data: BlockData) => void };
38
+ }).catch(cause => { if (!disposed) { fallback(data); report(cause); } });
39
+ return Object.assign(() => { disposed = true; if (component) void unmount(component).catch(report); }, { update: (next: BlockData) => {
40
+ try { const copy = cloneBlockData(next); block.validate(cloneBlockData(copy)); data = copy; component?.update(copy); }
41
+ catch (cause) { report(cause); }
42
+ } });
43
+ } catch (cause) { fallback(raw); report(cause); return failed; }
44
+ }
45
+ export function createExtensionPreview(name: string, data: unknown, doc: Document) {
46
+ const element = doc.createElement("div");
47
+ element.dataset.rigelExtension = name;
48
+ element.dataset.rigelExtensionData = JSON.stringify(cloneBlockData(data));
49
+ element.textContent = `Extension: ${name}`;
50
+ return element;
51
+ }
52
+ const previewMounts = new WeakMap<HTMLElement, ExtensionMountHandle>();
53
+ export function mountExtensionPreviews(root: ParentNode): () => void {
54
+ const doc = root.nodeType === 9 ? root as Document : root.ownerDocument!;
55
+ const frame = doc.defaultView?.frameElement;
56
+ const mounted = new Map<HTMLElement, { stop: ExtensionMountHandle; signature: string }>();
57
+ const markers = () => [...(root.nodeType === 1 && (root as Element).hasAttribute("data-rigel-extension") ? [root as HTMLElement] : []), ...root.querySelectorAll<HTMLElement>("[data-rigel-extension]")];
58
+ const sync = () => {
59
+ if (frame && !frame.isConnected) { cleanup(); return; }
60
+ for (const [element, entry] of mounted) if (!element.isConnected || !root.contains(element) || element.closest('[contenteditable="true"]')) { entry.stop(); mounted.delete(element); previewMounts.delete(element); }
61
+ for (const element of markers()) {
62
+ if (!element.isConnected || element.closest('[contenteditable="true"]')) continue;
63
+ const signature = `${element.dataset.rigelExtension}\n${element.dataset.rigelExtensionData}`;
64
+ const existing = mounted.get(element);
65
+ if (existing?.signature === signature || (!existing && previewMounts.has(element))) continue;
66
+ if (existing) { existing.stop(); mounted.delete(element); previewMounts.delete(element); }
67
+ try {
68
+ const stop = mountExtensionComponent(element, element.dataset.rigelExtension!, JSON.parse(element.dataset.rigelExtensionData ?? "null"));
69
+ mounted.set(element, { stop, signature }); previewMounts.set(element, stop);
70
+ } catch (cause) { element.textContent = "Extension unavailable"; reportExtensionError(element, cause); }
71
+ }
72
+ };
73
+ const observer = new doc.defaultView!.MutationObserver(sync);
74
+ observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-rigel-extension", "data-rigel-extension-data", "contenteditable"] });
75
+ if (frame) observer.observe(frame.ownerDocument, { childList: true, subtree: true });
76
+ const cleanup = () => { observer.disconnect(); for (const [element, entry] of mounted) { entry.stop(); previewMounts.delete(element); } mounted.clear(); doc.defaultView?.removeEventListener("pagehide", cleanup); };
77
+ doc.defaultView?.addEventListener("pagehide", cleanup);
78
+ sync();
79
+ return cleanup;
80
+ }
81
+
82
+ export function registerExtensionNodes(editor: LexicalEditor) {
83
+ const mounted = new Map<string, { element: HTMLElement; name: string; json: string; stop: ExtensionMountHandle }>();
84
+ const doc = editor.getRootElement()!.ownerDocument;
85
+ const frame = doc.defaultView?.frameElement;
86
+ let disposed = false;
87
+ const sync = () => {
88
+ if (disposed) return;
89
+ if (frame && !frame.isConnected) { cleanup(); return; }
90
+ editor.getEditorState().read(() => {
91
+ const nodes = new Map($getRoot().getChildren().filter((node): node is ExtensionNode => node instanceof ExtensionNode).map(node => [node.getKey(), node]));
92
+ for (const [key, entry] of mounted) {
93
+ const node = nodes.get(key), element = editor.getElementByKey(key);
94
+ if (!node || !element?.isConnected || element !== entry.element || node.__name !== entry.name) { entry.stop(); mounted.delete(key); }
95
+ }
96
+ for (const [key, node] of nodes) {
97
+ const element = editor.getElementByKey(key);
98
+ if (!element?.isConnected) continue;
99
+ const json = JSON.stringify(node.__data), existing = mounted.get(key);
100
+ if (existing) { if (existing.json !== json) { existing.json = json; existing.stop.update(node.__data); } continue; }
101
+ const stop = mountExtensionComponent(element, node.__name, node.__data, data => {
102
+ try {
103
+ const copy = cloneBlockData(data), block = extensionBlock(node.__name);
104
+ if (disposed || !element.isConnected || !mounted.has(key) || !editor.isEditable() || !block) throw new Error("Extension editor is no longer active");
105
+ block.validate(cloneBlockData(copy));
106
+ editor.update(() => { const current = $getNodeByKey(key); if (current instanceof ExtensionNode) current.setData(copy); }, { discrete: true, tag: "history-push" });
107
+ } catch (cause) { reportExtensionError(element, cause); }
108
+ });
109
+ mounted.set(key, { element, name: node.__name, json, stop });
110
+ }
111
+ });
112
+ };
113
+ const observer = new doc.defaultView!.MutationObserver(sync);
114
+ observer.observe(doc, { childList: true, subtree: true });
115
+ if (frame) observer.observe(frame.ownerDocument, { childList: true, subtree: true });
116
+ const stopUpdates = editor.registerUpdateListener(sync);
117
+ const cleanup = () => { if (disposed) return; disposed = true; stopUpdates(); observer.disconnect(); for (const entry of mounted.values()) entry.stop(); mounted.clear(); doc.defaultView?.removeEventListener("pagehide", cleanup); };
118
+ doc.defaultView?.addEventListener("pagehide", cleanup);
119
+ sync();
120
+ return cleanup;
121
+ }
@@ -1,3 +1,4 @@
1
+ import { translate } from "../i18n";
1
2
  export interface GitHubAccess {
2
3
  user: { login: string; name?: string; avatar_url: string; html_url: string };
3
4
  status: "ready" | "installation-required" | "read-only";
@@ -12,7 +13,7 @@ export function startGitHubLogin(authOrigin: string): Promise<{ accessToken: str
12
13
  try {
13
14
  origin = new URL(authOrigin);
14
15
  if (origin.username || origin.password || origin.search || origin.hash || origin.pathname !== "/" || (origin.protocol !== "https:" && !(origin.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname)))) throw new Error();
15
- } catch { reject(new Error("Invalid authentication gateway origin.")); return; }
16
+ } catch { reject(new Error(translate("connection.invalidAuthOrigin"))); return; }
16
17
  const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, "0")).join("");
17
18
  const url = new URL("/authorize", origin);
18
19
  url.searchParams.set("origin", window.location.origin);
@@ -33,27 +34,27 @@ export function startGitHubLogin(authOrigin: string): Promise<{ accessToken: str
33
34
  if (!popup || event.source !== popup || event.origin !== origin.origin || !data || data.type !== "rigel:auth" || data.provider !== "github" || data.nonce !== nonce) return;
34
35
  if (data.error) {
35
36
  finish(data.error === "Authorization denied"
36
- ? "GitHub authorization was denied. Sign in again and approve access to continue."
37
+ ? translate("connection.authorizationDenied")
37
38
  : data.error === "Token exchange failed"
38
- ? "The auth gateway could not exchange GitHub's authorization code for a token. Repository access has not been checked."
39
- : "GitHub login failed. Please try signing in again.");
39
+ ? translate("connection.tokenExchangeFailed")
40
+ : translate("connection.loginFailed"));
40
41
  return;
41
42
  }
42
- if (typeof data.access_token !== "string" || !/^ghu_[a-zA-Z0-9_]{1,4092}$/.test(data.access_token) || !Number.isInteger(data.expires_in) || data.expires_in <= 0 || data.expires_in > 86400) { finish("GitHub returned invalid or expired credentials. Please sign in again."); return; }
43
+ if (typeof data.access_token !== "string" || !/^ghu_[a-zA-Z0-9_]{1,4092}$/.test(data.access_token) || !Number.isInteger(data.expires_in) || data.expires_in <= 0 || data.expires_in > 86400) { finish(translate("connection.invalidCredentials")); return; }
43
44
  finish(undefined, { accessToken: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 });
44
45
  };
45
46
  window.addEventListener("message", receive);
46
47
  try { popup = window.open(url.href, "rigel-github-" + nonce, "popup,width=600,height=720"); }
47
- catch { finish("Unable to open GitHub login. Allow popups and try again."); return; }
48
- if (!popup) { finish("GitHub login popup was blocked. Allow popups and try again."); return; }
49
- timer = setInterval(() => { if (popup?.closed) finish("GitHub login was cancelled. Please try again."); }, 250);
50
- timeout = setTimeout(() => finish("GitHub login timed out. Please try again."), 120000);
48
+ catch { finish(translate("connection.loginOpenFailed")); return; }
49
+ if (!popup) { finish(translate("connection.loginBlocked")); return; }
50
+ timer = setInterval(() => { if (popup?.closed) finish(translate("connection.loginCancelled")); }, 250);
51
+ timeout = setTimeout(() => finish(translate("connection.loginTimeout")), 120000);
51
52
  });
52
53
  }
53
54
 
54
55
  export async function checkGitHubAccess(token: string, repo: string, appSlug: string): Promise<GitHubAccess> {
55
- if (typeof token !== "string" || !/^ghu_[a-zA-Z0-9_]{1,4092}$/.test(token)) throw new Error("GitHub session is invalid. Please sign in again.");
56
- if (typeof repo !== "string" || !/^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\/[a-zA-Z0-9_.-]+$/.test(repo) || [".", ".."].includes(repo.split("/")[1]) || typeof appSlug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(appSlug)) throw new Error("Invalid GitHub repository or app configuration.");
56
+ if (typeof token !== "string" || !/^ghu_[a-zA-Z0-9_]{1,4092}$/.test(token)) throw new Error(translate("connection.invalidSession"));
57
+ if (typeof repo !== "string" || !/^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\/[a-zA-Z0-9_.-]+$/.test(repo) || [".", ".."].includes(repo.split("/")[1]) || typeof appSlug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(appSlug)) throw new Error(translate("connection.invalidConfiguration"));
57
58
  const get = async (endpoint: string) => {
58
59
  let response: Response;
59
60
  try {
@@ -61,30 +62,30 @@ export async function checkGitHubAccess(token: string, repo: string, appSlug: st
61
62
  method: "GET", redirect: "error", credentials: "omit", cache: "no-store", signal: AbortSignal.timeout(15000),
62
63
  headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" },
63
64
  });
64
- } catch { throw new Error("Unable to reach GitHub. Check your connection and try again."); }
65
+ } catch { throw new Error(translate("connection.unreachable")); }
65
66
  if (!response.ok) {
66
- if (response.status === 401) throw Object.assign(new Error("GitHub session expired or was revoked. Please sign in again."), { status: 401 });
67
- if (response.status === 429 || (response.status === 403 && (response.headers.get("x-ratelimit-remaining") === "0" || response.headers.has("retry-after")))) throw new Error("GitHub rate limit reached. Wait a few minutes and try again.");
68
- if (response.status === 403) throw new Error("GitHub access denied. Check app permissions and organization approval, then sign in again.");
69
- if (response.status === 404) throw new Error("GitHub repository or installation is unavailable. Check the repository name and app installation, then try again.");
70
- throw new Error("GitHub could not verify access. Please try again later.");
67
+ if (response.status === 401) throw Object.assign(new Error(translate("connection.sessionExpired")), { status: 401 });
68
+ if (response.status === 429 || (response.status === 403 && (response.headers.get("x-ratelimit-remaining") === "0" || response.headers.has("retry-after")))) throw new Error(translate("connection.accessRateLimited"));
69
+ if (response.status === 403) throw new Error(translate("connection.accessDenied"));
70
+ if (response.status === 404) throw new Error(translate("connection.repositoryUnavailable"));
71
+ throw new Error(translate("connection.accessCheckFailed"));
71
72
  }
72
73
  try { return await response.json(); }
73
- catch { throw new Error("GitHub returned an invalid access response. Please try again."); }
74
+ catch { throw new Error(translate("connection.invalidAccessResponse")); }
74
75
  };
75
76
  const list = async <T>(endpoint: string, key: string): Promise<T[]> => {
76
77
  const entries: T[] = [];
77
78
  for (let page = 1; page <= 100; page++) {
78
79
  const data = await get(`${endpoint}?per_page=100&page=${page}`);
79
- if (!data || !Array.isArray(data[key])) throw new Error("GitHub returned an invalid access response. Please try again.");
80
+ if (!data || !Array.isArray(data[key])) throw new Error(translate("connection.invalidAccessResponse"));
80
81
  entries.push(...data[key]);
81
82
  if (data[key].length < 100 && !(typeof data.total_count === "number" && entries.length < data.total_count)) return entries;
82
83
  if (!data[key].length) break;
83
84
  }
84
- throw new Error("GitHub access listing is incomplete. Please try again or narrow the app's repository selection.");
85
+ throw new Error(translate("connection.incompleteAccessListing"));
85
86
  };
86
87
  const rawUser = await get("/user");
87
- if (!rawUser || ![rawUser.login, rawUser.avatar_url, rawUser.html_url].every(value => typeof value === "string" && value)) throw new Error("GitHub returned an invalid user. Please sign in again.");
88
+ if (!rawUser || ![rawUser.login, rawUser.avatar_url, rawUser.html_url].every(value => typeof value === "string" && value)) throw new Error(translate("connection.invalidUser"));
88
89
  const name = typeof rawUser.name === "string" ? rawUser.name.trim() : "";
89
90
  const user = { login: rawUser.login, ...(name ? { name } : {}), avatar_url: rawUser.avatar_url, html_url: rawUser.html_url };
90
91
  const installUrl = `https://github.com/apps/${appSlug}/installations/new`;
@@ -106,7 +107,7 @@ export async function checkGitHubAccess(token: string, repo: string, appSlug: st
106
107
  const repositories = await list<NonNullable<GitHubAccess["repository"]>>(`/user/installations/${installation.id}/repositories`, "repositories");
107
108
  const repository = repositories.find(repository => repository && typeof repository.full_name === "string" && repository.full_name.toLowerCase() === repo.toLowerCase());
108
109
  if (!repository) continue;
109
- if (!Number.isSafeInteger(repository.id) || repository.id <= 0 || typeof repository.default_branch !== "string" || !repository.default_branch) throw new Error("GitHub returned an invalid repository. Please try again.");
110
+ if (!Number.isSafeInteger(repository.id) || repository.id <= 0 || typeof repository.default_branch !== "string" || !repository.default_branch) throw new Error(translate("connection.invalidRepository"));
110
111
  const push = repository.permissions?.push === true;
111
112
  return {
112
113
  user, status: push && installation.permissions?.contents === "write" && !installation.suspended_at ? "ready" : "read-only", installUrl: configureUrl,