@rigel-cms/rigel 0.1.4-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 (46) 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/content.ts +3 -1
  7. package/src/editor/App.svelte +114 -25
  8. package/src/editor/blocks/embed.ts +21 -0
  9. package/src/editor/blocks/index.ts +3 -1
  10. package/src/editor/blocks/video.ts +35 -0
  11. package/src/editor/lib/ActionDialog.svelte +2 -1
  12. package/src/editor/lib/BlockEditor.svelte +84 -15
  13. package/src/editor/lib/CollectionPanel.svelte +9 -4
  14. package/src/editor/lib/ImagePicker.svelte +141 -47
  15. package/src/editor/lib/MediaPicker.svelte +73 -0
  16. package/src/editor/lib/PreviewFrame.svelte +64 -22
  17. package/src/editor/lib/PublishDialog.svelte +65 -0
  18. package/src/editor/lib/SchemaPanel.svelte +6 -1
  19. package/src/editor/lib/Select.svelte +5 -2
  20. package/src/editor/lib/VisualEditor.svelte +11 -6
  21. package/src/editor/lib/embed-preview.ts +57 -0
  22. package/src/editor/lib/embeds.ts +47 -0
  23. package/src/editor/lib/github-backend.ts +220 -20
  24. package/src/editor/lib/icons.ts +13 -0
  25. package/src/editor/lib/images.ts +20 -8
  26. package/src/editor/lib/media-library.ts +11 -1
  27. package/src/editor/lib/settings.ts +2 -2
  28. package/src/editor/lib/slugify.ts +1 -15
  29. package/src/editor/lib/tooltip.ts +23 -4
  30. package/src/editor/styles.css +44 -13
  31. package/src/env.d.ts +1 -1
  32. package/src/index.ts +4 -6
  33. package/src/json-output.ts +5 -0
  34. package/src/manifest/types.ts +1 -0
  35. package/src/media-types.ts +14 -1
  36. package/src/preview.ts +14 -3
  37. package/src/routes/local-documents.ts +4 -4
  38. package/src/routes/local-media.ts +33 -28
  39. package/src/routes/local-menu.ts +2 -1
  40. package/src/routes/local-post.ts +18 -5
  41. package/src/routes/media-files.ts +36 -1
  42. package/src/routes/media-validation.ts +2 -2
  43. package/src/schema/paths.ts +3 -1
  44. package/src/schema/store.ts +24 -3
  45. package/src/slugs.ts +46 -0
  46. package/src/vite/manifest-plugin.ts +9 -2
@@ -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,15 +1 @@
1
- export function slugify(input: string, existingSlugs: Iterable<string> = []): string {
2
- const base = input
3
- .normalize("NFKD")
4
- .replace(/[̀-ͯ]/g, "") // combining marks → strip (é→e)
5
- .toLowerCase()
6
- .trim()
7
- .replace(/[^a-z0-9]+/g, "-")
8
- .replace(/^-+|-+$/g, "")
9
- .replace(/-{2,}/g, "-");
10
- if (!base) return "";
11
- const existing = new Set(existingSlugs);
12
- let slug = base;
13
- for (let suffix = 2; existing.has(slug); suffix++) slug = `${base}-${suffix}`;
14
- return slug;
15
- }
1
+ export { slugify } from "../../slugs";
@@ -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;
@@ -175,6 +175,8 @@
175
175
  .rigel .rail-scroll > button:hover { background: var(--surface-raised); color: var(--text); }
176
176
  .rigel .sidebar-asset-icon { display: inline-block; width: 18px; height: 18px; flex-shrink: 0; background-color: currentColor; mask-image: var(--sidebar-icon); mask-position: center; mask-size: contain; mask-repeat: no-repeat; }
177
177
  .rigel .rail-scroll > button { gap: 0.625rem; }
178
+ .rigel aside > button.sidebar-publish { gap: 0.625rem; width: calc(100% - 1.5rem); min-height: 44px; margin: 0 0.75rem 0.5rem; padding: 0.625rem; border: 1px solid var(--border-control); color: var(--text); }
179
+ .rigel aside > button.sidebar-publish:disabled { opacity: 0.5; cursor: not-allowed; }
178
180
  .rigel aside > button.sidebar-toggle { gap: 0.625rem; width: calc(100% - 1.5rem); margin: 0 0.75rem 0.5rem; padding: 0.625rem; font-size: 0.75rem; }
