@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
@@ -0,0 +1,81 @@
1
+ export type ScalarFieldType = "string" | "text" | "markdown" | "boolean" | "number" | "date" | "image" | "url" | "color" | "code" | "password";
2
+
3
+ export type ArrayItem =
4
+ | { type: "object"; name?: string; title?: string; fields?: FieldDef[] }
5
+ | { type: "reference"; to: string[] }
6
+ | { type: ScalarFieldType };
7
+
8
+ export interface FieldDef {
9
+ key: string;
10
+ label: string;
11
+ type: ScalarFieldType | "object" | "array" | "reference";
12
+ placeholder?: string;
13
+ required?: boolean;
14
+ initialValue?: unknown;
15
+ fields?: FieldDef[];
16
+ of?: string;
17
+ items?: ArrayItem[];
18
+ to?: string[];
19
+ min?: number;
20
+ max?: number;
21
+ }
22
+
23
+ export interface ObjectDef {
24
+ name: string;
25
+ title: string;
26
+ type: "object" | "section";
27
+ fields: FieldDef[];
28
+ }
29
+
30
+ export interface DocumentTemplateSection {
31
+ type: string;
32
+ values?: Record<string, unknown>;
33
+ }
34
+
35
+ export interface DocumentDef {
36
+ name: string;
37
+ title: string;
38
+ type: "document";
39
+ folder: string;
40
+ fields: FieldDef[];
41
+ columns?: string[];
42
+ shared?: boolean;
43
+ singleton?: boolean;
44
+ demo?: boolean;
45
+ creatable?: boolean;
46
+ template?: DocumentTemplateSection[];
47
+ initialValue?: Record<string, unknown>;
48
+ }
49
+
50
+ export interface Schema {
51
+ documents: DocumentDef[];
52
+ objects: ObjectDef[];
53
+ }
54
+
55
+ export interface ContentDocument {
56
+ id: string;
57
+ type: string;
58
+ slug: string;
59
+ data: Record<string, unknown>;
60
+ }
61
+
62
+ export const RESERVED_KEYS = new Set(["_id", "_type", "_key", "slug"]);
63
+ export const KEY_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
64
+ export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
65
+ export const FIELD_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
66
+
67
+ export function defineField(field: FieldDef): FieldDef {
68
+ return field;
69
+ }
70
+
71
+ export function defineObject(object: ObjectDef): ObjectDef {
72
+ return object;
73
+ }
74
+
75
+ export function defineDocument(document: DocumentDef): DocumentDef {
76
+ return document;
77
+ }
78
+
79
+ export function defineSchema(schema: Schema): Schema {
80
+ return schema;
81
+ }
@@ -0,0 +1,327 @@
1
+ import {
2
+ FIELD_KEY_PATTERN,
3
+ KEY_PATTERN,
4
+ RESERVED_KEYS,
5
+ SLUG_PATTERN,
6
+ type ArrayItem,
7
+ type ContentDocument,
8
+ type DocumentDef,
9
+ type FieldDef,
10
+ type ObjectDef,
11
+ type ScalarFieldType,
12
+ type Schema,
13
+ } from "./types";
14
+
15
+ const SCALARS: ScalarFieldType[] = ["string", "text", "markdown", "boolean", "number", "date", "image", "url", "color", "code", "password"];
16
+ const UNSAFE = new Set(["__proto__", "constructor", "prototype"]);
17
+ const FOLDER_PART = /^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*$/;
18
+
19
+ export class SchemaError extends Error {}
20
+
21
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
22
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
23
+ const proto = Object.getPrototypeOf(value);
24
+ return proto === Object.prototype || proto === null;
25
+ }
26
+
27
+ function fail(path: string, message: string): never {
28
+ throw new SchemaError(`${path}: ${message}`);
29
+ }
30
+
31
+ export function objectDef(schema: Schema, name: string): ObjectDef {
32
+ const def = schema.objects.find((entry) => entry.name === name);
33
+ if (!def) fail(name, "unknown object type");
34
+ return def;
35
+ }
36
+
37
+ export function documentDef(schema: Schema, name: string): DocumentDef {
38
+ const def = schema.documents.find((entry) => entry.name === name);
39
+ if (!def) fail(name, "unknown document type");
40
+ return def;
41
+ }
42
+
43
+ export function validateSchema(input: Schema): Schema {
44
+ if (!isPlainObject(input) || !Array.isArray(input.documents) || !Array.isArray(input.objects))
45
+ throw new SchemaError("Schema requires documents and objects arrays");
46
+ const schema = input;
47
+ const names = new Set<string>();
48
+ const folders = new Set<string>();
49
+ for (const doc of schema.documents) {
50
+ if (doc.type !== "document" || !/^[a-z][a-zA-Z0-9-]*$/.test(doc.name)) throw new SchemaError(`Invalid document type name: ${doc.name}`);
51
+ if (names.has(doc.name)) throw new SchemaError(`Duplicate document type: ${doc.name}`);
52
+ names.add(doc.name);
53
+ if (doc.demo !== undefined && typeof doc.demo !== "boolean") throw new SchemaError(`Document type ${doc.name}: demo must be boolean`);
54
+ if (doc.creatable !== undefined && typeof doc.creatable !== "boolean") throw new SchemaError(`Document type ${doc.name}: creatable must be boolean`);
55
+ if (typeof doc.title !== "string" || !doc.title.trim()) throw new SchemaError(`Document type ${doc.name} requires a title`);
56
+ const parts = String(doc.folder).split("/");
57
+ if (!parts.length || parts.some((part) => !FOLDER_PART.test(part))) throw new SchemaError(`Document type ${doc.name} has an unsafe folder`);
58
+ if (folders.has(doc.folder)) throw new SchemaError(`Duplicate document folder: ${doc.folder}`);
59
+ folders.add(doc.folder);
60
+ if (!Array.isArray(doc.fields) || !doc.fields.length) throw new SchemaError(`Document type ${doc.name} requires fields`);
61
+ validateFieldsShape(schema, doc.fields, doc.name);
62
+ if (doc.columns !== undefined && (!Array.isArray(doc.columns) || doc.columns.some(key => typeof key !== "string" || !["title", "slug", ...doc.fields.map(field => field.key)].includes(key)))) throw new SchemaError(`Document type ${doc.name}: columns must name declared fields, title or slug`);
63
+ for (const section of doc.template ?? []) {
64
+ const def = schema.objects.find((entry) => entry.name === section.type);
65
+ if (!def || def.type !== "section") throw new SchemaError(`Document type ${doc.name} template references unknown section ${section.type}`);
66
+ }
67
+ }
68
+ const objectNames = new Set<string>();
69
+ for (const object of schema.objects) {
70
+ if (!/^[a-z][a-zA-Z0-9-]*$/.test(object.name)) throw new SchemaError(`Invalid object name: ${object.name}`);
71
+ if (objectNames.has(object.name)) throw new SchemaError(`Duplicate object type: ${object.name}`);
72
+ objectNames.add(object.name);
73
+ if (object.type !== "object" && object.type !== "section") throw new SchemaError(`Object ${object.name} must be an object or section`);
74
+ if (!Array.isArray(object.fields) || !object.fields.length) throw new SchemaError(`Object ${object.name} requires fields`);
75
+ validateFieldsShape(schema, object.fields, object.name);
76
+ }
77
+ for (const doc of schema.documents) validateFieldTargets(schema, doc.fields, doc.name);
78
+ for (const object of schema.objects) validateFieldTargets(schema, object.fields, object.name);
79
+ return schema;
80
+ }
81
+
82
+ function validateFieldsShape(schema: Schema, fields: FieldDef[], scope: string) {
83
+ const keys = new Set<string>();
84
+ for (const field of fields) {
85
+ if (!isPlainObject(field)) throw new SchemaError(`${scope}: invalid field`);
86
+ if (!FIELD_KEY_PATTERN.test(String(field.key)) || RESERVED_KEYS.has(field.key)) throw new SchemaError(`${scope}: invalid field key ${field.key}`);
87
+ if (keys.has(field.key)) throw new SchemaError(`${scope}: duplicate field key ${field.key}`);
88
+ keys.add(field.key);
89
+ if (typeof field.label !== "string" || !field.label.trim()) throw new SchemaError(`${scope}.${field.key}: label required`);
90
+ if (![...SCALARS, "object", "array", "reference"].includes(field.type)) throw new SchemaError(`${scope}.${field.key}: unknown field type ${field.type}`);
91
+ if (field.type === "object") {
92
+ if (field.of) objectDef(schema, field.of);
93
+ else if (!field.fields?.length) throw new SchemaError(`${scope}.${field.key}: object requires fields or a declared object`);
94
+ else validateFieldsShape(schema, field.fields, `${scope}.${field.key}`);
95
+ }
96
+ if (field.type === "array") {
97
+ if (!field.items?.length) throw new SchemaError(`${scope}.${field.key}: array requires items`);
98
+ if (field.min !== undefined && (!Number.isInteger(field.min) || field.min < 0)) throw new SchemaError(`${scope}.${field.key}: invalid min`);
99
+ if (field.max !== undefined && (!Number.isInteger(field.max) || field.max < 1)) throw new SchemaError(`${scope}.${field.key}: invalid max`);
100
+ for (const item of field.items) {
101
+ if (item.type === "object") {
102
+ if (item.name) objectDef(schema, item.name);
103
+ else if (!item.fields?.length) throw new SchemaError(`${scope}.${field.key}: array object item requires fields`);
104
+ else validateFieldsShape(schema, item.fields, `${scope}.${field.key}[]`);
105
+ }
106
+ if (item.type === "reference" && (!item.to?.length || item.to.some((name) => !schema.documents.some((doc) => doc.name === name))))
107
+ throw new SchemaError(`${scope}.${field.key}: reference target must be a declared document type`);
108
+ }
109
+ }
110
+ if (field.type === "reference" && (!field.to?.length || field.to.some((name) => !schema.documents.some((doc) => doc.name === name))))
111
+ throw new SchemaError(`${scope}.${field.key}: reference target must be a declared document type`);
112
+ }
113
+ }
114
+
115
+ function validateFieldTargets(schema: Schema, fields: FieldDef[], scope: string) {
116
+ for (const field of fields) {
117
+ if (field.type === "object" && field.fields) validateFieldTargets(schema, field.fields, `${scope}.${field.key}`);
118
+ for (const item of field.items ?? []) if (item.type === "object" && item.fields) validateFieldTargets(schema, item.fields, `${scope}.${field.key}[]`);
119
+ }
120
+ }
121
+
122
+ function safeUrl(value: string): boolean {
123
+ if (!value || /[\s\u0000-\u001f\u007f\\]/.test(value) || value.startsWith("//")) return false;
124
+ if (value.startsWith("/")) return !value.split("/").includes("..");
125
+ return /^https?:\/\/[^\s]+$/i.test(value) || /^mailto:[^\s]+$/i.test(value);
126
+ }
127
+
128
+ function scalarOk(type: ScalarFieldType, value: unknown): boolean {
129
+ if (type === "boolean") return typeof value === "boolean";
130
+ if (type === "number") return typeof value === "number" && Number.isFinite(value);
131
+ if (typeof value !== "string") return false;
132
+ if (type === "color") return value === "" || /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(value);
133
+ if (type === "date") return /^\d{4}-\d{2}-\d{2}$/.test(value);
134
+ if (type === "image" || type === "url") return safeUrl(value);
135
+ return true;
136
+ }
137
+
138
+ function requireValue(field: FieldDef, value: unknown, path: string) {
139
+ if (value === undefined || value === null) fail(path, "required");
140
+ if (typeof value === "string" && ["string", "text", "markdown", "image", "url"].includes(field.type) && !value.trim()) fail(path, "required");
141
+ if (field.required && Array.isArray(value) && !value.length) fail(path, "required");
142
+ }
143
+
144
+ function checkKeys(value: unknown, path: string) {
145
+ if (Array.isArray(value)) {
146
+ value.forEach((item, index) => checkKeys(item, `${path}[${index}]`));
147
+ return;
148
+ }
149
+ if (!isPlainObject(value)) return;
150
+ for (const [key, child] of Object.entries(value)) {
151
+ if (UNSAFE.has(key)) fail(path, `unsafe key ${key}`);
152
+ checkKeys(child, `${path}.${key}`);
153
+ }
154
+ }
155
+
156
+ function validateObjectFields(
157
+ schema: Schema,
158
+ fields: FieldDef[],
159
+ value: Record<string, unknown>,
160
+ path: string,
161
+ documents: ContentDocument[],
162
+ ) {
163
+ for (const field of fields) {
164
+ if (field.required) requireValue(field, value[field.key], `${path}.${field.key}`);
165
+ if (value[field.key] === undefined) continue;
166
+ validateFieldValue(schema, field, value[field.key], `${path}.${field.key}`, documents);
167
+ }
168
+ for (const key of Object.keys(value)) {
169
+ if (UNSAFE.has(key)) fail(path, `unsafe key ${key}`);
170
+ }
171
+ }
172
+
173
+ function validateFieldValue(schema: Schema, field: FieldDef, value: unknown, path: string, documents: ContentDocument[]) {
174
+ if (field.type === "object") {
175
+ if (!isPlainObject(value)) fail(path, "must be an object");
176
+ checkKeys(value, path);
177
+ const def = field.of ? objectDef(schema, field.of) : undefined;
178
+ validateObjectFields(schema, def?.fields ?? field.fields ?? [], value, path, documents);
179
+ return;
180
+ }
181
+ if (field.type === "array") {
182
+ if (!Array.isArray(value)) fail(path, "must be an array");
183
+ if (field.min !== undefined && value.length < field.min) fail(path, `needs at least ${field.min} items`);
184
+ if (field.max !== undefined && value.length > field.max) fail(path, `allows at most ${field.max} items`);
185
+ checkKeys(value, path);
186
+ const keys = new Set<string>();
187
+ value.forEach((item, index) => {
188
+ const itemPath = `${path}[${index}]`;
189
+ const def = matchItem(schema, field.items!, item, itemPath);
190
+ if (def.type === "reference") {
191
+ if (!isPlainObject(item) || typeof item._ref !== "string") fail(itemPath, "must be a reference");
192
+ validateReference(schema, item, def.to ?? [], itemPath, documents);
193
+ } else if (def.type === "object") {
194
+ if (!isPlainObject(item)) fail(itemPath, "must be an object");
195
+ if (def.name && item._type !== def.name) fail(itemPath, `must be a ${def.name} section`);
196
+ checkKeys(item, itemPath);
197
+ validateObjectFields(schema, def.fields ?? objectDef(schema, def.name!).fields, item, itemPath, documents);
198
+ } else if (!scalarOk(def.type as ScalarFieldType, item)) {
199
+ fail(itemPath, `must be a ${def.type}`);
200
+ }
201
+ if (def.type === "object" || def.type === "reference") {
202
+ const key = item && typeof item === "object" ? (item as Record<string, unknown>)._key : undefined;
203
+ if (typeof key !== "string" || !KEY_PATTERN.test(key)) fail(itemPath, "requires a stable _key");
204
+ if (keys.has(key as string)) fail(itemPath, `duplicate _key ${key}`);
205
+ keys.add(key as string);
206
+ }
207
+ });
208
+ return;
209
+ }
210
+ if (field.type === "reference") {
211
+ if (!isPlainObject(value)) fail(path, "must be a reference");
212
+ validateReference(schema, value, field.to ?? [], path, documents);
213
+ return;
214
+ }
215
+ if ((field.type === "url" || field.type === "image") && value === "" && !field.required) return;
216
+ if (!scalarOk(field.type as ScalarFieldType, value)) fail(path, `must be a ${field.type}`);
217
+ }
218
+
219
+ type ItemDef = { type: ScalarFieldType | "object" | "reference"; name?: string; fields?: FieldDef[]; to?: string[] };
220
+
221
+ function matchItem(schema: Schema, items: ArrayItem[], item: unknown, path: string): ItemDef {
222
+ if (isPlainObject(item) && typeof item._type === "string") {
223
+ const named = items.find((entry) => entry.type === "object" && entry.name === item._type);
224
+ if (named && named.type === "object") return { type: "object", name: named.name, fields: named.name ? objectDef(schema, named.name).fields : named.fields };
225
+ fail(path, `unknown section type ${item._type}`);
226
+ }
227
+ if (items.length === 1) {
228
+ const only = items[0];
229
+ if (only.type === "object") return { type: "object", name: only.name, fields: only.name ? objectDef(schema, only.name).fields : only.fields };
230
+ if (only.type === "reference") return { type: "reference", to: only.to };
231
+ return only;
232
+ }
233
+ const reference = items.find((entry) => entry.type === "reference");
234
+ if (isPlainObject(item) && typeof item._ref === "string" && reference && reference.type === "reference") return { type: "reference", to: reference.to };
235
+ const inline = items.find((entry) => entry.type === "object" && !entry.name);
236
+ if (isPlainObject(item) && inline && inline.type === "object") return { type: "object", fields: inline.fields };
237
+ const scalar = items.find((entry) => entry.type !== "object" && entry.type !== "reference" && scalarOk(entry.type, item));
238
+ if (scalar) return scalar;
239
+ fail(path, "does not match an allowed array item type");
240
+ }
241
+
242
+ function validateReference(schema: Schema, value: Record<string, unknown>, to: string[], path: string, documents: ContentDocument[]) {
243
+ const ref = value._ref;
244
+ if (typeof ref !== "string" || !/^[a-z][a-zA-Z0-9-]*:[a-z0-9]+(?:-[a-z0-9]+)*$/.test(ref)) fail(path, "invalid reference id");
245
+ if (!to.some((name) => ref.startsWith(`${name}:`))) fail(path, `reference must target ${to.join(" or ")}`);
246
+ for (const key of Object.keys(value)) if (key !== "_ref" && !key.startsWith("_")) fail(path, `unknown reference property ${key}`);
247
+ if (!documents.some((doc) => doc.id === ref)) fail(path, `reference target ${ref} does not resolve`);
248
+ if (!schema.documents.some((doc) => ref.startsWith(`${doc.name}:`))) fail(path, `reference target type is not declared`);
249
+ }
250
+
251
+ export function documentIndex(documents: ContentDocument[]): { byId: Map<string, ContentDocument>; list: ContentDocument[] } {
252
+ const byId = new Map<string, ContentDocument>();
253
+ for (const doc of documents) {
254
+ if (byId.has(doc.id)) throw new SchemaError(`Duplicate document id: ${doc.id}`);
255
+ byId.set(doc.id, doc);
256
+ }
257
+ return { byId, list: documents };
258
+ }
259
+
260
+ export function validateDocument(
261
+ schema: Schema,
262
+ typeName: string,
263
+ slug: string,
264
+ data: Record<string, unknown>,
265
+ documents: ContentDocument[],
266
+ ): Record<string, unknown> {
267
+ const def = documentDef(schema, typeName);
268
+ if (!SLUG_PATTERN.test(slug)) fail(slug, "slug must be lowercase letters, numbers and single dashes");
269
+ if (!isPlainObject(data)) fail(slug, "document must be an object");
270
+ if (data._type !== undefined && data._type !== typeName) fail(slug, `_type must be ${typeName}`);
271
+ if (data._id !== undefined && data._id !== `${typeName}:${slug}`) fail(slug, `_id must be ${typeName}:${slug}`);
272
+ checkKeys(data, slug);
273
+ validateObjectFields(schema, def.fields, data, slug, documents);
274
+ validateReferencesDeep(schema, def.fields, data, `${typeName}:${slug}`, `${typeName}:${slug}`, documents);
275
+ return data;
276
+ }
277
+
278
+ function validateReferencesDeep(
279
+ schema: Schema,
280
+ fields: FieldDef[],
281
+ value: Record<string, unknown>,
282
+ path: string,
283
+ self: string,
284
+ documents: ContentDocument[],
285
+ ) {
286
+ for (const field of fields) {
287
+ const current = value[field.key];
288
+ if (current === undefined) continue;
289
+ if (field.type === "reference") {
290
+ const ref = (current as Record<string, unknown>)._ref;
291
+ if (ref === self) fail(path, "a document cannot reference itself");
292
+ const target = documents.find((doc) => doc.id === ref);
293
+ if (!target) fail(`${path}.${field.key}`, `reference ${ref} does not resolve`);
294
+ const def = schema.documents.find((doc) => doc.name === target.type);
295
+ if (!field.to?.includes(target.type) || !def) fail(`${path}.${field.key}`, `reference ${ref} is not an allowed target`);
296
+ } else if (field.type === "object" && isPlainObject(current)) {
297
+ const objectFields = field.of ? objectDef(schema, field.of).fields : field.fields ?? [];
298
+ validateReferencesDeep(schema, objectFields, current, `${path}.${field.key}`, self, documents);
299
+ } else if (field.type === "array" && Array.isArray(current)) {
300
+ current.forEach((item, index) => {
301
+ if (!isPlainObject(item)) return;
302
+ for (const entry of field.items ?? []) {
303
+ if (entry.type === "reference" && typeof item._ref === "string") {
304
+ const target = documents.find((doc) => doc.id === item._ref);
305
+ if (!target) fail(`${path}.${field.key}[${index}]`, `reference ${item._ref} does not resolve`);
306
+ if (!entry.to.includes(target.type)) fail(`${path}.${field.key}[${index}]`, `reference ${item._ref} is not an allowed target`);
307
+ }
308
+ if (entry.type === "object") {
309
+ const objectFields = entry.name ? objectDef(schema, entry.name).fields : entry.fields ?? [];
310
+ validateReferencesDeep(schema, objectFields, item, `${path}.${field.key}[${index}]`, self, documents);
311
+ }
312
+ }
313
+ });
314
+ }
315
+ }
316
+ }
317
+
318
+ export function referrers(documents: ContentDocument[], id: string): ContentDocument[] {
319
+ return documents.filter((doc) => JSON.stringify(doc.data).includes(`"_ref":"${id}"`));
320
+ }
321
+
322
+ export function validateDocumentId(schema: Schema, id: string): { type: DocumentDef; slug: string } | null {
323
+ const match = /^([a-z][a-zA-Z0-9-]*):([a-z0-9]+(?:-[a-z0-9]+)*)$/.exec(id);
324
+ if (!match) return null;
325
+ const type = schema.documents.find((doc) => doc.name === match[1]);
326
+ return type ? { type, slug: match[2] } : null;
327
+ }
@@ -0,0 +1,180 @@
1
+ import type { HotPayload, Plugin, WebSocketClient } from "vite";
2
+ import { isRunnableDevEnvironment } from "vite";
3
+ import { fileURLToPath } from "node:url";
4
+ import { lstatSync, readdirSync, realpathSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { parseConfig } from "../config";
7
+ import type { RigelConfig } from "../config";
8
+ import type { GitHubBackendConfig } from "../manifest/types";
9
+
10
+ const VIRTUAL_ID = "virtual:rigel-manifest";
11
+ const CONTENT_ID = "virtual:rigel-content";
12
+
13
+
14
+ export function rigelManifestPlugin(cfg: RigelConfig, publicDir?: URL, root?: URL): Plugin {
15
+ cfg = parseConfig(cfg);
16
+ const pattern = `/${cfg.copyDir}/**/*.ts`;
17
+ const collections = cfg.collections ?? [];
18
+ const documentTypes = cfg.schema?.documents ?? [];
19
+ const github: GitHubBackendConfig | undefined = cfg.backend === "github" ? {
20
+ repo: cfg.repo!, branch: cfg.branch!, publishBranch: cfg.publishBranch!, repoRoot: cfg.repoRoot!, authOrigin: cfg.authOrigin!, appSlug: cfg.appSlug!, copyDir: cfg.copyDir,
21
+ collections: collections.map(({ name, folder }) => ({ name, folder })),
22
+ ...(cfg.menus ? { menus: { folder: cfg.menus.folder, maxDepth: cfg.menus.maxDepth! } } : {}),
23
+ } : undefined;
24
+
25
+ const documentsSrc = documentTypes.length
26
+ ? `
27
+ ${documentTypes.map((type, index) => `const __rigelDoc${index} = import.meta.glob(${JSON.stringify(`/${type.folder}/*.json`)}, { eager: true, import: "default" });`).join("\n")}
28
+ export const documentTypes = ${JSON.stringify(documentTypes.map((type) => ({ name: type.name, shared: Boolean(type.shared) })))};
29
+ const __rigelRawDocuments = { ${documentTypes.map((type, index) => `${JSON.stringify(type.name)}: __rigelDoc${index}`).join(", ")} };
30
+ export const documents = Object.entries(__rigelRawDocuments)
31
+ .flatMap(([type, modules]) => Object.entries(modules).map(([file, data]) => {
32
+ const slug = file.split("/").pop().replace(/\\.json$/, "");
33
+ return { id: type + ":" + slug, type, slug, data: { ...data, _id: type + ":" + slug, _type: type } };
34
+ }))
35
+ .sort((a, b) => a.id.localeCompare(b.id));
36
+ const __rigelReservedSlugs = new Set(documents.filter((doc) => !documentTypes.find((type) => type.name === doc.type).shared).map((doc) => doc.slug));
37
+ `
38
+ : `export const documentTypes = [];\nexport const documents = [];\nconst __rigelReservedSlugs = new Set();\n`;
39
+
40
+ const collectionCode = collections
41
+ .map(
42
+ (c) => ` {
43
+ name: ${JSON.stringify(c.name)},
44
+ label: ${JSON.stringify(c.label ?? c.name)},
45
+ listTitle: ${JSON.stringify(c.listTitle)},
46
+ fields: ${JSON.stringify(c.fields)},
47
+ columns: ${JSON.stringify(c.columns ?? null)},
48
+ posts: Object.entries(
49
+ import.meta.glob(${JSON.stringify(`/${c.folder}/**/*.json`)}, { eager: true, query: "?raw", import: "default" })
50
+ ).map(([path, raw]) => {
51
+ const post = JSON.parse(raw);
52
+ __rigelValidateBody(post.body);
53
+ post.slug = path.split("/").pop().replace(/\\.json$/, "");
54
+ return post;
55
+ }),
56
+ },`,
57
+ )
58
+ .join("\n");
59
+
60
+ const menuCode = cfg.menus ? `
61
+ import { validateMenu } from ${JSON.stringify(fileURLToPath(new URL("../manifest/menu.ts", import.meta.url)))};
62
+ export const menus = {
63
+ maxDepth: ${cfg.menus.maxDepth ?? 3},
64
+ entries: Object.entries(import.meta.glob(${JSON.stringify(`/${cfg.menus.folder}/*.json`)}, { eager: true, query: "?raw", import: "default" }))
65
+ .map(([path, raw]) => {
66
+ try {
67
+ const menu = validateMenu(JSON.parse(raw), ${cfg.menus.maxDepth ?? 3});
68
+ if (path.split("/").pop() !== menu.slug + ".json") throw new Error("Menu slug must match filename");
69
+ return menu;
70
+ } catch (error) { throw new Error("Invalid menu " + path + ": " + error.message); }
71
+ }).sort((a, b) => a.slug.localeCompare(b.slug)),
72
+ };` : "export const menus = undefined;";
73
+
74
+ return {
75
+ name: "rigel-manifest",
76
+ enforce: "pre",
77
+ configureServer(server) {
78
+ const editors = new WeakSet<WebSocketClient>();
79
+ server.ws.on("rigel:editor-ready", (_data, client) => editors.add(client));
80
+ const hot = server.environments.client.hot;
81
+ const broadcast = hot.send.bind(hot);
82
+ hot.send = (payload: HotPayload | string, data?: unknown) => {
83
+ if (typeof payload === "string") return broadcast(payload, data);
84
+ if (payload.type === "full-reload" && payload.path === "*") {
85
+ for (const client of server.ws.clients) if (!editors.has(client)) client.send(payload);
86
+ return;
87
+ }
88
+ broadcast(payload);
89
+ };
90
+ },
91
+ hotUpdate: {
92
+ order: "post",
93
+ handler({ file, modules, timestamp }) {
94
+ if (!root || !/\.(json|md)$/.test(file)) return;
95
+ const directory = path.dirname(file);
96
+ const resolvedRoot = path.resolve(fileURLToPath(root));
97
+ const menuFolder = cfg.menus ? path.resolve(resolvedRoot, cfg.menus.folder) : null;
98
+ const documentFolders = new Set((cfg.schema?.documents ?? []).map((type) => path.resolve(resolvedRoot, type.folder)));
99
+ const isMenu = menuFolder !== null && directory === menuFolder;
100
+ const isDocument = documentFolders.has(directory);
101
+ const isCollection = collections.some(collection => {
102
+ const folder = path.resolve(resolvedRoot, collection.folder);
103
+ return directory === folder || directory.startsWith(folder + path.sep);
104
+ });
105
+ if (!isMenu && !isDocument && !isCollection) return file.endsWith(".json") ? [] : undefined;
106
+ const invalidated = new Set<typeof modules[number]>();
107
+ for (const mod of modules) this.environment.moduleGraph.invalidateModule(mod, invalidated, timestamp, true);
108
+ for (const id of [VIRTUAL_ID, CONTENT_ID]) {
109
+ const virtual = this.environment.moduleGraph.getModuleById("\0" + id);
110
+ if (virtual) this.environment.moduleGraph.invalidateModule(virtual, invalidated, timestamp, true);
111
+ }
112
+ if (isRunnableDevEnvironment(this.environment)) {
113
+ for (const mod of invalidated) {
114
+ const evaluated = mod.id && this.environment.runner.evaluatedModules.getModuleById(mod.id);
115
+ if (evaluated) this.environment.runner.evaluatedModules.invalidateModule(evaluated);
116
+ }
117
+ }
118
+ if (this.environment.name === "client") {
119
+ this.environment.hot.send({ type: "custom", event: isMenu ? "rigel:menus-updated" : isCollection ? "rigel:collections-updated" : "rigel:content-updated", data: {} });
120
+ }
121
+ return [];
122
+ },
123
+ },
124
+ resolveId(id) {
125
+ if (id === VIRTUAL_ID || id === CONTENT_ID || id === "virtual:rigel-config") return "\0" + id;
126
+ },
127
+ load(id) {
128
+ if (id === "\0virtual:rigel-config") {
129
+ return `export default ${JSON.stringify({
130
+ copyDir: cfg.copyDir,
131
+ backend: cfg.backend,
132
+ demo: cfg.demo,
133
+ publicDir: publicDir?.href,
134
+ mediaDir: cfg.mediaDir,
135
+ root: root?.href,
136
+ menus: cfg.menus,
137
+ collections: cfg.collections ?? [],
138
+ schema: cfg.schema,
139
+ })};`;
140
+ }
141
+ if (id === "\0" + CONTENT_ID) {
142
+ return `${documentsSrc}
143
+ import { buildDocumentIndex, deref } from ${JSON.stringify(fileURLToPath(new URL("../schema/docs.ts", import.meta.url)))};
144
+ const __rigelIndex = buildDocumentIndex(documents);
145
+ export function getDocument(id) { return __rigelIndex.byId.get(id); }
146
+ export function derefDocument(value) { return deref(value, __rigelIndex); }
147
+ `;
148
+ }
149
+ if (id !== "\0" + VIRTUAL_ID) return;
150
+ if (cfg.menus && root) {
151
+ let directory = realpathSync(fileURLToPath(root));
152
+ try {
153
+ for (const part of cfg.menus.folder.split("/")) {
154
+ directory = path.join(directory, part);
155
+ const info = lstatSync(directory);
156
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("rigel: unsafe menu folder");
157
+ }
158
+ for (const name of readdirSync(directory).filter((name) => name.endsWith(".json"))) {
159
+ const info = lstatSync(path.join(directory, name));
160
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("rigel: unsafe menu file " + name);
161
+ }
162
+ } catch (error) {
163
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
164
+ }
165
+ }
166
+ return `
167
+ import { validateBody as __rigelValidateBody } from ${JSON.stringify(fileURLToPath(new URL("../article-body.ts", import.meta.url)))};
168
+ const modules = import.meta.glob(${JSON.stringify(pattern)}, { eager: true });
169
+ const loaded = Object.values(modules).map((m) => m.default);
170
+ ${documentsSrc}
171
+ export const pages = loaded.filter((page) => !__rigelReservedSlugs.has(page.slug)).sort((a, b) => a.slug.localeCompare(b.slug));
172
+ export const collections = [
173
+ ${collectionCode}
174
+ ];
175
+ ${menuCode}
176
+ export default { pages, collections, documents${github ? ', backend: "github", github: ' + JSON.stringify(github) : ""}${cfg.menus ? ", menus" : ""}${cfg.schema ? ", schema: " + JSON.stringify(cfg.schema) : ""}${cfg.preview ? ", preview: " + JSON.stringify(cfg.preview) : ""}${cfg.sidebar ? ", sidebar: " + JSON.stringify(cfg.sidebar) : ""} };
177
+ `;
178
+ },
179
+ };
180
+ }