@rigel-cms/rigel 0.0.0-stage → 0.1.0-beta.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 (117) hide show
  1. package/LICENSE +203 -0
  2. package/README.md +8 -2
  3. package/package.json +78 -4
  4. package/src/article-body.ts +60 -0
  5. package/src/article-render.ts +87 -0
  6. package/src/config.ts +97 -0
  7. package/src/content.ts +5 -0
  8. package/src/editor/App.svelte +972 -0
  9. package/src/editor/assets/rigelcms-icon.png +0 -0
  10. package/src/editor/assets/sidebar/LICENSE +21 -0
  11. package/src/editor/assets/sidebar/book-open.svg +1 -0
  12. package/src/editor/assets/sidebar/calendar.svg +1 -0
  13. package/src/editor/assets/sidebar/chart-bar.svg +1 -0
  14. package/src/editor/assets/sidebar/file-text.svg +1 -0
  15. package/src/editor/assets/sidebar/files.svg +1 -0
  16. package/src/editor/assets/sidebar/folder.svg +1 -0
  17. package/src/editor/assets/sidebar/folders.svg +1 -0
  18. package/src/editor/assets/sidebar/gear.svg +1 -0
  19. package/src/editor/assets/sidebar/globe.svg +1 -0
  20. package/src/editor/assets/sidebar/heart.svg +1 -0
  21. package/src/editor/assets/sidebar/house.svg +1 -0
  22. package/src/editor/assets/sidebar/images.svg +1 -0
  23. package/src/editor/assets/sidebar/layout.svg +1 -0
  24. package/src/editor/assets/sidebar/list.svg +1 -0
  25. package/src/editor/assets/sidebar/magnifying-glass.svg +1 -0
  26. package/src/editor/assets/sidebar/megaphone.svg +1 -0
  27. package/src/editor/assets/sidebar/newspaper.svg +1 -0
  28. package/src/editor/assets/sidebar/pencil-simple.svg +1 -0
  29. package/src/editor/assets/sidebar/shopping-bag.svg +1 -0
  30. package/src/editor/assets/sidebar/sidebar-simple.svg +1 -0
  31. package/src/editor/assets/sidebar/square-half.svg +1 -0
  32. package/src/editor/assets/sidebar/squares-four.svg +1 -0
  33. package/src/editor/assets/sidebar/tag.svg +1 -0
  34. package/src/editor/assets/sidebar/user.svg +1 -0
  35. package/src/editor/assets/sidebar/users.svg +1 -0
  36. package/src/editor/blocks/bulleted-list.ts +2 -0
  37. package/src/editor/blocks/code.ts +1 -0
  38. package/src/editor/blocks/divider.ts +14 -0
  39. package/src/editor/blocks/h1.ts +2 -0
  40. package/src/editor/blocks/h2.ts +2 -0
  41. package/src/editor/blocks/h3.ts +2 -0
  42. package/src/editor/blocks/h4.ts +2 -0
  43. package/src/editor/blocks/h5.ts +2 -0
  44. package/src/editor/blocks/h6.ts +2 -0
  45. package/src/editor/blocks/image.ts +34 -0
  46. package/src/editor/blocks/index.ts +40 -0
  47. package/src/editor/blocks/numbered-list.ts +2 -0
  48. package/src/editor/blocks/paragraph.ts +1 -0
  49. package/src/editor/blocks/quote.ts +1 -0
  50. package/src/editor/blocks/table.ts +5 -0
  51. package/src/editor/lib/ActionDialog.svelte +51 -0
  52. package/src/editor/lib/BlockEditor.svelte +614 -0
  53. package/src/editor/lib/CollectionPanel.svelte +103 -0
  54. package/src/editor/lib/DatePicker.svelte +106 -0
  55. package/src/editor/lib/Field.svelte +80 -0
  56. package/src/editor/lib/Icon.svelte +34 -0
  57. package/src/editor/lib/ImagePicker.svelte +264 -0
  58. package/src/editor/lib/MarkdownEditor.svelte +206 -0
  59. package/src/editor/lib/MenuEditor.svelte +209 -0
  60. package/src/editor/lib/PropertiesTabs.svelte +15 -0
  61. package/src/editor/lib/ReferenceSelect.svelte +17 -0
  62. package/src/editor/lib/SchemaField.svelte +156 -0
  63. package/src/editor/lib/SchemaPanel.svelte +189 -0
  64. package/src/editor/lib/Select.svelte +138 -0
  65. package/src/editor/lib/SeoGuidance.svelte +21 -0
  66. package/src/editor/lib/Settings.svelte +183 -0
  67. package/src/editor/lib/Shortcuts.svelte +29 -0
  68. package/src/editor/lib/SidebarIcon.svelte +8 -0
  69. package/src/editor/lib/SitePreview.svelte +50 -0
  70. package/src/editor/lib/TopBar.svelte +165 -0
  71. package/src/editor/lib/UnsavedChanges.svelte +38 -0
  72. package/src/editor/lib/VisualEditor.svelte +494 -0
  73. package/src/editor/lib/calendar.ts +55 -0
  74. package/src/editor/lib/github-auth.ts +117 -0
  75. package/src/editor/lib/github-backend.ts +263 -0
  76. package/src/editor/lib/icons.ts +111 -0
  77. package/src/editor/lib/images.ts +195 -0
  78. package/src/editor/lib/markdown.ts +71 -0
  79. package/src/editor/lib/media-library.ts +39 -0
  80. package/src/editor/lib/menu-tree.ts +37 -0
  81. package/src/editor/lib/rich-markdown.ts +30 -0
  82. package/src/editor/lib/schema-edit.ts +110 -0
  83. package/src/editor/lib/settings.ts +103 -0
  84. package/src/editor/lib/slugify.ts +10 -0
  85. package/src/editor/lib/storage.ts +17 -0
  86. package/src/editor/lib/store.svelte.ts +33 -0
  87. package/src/editor/main.ts +9 -0
  88. package/src/editor/styles.css +1280 -0
  89. package/src/env.d.ts +22 -0
  90. package/src/index.ts +128 -0
  91. package/src/manifest/frontmatter.ts +56 -0
  92. package/src/manifest/loader.ts +14 -0
  93. package/src/manifest/menu.ts +46 -0
  94. package/src/manifest/navigation.ts +94 -0
  95. package/src/manifest/references.ts +39 -0
  96. package/src/manifest/seo.ts +15 -0
  97. package/src/manifest/types.ts +75 -0
  98. package/src/media-types.ts +21 -0
  99. package/src/preview-urls.ts +3 -0
  100. package/src/preview.ts +167 -0
  101. package/src/routes/local-api.ts +42 -0
  102. package/src/routes/local-documents.ts +83 -0
  103. package/src/routes/local-media.ts +124 -0
  104. package/src/routes/local-menu.ts +81 -0
  105. package/src/routes/local-post.ts +80 -0
  106. package/src/routes/manifest.json.ts +15 -0
  107. package/src/routes/media-files.ts +126 -0
  108. package/src/routes/media-validation.ts +37 -0
  109. package/src/routes/media.json.ts +12 -0
  110. package/src/routes/rigel-page.astro +25 -0
  111. package/src/routes/serialize.ts +139 -0
  112. package/src/schema/docs.ts +33 -0
  113. package/src/schema/paths.ts +136 -0
  114. package/src/schema/store.ts +141 -0
  115. package/src/schema/types.ts +81 -0
  116. package/src/schema/validate.ts +327 -0
  117. package/src/vite/manifest-plugin.ts +180 -0