179
181
  @media (min-width: 768px) {
180
182
  .rigel.sidebar-collapsed { --sidebar-w: 64px; }
@@ -186,6 +188,7 @@
186
188
  .rigel.sidebar-collapsed .user-btn > .user-menu-icon { display: none; }
187
189
  .rigel.sidebar-collapsed .rail-scroll > button,
188
190
  .rigel.sidebar-collapsed .search-trigger,
191
+ .rigel.sidebar-collapsed .sidebar-publish,
189
192
  .rigel.sidebar-collapsed .sidebar-toggle { justify-content: center; }
190
193
  .rigel.sidebar-collapsed .sidebar-heading { height: 1px; font-size: 0; padding: 0; margin: 0.75rem 0.625rem; border-top: 1px solid var(--border-subtle); }
191
194
  .rigel.sidebar-collapsed .aside-foot { padding: 0.75rem 0.5rem; }
@@ -365,7 +368,18 @@
365
368
  .rigel .preview-heading span, .rigel .site-preview small { color: var(--text-muted); font-size: 0.75rem; }
366
369
  .rigel .site-preview p { overflow-wrap: anywhere; font-size: 0.8125rem; }
367
370
  .rigel .site-preview .preview-error { color: var(--danger); }
368
- .rigel .site-preview iframe { flex: 1; width: 100%; min-height: 300px; border: 1px solid var(--border); background: white; }
371
+ .rigel .preview-frame { position: relative; display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; background: var(--panel); }
372
+ .rigel .preview-frame iframe { opacity: 0; visibility: hidden; transition: none; }
373
+ .rigel .preview-frame iframe.revealed { opacity: 1; visibility: visible; transition: opacity 200ms ease-out; }
374
+ .rigel .preview-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 0.625rem; color: var(--text); background: var(--panel); font-size: 0.8125rem; }
375
+ .rigel .preview-spinner { width: 1rem; height: 1rem; border: 2px solid var(--border); border-top-color: currentColor; border-radius: 50%; animation: preview-spin 800ms linear infinite; }
376
+ @keyframes preview-spin { to { transform: rotate(360deg); } }
377
+ @media (prefers-reduced-motion: reduce) {
378
+ .rigel .preview-spinner { animation: none; }
379
+ .rigel .preview-frame iframe.revealed { transition: none; }
380
+ }
381
+ .rigel .site-preview .preview-frame { min-height: 300px; border: 1px solid var(--border); }
382
+ .rigel .site-preview iframe { flex: 1; width: 100%; min-height: 0; border: 0; background: white; }
369
383
  .rigel .site-preview small { padding-top: 0.75rem; }
370
384
  .rigel .preview-retry { align-self: start; }
371
385
  @media (max-width: 1050px) {
@@ -826,9 +840,9 @@
826
840
  .rigel .outlined:hover:not(:disabled), .rigel .image-picker input::file-selector-button:hover { background: var(--surface-raised); }
827
841
  .rigel .outlined:disabled, .rigel .image-picker .tabs button:disabled, .rigel .image-picker .thumb:disabled { opacity: 0.5; cursor: default; }
828
842
  .rigel .image-picker input::file-selector-button { margin-right: 0.75rem; }
829
- .rigel.image-dialog { width: 100%; height: 100%; max-width: none; max-height: none; margin: 0; padding: 1rem; border: none; }
830
- .rigel.image-dialog:not([open]) { display: none; }
831
- .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; }
832
846
  .rigel .image-picker { width: min(760px, 100%); }
833
847
  .rigel .media-head, .rigel .media-tools, .rigel .media-actions { display: flex; align-items: center; gap: 0.75rem; }
834
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); }
@@ -846,8 +860,10 @@
846
860
  .rigel .media-table :is(th, td) { padding: 0.625rem; overflow-wrap: anywhere; }
847
861
  .rigel .media-table th:first-child { width: 48%; }
848
862
  .rigel .media-file { display: flex; align-items: center; gap: 0.625rem; text-align: left; width: 100%; }
