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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,18 +1,26 @@
1
1
  # Rigel CMS
2
2
 
3
- A CMS for Astro sites. You edit the text, git keeps the history.
3
+ [![npm version](https://img.shields.io/npm/v/@rigel-cms/rigel)](https://www.npmjs.com/package/@rigel-cms/rigel)
4
+ [![Astro 7](https://img.shields.io/badge/Astro-7-BC52EE?logo=astro&logoColor=white)](https://astro.build/)
5
+ [![Node.js 24](https://img.shields.io/badge/Node.js-24-5FA04E?logo=nodedotjs&logoColor=white)](package.json)
6
+ [![License](https://img.shields.io/badge/license-Apache--2.0-green)](LICENSE)
4
7
 
5
- ## Collection validation
8
+ A CMS for Astro sites. You edit the text, Git keeps the history.
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.
10
+ Edit page copy, structured content and articles at `/rigel/`. Your content stays in your repository, and your Astro site controls how it looks.
8
11
 
9
- Rigel validates configured collection field types and `required: true` on load and before local or GitHub saves. Required strings must contain non-whitespace text, required arrays must contain an item, and `false` is a valid required boolean. Dates use real `YYYY-MM-DD` calendar dates; optional dates may be empty. Tags are arrays of strings and references must select configured entries.
12
+ - Visual editing on prepared page templates, with unsaved changes shown in the preview.
13
+ - A block editor for articles and a media library with local uploads.
14
+ - Explicit Save, with warnings before leaving unsaved changes.
15
+ - Local file editing or GitHub drafts with pull-request publishing.
10
16
 
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`.
17
+ Rigel edits content, not `.astro` components. Developers define the editable fields and add preview bindings to their templates. Saving content and publishing the website are separate steps.
12
18
 
13
- ## Media uploads
19
+ ## Get started
14
20
 
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.
21
+ Use the [Astro starter](https://github.com/rigel-cms/rigel-starter), or follow the [installation guide](https://rigelcms.dev/docs/getting-started/) to add Rigel to an existing site. Requires Node.js 24 and Astro 7.3 or later within Astro 7.
22
+
23
+ [Documentation](https://rigelcms.dev/docs/) · [Content model](https://rigelcms.dev/docs/content-model/) · [Previews](https://rigelcms.dev/docs/preview/) · [GitHub setup](https://rigelcms.dev/docs/github-publishing/)
16
24
 
17
25
  ## License
18
26
 
package/package.json CHANGED
@@ -1,11 +1,7 @@
1
1
  {
2
2
  "name": "@rigel-cms/rigel",
3
- "version": "1.0.0-rc.1",
4
- "publishConfig": {
5
- "access": "public",
6
- "tag": "rc"
7
- },
8
- "description": "Rigel CMS — the copy editor for Astro sites. Clients edit text, git keeps the history.",
3
+ "version": "1.0.0",
4
+ "description": "A CMS for Astro sites. You edit the text, Git keeps the history.",
9
5
  "author": {
10
6
  "name": "FreshJuice",
11
7
  "email": "contact@freshjuice.dev",
@@ -53,7 +49,6 @@
53
49
  "vite": "^8.0.0"
54
50
  },
55
51
  "devDependencies": {
56
-
57
52
  "@types/markdown-it": "^14.2.0",
58
53
  "@types/node": "^24.0.0",
59
54
  "astro": "^7.3.2",
package/src/config.ts CHANGED
@@ -3,7 +3,7 @@ import { validateSchema } from "./schema/validate";
3
3
  import type { Schema } from "./schema/types";
4
4
  import { validateSidebar, type SidebarEntry } from "./manifest/navigation";
5
5
  import { withSeoFields } from "./manifest/seo";
6
- import { previewBaseUrl } from "./preview-urls";
6
+ import { previewBaseUrl, previewRoute } from "./preview-urls";
7
7
  import type { JsonOutput } from "./json-output";
8
8
  import { DEFAULT_MAX_FILE_SIZE } from "./media-types";
9
9
 
@@ -53,6 +53,11 @@ export function parseConfig(options: Partial<RigelConfig> = {}): RigelConfig {
53
53
  if (cfg.preview) {
54
54
  cfg.preview = { ...cfg.preview };
55
55
  for (const key of ["liveUrl", "draftUrl"] as const) if (cfg.preview[key] !== undefined) cfg.preview[key] = previewBaseUrl(cfg.preview[key]);
56
+ for (const mappings of [cfg.preview.pages, cfg.preview.documents, cfg.preview.collections]) {
57
+ for (const mapping of Object.values(mappings ?? {})) {
58
+ if (mapping && typeof mapping === "object" && mapping.template !== undefined && !previewRoute(mapping.template, true)) throw new Error("rigel: preview template must be a static root-relative path; only /rigel/preview/ is allowed inside the CMS");
59
+ }
60
+ }
56
61
  }
57
62
  if (cfg.mediaDir !== undefined && (typeof cfg.mediaDir !== "string" || !cfg.mediaDir.split("/").every(part => /^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*$/.test(part)))) throw new Error("rigel: mediaDir must be a relative folder inside publicDir");
58
63
  if (cfg.backend === "github") {
@@ -33,7 +33,7 @@
33
33
  import PublishDialog from "./lib/PublishDialog.svelte";
34
34
  import VisualEditor from "./lib/VisualEditor.svelte";
35
35
  import { newDocumentFromTemplate } from "../schema/paths";
36
- import { previewUrl } from "../preview";
36
+ import { previewUrl, previewPath } from "../preview";
37
37
  import type { ContentDocument, Schema } from "../schema/types";
38
38
  import { buildSidebar, navigationKey, type Selection } from "../manifest/navigation";
39
39
  import { startGitHubLogin, checkGitHubAccess, type GitHubAccess } from "./lib/github-auth";
@@ -851,7 +851,7 @@
851
851
 
852
852
  {#if githubMode && manifest.github?.publishBranch}
853
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>
854
+ <Icon name="rocketLaunch" size={18} /><span class="sidebar-label">Publish</span>
855
855
  </button>
856
856
  {/if}
857
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; }}>
@@ -1035,7 +1035,7 @@
1035
1035
 
1036
1036
  {:else if sel?.kind === "page" && page}
1037
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>
1038
+ <section><label><span>Website route</span><input type="text" value={previewPath(manifest.preview ?? {}, { kind: "page", slug: page.slug }) ?? "Not configured"} readonly /></label><p class="hint">This copy page uses a fixed Astro route. Its URL can only be changed in the website code; slug metadata cannot move it.</p></section>
1039
1039
  {/if}
1040
1040
  {#each page.groups as group (group.id)}
1041
1041
  <section>
@@ -66,6 +66,10 @@ export const ICONS = {
66
66
  viewBox: "0 0 256 256",
67
67
  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,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z",
68
68
  },
69
+ rocketLaunch: {
70
+ viewBox: "0 0 256 256",
71
+ d: "M223.85 47.12a16 16 0 0 0-15-15c-12.58-.75-44.73.4-71.41 27.07L132.69 64H74.36A15.9 15.9 0 0 0 63 68.68L28.7 103a16 16 0 0 0 9.07 27.16l38.47 5.37l44.21 44.21l5.37 38.49a15.94 15.94 0 0 0 10.78 12.92a16.1 16.1 0 0 0 5.1.83a15.9 15.9 0 0 0 11.3-4.68l34.32-34.3a15.9 15.9 0 0 0 4.68-11.36v-58.33l4.77-4.77c26.68-26.68 27.83-58.83 27.08-71.42M74.36 80h42.33l-39.53 39.52L40 114.34Zm74.41-9.45a76.65 76.65 0 0 1 59.11-22.47a76.46 76.46 0 0 1-22.42 59.16L128 164.68L91.32 128ZM176 181.64L141.67 216l-5.19-37.17L176 139.31Zm-74.16 9.5C97.34 201 82.29 224 40 224a8 8 0 0 1-8-8c0-42.29 23-57.34 32.86-61.85a8 8 0 0 1 6.64 14.56c-6.43 2.93-20.62 12.36-23.12 38.91c26.55-2.5 36-16.69 38.91-23.12a8 8 0 1 1 14.56 6.64Z",
72
+ },
69
73
  external: {
70
74
  viewBox: "0 0 256 256",
71
75
  d: "M224,104a8,8,0,0,1-16,0V59.32l-66.33,66.34a8,8,0,0,1-11.32-11.32L196.68,48H152a8,8,0,0,1,0-16h64a8,8,0,0,1,8,8Zm-40,24a8,8,0,0,0-8,8v72H48V80h72a8,8,0,0,0,0-16H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V136A8,8,0,0,0,184,128Z",
@@ -2,6 +2,12 @@ export function safePreviewUrl(value: string, image = false): boolean {
2
2
  return !!value && !/[ \t\r\n\f\v\u0000-\u001f\u007f\\]/.test(value) && !value.startsWith("//") && (!/^[a-z][a-z\d+.-]*:/i.test(value) || (image ? /^https?:/i : /^(https?:|mailto:)/i).test(value));
3
3
  }
4
4
 
5
+ export function previewRoute(value: unknown, template = false): string | null {
6
+ if (typeof value !== "string" || !/^\/(?!\/)/.test(value) || /[\\\s?#\u0000-\u001f\u007f]/.test(value) || /%(?:2e|2f|5c)/i.test(value) || value.split("/").some(part => part === "." || part === "..")) return null;
7
+ if (template && /[{}]/.test(value)) return null;
8
+ return !/^\/rigel(?:\/|$)/.test(value) || (template && /^\/rigel\/preview(?:\/|$)/.test(value)) ? value : null;
9
+ }
10
+
5
11
  export function previewBaseUrl(value: unknown): string {
6
12
  try {
7
13
  if (typeof value !== "string" || !/^https?:\/\/[^/@]+(?:\/|$)/.test(value) || /[\s\u0000-\u001f\u007f\\%?#]/.test(value) || value.split("/").some(part => part === "." || part === "..")) throw new Error();
package/src/preview.ts CHANGED
@@ -4,19 +4,20 @@ import { pageRoute } from "./schema/paths";
4
4
  import { slugify } from "./slugs";
5
5
  import { renderArticle } from "./article-render";
6
6
  import { createEmbedPreview } from "./editor/lib/embed-preview";
7
- import { safePreviewUrl, previewBaseUrl } from "./preview-urls";
7
+ import { safePreviewUrl, previewBaseUrl, previewRoute } from "./preview-urls";
8
8
  export { safePreviewUrl } from "./preview-urls";
9
9
 
10
10
  export interface PreviewDocumentMapping {
11
11
  url: string;
12
12
  index?: string;
13
+ template?: string;
13
14
  }
14
15
 
15
16
  export interface PreviewConfig {
16
17
  liveUrl?: string;
17
18
  draftUrl?: string;
18
- pages?: Record<string, string>;
19
- collections?: Record<string, { url: string; new: string }>;
19
+ pages?: Record<string, string | PreviewDocumentMapping>;
20
+ collections?: Record<string, { url: string; new?: string; template?: string }>;
20
21
  documents?: Record<string, PreviewDocumentMapping>;
21
22
  }
22
23
  export interface PreviewTarget {
@@ -27,13 +28,23 @@ export interface PreviewTarget {
27
28
  type?: string;
28
29
  }
29
30
 
31
+ function templatePath(config: PreviewConfig, target: PreviewTarget): string | null | undefined {
32
+ const mapping = target.kind === "post" ? config.collections?.[target.collection ?? ""] : target.kind === "document" ? config.documents?.[target.type ?? ""] : target.kind === "page" ? config.pages?.[target.slug] : undefined;
33
+ return mapping && typeof mapping === "object" && mapping.template !== undefined ? previewRoute(mapping.template, true) : undefined;
34
+ }
35
+
30
36
  export function previewPath(config: PreviewConfig, target: PreviewTarget, isNew = false): string | null {
37
+ const template = isNew ? templatePath(config, target) : undefined;
38
+ if (template !== undefined) return template;
31
39
  const mapping = target.kind === "post" ? config.collections?.[target.collection ?? ""] : undefined;
32
- const path = target.kind === "document" ? pageRoute(target.slug, config.documents?.[target.type ?? ""]) : target.kind === "page" ? config.pages?.[target.slug] : mapping ? (isNew ? mapping.new : mapping.url.replaceAll("{slug}", encodeURIComponent(target.slug))) : undefined;
33
- return typeof path === "string" && /^\/(?!\/)/.test(path) && !/[\\\s?#]/.test(path) && !/%(?:2e|2f|5c)/i.test(path) && !path.split("/").some(part => part === "." || part === "..") && !/^\/rigel(?:\/|$)/.test(path) ? path : null;
40
+ const page = config.pages?.[target.slug];
41
+ const path = target.kind === "document" ? pageRoute(target.slug, config.documents?.[target.type ?? ""]) : target.kind === "page" ? (typeof page === "string" ? page : page?.url) : mapping ? (isNew ? mapping.new : mapping.url.replaceAll("{slug}", encodeURIComponent(target.slug))) : undefined;
42
+ return previewRoute(path);
34
43
  }
35
44
 
36
45
  export function previewUrl(config: PreviewConfig, target: PreviewTarget, mode: "live" | "draft" | "website" = "draft", isNew = false): string | null {
46
+ const template = mode === "draft" ? templatePath(config, target) : undefined;
47
+ if (template !== undefined) return template;
37
48
  if (mode !== "draft" && target.kind !== "page" && target.urlSlug !== undefined) {
38
49
  const slug = slugify(target.urlSlug);
39
50
  if (!slug) return null;
package/src/slugs.ts CHANGED
@@ -33,7 +33,7 @@ export function assertUniqueUrlSlugs(entries: { slug: string; values: Record<str
33
33
  export function validateDocumentSlugs(schema: Schema, documents: ContentDocument[], preview?: PreviewConfig): void {
34
34
  const seen = new Set<string>();
35
35
  const pages = documents.filter(doc => schema.documents.some(type => type.name === doc.type && !type.shared));
36
- const fixedRoutes = Object.entries(preview?.pages ?? {}).filter(([key]) => !pages.some(doc => doc.slug === key)).map(([, route]) => route);
36
+ const fixedRoutes = Object.entries(preview?.pages ?? {}).filter(([key]) => !pages.some(doc => doc.slug === key)).map(([, route]) => typeof route === "string" ? route : route.url);
37
37
  for (const doc of documents) {
38
38
  const type = schema.documents.find(type => type.name === doc.type);
39
39
  if (!type || type.shared) continue;