@rigel-cms/rigel 0.2.0-beta.1 → 1.0.0-rc.1

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 (39) hide show
  1. package/README.md +4 -2
  2. package/package.json +3 -4
  3. package/src/article-body.ts +9 -2
  4. package/src/article-render.ts +27 -1
  5. package/src/config.ts +8 -0
  6. package/src/editor/App.svelte +6 -7
  7. package/src/editor/blocks/embed.ts +21 -0
  8. package/src/editor/blocks/index.ts +3 -1
  9. package/src/editor/blocks/video.ts +35 -0
  10. package/src/editor/lib/ActionDialog.svelte +2 -1
  11. package/src/editor/lib/BlockEditor.svelte +84 -15
  12. package/src/editor/lib/CollectionPanel.svelte +4 -3
  13. package/src/editor/lib/ImagePicker.svelte +141 -47
  14. package/src/editor/lib/MediaPicker.svelte +73 -0
  15. package/src/editor/lib/Select.svelte +5 -2
  16. package/src/editor/lib/VisualEditor.svelte +11 -6
  17. package/src/editor/lib/embed-preview.ts +57 -0
  18. package/src/editor/lib/embeds.ts +47 -0
  19. package/src/editor/lib/github-backend.ts +2 -1
  20. package/src/editor/lib/icons.ts +13 -0
  21. package/src/editor/lib/images.ts +20 -8
  22. package/src/editor/lib/media-library.ts +11 -1
  23. package/src/editor/lib/settings.ts +2 -2
  24. package/src/editor/lib/tooltip.ts +23 -4
  25. package/src/editor/styles.css +29 -12
  26. package/src/env.d.ts +1 -1
  27. package/src/index.ts +4 -6
  28. package/src/json-output.ts +5 -0
  29. package/src/manifest/types.ts +1 -0
  30. package/src/media-types.ts +14 -1
  31. package/src/preview.ts +7 -3
  32. package/src/routes/local-documents.ts +1 -1
  33. package/src/routes/local-media.ts +33 -28
  34. package/src/routes/local-menu.ts +2 -1
  35. package/src/routes/local-post.ts +2 -1
  36. package/src/routes/media-files.ts +36 -1
  37. package/src/routes/media-validation.ts +2 -2
  38. package/src/schema/store.ts +3 -1
  39. package/src/vite/manifest-plugin.ts +3 -0