package/src/env.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ declare module "virtual:rigel-manifest" {
2
+ import type { CopyPage, ManifestCollection } from "@rigel-cms/rigel/content";
3
+ export const pages: CopyPage[];
4
+ export const collections: ManifestCollection[];
5
+ export const documents: import("@rigel-cms/rigel/content").ContentDocument[];
6
+ export const menus: import("@rigel-cms/rigel/content").Manifest["menus"];
7
+ const manifest: import("@rigel-cms/rigel/content").Manifest;
8
+ export default manifest;
9
+ }
10
+
11
+ declare module "virtual:rigel-content" {
12
+ import type { ContentDocument } from "@rigel-cms/rigel/content";
13
+ export const documents: ContentDocument[];
14
+ export function getDocument(id: string): ContentDocument | undefined;
15
+ export function derefDocument(value: unknown): ContentDocument | undefined;
16
+ }
17
+
18
+ declare module "virtual:rigel-config" {
19
+ import type { RigelConfig } from "@rigel-cms/rigel";
20
+ const config: Pick<RigelConfig, "copyDir" | "backend" | "demo" | "collections" | "menus" | "mediaDir" | "schema"> & { publicDir?: string; root?: string };
21
+ export default config;
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,128 @@
1
+ import type { AstroIntegration } from "astro";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+ import { lstat, readFile } from "node:fs/promises";
5
+ import { mediaMetadata, safeMediaName } from "./media-types";
6
+ import { mediaDirectory } from "./routes/media-files";
7
+ import { parseConfig, type RigelConfig } from "./config";
8
+ import { rigelManifestPlugin } from "./vite/manifest-plugin";
9
+ import { svelte } from "@sveltejs/vite-plugin-svelte";
10
+
11
+ export function rigel(options: Partial<RigelConfig> = {}): AstroIntegration {
12
+ let cfg: RigelConfig;
13
+ return {
14
+ name: "rigel",
15
+ hooks: {
16
+ "astro:config:setup": ({ injectRoute, injectScript, updateConfig, command, config }) => {
17
+ cfg = parseConfig(options);
18
+ if (cfg.menus && command === "dev") injectScript("page", `if (import.meta.hot) import.meta.hot.on("rigel:menus-updated", () => { if (!/^\\/rigel(?:\\/|$)/.test(location.pathname)) location.reload(); });`);
19
+ if (cfg.schema && command === "dev") injectScript("page", `if (import.meta.hot) import.meta.hot.on("rigel:content-updated", () => { if (!/^\\/rigel(?:\\/|$)/.test(location.pathname)) location.reload(); });`);
20
+ if (cfg.collections?.length && command === "dev") injectScript("page", `if (import.meta.hot) import.meta.hot.on("rigel:collections-updated", () => { if (!/^\\/rigel(?:\\/|$)/.test(location.pathname)) location.reload(); });`);
21
+
22
+
23
+ updateConfig({
24
+ vite: {
25
+ plugins: [rigelManifestPlugin(cfg, config.publicDir, config.root), svelte(), {
26
+ name: "rigel-svelte-environment",
27
+ enforce: "pre",
28
+ configEnvironment(name) {
29
+ if (name !== "client") return { optimizeDeps: { exclude: ["svelte"] } };
30
+ },
31
+ }, {
32
+ name: "rigel-media-headers",
33
+ configureServer(server) {
34
+ const prefix = `/${cfg.mediaDir ?? "rigel-media"}/`;
35
+ server.middlewares.use(async (request, response, next) => {
36
+ let pathname: string;
37
+ try { pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname); } catch { return next(); }
38
+ if (!pathname.startsWith(prefix)) return next();
39
+ try {
40
+ const parts = pathname.slice(prefix.length).split("/");
41
+ const filename = parts.pop()!;
42
+ if (!safeMediaName(filename) || !parts.every(part => /^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*$/.test(part))) throw new Error();
43
+ let directory = await mediaDirectory(config.publicDir.href, cfg.mediaDir);
44
+ for (const part of parts) {
45
+ directory = path.join(directory, part);
46
+ const info = await lstat(directory);
47
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error();
48
+ }
49
+ const file = path.join(directory, filename), info = await lstat(file);
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}"`);
55
+ response.end(request.method === "HEAD" ? undefined : await readFile(file));
56
+ } catch { response.statusCode = 404; response.end("Not found"); }
57
+ });
58
+ },
59
+ }],
60
+ ...(cfg.backend === "local" && command === "dev" ? {
61
+ server: { watch: { ignored: [(file: string) =>
62
+ path.dirname(file) === path.resolve(fileURLToPath(config.publicDir), cfg.mediaDir ?? "rigel-media")
63
+ && /^[a-f0-9]{64}\.[a-z0-9]+$/.test(path.basename(file))
64
+ ] } },
65
+ } : {}),
66
+ },
67
+ });
68
+
69
+ injectRoute({
70
+ pattern: "/rigel",
71
+ entrypoint: "@rigel-cms/rigel/routes/rigel-page.astro",
72
+ prerender: true,
73
+ });
74
+ injectRoute({
75
+ pattern: "/rigel/manifest.json",
76
+ entrypoint: "@rigel-cms/rigel/routes/manifest.json.ts",
77
+ prerender: true,
78
+ });
79
+ injectRoute({ pattern: "/rigel/media.json", entrypoint: "@rigel-cms/rigel/routes/media.json.ts", prerender: true });
80
+ if (cfg.backend === "local" && command === "dev") {
81
+ injectRoute({
82
+ pattern: `/${cfg.mediaDir ?? "rigel-media"}/[filename]`,
83
+ entrypoint: "@rigel-cms/rigel/routes/local-media.ts",
84
+ prerender: false,
85
+ });
86
+ injectRoute({
87
+ pattern: "/rigel/api/local/media",
88
+ entrypoint: "@rigel-cms/rigel/routes/local-media.ts",
89
+ prerender: false,
90
+ });
91
+ injectRoute({
92
+ pattern: "/rigel/api/local/save",
93
+ entrypoint: "@rigel-cms/rigel/routes/local-api.ts",
94
+ prerender: false,
95
+ });
96
+ injectRoute({
97
+ pattern: "/rigel/api/local/[collection]/[slug]",
98
+ entrypoint: "@rigel-cms/rigel/routes/local-post.ts",
99
+ prerender: false,
100
+ });
101
+ if (cfg.menus) injectRoute({
102
+ pattern: "/rigel/api/local/menus/[slug]",
103
+ entrypoint: "@rigel-cms/rigel/routes/local-menu.ts",
104
+ prerender: false,
105
+ });
106
+ if (cfg.schema) injectRoute({
107
+ pattern: "/rigel/api/local/documents/[id]",
108
+ entrypoint: "@rigel-cms/rigel/routes/local-documents.ts",
109
+ prerender: false,
110
+ });
111
+ }
112
+ },
113
+ },
114
+ };
115
+ }
116
+
117
+ export { validateMenu } from "./manifest/menu";
118
+ export type { MenuItem, MenuDocument } from "./manifest/menu";
119
+ export { defineCopy } from "./manifest/types";
120
+ export type { CopyField, CopyGroup, CopyPage, Manifest, CollectionField } from "./manifest/types";
121
+ export type { RigelConfig, CollectionConfig } from "./config";
122
+ export type { ReferenceOption } from "./manifest/references";
123
+ export type { SidebarEntry, SidebarTarget, SidebarIconName } from "./manifest/navigation";
124
+ export { defineSchema, defineDocument, defineObject, defineField } from "./schema/types";
125
+ export type { Schema, DocumentDef, ObjectDef, FieldDef, ContentDocument } from "./schema/types";
126
+ export { validateSchema, validateDocument, SchemaError } from "./schema/validate";
127
+ export { resolvePath, setPath, newKey, newDocumentFromTemplate, regenerateKeys, pageRoute } from "./schema/paths";
128
+ export { buildDocumentIndex, deref, referencedIds } from "./schema/docs";
@@ -0,0 +1,56 @@
1
+ type MarkdownPost = { slug: string; frontmatter: Record<string, unknown>; body: string };
2
+
3
+ export function parseFrontmatter(raw: string): MarkdownPost {
4
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
5
+ const fm = m ? parseYamlFlat(m[1]) : {};
6
+ const body = m ? m[2] : raw;
7
+ return {
8
+ slug: String(fm.slug ?? ""),
9
+ frontmatter: fm,
10
+ body: body.replace(/\r\n/g, "\n"),
11
+ };
12
+ }
13
+
14
+ export function serializeFrontmatter(post: MarkdownPost): string {
15
+ const lines = Object.entries(post.frontmatter).map(([k, v]) => {
16
+ if (Array.isArray(v))
17
+ return `${k}: [${v.map((x) => JSON.stringify(String(x))).join(", ")}]`;
18
+ if (typeof v === "boolean") return `${k}: ${v}`;
19
+ return `${k}: ${JSON.stringify(String(v))}`;
20
+ });
21
+ return `---\n${lines.join("\n")}\n---\n${post.body.replace(/\n+$/, "")}\n`;
22
+ }
23
+
24
+ function parseYamlFlat(src: string): Record<string, unknown> {
25
+ const out: Record<string, unknown> = {};
26
+ for (const line of src.split("\n")) {
27
+ if (!line.trim() || line.startsWith("#")) continue;
28
+ const i = line.indexOf(":");
29
+ if (i < 0) continue;
30
+ const key = line.slice(0, i).trim();
31
+ let v: unknown = line.slice(i + 1).trim();
32
+ if (typeof v === "string" && v.startsWith("[") && v.endsWith("]")) {
33
+ v = v
34
+ .slice(1, -1)
35
+ .split(",")
36
+ .map((s) => unquote(s.trim()))
37
+ .filter(Boolean);
38
+ } else if (v === "true" || v === "false") {
39
+ v = v === "true";
40
+ } else {
41
+ v = unquote(v as string);
42
+ }
43
+ out[key] = v;
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function unquote(s: string): string {
49
+ if (
50
+ (s.startsWith('"') && s.endsWith('"')) ||
51
+ (s.startsWith("'") && s.endsWith("'"))
52
+ ) {
53
+ return s.slice(1, -1);
54
+ }
55
+ return s;
56
+ }
@@ -0,0 +1,14 @@
1
+ import type { CopyPage, Manifest } from "./types";
2
+
3
+ export function loadManifest(modules: CopyPage[]): Manifest {
4
+ const pages = [...modules].sort((a, b) => a.slug.localeCompare(b.slug));
5
+ for (const page of pages) {
6
+ if (!page.slug || !page.groups?.length)
7
+ throw new Error(`Copy module ${page.label || "?"} missing slug or groups`);
8
+ for (const group of page.groups) {
9
+ if (!group.id || !group.fields?.length)
10
+ throw new Error(`Group ${group.id || "?"} in ${page.slug} missing id or fields`);
11
+ }
12
+ }
13
+ return { pages };
14
+ }
@@ -0,0 +1,46 @@
1
+ export interface MenuItem {
2
+ id: string;
3
+ label: string;
4
+ url: string;
5
+ newTab?: boolean;
6
+ children: MenuItem[];
7
+ }
8
+
9
+ export interface MenuDocument {
10
+ slug: string;
11
+ title: string;
12
+ items: MenuItem[];
13
+ }
14
+
15
+ export const MENU_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
16
+
17
+ export function validateMenu(value: unknown, maxDepth: number): MenuDocument {
18
+ if (!Number.isInteger(maxDepth) || maxDepth < 1 || maxDepth > 10)
19
+ throw new Error("Menu maxDepth must be an integer from 1 to 10");
20
+ const object = (v: unknown): v is Record<string, unknown> =>
21
+ v !== null && typeof v === "object" && !Array.isArray(v);
22
+ const text = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0;
23
+ if (!object(value) || typeof value.slug !== "string" || !MENU_SLUG.test(value.slug) || !text(value.title) || !Array.isArray(value.items))
24
+ throw new Error("Menu requires a lowercase alphanumeric-hyphen slug, title, and items array");
25
+ const ids = new Set<string>();
26
+ function items(values: unknown[], depth: number): MenuItem[] {
27
+ return values.map((item) => {
28
+ if (depth > maxDepth) throw new Error(`Menu exceeds maximum depth ${maxDepth}`);
29
+ if (!object(item) || !text(item.id) || !text(item.label) || !text(item.url) || !Array.isArray(item.children))
30
+ throw new Error("Menu items require nonempty id, label, url, and children array");
31
+ if (ids.has(item.id)) throw new Error(`Duplicate menu item id: ${item.id}`);
32
+ ids.add(item.id);
33
+ if (item.newTab !== undefined && typeof item.newTab !== "boolean") throw new Error("Menu item newTab must be a boolean");
34
+ const url = item.url;
35
+ if (url !== url.trim() || /[\u0000-\u0020\u007f\\]/.test(url) || url.startsWith("//"))
36
+ throw new Error("Unsafe menu URL");
37
+ let parsed: URL;
38
+ try { parsed = new URL(url, "https://rigel.invalid/"); }
39
+ catch { throw new Error("Invalid menu URL"); }
40
+ if (!["http:", "https:", "mailto:", "tel:"].includes(parsed.protocol))
41
+ throw new Error("Unsafe menu URL protocol");
42
+ return { id: item.id, label: item.label, url, ...(item.newTab === undefined ? {} : { newTab: item.newTab }), children: items(item.children, depth + 1) };
43
+ });
44
+ }
45
+ return { slug: value.slug, title: value.title, items: items(value.items, 1) };
46
+ }
@@ -0,0 +1,94 @@
1
+ import type { Manifest } from "./types";
2
+
3
+ export type Selection =
4
+ | { kind: "media" }
5
+ | { kind: "menus" }
6
+ | { kind: "menu"; slug: string }
7
+ | { kind: "pages"; category?: string }
8
+ | { kind: "page"; slug: string }
9
+ | { kind: "documents"; type: string }
10
+ | { kind: "document"; type: string; id: string }
11
+ | { kind: "collection"; name: string }
12
+ | { kind: "post"; collection: string; slug: string };
13
+
14
+ export type SidebarTarget = Exclude<Selection, { kind: "post" } | { kind: "menu" }>;
15
+ export const SIDEBAR_ICONS = ["files", "file-text", "newspaper", "megaphone", "images", "squares-four", "list", "layout", "sidebar-simple", "magnifying-glass", "house", "users", "user", "gear", "globe", "folder", "folders", "book-open", "calendar", "tag", "heart", "shopping-bag", "chart-bar", "pencil-simple", "square-half"] as const;
16
+ export type SidebarIconName = typeof SIDEBAR_ICONS[number];
17
+ export function isSidebarIconPath(value: unknown): value is string {
18
+ return typeof value === "string" && /^\/(?!\/)[\w./-]+\.svg$/i.test(value) && !value.split("/").some(part => part === "." || part === "..");
19
+ }
20
+ export type SidebarEntry =
21
+ | { kind: "heading"; label: string }
22
+ | { kind: "separator" }
23
+ | { kind: "link"; target: SidebarTarget; label?: string; icon?: string };
24
+ export type SidebarLink = { kind: "link"; target: SidebarTarget; label: string; icon?: string };
25
+ export type SidebarNode = Exclude<SidebarEntry, { kind: "link" }> | SidebarLink;
26
+ export function navigationKey(target: Selection): string {
27
+ return JSON.stringify([target.kind, "category" in target ? target.category : null, "name" in target ? target.name : null, "slug" in target ? target.slug : null, "type" in target ? target.type : null, "id" in target ? target.id : null, "collection" in target ? target.collection : null]);
28
+ }
29
+
30
+ export function buildSidebar(manifest: Manifest): SidebarNode[] {
31
+ const link = (target: SidebarTarget, label: string): SidebarLink => ({ kind: "link", target, label });
32
+ const types = manifest.schema?.documents ?? [];
33
+ const pageTypes = types.filter(type => !type.shared);
34
+ const pages = manifest.pages.filter(page => !page.shared);
35
+ const categories = [...new Set(pages.map(page => page.category).filter((category): category is string => !!category))].sort();
36
+ const defaults: SidebarNode[] = [
37
+ { kind: "heading", label: "Website" },
38
+ link({ kind: "pages" }, "Pages"),
39
+ ...(manifest.collections ?? []).map(collection => link({ kind: "collection", name: collection.name }, collection.label)),
40
+ ...categories.map(category => link({ kind: "pages", category }, category)),
41
+ ...pageTypes.slice(1).map(type => link({ kind: "documents", type: type.name }, type.title)),
42
+ { kind: "heading", label: "Shared content" },
43
+ ...types.filter(type => type.shared && !type.singleton).map(type => link({ kind: "documents", type: type.name }, type.title)),
44
+ ...manifest.pages.filter(page => page.shared).map(page => link({ kind: "page", slug: page.slug }, page.label)),
45
+ ...(manifest.menus ? [link({ kind: "menus" }, "Menus")] : []),
46
+ { kind: "heading", label: "Assets" },
47
+ link({ kind: "media" }, "Media"),
48
+ ...(types.some(type => type.shared && type.singleton) ? [{ kind: "separator" } as SidebarNode] : []),
49
+ ...types.filter(type => type.shared && type.singleton).map(type => {
50
+ const doc = manifest.documents?.find(doc => doc.type === type.name);
51
+ return doc ? { ...link({ kind: "document", type: type.name, id: doc.id }, type.title), icon: "gear" } : link({ kind: "documents", type: type.name }, type.title);
52
+ }),
53
+ ];
54
+ if (!manifest.sidebar) return defaults;
55
+ const available = new Map(defaults.filter((node): node is SidebarLink => node.kind === "link").map(node => [navigationKey(node.target), node]));
56
+ for (const page of manifest.pages) available.set(navigationKey({ kind: "page", slug: page.slug }), link({ kind: "page", slug: page.slug }, page.label));
57
+ for (const doc of manifest.documents ?? []) {
58
+ const target: SidebarTarget = { kind: "document", type: doc.type, id: doc.id };
59
+ const key = navigationKey(target);
60
+ if (!available.has(key)) available.set(key, link(target, String(doc.data.title ?? doc.data.name ?? doc.slug)));
61
+ }
62
+ const nodes: SidebarNode[] = [];
63
+ const used = new Set<string>();
64
+ for (const entry of manifest.sidebar) {
65
+ if (entry.kind !== "link") { nodes.push(entry); continue; }
66
+ const key = navigationKey(entry.target);
67
+ const item = available.get(key);
68
+ if (!item || used.has(key)) continue;
69
+ used.add(key);
70
+ nodes.push({ ...item, label: entry.label ?? item.label, ...(entry.icon ? { icon: entry.icon } : {}) });
71
+ }
72
+ const remaining = defaults.filter((node): node is SidebarLink => node.kind === "link" && !used.has(navigationKey(node.target)));
73
+ if (remaining.length) nodes.push({ kind: "heading", label: "More content" }, ...remaining);
74
+ return nodes;
75
+ }
76
+
77
+ export function validateSidebar(value: unknown): void {
78
+ const fail = () => { throw new Error("rigel: sidebar requires headings, separators or links with valid content targets"); };
79
+ if (!Array.isArray(value)) fail();
80
+ for (const entry of value as unknown[]) {
81
+ if (!entry || typeof entry !== "object") fail();
82
+ const item = entry as Record<string, unknown>;
83
+ if (item.kind === "separator") continue;
84
+ if (item.label !== undefined && (typeof item.label !== "string" || !item.label.trim())) fail();
85
+ if (item.kind === "heading") { if (!item.label) fail(); continue; }
86
+ if (item.kind !== "link" || !item.target || typeof item.target !== "object") fail();
87
+ if (item.icon !== undefined && !SIDEBAR_ICONS.includes(item.icon as SidebarIconName) && !isSidebarIconPath(item.icon)) fail();
88
+ const target = item.target as Record<string, unknown>;
89
+ const fields: Record<string, string[]> = { media: [], menus: [], pages: [], page: ["slug"], collection: ["name"], documents: ["type"], document: ["type", "id"] };
90
+ if (typeof target.kind !== "string" || !Object.hasOwn(fields, target.kind)) fail();
91
+ for (const key of fields[target.kind as string]) if (typeof target[key] !== "string" || !String(target[key]).trim()) fail();
92
+ if (target.category !== undefined && (target.kind !== "pages" || typeof target.category !== "string" || !target.category.trim())) fail();
93
+ }
94
+ }
@@ -0,0 +1,39 @@
1
+ import type { CollectionField } from "./types";
2
+ import type { ContentDocument } from "../schema/types";
3
+
4
+ export interface ReferenceOption {
5
+ value: string;
6
+ label: string;
7
+ description?: string;
8
+ photo?: string;
9
+ email?: string;
10
+ url?: string;
11
+ }
12
+
13
+ export function referenceOptions(field: CollectionField, documents: ContentDocument[] = []): ReferenceOption[] {
14
+ if (field.options) return field.options;
15
+ return documents.filter(doc => doc.type === field.to).map(doc => ({
16
+ value: doc.id,
17
+ label: String(doc.data.name ?? doc.data.title ?? doc.slug),
18
+ ...Object.fromEntries(["description", "photo", "email", "url"].filter(key => typeof doc.data[key] === "string").map(key => [key, doc.data[key]])),
19
+ }));
20
+ }
21
+
22
+ export function referenceLabel(value: unknown, options: ReferenceOption[]): string {
23
+ return (Array.isArray(value) ? value : [value]).filter(Boolean).map(id => options.find(option => option.value === id)?.label ?? `Unavailable (${String(id)})`).join(", ");
24
+ }
25
+
26
+ export function collectionPreviewValues(fields: CollectionField[], values: Record<string, unknown>, documents: ContentDocument[]): Record<string, unknown> {
27
+ return { ...values, ...Object.fromEntries(fields.filter(field => field.type === "reference").map(field => [field.key, referenceLabel(values[field.key], referenceOptions(field, documents))])) };
28
+ }
29
+
30
+ export function validateCollectionReferences(fields: CollectionField[], values: Record<string, unknown>, documents: ContentDocument[]): void {
31
+ for (const field of fields.filter(field => field.type === "reference")) {
32
+ const value = values[field.key];
33
+ if (value === undefined) continue;
34
+ if (field.multiple ? !Array.isArray(value) : typeof value !== "string") throw new Error(`${field.label}: expected ${field.multiple ? "an array of references" : "one reference"}`);
35
+ const selected = field.multiple ? value as unknown[] : value === "" ? [] : [value];
36
+ const options = referenceOptions(field, documents);
37
+ if (new Set(selected).size !== selected.length || selected.some(id => typeof id !== "string" || !options.some(option => option.value === id))) throw new Error(`${field.label}: select existing entries only`);
38
+ }
39
+ }
@@ -0,0 +1,15 @@
1
+ import type { CollectionField } from "./types";
2
+
3
+ export const SEO_FIELDS = [
4
+ { key: "metaTitle", label: "SEO title", type: "string" },
5
+ { key: "description", label: "SEO description", type: "text" },
6
+ { key: "ogImage", label: "Social image", type: "image" },
7
+ ] satisfies CollectionField[];
8
+
9
+ export function isSeoField(key: string): boolean {
10
+ return key === "metaTitle" || key === "description" || key === "ogImage";
11
+ }
12
+
13
+ export function withSeoFields<T extends { key: string }>(fields: T[]): (T | typeof SEO_FIELDS[number])[] {
14
+ return [...fields, ...SEO_FIELDS.filter(field => !fields.some(existing => existing.key === field.key)).map(field => ({ ...field }))];
15
+ }
@@ -0,0 +1,75 @@
1
+ import type { MenuDocument } from "./menu";
2
+
3
+ export type CopyField =
4
+ | { key: string; type: "string"; label: string; placeholder?: string }
5
+ | { key: string; type: "text"; label: string }
6
+ | { key: string; type: "markdown"; label: string }
7
+ | { key: string; type: "image"; label: string }
8
+ | { key: string; type: "list"; label: string; item: { fields: CopyField[] } };
9
+
10
+ export interface CopyGroup {
11
+ id: string;
12
+ label: string;
13
+ fields: CopyField[];
14
+ }
15
+
16
+ export interface CopyPage {
17
+ slug: string;
18
+ label: string;
19
+ shared?: boolean;
20
+ category?: string;
21
+ groups: CopyGroup[];
22
+ values: Record<string, Record<string, unknown>>;
23
+ }
24
+
25
+ export interface GitHubBackendConfig {
26
+ repo: string;
27
+ branch: string;
28
+ publishBranch: string;
29
+ repoRoot: string;
30
+ authOrigin: string;
31
+ appSlug: string;
32
+ copyDir: string;
33
+ collections: { name: string; folder: string }[];
34
+ menus?: { folder: string; maxDepth: number };
35
+ }
36
+
37
+ export interface Manifest {
38
+ backend?: "github";
39
+ github?: GitHubBackendConfig;
40
+ sidebar?: import("./navigation").SidebarEntry[];
41
+ preview?: import("../preview").PreviewConfig;
42
+ pages: CopyPage[];
43
+ collections?: ManifestCollection[];
44
+ menus?: { maxDepth: number; entries: MenuDocument[] };
45
+ schema?: import("../schema/types").Schema;
46
+ documents?: import("../schema/types").ContentDocument[];
47
+ }
48
+
49
+ export interface CollectionField {
50
+ key: string;
51
+ label: string;
52
+ type: "string" | "text" | "boolean" | "date" | "tags" | "markdown" | "image" | "reference";
53
+ to?: string;
54
+ multiple?: boolean;
55
+ options?: import("./references").ReferenceOption[];
56
+ }
57
+
58
+ export interface CollectionPost {
59
+ slug: string;
60
+ frontmatter: Record<string, unknown>;
61
+ body: import("../article-body").ArticleBody;
62
+ }
63
+
64
+ export interface ManifestCollection {
65
+ name: string;
66
+ label: string;
67
+ listTitle?: string;
68
+ fields: CollectionField[];
69
+ columns?: string[];
70
+ posts: CollectionPost[];
71
+ }
72
+
73
+ export function defineCopy(page: CopyPage): CopyPage {
74
+ return page;
75
+ }
@@ -0,0 +1,21 @@
1
+ export const MEDIA_TYPES: Record<string, { type: string; category: string }> = {};
2
+ for (const [category, entries] of Object.entries({
3
+ Images: { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif' },
4
+ Audio: { mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4' },
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' },
7
+ Spreadsheets: { xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', csv: 'text/csv', ods: 'application/vnd.oasis.opendocument.spreadsheet' },
8
+ Archives: { zip: 'application/zip', gz: 'application/gzip', tar: 'application/x-tar', '7z': 'application/x-7z-compressed', rar: 'application/vnd.rar' },
9
+ })) for (const [extension, type] of Object.entries(entries)) MEDIA_TYPES[extension] = { type, category };
10
+
11
+ export const MEDIA_CATEGORIES = ['Images', 'Audio', 'Video', 'Documents', 'Spreadsheets', 'Archives', 'Other'];
12
+ export function mediaMetadata(name: string) {
13
+ const extension = name.split('.').pop()?.toLowerCase() ?? '';
14
+ return Object.hasOwn(MEDIA_TYPES, extension) ? { ...MEDIA_TYPES[extension], extension } : undefined;
15
+ }
16
+ export function safeMediaName(name: string) {
17
+ return /^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*$/.test(name) && !!mediaMetadata(name);
18
+ }
19
+ export function mediaAccept(imagesOnly = false) {
20
+ return Object.keys(MEDIA_TYPES).filter(ext => !imagesOnly || MEDIA_TYPES[ext].category === 'Images').map(ext => '.' + ext).join(',');
21
+ }
@@ -0,0 +1,3 @@
1
+ export function safePreviewUrl(value: string, image = false): boolean {
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
+ }