@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
package/README.md CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  A CMS for Astro sites. You edit the text, git keeps the history.
4
4
 
5
- `@rigel-cms/rigel` is a beta package and a work in progress. Expect bugs and breaking changes.
6
-
7
5
  ## Collection validation
8
6
 
9
7
  New collection drafts initialize every configured field, including Rigel's automatic `description`, `metaTitle`, and `ogImage` strings. Existing content is never filled in automatically; unknown properties are preserved and missing optional fields remain allowed.
@@ -12,6 +10,10 @@ Rigel validates configured collection field types and `required: true` on load a
12
10
 
13
11
  Astro content schemas remain the host site's contract: Rigel does not discover or execute them. Align Rigel field types and requiredness with the host schema. For example, Astro's `z.string()` accepts an initialized empty description; `z.string().min(1)` also needs Rigel's `required: true`.
14
12
 
13
+ ## Media uploads
14
+
15
+ Set `maxFileSize` in the `rigel()` options in `astro.config.mjs`. The value is bytes and defaults to `2 * 1024 * 1024` (2 MB). For example, `rigel({ maxFileSize: 50 * 1024 * 1024 })` allows 50 MB per file. The limit applies to uploads and downloaded image imports, not existing files or linked videos. Your host may impose its own limit.
16
+
15
17
  ## License
16
18
 
17
19
  [Apache-2.0](LICENSE). Copyright 2026 [Alex Zappa](https://alex.zappa.dev/) ([FreshJuice](https://freshjuice.dev/)).
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@rigel-cms/rigel",
3
- "version": "0.1.4-beta.1",
3
+ "version": "1.0.0-rc.1",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
- "tag": "beta"
6
+ "tag": "rc"
7
7
  },
8
8
  "description": "Rigel CMS — the copy editor for Astro sites. Clients edit text, git keeps the history.",