@@ -0,0 +1,47 @@
1
+ export type EmbedProvider = "youtube" | "vimeo";
2
+ export type EmbedRef = { provider: EmbedProvider; id: string };
3
+
4
+ const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
5
+ const VIMEO_ID = /^\d{6,12}$/;
6
+ const YOUTUBE_HOSTS = new Set(["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "music.youtube.com", "youtube-nocookie.com", "www.youtube-nocookie.com"]);
7
+ const VIMEO_HOSTS = new Set(["vimeo.com", "www.vimeo.com", "player.vimeo.com"]);
8
+
9
+ export function parseEmbedUrl(value: string): EmbedRef | undefined {
10
+ const raw = value.trim();
11
+ if (!/^https?:\/\//i.test(raw) || /[\s\\\u0000-\u001f\u007f]/.test(raw)) return undefined;
12
+ let url: URL;
13
+ try { url = new URL(raw); } catch { return undefined; }
14
+ const host = url.hostname.toLowerCase();
15
+ if (YOUTUBE_HOSTS.has(host)) {
16
+ const parts = url.pathname.split("/").filter(Boolean);
17
+ const candidate = host === "youtu.be" ? parts[0] : url.searchParams.get("v") ?? (["embed", "shorts", "live", "v"].includes(parts[0] ?? "") ? parts[1] : undefined);
18
+ return candidate && YOUTUBE_ID.test(candidate) ? { provider: "youtube", id: candidate } : undefined;
19
+ }
20
+ if (VIMEO_HOSTS.has(host)) {
21
+ const parts = url.pathname.split("/").filter(Boolean);
22
+ const candidate = parts[0] === "video" ? parts[1] : /^\d{6,12}$/.test(parts.at(-1) ?? "") ? parts.at(-1) : undefined;
23
+ return candidate && VIMEO_ID.test(candidate) ? { provider: "vimeo", id: candidate } : undefined;
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ export function embedSrc(ref: EmbedRef, privacy = true): string {
29
+ if (ref.provider === "youtube") return `https://www.${privacy ? "youtube-nocookie.com" : "youtube.com"}/embed/${ref.id}?rel=0`;
30
+ return `https://player.vimeo.com/video/${ref.id}${privacy ? "?dnt=1" : ""}`;
31
+ }
32
+
33
+ export function embedWatchUrl(ref: EmbedRef): string {
34
+ return ref.provider === "youtube" ? `https://www.youtube.com/watch?v=${ref.id}` : `https://vimeo.com/${ref.id}`;
35
+ }
36
+
37
+ export function embedProviderLabel(ref: EmbedRef): string {
38
+ return ref.provider === "youtube" ? "YouTube" : "Vimeo";
39
+ }
40
+
41
+ export function safeEmbed(ref: unknown): EmbedRef | undefined {
42
+ if (!ref || typeof ref !== "object") return undefined;
43
+ const { provider, id } = ref as EmbedRef;
44
+ if (provider === "youtube" && typeof id === "string" && YOUTUBE_ID.test(id)) return { provider, id };
45
+ if (provider === "vimeo" && typeof id === "string" && VIMEO_ID.test(id)) return { provider, id };
46
+ return undefined;
47
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Manifest } from "../../manifest/types";
2
+ import { serializeContentJson } from "../../json-output";
2
3
  import type { Selection } from "../../manifest/navigation";
3
4
  import { validateBody, type ArticleBody } from "../../article-body";
4
5
  import { validateMenu } from "../../manifest/menu";
@@ -289,7 +290,7 @@ async function saveSelection(ctx: GitContext, manifest: Manifest, selection: Sel
289
290
  data = validateMenu({...values,slug:selection.slug},cfg.menus.maxDepth);
290
291
  path = filePath(ctx,manifest,cfg.menus.folder,selection.slug);
291
292
  } else throw new Error("Select editable content before saving");
292
- await mutate(ctx,manifest,path,JSON.stringify(data,null,2) + "\n",creating);
293
+ await mutate(ctx,manifest,path,serializeContentJson(data,cfg.jsonOutput),creating);
293
294
  }
294
295
 
295
296
  export interface PublishFile { filename: string; status: "added" | "modified" | "removed" | "renamed"; previous_filename?: string }
@@ -1,4 +1,14 @@
1
1
  export const ICONS = {
2
+ file: { viewBox: "0 0 256 256", d: "M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Z" },
3
+ fileAudio: { viewBox: "0 0 256 256", d: "M99.06,128.61a8,8,0,0,0-8.72,1.73L68.69,152H48a8,8,0,0,0-8,8v40a8,8,0,0,0,8,8H68.69l21.65,21.66A8,8,0,0,0,104,224V136A8,8,0,0,0,99.06,128.61ZM88,204.69,77.66,194.34A8,8,0,0,0,72,192H56V168H72a8,8,0,0,0,5.66-2.34L88,155.31ZM152,180a40.55,40.55,0,0,1-20,34.91A8,8,0,0,1,124,201.09a24.49,24.49,0,0,0,0-42.18A8,8,0,0,1,132,145.09,40.55,40.55,0,0,1,152,180Zm61.66-97.66-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40v80a8,8,0,0,0,16,0V40h88V88a8,8,0,0,0,8,8h48V216H168a8,8,0,0,0,0,16h32a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160Z" },
4
+ fileDoc: { viewBox: "0 0 256 256", d: "M52,144H36a8,8,0,0,0-8,8v56a8,8,0,0,0,8,8H52a36,36,0,0,0,0-72Zm0,56H44V160h8a20,20,0,0,1,0,40Zm169.53-4.91a8,8,0,0,1,.25,11.31A30.06,30.06,0,0,1,200,216c-17.65,0-32-16.15-32-36s14.35-36,32-36a30.06,30.06,0,0,1,21.78,9.6,8,8,0,0,1-11.56,11.06A14.24,14.24,0,0,0,200,160c-8.82,0-16,9-16,20s7.18,20,16,20a14.24,14.24,0,0,0,10.22-4.66A8,8,0,0,1,221.53,195.09ZM128,144c-17.65,0-32,16.15-32,36s14.35,36,32,36,32-16.15,32-36S145.65,144,128,144Zm0,56c-8.82,0-16-9-16-20s7.18-20,16-20,16,9,16,20S136.82,200,128,200ZM48,120a8,8,0,0,0,8-8V40h88V88a8,8,0,0,0,8,8h48v16a8,8,0,0,0,16,0V88a8,8,0,0,0-2.34-5.66l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40v72A8,8,0,0,0,48,120ZM160,51.31,188.69,80H160Z" },
5
+ filePdf: { viewBox: "0 0 256 256", d: "M224,152a8,8,0,0,1-8,8H192v16h16a8,8,0,0,1,0,16H192v16a8,8,0,0,1-16,0V152a8,8,0,0,1,8-8h32A8,8,0,0,1,224,152ZM92,172a28,28,0,0,1-28,28H56v8a8,8,0,0,1-16,0V152a8,8,0,0,1,8-8H64A28,28,0,0,1,92,172Zm-16,0a12,12,0,0,0-12-12H56v24h8A12,12,0,0,0,76,172Zm88,8a36,36,0,0,1-36,36H112a8,8,0,0,1-8-8V152a8,8,0,0,1,8-8h16A36,36,0,0,1,164,180Zm-16,0a20,20,0,0,0-20-20h-8v40h8A20,20,0,0,0,148,180ZM40,112V40A16,16,0,0,1,56,24h96a8,8,0,0,1,5.66,2.34l56,56A8,8,0,0,1,216,88v24a8,8,0,0,1-16,0V96H152a8,8,0,0,1-8-8V40H56v72a8,8,0,0,1-16,0ZM160,80h28.69L160,51.31Z" },
6
+ filePpt: { viewBox: "0 0 256 256", d: "M224,152a8,8,0,0,1-8,8H204v48a8,8,0,0,1-16,0V160H176a8,8,0,0,1,0-16h40A8,8,0,0,1,224,152ZM92,172a28,28,0,0,1-28,28H56v8a8,8,0,0,1-16,0V152a8,8,0,0,1,8-8H64A28,28,0,0,1,92,172Zm-16,0a12,12,0,0,0-12-12H56v24h8A12,12,0,0,0,76,172Zm84,0a28,28,0,0,1-28,28h-8v8a8,8,0,0,1-16,0V152a8,8,0,0,1,8-8h16A28,28,0,0,1,160,172Zm-16,0a12,12,0,0,0-12-12h-8v24h8A12,12,0,0,0,144,172ZM40,112V40A16,16,0,0,1,56,24h96a8,8,0,0,1,5.66,2.34l56,56A8,8,0,0,1,216,88v24a8,8,0,0,1-16,0V96H152a8,8,0,0,1-8-8V40H56v72a8,8,0,0,1-16,0ZM160,80h28.69L160,51.31Z" },
7
+ fileText: { viewBox: "0 0 256 256", d: "M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Zm-32-80a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,136Zm0,32a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,168Z" },
8
+ fileTxt: { viewBox: "0 0 256 256", d: "M48,120a8,8,0,0,0,8-8V40h88V88a8,8,0,0,0,8,8h48v16a8,8,0,0,0,16,0V88a8,8,0,0,0-2.34-5.66l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40v72A8,8,0,0,0,48,120ZM160,51.31,188.69,80H160Zm-5.49,105.34L137.83,180l16.68,23.35a8,8,0,0,1-13,9.3L128,193.76l-13.49,18.89a8,8,0,1,1-13-9.3L118.17,180l-16.68-23.35a8,8,0,1,1,13-9.3L128,166.24l13.49-18.89a8,8,0,0,1,13,9.3ZM92,152a8,8,0,0,1-8,8H72v48a8,8,0,0,1-16,0V160H44a8,8,0,0,1,0-16H84A8,8,0,0,1,92,152Zm128,0a8,8,0,0,1-8,8H200v48a8,8,0,0,1-16,0V160H172a8,8,0,0,1,0-16h40A8,8,0,0,1,220,152Z" },
9
+ fileVideo: { viewBox: "0 0 256 256", d: "M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40v72a8,8,0,0,0,16,0V40h88V88a8,8,0,0,0,8,8h48V216h-8a8,8,0,0,0,0,16h8a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM155.88,145a8,8,0,0,0-8.12.22l-19.95,12.46A16,16,0,0,0,112,144H48a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h64a16,16,0,0,0,15.81-13.68l19.95,12.46A8,8,0,0,0,160,216V152A8,8,0,0,0,155.88,145ZM112,208H48V160h64v48Zm32-6.43-16-10V176.43l16-10Z" },
10
+ fileXls: { viewBox: "0 0 256 256", d: "M156,208a8,8,0,0,1-8,8H120a8,8,0,0,1-8-8V152a8,8,0,0,1,16,0v48h20A8,8,0,0,1,156,208ZM92.65,145.49a8,8,0,0,0-11.16,1.86L68,166.24,54.51,147.35a8,8,0,1,0-13,9.3L58.17,180,41.49,203.35a8,8,0,0,0,13,9.3L68,193.76l13.49,18.89a8,8,0,0,0,13-9.3L77.83,180l16.68-23.35A8,8,0,0,0,92.65,145.49Zm98.94,25.82c-4-1.16-8.14-2.35-10.45-3.84-1.25-.82-1.23-1-1.12-1.9a4.54,4.54,0,0,1,2-3.67c4.6-3.12,15.34-1.72,19.82-.56a8,8,0,0,0,4.07-15.48c-2.11-.55-21-5.22-32.83,2.76a20.58,20.58,0,0,0-8.95,14.95c-2,15.88,13.65,20.41,23,23.11,12.06,3.49,13.12,4.92,12.78,7.59-.31,2.41-1.26,3.33-2.15,3.93-4.6,3.06-15.16,1.55-19.54.35A8,8,0,0,0,173.93,214a60.63,60.63,0,0,0,15.19,2c5.82,0,12.3-1,17.49-4.46a20.81,20.81,0,0,0,9.18-15.23C218,179,201.48,174.17,191.59,171.31ZM40,112V40A16,16,0,0,1,56,24h96a8,8,0,0,1,5.66,2.34l56,56A8,8,0,0,1,216,88v24a8,8,0,1,1-16,0V96H152a8,8,0,0,1-8-8V40H56v72a8,8,0,0,1-16,0ZM160,80h28.68L160,51.31Z" },
11
+ fileZip: { viewBox: "0 0 256 256", d: "M184,144H168a8,8,0,0,0-8,8v56a8,8,0,0,0,16,0v-8h8a28,28,0,0,0,0-56Zm0,40h-8V160h8a12,12,0,0,1,0,24Zm-48-32v56a8,8,0,0,1-16,0V152a8,8,0,0,1,16,0ZM96,208a8,8,0,0,1-8,8H56a8,8,0,0,1-7-12l25.16-44H56a8,8,0,0,1,0-16H88a8,8,0,0,1,7,12L69.79,200H88A8,8,0,0,1,96,208ZM213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40v72a8,8,0,0,0,16,0V40h88V88a8,8,0,0,0,8,8h48v16a8,8,0,0,0,16,0V88A8,8,0,0,0,213.66,82.34ZM160,80V51.31L188.69,80Z" },
2
12
  calendar: { viewBox: "0 0 256 256", d: "M208,32H184V24a8,8,0,0,0-16,0v8H88V24a8,8,0,0,0-16,0v8H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM72,48v8a8,8,0,0,0,16,0V48h80v8a8,8,0,0,0,16,0V48h24V80H48V48ZM208,208H48V96H208V208Zm-96-88v64a8,8,0,0,1-16,0V132.94l-4.42,2.22a8,8,0,0,1-7.16-14.32l16-8A8,8,0,0,1,112,120Zm59.16,30.45L152,176h16a8,8,0,0,1,0,16H136a8,8,0,0,1-6.4-12.8l28.78-38.37A8,8,0,1,0,145.07,132a8,8,0,1,1-13.85-8A24,24,0,0,1,176,136,23.76,23.76,0,0,1,171.16,150.45Z" },
3
13
  textUnderline: { viewBox: "0 0 256 256", d: "M200,224a8,8,0,0,1-8,8H64a8,8,0,0,1,0-16H192A8,8,0,0,1,200,224Zm-72-24a64.07,64.07,0,0,0,64-64V56a8,8,0,0,0-16,0v80a48,48,0,0,1-96,0V56a8,8,0,0,0-16,0v80A64.07,64.07,0,0,0,128,200Z" },
4
14
  highlighter: { viewBox: "0 0 256 256", d: "M253.66,106.34a8,8,0,0,0-11.32,0L192,156.69,107.31,72l50.35-50.34a8,8,0,1,0-11.32-11.32L96,60.69A16,16,0,0,0,93.18,79.5L72,100.69a16,16,0,0,0,0,22.62L76.69,128,18.34,186.34a8,8,0,0,0,3.13,13.25l72,24A7.88,7.88,0,0,0,96,224a8,8,0,0,0,5.66-2.34L136,187.31l4.69,4.69a16,16,0,0,0,22.62,0l21.19-21.18A16,16,0,0,0,203.31,168l50.35-50.34A8,8,0,0,0,253.66,106.34ZM93.84,206.85l-55-18.35L88,139.31,124.69,176ZM152,180.69,83.31,112,104,91.31,172.69,160Z" },
@@ -100,6 +110,9 @@ export const ICONS = {
100
110
  viewBox: "0 0 256 256",
101
111
  d: "M226.76,69a8,8,0,0,0-12.84-2.88l-40.3,37.19-17.23-3.7-3.7-17.23,37.19-40.3A8,8,0,0,0,187,29.24,72,72,0,0,0,88,96,72.34,72.34,0,0,0,94,124.94L33.79,177c-.15.12-.29.26-.43.39a32,32,0,0,0,45.26,45.26c.13-.13.27-.28.39-.42L131.06,162A72,72,0,0,0,232,96,71.56,71.56,0,0,0,226.76,69ZM160,152a56.14,56.14,0,0,1-27.07-7,8,8,0,0,0-9.92,1.77L67.11,211.51a16,16,0,0,1-22.62-22.62L109.18,133a8,8,0,0,0,1.77-9.93,56,56,0,0,1,58.36-82.31l-31.2,33.81a8,8,0,0,0-1.94,7.1L141.83,108a8,8,0,0,0,6.14,6.14l26.35,5.66a8,8,0,0,0,7.1-1.94l33.81-31.2A56.06,56.06,0,0,1,160,152Z",
102
112
  },
113
+ filmStrip: { viewBox: "0 0 256 256", d: "M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM40,88h80v80H40Zm96-16V56h32V72Zm-16,0H88V56h32Zm0,112v16H88V184Zm16,0h32v16H136Zm0-16V88h80v80Zm80-96H184V56h32ZM72,56V72H40V56ZM40,184H72v16H40Zm176,16H184V184h32v16Z" },
114
+ youtubeLogo: { viewBox: "0 0 256 256", d: "M164.44,121.34l-48-32A8,8,0,0,0,104,96v64a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM120,145.05V111l25.58,17ZM234.33,69.52a24,24,0,0,0-14.49-16.4C185.56,39.88,131,40,128,40s-57.56-.12-91.84,13.12a24,24,0,0,0-14.49,16.4C19.08,79.5,16,97.74,16,128s3.08,48.5,5.67,58.48a24,24,0,0,0,14.49,16.41C69,215.56,120.4,216,127.34,216h1.32c6.94,0,58.37-.44,91.18-13.11a24,24,0,0,0,14.49-16.41c2.59-10,5.67-28.22,5.67-58.48S236.92,79.5,234.33,69.52Zm-15.49,113a8,8,0,0,1-4.77,5.49c-31.65,12.22-85.48,12-86,12H128c-.54,0-54.33.2-86-12a8,8,0,0,1-4.77-5.49C34.8,173.39,32,156.57,32,128s2.8-45.39,5.16-54.47A8,8,0,0,1,41.93,68c30.52-11.79,81.66-12,85.85-12h.27c.54,0,54.38-.18,86,12a8,8,0,0,1,4.77,5.49C221.2,82.61,224,99.43,224,128S221.2,173.39,218.84,182.47Z" },
115
+ playCircle: { viewBox: "0 0 256 256", d: "M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm48.24-94.78-64-40A8,8,0,0,0,100,88v80a8,8,0,0,0,12.24,6.78l64-40a8,8,0,0,0,0-13.56ZM116,153.57V102.43L156.91,128Z" },
103
116
  image: {
104
117
  viewBox: "0 0 256 256",
105
118
  d: "M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,16V158.75l-26.07-26.06a16,16,0,0,0-22.63,0l-20,20-44-44a16,16,0,0,0-22.62,0L40,149.37V56ZM40,172l52-52,80,80H40Zm176,28H194.63l-36-36,20-20L216,181.38V200ZM144,100a12,12,0,1,1,12,12A12,12,0,0,1,144,100Z",
@@ -1,5 +1,6 @@
1
1
  import { getSettings, setSettings } from "./settings";
2
- import { mediaMetadata, safeMediaName } from "../../media-types";
2
+ import { DEFAULT_MAX_FILE_SIZE, mediaMetadata, safeMediaName, storedMediaFile } from "../../media-types";
3
+ import config from "virtual:rigel-config";
3
4
  export function getKeys(): StockKeys { return getSettings().apiKeys; }
4
5
  export function setKeys(keys: StockKeys) { setSettings({ apiKeys: keys }); }
5
6
 
@@ -19,6 +20,9 @@ export interface StockResult {
19
20
  thumb: string;
20
21
  credit: string;
21
22
  creditURL?: string;
23
+ title?: string;
24
+ description?: string;
25
+ alt?: string;
22
26
  }
23
27
 
24
28
  export function picsum(query: string): StockResult[] {
@@ -64,6 +68,9 @@ export async function pexels(query: string, key: string): Promise<StockResult[]>
64
68
  thumb: p.src?.medium,
65
69
  credit: `${p.photographer ?? "?"} / Pexels`,
66
70
  creditURL: p.url,
71
+ title: typeof p.alt === "string" ? p.alt : "",
72
+ description: typeof p.alt === "string" ? p.alt : "",
73
+ alt: typeof p.alt === "string" ? p.alt : "",
67
74
  }));
68
75
  }
69
76
 
@@ -80,6 +87,9 @@ export async function pixabay(query: string, key: string): Promise<StockResult[]
80
87
  thumb: p.webformatURL,
81
88
  credit: `${p.user ?? "?"} / Pixabay`,
82
89
  creditURL: p.pageURL,
90
+ title: typeof p.tags === "string" ? p.tags : "",
91
+ description: typeof p.tags === "string" ? p.tags : "",
92
+ alt: typeof p.tags === "string" ? p.tags : "",
83
93
  }));