849
- .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; }
850
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); }
851
867
  .rigel .media-file span { min-width: 0; overflow-wrap: anywhere; }
852
868
  .rigel .media-file[aria-pressed=true] { color: var(--on-accent-soft); background: var(--accent-soft); }
853
869
  .rigel :is(.image-picker, .media-library) .thumb[aria-pressed=true] { border-color: var(--accent); }
@@ -857,12 +873,16 @@
857
873
  .rigel .media-table th:first-child { width: 40%; }
858
874
  .rigel .media-file { flex-direction: column; align-items: flex-start; }
859
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; }
860
877
  .rigel .media-actions {
878
+ position: relative;
861
879
  justify-content: flex-end; gap: 0.75rem;
862
880
  margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--border);
863
881
  }
864
882
  .rigel .media-selection { margin-top: 1rem; }
865
- .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); }
866
886
  .rigel .media-button-actions { display: flex; align-items: center; gap: 0.75rem; margin-left: auto; flex-shrink: 0; }
867
887
  .rigel .media-actions.with-media-path { flex-wrap: wrap; }
868
888
  .rigel .media-download { flex-shrink: 0; white-space: nowrap; text-decoration: none; }
@@ -872,16 +892,19 @@
872
892
  .rigel .media-meta { border-top: 1px solid var(--border-subtle); padding-top: 0.5rem; }
873
893
  .rigel .media-meta label { margin-top: 0.75rem; }
874
894
  .rigel .media-details { min-width: 0; }
895
+ .rigel .media-details .hint { overflow-wrap: anywhere; }
875
896
  .rigel .image-picker:has(.media-editing) { width: min(1040px, 100%); }
876
897
  .rigel .media-selection.media-editing { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 1.5rem; }
877
898
  .rigel .media-editing .media-preview { min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
878
- .rigel .media-editing .media-preview > img { max-height: 60dvh; }
899
+ .rigel .media-editing .media-preview > :is(img, video) { max-height: 60dvh; }
879
900
  .rigel .media-editing .media-meta { border-top: 0; padding-top: 0; }
880
901
  .rigel .media-editing .media-meta label:first-child { margin-top: 0; }
881
902
  .rigel .media-editing > .media-actions { grid-column: 1 / -1; margin-top: 0; }
882
903
  @media (max-width: 700px) {
883
904
  .rigel .media-selection.media-editing { grid-template-columns: minmax(0, 1fr); }
884
- .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%; }
885
908
  .rigel .with-media-path .media-button-actions { flex-basis: 100%; justify-content: flex-end; }
886
909
  }
887
910
  .rigel .image-picker input[type=file] { width: 100%; color: var(--text); }
@@ -913,12 +936,15 @@
913
936
  display: grid; grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr)); gap: 1rem; margin-top: 1rem;
914
937
  }
915
938
  .rigel :is(.image-picker, .media-library) .thumb {
939
+ position: relative;
916
940
  border: 2px solid transparent; border-radius: 0; overflow: hidden;
917
941
  padding: 0; cursor: pointer; background: none; aspect-ratio: 3 / 2;
918
942
  transition: border-color 0.1s, transform 0.1s;
919
943
  }
920
944
  .rigel :is(.image-picker, .media-library) .thumb:hover { border-color: var(--accent); transform: translateY(-1px); }
921
- .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; }
922
948
  .rigel :is(.image-picker, .media-library) .grid figure { margin: 0; min-width: 0; }
923
949
  .rigel :is(.image-picker, .media-library) .grid .thumb { width: 100%; }
924
950
  .rigel :is(.image-picker, .media-library) .grid figcaption { font-size: 0.75rem; color: var(--text-2); overflow-wrap: anywhere; }
@@ -1140,7 +1166,7 @@
1140
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; }
1141
1167
  .rigel.rigel-inline .block-editor { margin: 0; border: 0; }
1142
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; }
1143
- .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; }
1144
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; }
1145
1171
  .rigel.rigel-inline :where(.block-editor .body-toolbar) > button { width: 32px; height: 32px; padding: 0; background: transparent; color: var(--text); border: 0; }
1146
1172
  .rigel.rigel-inline .block-handle { position: fixed; width: 24px; height: 28px; }