9
9
  "author": {
@@ -32,8 +32,7 @@
32
32
  "scripts": {
33
33
  "build": "pnpm typecheck",
34
34
  "test": "vitest run",
35
- "typecheck": "tsc --noEmit",
36
- "publish:beta": "npm publish --access public --tag beta"
35
+ "typecheck": "tsc --noEmit"
37
36
  },
38
37
  "exports": {
39
38
  ".": "./src/index.ts",
@@ -1,5 +1,6 @@
1
1
  import type { SerializedEditorState } from "lexical";
2
2
  import { safePreviewUrl } from "./preview-urls";
3
+ import { safeEmbed } from "./editor/lib/embeds";
3
4
 
4
5
  export type ArticleBody = SerializedEditorState;
5
6
  export type ArticleNode = { type: string; version: number; children?: ArticleNode[]; text?: string; format?: number | string; tag?: string; listType?: string; start?: number; url?: string; src?: string; alt?: string; credit?: string; creditURL?: string; [key: string]: unknown };
@@ -12,7 +13,7 @@ export function emptyBody(): ArticleBody {
12
13
  }
13
14
 
14
15
  export function validateBody(value: unknown): asserts value is ArticleBody {
15
- const types = new Set(["root", "paragraph", "heading", "quote", "list", "listitem", "code", "text", "linebreak", "link", "image", "divider", "table", "tablerow", "tablecell"]);
16
+ const types = new Set(["root", "paragraph", "heading", "quote", "list", "listitem", "code", "text", "linebreak", "link", "image", "video", "embed", "divider", "table", "tablerow", "tablecell"]);
16
17
  let count = 0;
17
18
  let textSize = 0;
18
19
  function check(raw: unknown, parent = "", depth = 0) {
@@ -43,9 +44,15 @@ export function validateBody(value: unknown): asserts value is ArticleBody {
43
44
  if (node.type === "image" && !safePreviewUrl(node.src!, true)) throw new Error("Unsafe image URL");
44
45
  if (node.type === "image" && node.url !== undefined && (typeof node.url !== "string" || node.url && !safePreviewUrl(node.url))) throw new Error("Unsafe image link URL");
45
46
  if (node.type === "link" && !safePreviewUrl(node.url!)) throw new Error("Unsafe link URL");
47
+ if (node.type === "video" && (typeof node.src !== "string" || !safePreviewUrl(node.src, true))) throw new Error("Unsafe video URL");
48
+ if (node.type === "video" && node.poster !== undefined && (typeof node.poster !== "string" || !safePreviewUrl(node.poster, true))) throw new Error("Unsafe video poster URL");
49
+ if (node.type === "video" && node.caption !== undefined && typeof node.caption !== "string") throw new Error("Invalid video caption");
50
+ if (node.type === "video") for (const key of ["muted", "controls"]) if (node[key] !== undefined && typeof node[key] !== "boolean") throw new Error("Invalid video playback setting");
51
+ if (node.type === "embed" && (!safeEmbed(node) || typeof node.privacy !== "boolean")) throw new Error("Invalid video embed");
52
+ if (node.type === "embed" && node.title !== undefined && typeof node.title !== "string") throw new Error("Invalid embed title");
46
53
  for (const key of ["credit", "creditURL"]) if (node[key] !== undefined && typeof node[key] !== "string") throw new Error("Invalid image credit");
47
54
  if (node.creditURL && !safePreviewUrl(node.creditURL)) throw new Error("Unsafe image credit URL");
48
- if (!["text", "linebreak", "image", "divider"].includes(node.type)) {
55
+ if (!["text", "linebreak", "image", "video", "embed", "divider"].includes(node.type)) {
49
56
  if (!Array.isArray(node.children)) throw new Error("Article block requires children");
50
57
  for (const child of node.children) check(child, node.type, depth + 1);
51
58
  if (["table", "tablerow", "tablecell"].includes(node.type) && !node.children.length) throw new Error("Table elements cannot be empty");
@@ -1,5 +1,6 @@
1
1
  import { validateBody, type ArticleBody, type ArticleNode } from "./article-body";
2
2
  import { safePreviewUrl } from "./preview-urls";
3
+ import { embedSrc, embedWatchUrl, safeEmbed, embedProviderLabel } from "./editor/lib/embeds";
3
4
 
4
5
  const escape = (text: unknown) => String(text ?? "").replace(/[&<>"']/g, character => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]!);
5
6
 
@@ -10,7 +11,7 @@ function renderText(node: ArticleNode): string {
10
11
  return text;
11
12
  }
12
13
 
13
- export function renderArticle(body: ArticleBody): string {
14
+ export function renderArticle(body: ArticleBody, options: { preview?: boolean } = {}): string {
14
15
  validateBody(body);
15
16
  function walk(node: ArticleNode): string {
16
17
  const content = () => (node.children ?? []).map(walk).join("");
@@ -32,6 +33,20 @@ export function renderArticle(body: ArticleBody): string {
32
33
  if (node.type === "tablecell") return Number(node.headerState) ? `<th scope="${Number(node.headerState) & 1 ? "col" : "row"}">${content()}</th>` : `<td>${content()}</td>`;
33
34
  if (node.type === "link") return safePreviewUrl(node.url!) ? `<a href="${escape(node.url)}">${content()}</a>` : content();
34
35
  if (node.type === "divider") return "<hr>";
36
+ if (node.type === "video") {
37
+ if (!safePreviewUrl(node.src!, true)) return "";
38
+ const poster = typeof node.poster === "string" && safePreviewUrl(node.poster, true) ? ` poster="${escape(node.poster)}"` : "";
39
+ const caption = node.caption ? `<figcaption>${escape(node.caption)}</figcaption>` : "";
40
+ return `<figure class="media-video"><video${node.controls !== false ? " controls" : ""}${node.muted === true ? " muted" : ""} preload="metadata" playsinline src="${escape(node.src)}"${poster}></video>${caption}</figure>`;
41
+ }
42
+ if (node.type === "embed") {
43
+ const ref = safeEmbed(node);
44
+ if (!ref) return "";
45
+ const title = escape(typeof node.title === "string" && node.title ? node.title : `${embedProviderLabel(ref)} video`);
46
+ if (options.preview) return `<figure data-rigel-embed="${ref.provider}" data-rigel-video-id="${ref.id}" data-rigel-video-title="${title}"></figure>`;
47
+ const src = escape(embedSrc(ref, node.privacy !== false));
48
+ return `<figure class="media-embed"><iframe src="${src}" title="${title}" loading="lazy" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe></figure>`;
49
+ }
35
50
  if (node.type === "image") {
36
51
  if (!safePreviewUrl(node.src!, true)) return "";
37
52
  const credit = node.credit ? (node.creditURL && safePreviewUrl(node.creditURL) ? `<a href="${escape(node.creditURL)}">${escape(node.credit)}</a>` : escape(node.credit)) : "";
@@ -81,6 +96,17 @@ export function articleMarkdown(body: ArticleBody): string {
81
96
  return (node.url ? `[${image}](<${node.url.replace(/[<>]/g, character => encodeURIComponent(character))}>)` : image) + "\n\n";
82
97
  }
83
98
  if (node.type === "divider") return "---\n\n";
99
+ if (node.type === "video") {
100
+ if (!safePreviewUrl(node.src!, true)) return "";
101
+ const label = node.caption ? `Video: ${node.caption}` : "Video";
102
+ return `[${label}](<${node.src!.replace(/[<>]/g, character => encodeURIComponent(character))}>)\n\n`;
103
+ }
104
+ if (node.type === "embed") {
105
+ const ref = safeEmbed(node);
106
+ if (!ref) return "";
107
+ const label = typeof node.title === "string" && node.title ? node.title : `${embedProviderLabel(ref)} video`;
108
+ return `[${escaped(label)}](<${embedWatchUrl(ref)}>)\n\n`;
109
+ }
84
110
  return content();
85
111
  }
86
112
  return walk(body.root as unknown as ArticleNode).trimEnd() + "\n";
package/src/config.ts CHANGED
@@ -4,12 +4,16 @@ import type { Schema } from "./schema/types";
4
4
  import { validateSidebar, type SidebarEntry } from "./manifest/navigation";
5
5
  import { withSeoFields } from "./manifest/seo";
6
6
  import { previewBaseUrl } from "./preview-urls";
7
+ import type { JsonOutput } from "./json-output";
8
+ import { DEFAULT_MAX_FILE_SIZE } from "./media-types";
7
9
 
8
10
  export interface RigelConfig {
9
11
  copyDir: string;
10
12
  mediaDir?: string;
13
+ maxFileSize?: number;
11
14
  preview?: import("./preview").PreviewConfig;
12
15
  backend: "local" | "github";
16
+ jsonOutput?: JsonOutput;
13
17
  demo?: boolean;
14
18
  collections?: CollectionConfig[];
15
19
  menus?: { folder: string; maxDepth?: number };
@@ -40,6 +44,10 @@ const DEFAULTS: Pick<RigelConfig, "copyDir" | "backend"> = {
40
44
 
41
45
  export function parseConfig(options: Partial<RigelConfig> = {}): RigelConfig {
42
46
  const cfg = { ...DEFAULTS, ...options };
47
+ if (cfg.maxFileSize === undefined) cfg.maxFileSize = DEFAULT_MAX_FILE_SIZE;
48
+ if (!Number.isSafeInteger(cfg.maxFileSize) || cfg.maxFileSize <= 0) throw new Error("rigel: maxFileSize must be a positive integer in bytes");
49
+ if (cfg.jsonOutput === undefined) cfg.jsonOutput = "pretty";
50
+ if (cfg.jsonOutput !== "pretty" && cfg.jsonOutput !== "minified") throw new Error("rigel: jsonOutput must be pretty or minified");
43
51
  if (cfg.demo === undefined) cfg.demo = true;
44
52
  if (typeof cfg.demo !== "boolean") throw new Error("rigel: demo must be boolean");
45
53
  if (cfg.preview) {
package/src/content.ts CHANGED
@@ -2,4 +2,6 @@ export type { MenuItem, MenuDocument } from "./manifest/menu";
2
2
  export { defineCopy } from "./manifest/types";
3
3
  export type { CopyField, CopyGroup, CopyPage, Manifest, ManifestCollection } from "./manifest/types";
4
4
  export { defineSchema, defineDocument, defineObject, defineField } from "./schema/types";
5
- export type { Schema, DocumentDef, ObjectDef, FieldDef, ContentDocument } from "./schema/types";
5
+ export type { Schema, DocumentDef, ObjectDef, FieldDef, ContentDocument } from "./schema/types";
6
+ export { effectiveSlug } from "./slugs";
7
+ export { pageRoute } from "./schema/paths";
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { onMount } from "svelte";
2
+ import { onMount, tick } from "svelte";
3
3
  import { tooltipRoot } from "./lib/tooltip";
4
4
  onMount(() => tooltipRoot(document.body).destroy);
5
5
  import type {
@@ -20,6 +20,7 @@
20
20
  import { isSeoField } from "../manifest/seo";
21
21
  import { initialCollectionValues } from "../manifest/collections";
22
22
  import { slugify } from "./lib/slugify";
23
+ import { effectiveSlug, normalizeUrlSlug } from "../slugs";
23
24
  import Icon from "./lib/Icon.svelte";
24
25
  import SidebarIcon from "./lib/SidebarIcon.svelte";
25
26
  import logo from "./assets/rigelcms-icon.png?url";
@@ -29,13 +30,14 @@
29
30
  import type { MenuItem } from "../manifest/menu";
30
31
  import UnsavedChanges from "./lib/UnsavedChanges.svelte";
31
32
  import ActionDialog from "./lib/ActionDialog.svelte";
33
+ import PublishDialog from "./lib/PublishDialog.svelte";
32
34
  import VisualEditor from "./lib/VisualEditor.svelte";
33
35
  import { newDocumentFromTemplate } from "../schema/paths";
34
36
  import { previewUrl } from "../preview";
35
37
  import type { ContentDocument, Schema } from "../schema/types";
36
38
  import { buildSidebar, navigationKey, type Selection } from "../manifest/navigation";
37
39
  import { startGitHubLogin, checkGitHubAccess, type GitHubAccess } from "./lib/github-auth";
38
- import { loadManifestRemote, saveRemoteSelection, deleteRemotePost, type GitContext } from "./lib/github-backend";
40
+ import { loadManifestRemote, saveRemoteSelection, deleteRemotePost, reviewRemotePublish, confirmRemotePublish, type GitContext, type PublishReview } from "./lib/github-backend";
39
41
 
40
42
  type Sel = Selection;
41
43
 
@@ -61,6 +63,75 @@
61
63
  let saveState = $state<"idle" | "saving" | "saved" | "error">("idle");
62
64
  let saveTask: Promise<boolean> | null = null;
63
65
  let saveError = $state("");
66
+ let publishOpen = $state(false);
67
+ let publishLoading = $state(false);
68
+ let publishBusy = $state(false);
69
+ let publishReview = $state<PublishReview | null>(null);
70
+ let publishError = $state("");
71
+ let publishMessage = $state("");
72
+ let publishRevision = 0;
73
+
74
+ function closePublish() {
75
+ publishRevision += 1;
76
+ publishOpen = false;
77
+ publishReview = null;
78
+ void tick().then(() => document.querySelector<HTMLButtonElement>("#publish-button")?.focus());
79
+ }
80
+
81
+ function publishGuard(context: GitContext, revision: number, auth: number) {
82
+ if (!publishOpen || revision !== publishRevision || auth !== authRevision || gitContext !== context) throw new Error("Publish review is no longer active. Reconnect and review again.");
83
+ requireGitContext();
84
+ if (dirty || saveTask || saveState === "saving") throw new Error("Save your unsaved edits first, then review publishing. Your edits are still here.");
85
+ }
86
+
87
+ async function reviewPublish() {
88
+ if (publishBusy || publishLoading) return;
89
+ publishOpen = true;
90
+ publishReview = null;
91
+ publishError = "";
92
+ publishMessage = "";
93
+ publishLoading = true;
94
+ const revision = ++publishRevision, auth = authRevision;
95
+ try {
96
+ const context = requireGitContext();
97
+ const guard = () => publishGuard(context, revision, auth);
98
+ guard();
99
+ const review = await reviewRemotePublish(context, manifest, guard);
100
+ guard();
101
+ publishReview = review;
102
+ } catch (error) {
103
+ handleAuthError(error);
104
+ if (revision === publishRevision) publishError = error instanceof Error ? error.message : "Could not review saved changes.";
105
+ } finally { publishLoading = false; }
106
+ }
107
+
108
+ async function publish() {
109
+ if (publishBusy || publishLoading || !publishReview) return;
110
+ publishBusy = true;
111
+ publishError = "";
112
+ const review = publishReview, revision = publishRevision, auth = authRevision;
113
+ try {
114
+ const context = requireGitContext();
115
+ const guard = () => publishGuard(context, revision, auth);
116
+ guard();
117
+ const result = await confirmRemotePublish(context, manifest, review, guard);
118
+ guard();
119
+ publishReview = null;
120
+ if (result === "synced") {
121
+ const remote = await loadManifestRemote(context, blueprint!, false);
122
+ guard();
123
+ manifest = remote;
124
+ openSelection(sel);
125
+ publishMessage = "Drafts synced. Nothing was published. Close this dialog and review again before publishing.";
126
+ } else publishMessage = `Published to ${review.publishBranch}. Your configured deployment can now start; the site is not instantly live. The drafts branch is preserved.`;
127
+ } catch (error) {
128
+ handleAuthError(error);
129
+ if (revision === publishRevision) {
130
+ publishError = error instanceof Error ? error.message : "Could not verify publishing. Check GitHub before retrying.";
131
+ if (/changed|no longer matches/.test(publishError)) publishReview = null;
132
+ }
133
+ } finally { publishBusy = false; }
134
+ }
64
135
  let pendingNavigation = $state<(() => void) | null>(null);
65
136
  let actionDialog = $state<{ title: string; description: string; confirmLabel: string; fieldLabel?: string; danger?: boolean; onconfirm: (value: string) => void | Promise<void> } | null>(null);
66
137
  let newEntry = $state<{ collection: string; post: CollectionPost } | null>(null);
@@ -129,6 +200,7 @@
129
200
  }
130
201
 
131
202
  function invalidateSession() {
203
+ closePublish();
132
204
  authRevision += 1;
133
205
  clearSession();
134
206
  gitContext = null;
@@ -142,7 +214,8 @@
142
214
  }
143
215
 
144
216
  async function connectGitHub() {
145
- if (connectionBusy || saveState === "saving" || !manifest.github) return;
217
+ if (connectionBusy || saveState === "saving" || publishBusy || !manifest.github) return;
218
+ closePublish();
146
219
  connectionBusy = true;
147
220
  connectionError = "";
148
221
  const revision = ++authRevision;
@@ -173,6 +246,8 @@
173
246
  function signOut() {
174
247
  if (saveState === "saving" || connectionBusy) return;
175
248
  userOpen = false;
249
+ if (publishBusy) { invalidateSession(); connected = false; return; }
250
+ closePublish();
176
251
  requestNavigation(() => {
177
252
  invalidateSession();
178
253
  connected = false;
@@ -202,6 +277,7 @@
202
277
  });
203
278
 
204
279
  function save(): Promise<boolean> {
280
+ if (publishOpen || publishBusy) return Promise.resolve(false);
205
281
  if (saveTask) return saveTask;
206
282
  saveTask = (async () => {
207
283
  if (dirty) {
@@ -215,6 +291,10 @@
215
291
  const docSel = sel?.kind === "document" ? sel : null;
216
292
  const creatingDoc = docSel !== null && newDocument?.id === docSel.id;
217
293
  try {
294
+ if ((selection?.kind === "post" || (docSel && !activeDocType?.shared)) && Object.hasOwn(values, "slug")) {
295
+ values.slug = normalizeUrlSlug(values.slug);
296
+ draft.slug = values.slug;
297
+ }
218
298
  if (githubMode) {
219
299
  if (!selection) throw new Error("Select content before saving.");
220
300
  await saveRemoteSelection(requireGitContext(), manifest, selection, values, body, !!creating || creatingDoc);
@@ -234,7 +314,7 @@
234
314
  newDocument = null;
235
315
  docBase = JSON.parse(JSON.stringify(values));
236
316
  } else if (m) { m.title = values.title; m.items = values.items; }
237
- else if (po) { po.frontmatter = { slug: po.slug, ...values }; po.body = body; bodyBase = body; }
317
+ else if (po) { po.frontmatter = values; po.body = body; bodyBase = body; }
238
318
  else if (p) p.values = values;
239
319
  if (creating && po && c) c.posts.push(po);
240
320
  newEntry = null;
@@ -411,8 +491,8 @@
411
491
  return map;
412
492
  });
413
493
  function documentPreview(mode: "draft" | "website") {
414
- if (sel?.kind !== "document" || newDocument?.id === sel.id) return null;
415
- return previewUrl(manifest.preview ?? {}, { kind: "document", type: sel.type, slug: sel.id.slice(sel.type.length + 1) }, mode);
494
+ if (sel?.kind !== "document" || (mode === "draft" && newDocument?.id === sel.id)) return null;
495
+ return previewUrl(manifest.preview ?? {}, { kind: "document", type: sel.type, slug: sel.id.slice(sel.type.length + 1), urlSlug: Object.hasOwn(draft, "slug") ? String(draft.slug) : undefined }, mode);
416
496
  }
417
497
  const standaloneDocument = $derived(sel?.kind === "document" && activeDocType?.shared === true);
418
498
  const visualSelection = $derived(sel?.kind === "post" || (sel?.kind === "document" && !standaloneDocument));
@@ -422,13 +502,13 @@
422
502
  ...manifest.pages.filter((p) => !p.shared && (pageCategory === undefined || p.category === pageCategory))
423
503
  .map((p) => ({ slug: p.slug, title: p.label, category: p.category, draft: false, target: { kind: "page", slug: p.slug } as Sel })),
424
504
  ...(pageCategory === undefined ? pageDocuments
425
- .map((doc) => ({ slug: doc.slug, title: String(doc.data.title ?? doc.slug), category: undefined, draft: doc.data.draft === true, target: { kind: "document", type: doc.type, id: doc.id } as Sel })) : []),
505
+ .map((doc) => ({ slug: effectiveSlug(doc.data, doc.slug), title: String(doc.data.title ?? doc.slug), category: undefined, draft: doc.data.draft === true, target: { kind: "document", type: doc.type, id: doc.id } as Sel })) : []),
426
506
  ];
427
507
  const key = sortBy === "slug" || sortBy === "category" ? sortBy : "title";
428
508
  return rows.sort((a, b) => String(a[key] ?? "").localeCompare(String(b[key] ?? "")) * sortDir);
429
509
  });
430
510
  function documentCell(doc: ContentDocument, key: string): string {
431
- return String(key === "title" ? doc.data.title ?? doc.data.name ?? doc.slug : key === "slug" ? doc.slug : doc.data[key] ?? "");
511
+ return String(key === "title" ? doc.data.title ?? doc.data.name ?? doc.slug : key === "slug" ? activeDocType?.shared ? doc.slug : effectiveSlug(doc.data, doc.slug) : doc.data[key] ?? "");
432
512
  }
433
513
  const typeDocuments = $derived((manifest.documents ?? []).filter((doc) => doc.type === docTypeName(sel)).sort((a, b) => documentCell(a, sortBy).localeCompare(documentCell(b, sortBy)) * sortDir));
434
514
 
@@ -507,6 +587,7 @@
507
587
  }
508
588
 
509
589
  function requestNavigation(action: () => void) {
590
+ if (publishOpen || publishBusy) return;
510
591
  if (dirty || saveState === "saving") pendingNavigation = action;
511
592
  else action();
512
593
  }
@@ -564,7 +645,6 @@
564
645
  const po = post;
565
646
  if (!po) return;
566
647
  const fm: Record<string, unknown> = { ...po.frontmatter };
567
- delete fm.slug;
568
648
  draft = JSON.parse(JSON.stringify(fm));
569
649
  bodyDraft = po.body;
570
650
  bodyBase = po.body;
@@ -590,9 +670,7 @@
590
670
  }
591
671
  const ref =
592
672
  sel?.kind === "menu" && menu ? { title: menu.title, items: menu.items } : sel?.kind === "post" && post
593
- ? Object.fromEntries(
594
- Object.entries(post.frontmatter).filter(([k]) => k !== "slug"),
595
- )
673
+ ? post.frontmatter
596
674
  : page?.values;
597
675
  dirty =
598
676
  newEntry !== null ||
@@ -602,7 +680,7 @@
602
680
  }
603
681
 
604
682
  function cellValue(p: { slug: string; frontmatter: Record<string, unknown> }, key: string): string {
605
- const v = key === "slug" ? p.slug : p.frontmatter[key];
683
+ const v = key === "slug" ? effectiveSlug(p.frontmatter, p.slug) : p.frontmatter[key];
606
684
  const field = coll?.fields.find(field => field.key === key);
607
685
  if (field?.type === "reference") return referenceLabel(v, referenceOptions(field, manifest.documents)) || "—";
608
686
  if (v === true) return "✓";
@@ -618,7 +696,7 @@
618
696
  onconfirm: (title) => {
619
697
  const slug = slugify(title);
620
698
  if (!slug) throw new Error("Enter a title containing letters or numbers.");
621
- const taken = [...(manifest.documents ?? []).filter(doc => !type.shared || doc.type === type.name).map((doc) => doc.slug), ...(!type.shared ? manifest.pages.map((page) => page.slug) : [])];
699
+ const taken = [...(manifest.documents ?? []).filter(doc => !type.shared || doc.type === type.name).flatMap((doc) => [doc.slug, ...(type.shared || docTypes.find(type => type.name === doc.type)?.shared ? [] : [effectiveSlug(doc.data, doc.slug)])]), ...(!type.shared ? manifest.pages.map((page) => page.slug) : [])];
622
700
  if (taken.includes(slug)) throw new Error(`The slug "${slug}" is already used by another document or page.`);
623
701
  const id = `${type.name}:${slug}`;
624
702
  const data = newDocumentFromTemplate(schema, type.name, title, slug);
@@ -634,15 +712,16 @@
634
712
  actionDialog = {
635
713
  title: `Add to ${c.label}`, description: "Enter a title to start a draft. Nothing is written until you press Save.", fieldLabel: "Title", confirmLabel: "Create draft",
636
714
  onconfirm: (title) => {
637
- const slug = slugify(title, c.posts.map((post) => post.slug));
715
+ const slug = slugify(title, c.posts.flatMap((post) => [post.slug, effectiveSlug(post.frontmatter, post.slug)]));
638
716
  if (!slug) throw new Error("Enter a title containing letters or numbers.");
639
- newEntry = { collection: c.name, post: { slug, frontmatter: initialCollectionValues(c.fields, title), body: emptyBody() } };
717
+ newEntry = { collection: c.name, post: { slug, frontmatter: { ...initialCollectionValues(c.fields, title), slug }, body: emptyBody() } };
640
718
  openSelection({ kind: "post", collection: c.name, slug });
641
719
  },
642
720
  };
643
721
  }
644
722
 
645
723
  function deletePost() {
724
+ if (publishOpen || publishBusy) return;
646
725
  const current = post, collection = coll, unsaved = newEntry !== null;
647
726
  if (!current || !collection) return;
648
727
  actionDialog = {
@@ -689,7 +768,7 @@
689
768
  onresize={endPreviewResize}
690
769
  onblur={endPreviewResize}
691
770
  oncontextmenu={(event) => { if (!settings.devMode) event.preventDefault(); }}
692
- onbeforeunload={(event) => { if (dirty || saveState === "saving") { event.preventDefault(); event.returnValue = ""; } }}
771
+ onbeforeunload={(event) => { if (dirty || saveState === "saving" || publishBusy) { event.preventDefault(); event.returnValue = ""; } }}
693
772
  onclick={(e) => {
694
773
  if (!(e.target instanceof Element) || !e.target.closest('.user-menu-wrap')) userOpen = false;
695
774
  }}
@@ -736,11 +815,10 @@
736
815
  </div>
737
816
  {:else}
738
817
  <div class="rigel" class:sidebar-collapsed={settings.userPref.sidebarCollapsed}>
739
- <aside id="rigel-sidebar">
740
- <a class="brand" href="/rigel/" role="button" aria-label="Welcome" aria-describedby="rigel-beta" onclick={(event) => { event.preventDefault(); goWelcome(); }}>
818
+ <aside id="rigel-sidebar" data-tooltip-position="right">
819
+ <a class="brand" href="/rigel/" role="button" aria-label="Welcome" onclick={(event) => { event.preventDefault(); goWelcome(); }}>
741
820
  <img src={logo} alt="" width="32" height="32" />
742
821
  <span>Rigel <span class="brand-suffix">CMS</span></span>
743
- <span id="rigel-beta" class="badge">Beta</span>
744
822
  </a>
745
823
  <TopBar {manifest} {sel} onselect={select} />
746
824
 
@@ -771,6 +849,11 @@
771
849
  {/each}
772
850
  </nav>
773
851
 
852
+ {#if githubMode && manifest.github?.publishBranch}
853
+ <button id="publish-button" class="sidebar-publish" aria-label="Publish" data-tooltip={dirty ? "Save your edits before publishing" : "Publish saved branch changes"} disabled={publishBusy || publishLoading || saveState === "saving" || connectionBusy || tokenExpired || githubAccess?.status !== "ready"} onclick={reviewPublish}>
854
+ <Icon name="external" size={18} /><span class="sidebar-label">Publish</span>
855
+ </button>
856
+ {/if}
774
857
  <button class="sidebar-toggle" aria-expanded={!settings.userPref.sidebarCollapsed} aria-controls="rigel-sidebar-navigation" aria-label={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} data-tooltip={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} onclick={() => { settings.userPref.sidebarCollapsed = !settings.userPref.sidebarCollapsed; }}>
775
858
  <SidebarIcon name="sidebar-simple" /><span class="sidebar-label">Collapse sidebar</span>
776
859
  </button>
@@ -793,11 +876,11 @@
793
876
  Live Site
794
877
  </a>
795
878
  <div class="divider"></div>
796
- <button role="menuitem" onclick={() => { userOpen = false; requestNavigation(() => { settingsOpen = true; }); }}>
879
+ <button role="menuitem" onclick={() => { userOpen = false; settingsOpen = true; }}>
797
880
  <Icon name="gear" size={13} />
798
881
  Settings
799
882
  </button>
800
- <button role="menuitem" onclick={() => { userOpen = false; requestNavigation(() => { shortcutsOpen = true; }); }}>
883
+ <button role="menuitem" onclick={() => { userOpen = false; shortcutsOpen = true; }}>
801
884
  <Icon name="keyboard" size={13} />
802
885
  Keyboard Shortcuts
803
886
  </button>
@@ -951,6 +1034,9 @@
951
1034
  <MenuEditor items={draft.items as MenuItem[]} maxDepth={manifest.menus.maxDepth} oninput={(items) => { draft.items = items; touch(); }} />
952
1035
 
953
1036
  {:else if sel?.kind === "page" && page}
1037
+ {#if !page.shared}
1038
+ <section><label><span>Website route</span><input type="text" value={manifest.preview?.pages?.[page.slug] ?? "Not configured"} readonly /></label><p class="hint">This copy page uses a fixed Astro route. Its URL can only be changed in the website code; slug metadata cannot move it.</p></section>
1039
+ {/if}
954
1040
  {#each page.groups as group (group.id)}
955
1041
  <section>
956
1042
  <h3>{group.label}</h3>
@@ -979,13 +1065,13 @@
979
1065
  bodyInline={settings.userPref.editorExperience === "inline"}
980
1066
  onbody={(value) => { bodyDraft = value; touch(); }}
981
1067
  previewPath={manifest.preview ? previewUrl(manifest.preview, sel, "draft", !!newEntry) : null}
982
- websitePath={manifest.preview ? previewUrl(manifest.preview, sel, "website") : null}
1068
+ websitePath={manifest.preview ? previewUrl(manifest.preview, { ...sel, urlSlug: Object.hasOwn(draft, "slug") ? String(draft.slug) : undefined }, "website") : null}
983
1069
  creating={!!newEntry}
984
1070
  devMode={settings.devMode}
985
1071
  onchange={(next) => { draft = next; touch(); }}
986
1072
  >
987
- {#snippet properties(selected, focusToken, bodyInline, oneditbody)}
988
- <CollectionPanel fields={coll.fields} values={draft} documents={manifest.documents ?? []} body={bodyDraft as ArticleBody} {bodyInline} {oneditbody} {selected} {focusToken} devMode={settings.devMode} onchange={(key, value) => { draft[key] = value; touch(); }} onbody={(value) => { bodyDraft = value; touch(); }} />
1073
+ {#snippet properties(selected, focusToken, bodyInline, oneditbody, bodyLoading)}
1074
+ <CollectionPanel fields={coll.fields} values={draft} storageSlug={post.slug} documents={manifest.documents ?? []} body={bodyDraft as ArticleBody} {bodyInline} {bodyLoading} {oneditbody} {selected} {focusToken} devMode={settings.devMode} onchange={(key, value) => { draft[key] = value; touch(); }} onbody={(value) => { bodyDraft = value; touch(); }} />
989
1075
  {/snippet}
990
1076
  </VisualEditor>
991
1077
  {/key}
@@ -1025,3 +1111,6 @@
1025
1111
  {#if actionDialog}
1026
1112
  <ActionDialog {...actionDialog} onclose={() => { actionDialog = null; }} />
1027
1113
  {/if}
1114
+ {#if publishOpen}
1115
+ <PublishDialog review={publishReview} loading={publishLoading} busy={publishBusy} error={publishError} message={publishMessage} onrefresh={reviewPublish} onconfirm={publish} onclose={closePublish} />
1116
+ {/if}
@@ -0,0 +1,21 @@
1
+ import { DecoratorNode, type NodeKey } from "lexical";
2
+ import type { EmbedProvider } from "../lib/embeds";
3
+ import { createEmbedPreview } from "../lib/embed-preview";
4
+
5
+ export type EmbedData = { provider: EmbedProvider; id: string; title?: string; privacy: boolean };
6
+ export class EmbedNode extends DecoratorNode<null> {
7
+ __embed: EmbedData;
8
+ static getType() { return "embed"; }
9
+ static clone(node: EmbedNode) { return new EmbedNode(node.__embed, node.__key); }
10
+ constructor(embed: EmbedData = { provider: "youtube", id: "", privacy: true }, key?: NodeKey) { super(key); this.__embed = embed; }
11
+ static importJSON(raw: unknown) { const embed = raw as EmbedData; return new EmbedNode({ provider: embed.provider, id: embed.id, title: embed.title, privacy: embed.privacy !== false }); }
12
+ exportJSON() { return { ...this.__embed, type: "embed", version: 1 }; }
13
+ createDOM() {
14
+ return createEmbedPreview(this.__embed, document, true);
15
+ }
16
+ updateDOM() { return true; }
17
+ decorate() { return null; }
18
+ isInline() { return false; }
19
+ getTextContent() { return this.__embed.title ?? ""; }
20
+ }
21
+ export const create = (embed?: EmbedData) => new EmbedNode(embed);
@@ -7,6 +7,8 @@ import { LinkNode } from "@lexical/link";
7
7
  import { CodeNode } from "@lexical/code";
8
8
  import { TableNode, TableRowNode, TableCellNode } from "@lexical/table";
9
9
  import { ImageNode, create as image } from "./image";
10
+ import { VideoNode } from "./video";
11
+ import { EmbedNode } from "./embed";
10
12
  import { DividerNode, create as divider } from "./divider";
11
13
  import { create as quote } from "./quote";
12
14
  import { create as bullet } from "./bulleted-list";
@@ -37,4 +39,4 @@ export const blockTypes: { value: string; label: string; icon: keyof typeof ICON
37
39
  { value: "divider", label: "Divider", icon: "minus", create: divider },
38
40
  { value: "table", label: "Table", icon: "table", create: table },
39
41
  ];
40
- export const blockNodes = [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, CodeNode, ImageNode, DividerNode, TableNode, TableRowNode, TableCellNode];
42
+ export const blockNodes = [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, CodeNode, ImageNode, VideoNode, EmbedNode, DividerNode, TableNode, TableRowNode, TableCellNode];
@@ -0,0 +1,35 @@
1
+ import { DecoratorNode, type NodeKey } from "lexical";
2
+ import { safePreviewUrl } from "../../preview-urls";
3
+ import { createVideoOverlay } from "../lib/embed-preview";
4
+
5
+ export type VideoData = { src: string; poster?: string; caption?: string; muted?: boolean; controls?: boolean };
6
+ export class VideoNode extends DecoratorNode<null> {
7
+ __video: VideoData;
8
+ static getType() { return "video"; }
9
+ static clone(node: VideoNode) { return new VideoNode(node.__video, node.__key); }
10
+ constructor(video: VideoData = { src: "" }, key?: NodeKey) { super(key); this.__video = video; }
11
+ static importJSON(raw: unknown) { const video = raw as VideoData; return new VideoNode({ src: video.src, poster: video.poster, caption: video.caption, muted: video.muted, controls: video.controls }); }
12
+ exportJSON() { return { ...this.__video, type: "video", version: 1 }; }
13
+ createDOM() {
14
+ const figure = document.createElement("figure");
15
+ figure.className = "media-video";
16
+ const video = document.createElement("video");
17
+ if (safePreviewUrl(this.__video.src, true)) video.src = this.__video.src;
18
+ if (this.__video.poster && safePreviewUrl(this.__video.poster, true)) video.poster = this.__video.poster;
19
+ video.controls = this.__video.controls !== false;
20
+ video.muted = video.defaultMuted = this.__video.muted === true;
21
+ video.preload = "metadata";
22
+ video.playsInline = true;
23
+ const preview = document.createElement("div");
24
+ preview.style.cssText = "position:relative;min-height:220px";
25
+ preview.append(video, createVideoOverlay(document, "Video", this.__video.src, true));
26
+ figure.append(preview);
27
+ if (this.__video.caption) { const caption = document.createElement("figcaption"); caption.textContent = this.__video.caption; figure.append(caption); }
28
+ return figure;
29
+ }
30
+ updateDOM() { return true; }
31
+ decorate() { return null; }
32
+ isInline() { return false; }
33
+ getTextContent() { return this.__video.caption ?? ""; }
34
+ }
35
+ export const create = (video?: VideoData) => new VideoNode(video);
@@ -25,7 +25,7 @@
25
25
  }
26
26
  </script>
27
27
 
28
- <dialog bind:this={dialog} class="rigel rigel-modal-backdrop action-dialog" aria-labelledby="action-title" aria-describedby="action-description" oncancel={(event) => { event.preventDefault(); if (!busy) onclose(); }}>
28
+ <dialog bind:this={dialog} class="rigel rigel-modal-backdrop action-dialog" aria-labelledby="action-title" aria-describedby="action-description" oncancel={(event) => { event.preventDefault(); event.stopPropagation(); if (!busy) onclose(); }} onkeydown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); if (!busy) onclose(); } }}>
29
29
  <form class="rigel-modal" onsubmit={(event) => { event.preventDefault(); void submit(); }}>
30
30
  <div class="action-head"><h2 id="action-title">{title}</h2><button type="button" class="icon-btn" aria-label="Cancel" disabled={busy} onclick={onclose}><Icon name="x" size={16} /></button></div>
31
31
  <p id="action-description">{description}</p>
@@ -45,6 +45,7 @@
45
45
  .action-dialog::backdrop { background: transparent; }
46
46
  .action-head, .action-actions { display: flex; align-items: center; gap: 0.75rem; }
47
47
  .action-head { justify-content: space-between; }
48
+ .action-head h2 { min-width: 0; overflow-wrap: anywhere; }
48
49
  .action-actions { justify-content: flex-end; flex-wrap: wrap; margin-top: 1.5rem; }
49
50
  .action-actions button[type=button] { padding: 0.4375rem 0.75rem; border: 1px solid var(--border-control); border-radius: 0; background: var(--panel); color: var(--text); font: inherit; font-size: 0.8125rem; cursor: pointer; }
50
51
  button:disabled { opacity: 0.5; cursor: wait; }