84
94
  }
85
95
 
@@ -134,8 +144,8 @@ export async function importImageUrl(value: string): Promise<string> {
134
144
  try { response = await fetch(value, { credentials: "omit", referrerPolicy: "no-referrer" }); }
135
145
  catch { throw new Error("Image download failed. The source must allow browser downloads (CORS); alternatively upload the image file."); }
136
146
  if (!response.ok) throw new Error(`Photo download failed (${response.status})`);
137
- const limit = 10 * 1024 * 1024;
138
- if (Number(response.headers.get("Content-Length")) > limit) throw new Error("Photo exceeds the 10 MB import limit");
147
+ const limit = config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
148
+ if (Number(response.headers.get("Content-Length")) > limit) throw new Error(`Photo exceeds the ${limit / 1024 / 1024} MB import limit`);
139
149
  const type = response.headers.get("Content-Type")?.split(";")[0].trim().toLowerCase();
140
150
  if (!["image/jpeg", "image/png", "image/webp"].includes(type ?? "")) throw new Error("Choose a JPEG, PNG or WebP image.");
141
151
  if (!response.body) throw new Error("Empty image download");
@@ -147,7 +157,7 @@ export async function importImageUrl(value: string): Promise<string> {
147
157
  const { done, value: chunk } = await reader.read();
148
158
  if (done) break;
149
159
  size += chunk.byteLength;
150
- if (size > limit) { await reader.cancel(); throw new Error("Photo exceeds the 10 MB import limit"); }
160
+ if (size > limit) { await reader.cancel(); throw new Error(`Photo exceeds the ${limit / 1024 / 1024} MB import limit`); }
151
161
  chunks.push(new Uint8Array(chunk));
152
162
  }
153
163
  } finally { reader.releaseLock(); }