@@ -1195,7 +1221,7 @@
1195
1221
  .rigel .block-table-controls button:hover:not(:disabled) { background: var(--accent-soft); color: var(--on-accent-soft); }
1196
1222
  .rigel .block-table-controls button:disabled { opacity: 0.35; cursor: default; }
1197
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; }
1198
- .rigel .block-table-controls label.inline input.switch { flex-shrink: 0; margin-left: auto; }
1224
+
1199
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; }
1200
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; }
1201
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; }
@@ -1289,3 +1315,8 @@
1289
1315
  .rigel main.visual-pane { padding: 0.75rem; }
1290
1316
  .rigel .visual-editor.with-panel { grid-template-rows: minmax(0, 1fr) 50%; }
1291
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"> & { 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
@@ -1,7 +1,9 @@
1
1
  import MarkdownIt from "markdown-it";
2
2
  import DOMPurify from "dompurify";
3
3
  import { pageRoute } from "./schema/paths";
4
+ import { slugify } from "./slugs";
4
5
  import { renderArticle } from "./article-render";
6
+ import { createEmbedPreview } from "./editor/lib/embed-preview";
5
7
  import { safePreviewUrl, previewBaseUrl } from "./preview-urls";
6
8
  export { safePreviewUrl } from "./preview-urls";
7
9
 
@@ -20,6 +22,7 @@ export interface PreviewConfig {
20
22
  export interface PreviewTarget {
21
23
  kind: "page" | "post" | "menu" | "document";
22
24
  slug: string;
25
+ urlSlug?: string;
23
26
  collection?: string;
24
27
  type?: string;
25
28
  }
@@ -31,6 +34,11 @@ export function previewPath(config: PreviewConfig, target: PreviewTarget, isNew
31
34
  }
32
35
 
33
36
  export function previewUrl(config: PreviewConfig, target: PreviewTarget, mode: "live" | "draft" | "website" = "draft", isNew = false): string | null {
37
+ if (mode !== "draft" && target.kind !== "page" && target.urlSlug !== undefined) {
38
+ const slug = slugify(target.urlSlug);
39
+ if (!slug) return null;
40
+ target = { ...target, slug };
41
+ }
34
42
  const path = previewPath(config, target, mode === "draft" && (target.kind === "post" || isNew));
35
43
  const base = mode === "live" ? config.liveUrl : config.draftUrl;
36
44
  if (!path || base === undefined) return path;
@@ -151,11 +159,14 @@ export function patchPreview(doc: Document, values: Record<string, unknown>, bod
151
159
  const source = blocks ? JSON.stringify(result.value) : typeof result.value === "string" ? result.value : "";
152
160
  if (rendered.get(el) === source) continue;
153
161
  rendered.set(el, source);
154
- 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), {
155
163
  RETURN_DOM_FRAGMENT: true,
156
- 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"],
157
- 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"] : [])],
158
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
+ }
159
170
  for (const link of fragment.querySelectorAll("[href], [src]")) {
160
171
  for (const attr of ["href", "src"]) if (link.hasAttribute(attr) && !safePreviewUrl(link.getAttribute(attr)!, attr === "src")) link.removeAttribute(attr);
161
172
  }
@@ -46,7 +46,7 @@ export const GET: APIRoute = async ({ params }) => {
46
46
  const target = validateDocumentId(guarded.schema, id);
47
47
  if (!target) return new Response("Unknown document", { status: 404 });
48
48
  try {
49
- const documents = await readDocuments(guarded.schema, guarded.root);
49
+ const documents = await readDocuments(guarded.schema, guarded.root, config.preview);
50
50
  const document = documents.find((entry) => entry.id === id);
51
51
  if (!document) return new Response("Unknown document", { status: 404 });
52
52
  return Response.json(document);
@@ -66,10 +66,10 @@ const handle: APIRoute = async ({ request, params }) => {
66
66
  if (!target) return new Response("Unknown document type or slug", { status: 400 });
67
67
  try {
68
68
  const data = await readBody(request);
69
- const documents = await readDocuments(guarded.schema, guarded.root);
70
- const next = { ...data, _id: id, _type: target.type.name, slug: target.slug };
69
+ const documents = await readDocuments(guarded.schema, guarded.root, config.preview);
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");
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 });