@@ -174,21 +184,23 @@ export function currentAuthor(): string {
174
184
 
175
185
  export async function uploadMedia(file: File): Promise<string> {
176
186
  const metadata = mediaMetadata(file.name);
177
- if (!safeMediaName(file.name) || !metadata || file.size > 10 * 1024 * 1024) throw new Error("Choose a supported file up to 10 MB with a safe filename.");
187
+ const limit = config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
188
+ if (!safeMediaName(file.name) || !metadata || file.size > limit) throw new Error(`Choose a supported file up to ${limit / 1024 / 1024} MB with a safe filename.`);
178
189
  const response = await fetch("/rigel/api/local/media", { method: "POST", headers: { "Content-Type": metadata.type, "X-Rigel-Filename": file.name, "X-Rigel-Author": currentAuthor() }, body: file });
179
190
  if (!response.ok) throw new Error(`Upload failed (${response.status}): ${await response.text()}`);
180
191
  const result = await response.json();
181
- if (typeof result.url !== "string" || !safeImageUrl(result.url) || !result.url.startsWith("/") || !/\/[a-f0-9]{64}\.[a-z0-9]+$/.test(result.url) || !mediaMetadata(result.url)) throw new Error("Invalid uploaded asset URL");
192
+ if (typeof result.url !== "string" || !safeImageUrl(result.url) || !result.url.startsWith("/") || !storedMediaFile.test(result.url.split("/").pop() ?? "") || !mediaMetadata(result.url)) throw new Error("Invalid uploaded asset URL");
182
193
  return result.url;
183
194
  }
184
195
 
185
196
  export async function uploadImage(blob: Blob): Promise<string> {
186
- if (blob.size > 10 * 1024 * 1024) throw new Error("Photo exceeds the 10 MB import limit");
197
+ const limit = config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
198
+ if (blob.size > limit) throw new Error(`Photo exceeds the ${limit / 1024 / 1024} MB import limit`);
187
199
  const saved = await fetch("/rigel/api/local/media", { method: "POST", body: blob });
188
200
  if (saved.status === 404) throw new Error("Photo import requires the local dev server");
189
201
  if (!saved.ok) throw new Error(`Photo import failed (${saved.status})`);
190
202
  const result = await saved.json();
191
- if (typeof result.url !== "string" || !safeImageUrl(result.url) || !result.url.startsWith("/") || !/\/[a-f0-9]{64}\.(jpg|png|webp)$/.test(result.url)) {
203
+ if (typeof result.url !== "string" || !safeImageUrl(result.url) || !result.url.startsWith("/") || !storedMediaFile.test(result.url.split("/").pop() ?? "") || !/\.(jpg|png|webp)$/.test(result.url)) {
192
204
  throw new Error("Invalid imported photo URL");
193
205
  }
194
206
  return result.url;
@@ -1,4 +1,14 @@
1
1
  import { mediaMetadata } from "../../media-types";
2
+ import type { ICONS } from "./icons";
3
+
4
+ const FILE_ICONS: Record<string, keyof typeof ICONS> = {
5
+ pdf: "filePdf", txt: "fileTxt", doc: "fileDoc", docx: "fileDoc", ppt: "filePpt", pptx: "filePpt",
6
+ Documents: "fileText", Spreadsheets: "fileXls", Archives: "fileZip", Audio: "fileAudio", Video: "fileVideo",
7
+ };
8
+ export function mediaIcon(file: string): keyof typeof ICONS {
9
+ const extension = file.split(".").pop()?.toLowerCase() ?? "";
10
+ return FILE_ICONS[extension] ?? FILE_ICONS[mediaMetadata(file)?.category ?? ""] ?? "file";
11
+ }
2
12
 
3
13
  export interface MediaAsset {
4
14
  url: string;
@@ -27,7 +37,7 @@ export function filterMedia(assets: MediaAsset[], search: string, type: string,
27
37
  && (!type || asset.type === type || assetCategory(asset) === type) && (!month || modifiedMonth(asset.modified) === month));
28
38
  }
29
39
  function assetCategory(asset: MediaAsset): string {
30
- const category = mediaMetadata(asset.name)?.category ?? '';
40
+ const category = mediaMetadata(assetFile(asset.url))?.category ?? '';
31
41
  if (!category) return 'Other';
32
42
  return CURATED.has(category) ? category : 'Other';
33
43
  }
@@ -26,7 +26,7 @@ const prefs = (theme: "system" | "light" | "dark" = "system", language: "en" | "
26
26
  const DEFAULTS: RigelSettings = {
27
27
  ...prefs(),
28
28
  apiKeys: {},
29
- userPref: { previewSplit: 0.5, mediaView: "grid", sidebarCollapsed: false, editorExperience: "sidebar" },
29
+ userPref: { previewSplit: 0.5, mediaView: "grid", sidebarCollapsed: false, editorExperience: "inline" },
30
30
  };
31
31
 
32
32
  const PROVIDERS = ["pexels", "pixabay", "unsplash"] as const;
@@ -46,7 +46,7 @@ export function getSettings(): RigelSettings {
46
46
  apiKeys: mergedKeys,
47
47
  userPref: {
48
48
  sidebarCollapsed: saved?.userPref?.sidebarCollapsed === true,
49
- editorExperience: saved?.userPref?.editorExperience === "inline" ? "inline" : "sidebar",
49
+ editorExperience: saved?.userPref?.editorExperience === "sidebar" ? "sidebar" : DEFAULTS.userPref.editorExperience,
50
50
  previewSplit: typeof saved?.userPref?.previewSplit === "number" && Number.isFinite(saved.userPref.previewSplit) && saved.userPref.previewSplit >= 0.2 && saved.userPref.previewSplit <= 0.8
51
51
  ? saved.userPref.previewSplit
52
52
  : typeof saved?.previewSplit === "number" && Number.isFinite(saved.previewSplit) && saved.previewSplit >= 0.2 && saved.previewSplit <= 0.8
@@ -1,7 +1,23 @@
1
- export function tooltipPosition(rect: Pick<DOMRect, "left" | "right" | "top" | "bottom">, width: number, height: number, viewportWidth: number, viewportHeight: number) {
1
+ export type TooltipSide = "top" | "bottom" | "left" | "right";
2
+ export type TooltipAlign = "start" | "center" | "end";
3
+
4
+ export function tooltipPosition(rect: Pick<DOMRect, "left" | "right" | "top" | "bottom">, width: number, height: number, viewportWidth: number, viewportHeight: number, position: TooltipSide = "top", align: TooltipAlign = "center") {
5
+ const sides: TooltipSide[] = ["top", "bottom", "left", "right"];
6
+ const preferred = sides.includes(position) ? position : "top";
7
+ const opposite: Record<TooltipSide, TooltipSide> = { top: "bottom", bottom: "top", left: "right", right: "left" };
8
+ const candidates = [preferred, opposite[preferred], ...sides.filter(side => side !== preferred && side !== opposite[preferred])];
9
+ const space = { top: rect.top - 8, bottom: viewportHeight - rect.bottom - 8, left: rect.left - 8, right: viewportWidth - rect.right - 8 };
10
+ const needed = { top: height + 8, bottom: height + 8, left: width + 8, right: width + 8 };
11
+ const side = candidates.find(side => space[side] >= needed[side]) ?? candidates.reduce((best, side) => space[side] / needed[side] > space[best] / needed[best] ? side : best);
12
+ const vertical = side === "top" || side === "bottom";
13
+ const start = vertical ? rect.left : rect.top, end = vertical ? rect.right : rect.bottom, size = vertical ? width : height;
14
+ const cross = align === "start" ? start : align === "end" ? end - size : (start + end - size) / 2;
15
+ const left = vertical ? cross : side === "left" ? rect.left - width - 8 : rect.right + 8;
16
+ const top = !vertical ? cross : side === "top" ? rect.top - height - 8 : rect.bottom + 8;
2
17
  return {
3
- left: Math.max(8, Math.min((rect.left + rect.right - width) / 2, viewportWidth - width - 8)),
4
- top: Math.max(8, Math.min(rect.top >= height + 14 ? rect.top - height - 6 : rect.bottom + 6, viewportHeight - height - 8)),
18
+ left: Math.max(8, Math.min(left, viewportWidth - width - 8)),
19
+ top: Math.max(8, Math.min(top, viewportHeight - height - 8)),
20
+ side,
5
21
  };
6
22
  }
7
23
 
@@ -41,7 +57,10 @@ function createTooltips() {
41
57
  if (!anchor || !tip || !view) return;
42
58
  if (!anchor.isConnected || !anchor.checkVisibility() || !anchor.dataset.tooltip) { hide(); return; }
43
59
  if (tip.textContent !== anchor.dataset.tooltip) tip.textContent = anchor.dataset.tooltip;
44
- const position = tooltipPosition(anchor.getBoundingClientRect(), tip.offsetWidth, tip.offsetHeight, view.innerWidth, view.innerHeight);
60
+ const preferred = anchor.closest<HTMLElement>("[data-tooltip-position]")?.dataset.tooltipPosition as TooltipSide | undefined;
61
+ const align = anchor.closest<HTMLElement>("[data-tooltip-align]")?.dataset.tooltipAlign as TooltipAlign | undefined;
62
+ const position = tooltipPosition(anchor.getBoundingClientRect(), tip.offsetWidth, tip.offsetHeight, view.innerWidth, view.innerHeight, preferred ?? (anchor.closest(".body-toolbar, .visual-toolbar") ? "bottom" : "top"), align);
63
+ tip.dataset.side = position.side;
45
64
  tip.style.left = `${position.left}px`;
46
65
  tip.style.top = `${position.top}px`;
47
66
  frame = view.requestAnimationFrame(update);
@@ -1,7 +1,7 @@
1
1
  .rigel, .rigel *, .rigel *::before, .rigel *::after { box-sizing: border-box; }
2
- .rigel .rigel-tooltip { position: fixed; pointer-events: auto; inset: auto; margin: 0; padding: 0.375rem 0.625rem; max-width: min(20rem, calc(100vw - 16px)); max-height: calc(100dvh - 16px); overflow: auto; border: 1px solid var(--border-control); border-radius: var(--radius); background: var(--text); color: var(--panel); box-shadow: var(--shadow-md); font: 500 0.75rem/1.5 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; overflow-wrap: anywhere; text-align: start; white-space: normal; }
2
+ .rigel .rigel-tooltip { position: fixed; pointer-events: auto; inset: auto; margin: 0; padding: 4px 10px; max-width: min(17.5rem, calc(100vw - 16px)); max-height: calc(100dvh - 16px); overflow: auto; border: 0; border-radius: 0; background: var(--text); color: var(--panel); box-shadow: 0 2px 8px rgb(0 0 0 / 0.15); font: 500 11px/1.4 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; overflow-wrap: anywhere; text-align: start; white-space: normal; }
3
3
  .rigel .rigel-tooltip::backdrop { background: transparent; pointer-events: none; }
4
- @media (forced-colors: active) { .rigel .rigel-tooltip { background: Canvas; color: CanvasText; border-color: CanvasText; } }
4
+ @media (forced-colors: active) { .rigel .rigel-tooltip { background: Canvas; color: CanvasText; border: 1px solid CanvasText; } }
5
5
  .rigel {
6
6
  --rigel-50: #EFFBFF;
7
7
  --rigel-100: #D9F4FF;
@@ -840,9 +840,9 @@
840
840
  .rigel .outlined:hover:not(:disabled), .rigel .image-picker input::file-selector-button:hover { background: var(--surface-raised); }
841
841
  .rigel .outlined:disabled, .rigel .image-picker .tabs button:disabled, .rigel .image-picker .thumb:disabled { opacity: 0.5; cursor: default; }
842
842
  .rigel .image-picker input::file-selector-button { margin-right: 0.75rem; }
843
- .rigel.image-dialog { width: 100%; height: 100%; max-width: none; max-height: none; margin: 0; padding: 1rem; border: none; }
844
- .rigel.image-dialog:not([open]) { display: none; }
845
- .rigel.image-dialog::backdrop { background: transparent; }
843
+ .rigel:is(.image-dialog, .media-insert) { width: 100%; height: 100%; max-width: none; max-height: none; margin: 0; padding: 1rem; border: none; }
844
+ .rigel:is(.image-dialog, .media-insert):not([open]) { display: none; }
845
+ .rigel:is(.image-dialog, .media-insert)::backdrop { background: transparent; }
846
846
  .rigel .image-picker { width: min(760px, 100%); }
847
847
  .rigel .media-head, .rigel .media-tools, .rigel .media-actions { display: flex; align-items: center; gap: 0.75rem; }
848
848
  .rigel .media-head { justify-content: space-between; margin: calc(1rem * -1) calc(1.25rem * -1) 0; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); }
@@ -860,8 +860,10 @@
860
860
  .rigel .media-table :is(th, td) { padding: 0.625rem; overflow-wrap: anywhere; }
861
861
  .rigel .media-table th:first-child { width: 48%; }
862
862
  .rigel .media-file { display: flex; align-items: center; gap: 0.625rem; text-align: left; width: 100%; }
863
- .rigel .media-file img { width: 48px; height: 40px; object-fit: cover; flex-shrink: 0; }
863
+ .rigel .media-file .media-thumbnail { display: flex; align-items: center; justify-content: center; width: 48px; height: 40px; flex: 0 0 auto; overflow: hidden; background: var(--surface-raised); color: var(--text-muted); border: 1px solid var(--border-subtle); }
864
+ .rigel .media-thumbnail img { width: 100%; height: 100%; object-fit: cover; display: block; }
864
865
  .rigel .media-file-icon { display: flex; min-height: 120px; flex-direction: column; align-items: center; justify-content: center; gap: 0.75rem; padding: 1rem; color: var(--text-muted); overflow-wrap: anywhere; }
866
+ .rigel .thumb .media-file-icon { height: 100%; min-height: 0; background: var(--surface-raised); }
865
867
  .rigel .media-file span { min-width: 0; overflow-wrap: anywhere; }
866
868
  .rigel .media-file[aria-pressed=true] { color: var(--on-accent-soft); background: var(--accent-soft); }
867
869
  .rigel :is(.image-picker, .media-library) .thumb[aria-pressed=true] { border-color: var(--accent); }
@@ -871,12 +873,16 @@
871
873
  .rigel .media-table th:first-child { width: 40%; }
872
874
  .rigel .media-file { flex-direction: column; align-items: flex-start; }
873
875
  }
876
+ .rigel .media-feedback { position: absolute; right: 0; bottom: calc(100% + 0.25rem); max-width: 100%; margin: 0; color: var(--success); font-size: 0.8125rem; text-align: right; }
874
877
  .rigel .media-actions {
878
+ position: relative;
875
879
  justify-content: flex-end; gap: 0.75rem;
876
880
  margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--border);
877
881
  }
878
882
  .rigel .media-selection { margin-top: 1rem; }
879
- .rigel .media-preview > img { display: block; max-width: 100%; max-height: 300px; margin: 0 auto 1rem; }
883
+ .rigel .media-preview > :is(img, video) { display: block; max-width: 100%; max-height: 300px; margin: 0 auto 1rem; }
884
+ .rigel .media-preview > video { width: 100%; object-fit: contain; background: #000; }
885
+ .rigel .media-preview > iframe { display: block; width: 100%; height: min(60dvh, 36rem); border: 1px solid var(--border-subtle); }
880
886
  .rigel .media-button-actions { display: flex; align-items: center; gap: 0.75rem; margin-left: auto; flex-shrink: 0; }
881
887
  .rigel .media-actions.with-media-path { flex-wrap: wrap; }
882
888
  .rigel .media-download { flex-shrink: 0; white-space: nowrap; text-decoration: none; }
@@ -886,16 +892,19 @@
886
892
  .rigel .media-meta { border-top: 1px solid var(--border-subtle); padding-top: 0.5rem; }
887
893
  .rigel .media-meta label { margin-top: 0.75rem; }
888
894
  .rigel .media-details { min-width: 0; }
895
+ .rigel .media-details .hint { overflow-wrap: anywhere; }
889
896
  .rigel .image-picker:has(.media-editing) { width: min(1040px, 100%); }
890
897
  .rigel .media-selection.media-editing { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 1.5rem; }
891
898
  .rigel .media-editing .media-preview { min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
892
- .rigel .media-editing .media-preview > img { max-height: 60dvh; }
899
+ .rigel .media-editing .media-preview > :is(img, video) { max-height: 60dvh; }
893
900
  .rigel .media-editing .media-meta { border-top: 0; padding-top: 0; }
894
901
  .rigel .media-editing .media-meta label:first-child { margin-top: 0; }
895
902
  .rigel .media-editing > .media-actions { grid-column: 1 / -1; margin-top: 0; }
896
903
  @media (max-width: 700px) {
897
904
  .rigel .media-selection.media-editing { grid-template-columns: minmax(0, 1fr); }
898
- .rigel .media-editing .media-preview > img { max-height: 35dvh; }
905
+ .rigel .media-editing .media-preview > :is(img, video) { max-height: 35dvh; }
906
+ .rigel .media-editing .media-preview > iframe { height: 35dvh; }
907
+ .rigel .with-media-path .media-path { flex-basis: 100%; }
899
908
  .rigel .with-media-path .media-button-actions { flex-basis: 100%; justify-content: flex-end; }
900
909
  }
901
910
  .rigel .image-picker input[type=file] { width: 100%; color: var(--text); }
@@ -927,12 +936,15 @@
927
936
  display: grid; grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr)); gap: 1rem; margin-top: 1rem;
928
937
  }
929
938
  .rigel :is(.image-picker, .media-library) .thumb {
939
+ position: relative;
930
940
  border: 2px solid transparent; border-radius: 0; overflow: hidden;
931
941
  padding: 0; cursor: pointer; background: none; aspect-ratio: 3 / 2;
932
942
  transition: border-color 0.1s, transform 0.1s;
933
943
  }
934
944
  .rigel :is(.image-picker, .media-library) .thumb:hover { border-color: var(--accent); transform: translateY(-1px); }
935
- .rigel :is(.image-picker, .media-library) .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
945
+ .rigel :is(.image-picker, .media-library) .thumb :is(img, video) { width: 100%; height: 100%; object-fit: cover; display: block; }
946
+ .rigel :is(.image-picker, .media-library) .thumb video { pointer-events: none; }
947
+ .rigel .media-extension { position: absolute; top: 0.25rem; right: 0.25rem; padding: 0.0625rem 0.25rem; border: 1px solid var(--border-control); background: var(--panel); color: var(--text); font-size: 0.625rem; font-weight: 600; line-height: 1.2; pointer-events: none; }
936
948
  .rigel :is(.image-picker, .media-library) .grid figure { margin: 0; min-width: 0; }
937
949
  .rigel :is(.image-picker, .media-library) .grid .thumb { width: 100%; }
938
950
  .rigel :is(.image-picker, .media-library) .grid figcaption { font-size: 0.75rem; color: var(--text-2); overflow-wrap: anywhere; }
@@ -1154,7 +1166,7 @@
1154
1166
  .rigel.rigel-inline { display: block; position: fixed; top: 0; left: 0; width: 100%; min-height: 0; height: 0; padding: 0; background: transparent; pointer-events: none; z-index: 120; }
1155
1167
  .rigel.rigel-inline .block-editor { margin: 0; border: 0; }
1156
1168
  .rigel.rigel-inline .block-editor > [role="alert"] { position: fixed; inset: 8px 8px auto; padding: 0.75rem; background: var(--danger-soft); color: var(--danger); pointer-events: auto; }
1157
- .rigel.rigel-inline :is(.body-toolbar, .block-handle, .block-insert, .block-menu, .block-slash, .markdown-link, .block-markdown-view) { pointer-events: auto; }
1169
+ .rigel.rigel-inline :is(.body-toolbar, .block-handle, .block-insert, .block-menu, .block-slash, .markdown-link, .block-markdown-view, .media-insert, .image-dialog) { pointer-events: auto; }
1158
1170
  .rigel.rigel-inline .block-editor .body-toolbar, .rigel .block-editor .body-toolbar.image-toolbar { position: fixed; inset: auto; width: max-content; max-width: calc(100vw - 16px); margin: 0; padding: 0.25rem; gap: 0.25rem; border: 1px solid var(--border-control); box-shadow: var(--shadow-lg); z-index: 120; }
1159
1171
  .rigel.rigel-inline :where(.block-editor .body-toolbar) > button { width: 32px; height: 32px; padding: 0; background: transparent; color: var(--text); border: 0; }
1160
1172
  .rigel.rigel-inline .block-handle { position: fixed; width: 24px; height: 28px; }
@@ -1209,7 +1221,7 @@
1209
1221
  .rigel .block-table-controls button:hover:not(:disabled) { background: var(--accent-soft); color: var(--on-accent-soft); }
1210
1222
  .rigel .block-table-controls button:disabled { opacity: 0.35; cursor: default; }
1211
1223
  .rigel .block-table-controls label.inline { display: flex; align-items: center; gap: 0.5rem; margin: 0.25rem 0 0; padding: 0.5rem; border-top: 1px solid var(--border-subtle); font-size: 0.8125rem; }
1212
- .rigel .block-table-controls label.inline input.switch { flex-shrink: 0; margin-left: auto; }
1224
+
1213
1225
  .rigel .block-content > p:has(> br:only-child)::before { content: "Type / to choose a block"; position: absolute; color: var(--text-2); pointer-events: none; }
1214
1226
  .rigel .block-handle { position: absolute; left: 2px; width: 28px; height: 32px; padding: 0; display: flex; align-items: center; justify-content: center; background: var(--panel); border: 1px solid var(--border-control); color: var(--text); cursor: grab; touch-action: none; }
1215
1227
  .rigel .block-insert { position: fixed; z-index: 119; display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; background: var(--panel); border: 1px solid var(--border-control); color: var(--text); cursor: pointer; }
@@ -1303,3 +1315,8 @@
1303
1315
  .rigel main.visual-pane { padding: 0.75rem; }
1304
1316
  .rigel .visual-editor.with-panel { grid-template-rows: minmax(0, 1fr) 50%; }
1305
1317
  }
1318
+
1319
+ .rigel .media-menu > button { display: flex; align-items: center; gap: 0.5rem; }
1320
+ .rigel .media-insert .media-button-actions { justify-content: space-between; margin: 1rem 0 0; }
1321
+
1322
+ .rigel .block-content figure.media-video video, .rigel .block-content figure.media-embed iframe { display: block; width: 100%; aspect-ratio: 16 / 9; border: 0; background: #000; }
package/src/env.d.ts CHANGED
@@ -17,6 +17,6 @@ declare module "virtual:rigel-content" {
17
17
 
18
18
  declare module "virtual:rigel-config" {
19
19
  import type { RigelConfig } from "@rigel-cms/rigel";
20
- const config: Pick<RigelConfig, "copyDir" | "backend" | "demo" | "collections" | "menus" | "mediaDir" | "schema" | "preview"> & { publicDir?: string; root?: string };
20
+ const config: Pick<RigelConfig, "copyDir" | "backend" | "jsonOutput" | "demo" | "collections" | "menus" | "mediaDir" | "maxFileSize" | "schema" | "preview"> & { publicDir?: string; root?: string };
21
21
  export default config;
22
22
  }
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ import type { AstroIntegration } from "astro";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import path from "node:path";
4
4
  import { lstat, readFile } from "node:fs/promises";
5
- import { mediaMetadata, safeMediaName } from "./media-types";
5
+ import { mediaHeaders, safeMediaName, storedMediaFile } from "./media-types";
6
6
  import { mediaDirectory } from "./routes/media-files";
7
7
  import { parseConfig, type RigelConfig } from "./config";
8
8
  import { rigelManifestPlugin } from "./vite/manifest-plugin";
@@ -48,10 +48,7 @@ export function rigel(options: Partial<RigelConfig> = {}): AstroIntegration {
48
48
  }
49
49
  const file = path.join(directory, filename), info = await lstat(file);
50
50
  if (!info.isFile() || info.isSymbolicLink()) throw new Error();
51
- const metadata = mediaMetadata(filename)!;
52
- response.setHeader("Content-Type", metadata.type);
53
- response.setHeader("X-Content-Type-Options", "nosniff");
54
- response.setHeader("Content-Disposition", `${metadata.category === "Images" ? "inline" : "attachment"}; filename="${filename}"`);
51
+ for (const [name, value] of Object.entries(mediaHeaders(filename, new URL(request.url ?? "/", "http://localhost").searchParams.get("preview") === "1"))) response.setHeader(name, value);
55
52
  response.end(request.method === "HEAD" ? undefined : await readFile(file));
56
53
  } catch { response.statusCode = 404; response.end("Not found"); }
57
54
  });
@@ -60,7 +57,7 @@ export function rigel(options: Partial<RigelConfig> = {}): AstroIntegration {
60
57
  ...(cfg.backend === "local" && command === "dev" ? {
61
58
  server: { watch: { ignored: [(file: string) =>
62
59
  path.dirname(file) === path.resolve(fileURLToPath(config.publicDir), cfg.mediaDir ?? "rigel-media")
63
- && /^[a-f0-9]{64}\.[a-z0-9]+$/.test(path.basename(file))
60
+ && storedMediaFile.test(path.basename(file))
64
61
  ] } },
65
62
  } : {}),
66
63
  },
@@ -119,6 +116,7 @@ export type { MenuItem, MenuDocument } from "./manifest/menu";
119
116
  export { defineCopy } from "./manifest/types";
120
117
  export type { CopyField, CopyGroup, CopyPage, Manifest, CollectionField } from "./manifest/types";
121
118
  export type { RigelConfig, CollectionConfig } from "./config";
119
+ export type { JsonOutput } from "./json-output";
122
120
  export type { ReferenceOption } from "./manifest/references";
123
121
  export type { SidebarEntry, SidebarTarget, SidebarIconName } from "./manifest/navigation";
124
122
  export { defineSchema, defineDocument, defineObject, defineField } from "./schema/types";
@@ -0,0 +1,5 @@
1
+ export type JsonOutput = "pretty" | "minified";
2
+
3
+ export function serializeContentJson(value: unknown, output: JsonOutput = "pretty"): string {
4
+ return JSON.stringify(value, null, output === "minified" ? undefined : 2) + "\n";
5
+ }
@@ -30,6 +30,7 @@ export interface GitHubBackendConfig {
30
30
  authOrigin: string;
31
31
  appSlug: string;
32
32
  copyDir: string;
33
+ jsonOutput?: import("../json-output").JsonOutput;
33
34
  collections: { name: string; folder: string }[];
34
35
  menus?: { folder: string; maxDepth: number };
35
36
  }
@@ -3,11 +3,24 @@ for (const [category, entries] of Object.entries({
3
3
  Images: { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif' },
4
4
  Audio: { mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4' },
5
5
  Video: { mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime' },
6
- Documents: { pdf: 'application/pdf', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', rtf: 'application/rtf', odt: 'application/vnd.oasis.opendocument.text', ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' },
6
+ Documents: { txt: 'text/plain', pdf: 'application/pdf', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', rtf: 'application/rtf', odt: 'application/vnd.oasis.opendocument.text', ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' },
7
7
  Spreadsheets: { xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', csv: 'text/csv', ods: 'application/vnd.oasis.opendocument.spreadsheet' },
8
8
  Archives: { zip: 'application/zip', gz: 'application/gzip', tar: 'application/x-tar', '7z': 'application/x-7z-compressed', rar: 'application/vnd.rar' },
9
9
  })) for (const [extension, type] of Object.entries(entries)) MEDIA_TYPES[extension] = { type, category };
10
10
 
11
+ export function mediaHeaders(filename: string, preview = false) {
12
+ const metadata = mediaMetadata(filename)!;
13
+ const inline = ['Images', 'Video'].includes(metadata.category) || (preview && ['pdf', 'txt'].includes(metadata.extension));
14
+ return {
15
+ 'Content-Type': metadata.type === 'text/plain' ? 'text/plain; charset=utf-8' : metadata.type,
16
+ 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename="${filename}"`,
17
+ 'X-Content-Type-Options': 'nosniff',
18
+ };
19
+ }
20
+
21
+ export const storedMediaFile = /^(?:[a-f0-9]{64}|[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})\.[a-z0-9]+$/;
22
+
23
+ export const DEFAULT_MAX_FILE_SIZE = 2 * 1024 * 1024;
11
24
  export const MEDIA_CATEGORIES = ['Images', 'Audio', 'Video', 'Documents', 'Spreadsheets', 'Archives', 'Other'];
12
25
  export function mediaMetadata(name: string) {
13
26
  const extension = name.split('.').pop()?.toLowerCase() ?? '';
package/src/preview.ts CHANGED
@@ -3,6 +3,7 @@ import DOMPurify from "dompurify";
3
3
  import { pageRoute } from "./schema/paths";
4
4
  import { slugify } from "./slugs";
5
5
  import { renderArticle } from "./article-render";
6
+ import { createEmbedPreview } from "./editor/lib/embed-preview";
6
7
  import { safePreviewUrl, previewBaseUrl } from "./preview-urls";
7
8
  export { safePreviewUrl } from "./preview-urls";
8
9
 
@@ -158,11 +159,14 @@ export function patchPreview(doc: Document, values: Record<string, unknown>, bod
158
159
  const source = blocks ? JSON.stringify(result.value) : typeof result.value === "string" ? result.value : "";
159
160
  if (rendered.get(el) === source) continue;
160
161
  rendered.set(el, source);
161
- const fragment = DOMPurify.sanitize(blocks ? (result.value ? renderArticle(result.value as import("./article-body").ArticleBody) : "") : markdown.render(source), {
162
+ const fragment = DOMPurify.sanitize(blocks ? (result.value ? renderArticle(result.value as import("./article-body").ArticleBody, { preview: true }) : "") : markdown.render(source), {
162
163
  RETURN_DOM_FRAGMENT: true,
163
- ALLOWED_TAGS: ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "blockquote", "aside", "figure", "figcaption", "pre", "code", "strong", "em", "s", "u", "mark", "sub", "sup", "a", "img", "br", "hr", "table", "thead", "tbody", "tr", "th", "td"],
164
- ALLOWED_ATTR: ["href", "src", "alt", "title", "start", "scope"],
164
+ ALLOWED_TAGS: ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "blockquote", "aside", "figure", "figcaption", "pre", "code", "strong", "em", "s", "u", "mark", "sub", "sup", "a", "img", "br", "hr", "table", "thead", "tbody", "tr", "th", "td", ...(blocks ? ["video"] : [])],
165
+ ALLOWED_ATTR: ["href", "src", "alt", "title", "start", "scope", ...(blocks ? ["controls", "muted", "preload", "playsinline", "poster", "class"] : [])],
165
166
  });
167
+ if (blocks) for (const marker of fragment.querySelectorAll<HTMLElement>("[data-rigel-embed]")) {
168
+ marker.replaceWith(createEmbedPreview({ provider: marker.dataset.rigelEmbed, id: marker.dataset.rigelVideoId, title: marker.dataset.rigelVideoTitle }, doc));
169
+ }
166
170
  for (const link of fragment.querySelectorAll("[href], [src]")) {
167
171
  for (const attr of ["href", "src"]) if (link.hasAttribute(attr) && !safePreviewUrl(link.getAttribute(attr)!, attr === "src")) link.removeAttribute(attr);
168
172
  }
@@ -69,7 +69,7 @@ const handle: APIRoute = async ({ request, params }) => {
69
69
  const documents = await readDocuments(guarded.schema, guarded.root, config.preview);
70
70
  const next = { ...data, _id: id, _type: target.type.name };
71
71
  validateDocument(guarded.schema, target.type.name, target.slug, next, documents);
72
- await writeDocument(guarded.schema, guarded.root, target.type.name, target.slug, next, request.method === "POST" ? "create" : "update", config.preview);
72
+ await writeDocument(guarded.schema, guarded.root, target.type.name, target.slug, next, request.method === "POST" ? "create" : "update", config.preview, config.jsonOutput);
73
73
  return new Response(null, { status: 204 });
74
74
  } catch (error) {
75
75
  if (error instanceof DocumentStoreError) return new Response(error.message, { status: error.status });