@rigel-cms/rigel 1.0.0-rc.1 → 1.1.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.
Files changed (158) hide show
  1. package/README.md +19 -7
  2. package/docs/extensions.md +194 -0
  3. package/package.json +16 -9
  4. package/src/article-body.ts +49 -37
  5. package/src/article-render.ts +25 -2
  6. package/src/article.ts +44 -0
  7. package/src/config.ts +18 -3
  8. package/src/editor/App.svelte +99 -98
  9. package/src/editor/blocks/extension.ts +29 -0
  10. package/src/editor/blocks/index.ts +17 -15
  11. package/src/editor/blocks/video.ts +2 -1
  12. package/src/editor/i18n/README.md +23 -0
  13. package/src/editor/i18n/de/connection.json +136 -0
  14. package/src/editor/i18n/de/defaults.json +6 -0
  15. package/src/editor/i18n/de/fields.json +130 -0
  16. package/src/editor/i18n/de/local.json +49 -0
  17. package/src/editor/i18n/de/media.json +166 -0
  18. package/src/editor/i18n/de/richtext.json +116 -0
  19. package/src/editor/i18n/de/shell.json +181 -0
  20. package/src/editor/i18n/de/system.json +14 -0
  21. package/src/editor/i18n/de/validation.json +82 -0
  22. package/src/editor/i18n/en/connection.json +136 -0
  23. package/src/editor/i18n/en/defaults.json +6 -0
  24. package/src/editor/i18n/en/fields.json +130 -0
  25. package/src/editor/i18n/en/local.json +49 -0
  26. package/src/editor/i18n/en/media.json +166 -0
  27. package/src/editor/i18n/en/richtext.json +116 -0
  28. package/src/editor/i18n/en/shell.json +181 -0
  29. package/src/editor/i18n/en/system.json +14 -0
  30. package/src/editor/i18n/en/validation.json +82 -0
  31. package/src/editor/i18n/es/connection.json +136 -0
  32. package/src/editor/i18n/es/defaults.json +6 -0
  33. package/src/editor/i18n/es/fields.json +130 -0
  34. package/src/editor/i18n/es/local.json +49 -0
  35. package/src/editor/i18n/es/media.json +166 -0
  36. package/src/editor/i18n/es/richtext.json +116 -0
  37. package/src/editor/i18n/es/shell.json +181 -0
  38. package/src/editor/i18n/es/system.json +14 -0
  39. package/src/editor/i18n/es/validation.json +82 -0
  40. package/src/editor/i18n/format.ts +26 -0
  41. package/src/editor/i18n/fr/connection.json +136 -0
  42. package/src/editor/i18n/fr/defaults.json +6 -0
  43. package/src/editor/i18n/fr/fields.json +130 -0
  44. package/src/editor/i18n/fr/local.json +49 -0
  45. package/src/editor/i18n/fr/media.json +166 -0
  46. package/src/editor/i18n/fr/richtext.json +116 -0
  47. package/src/editor/i18n/fr/shell.json +181 -0
  48. package/src/editor/i18n/fr/system.json +14 -0
  49. package/src/editor/i18n/fr/validation.json +82 -0
  50. package/src/editor/i18n/index.ts +18 -0
  51. package/src/editor/i18n/it/connection.json +136 -0
  52. package/src/editor/i18n/it/defaults.json +6 -0
  53. package/src/editor/i18n/it/fields.json +130 -0
  54. package/src/editor/i18n/it/local.json +49 -0
  55. package/src/editor/i18n/it/media.json +166 -0
  56. package/src/editor/i18n/it/richtext.json +116 -0
  57. package/src/editor/i18n/it/shell.json +181 -0
  58. package/src/editor/i18n/it/system.json +14 -0
  59. package/src/editor/i18n/it/validation.json +82 -0
  60. package/src/editor/i18n/ja/connection.json +136 -0
  61. package/src/editor/i18n/ja/defaults.json +6 -0
  62. package/src/editor/i18n/ja/fields.json +130 -0
  63. package/src/editor/i18n/ja/local.json +49 -0
  64. package/src/editor/i18n/ja/media.json +166 -0
  65. package/src/editor/i18n/ja/richtext.json +116 -0
  66. package/src/editor/i18n/ja/shell.json +181 -0
  67. package/src/editor/i18n/ja/system.json +14 -0
  68. package/src/editor/i18n/ja/validation.json +82 -0
  69. package/src/editor/i18n/locales.ts +13 -0
  70. package/src/editor/i18n/nl/connection.json +136 -0
  71. package/src/editor/i18n/nl/defaults.json +6 -0
  72. package/src/editor/i18n/nl/fields.json +130 -0
  73. package/src/editor/i18n/nl/local.json +49 -0
  74. package/src/editor/i18n/nl/media.json +166 -0
  75. package/src/editor/i18n/nl/richtext.json +116 -0
  76. package/src/editor/i18n/nl/shell.json +181 -0
  77. package/src/editor/i18n/nl/system.json +14 -0
  78. package/src/editor/i18n/nl/validation.json +82 -0
  79. package/src/editor/i18n/ru/connection.json +138 -0
  80. package/src/editor/i18n/ru/defaults.json +6 -0
  81. package/src/editor/i18n/ru/fields.json +132 -0
  82. package/src/editor/i18n/ru/local.json +49 -0
  83. package/src/editor/i18n/ru/media.json +166 -0
  84. package/src/editor/i18n/ru/richtext.json +116 -0
  85. package/src/editor/i18n/ru/shell.json +181 -0
  86. package/src/editor/i18n/ru/system.json +14 -0
  87. package/src/editor/i18n/ru/validation.json +82 -0
  88. package/src/editor/i18n/server.ts +13 -0
  89. package/src/editor/i18n/zh/connection.json +136 -0
  90. package/src/editor/i18n/zh/defaults.json +6 -0
  91. package/src/editor/i18n/zh/fields.json +130 -0
  92. package/src/editor/i18n/zh/local.json +49 -0
  93. package/src/editor/i18n/zh/media.json +166 -0
  94. package/src/editor/i18n/zh/richtext.json +116 -0
  95. package/src/editor/i18n/zh/shell.json +181 -0
  96. package/src/editor/i18n/zh/system.json +14 -0
  97. package/src/editor/i18n/zh/validation.json +82 -0
  98. package/src/editor/lib/ActionDialog.svelte +5 -4
  99. package/src/editor/lib/BlockEditor.svelte +305 -88
  100. package/src/editor/lib/CollectionPanel.svelte +17 -15
  101. package/src/editor/lib/DatePicker.svelte +15 -12
  102. package/src/editor/lib/ExtensionMount.svelte +12 -0
  103. package/src/editor/lib/Field.svelte +3 -2
  104. package/src/editor/lib/ImagePicker.svelte +82 -81
  105. package/src/editor/lib/MarkdownEditor.svelte +25 -24
  106. package/src/editor/lib/MediaPicker.svelte +6 -5
  107. package/src/editor/lib/MenuEditor.svelte +31 -30
  108. package/src/editor/lib/PreviewFrame.svelte +11 -6
  109. package/src/editor/lib/PropertiesTabs.svelte +3 -2
  110. package/src/editor/lib/PublishDialog.svelte +15 -14
  111. package/src/editor/lib/ReferenceSelect.svelte +3 -2
  112. package/src/editor/lib/SchemaField.svelte +15 -13
  113. package/src/editor/lib/SchemaPanel.svelte +20 -19
  114. package/src/editor/lib/Select.svelte +3 -2
  115. package/src/editor/lib/SeoGuidance.svelte +6 -5
  116. package/src/editor/lib/Settings.svelte +54 -54
  117. package/src/editor/lib/Shortcuts.svelte +7 -6
  118. package/src/editor/lib/SitePreview.svelte +10 -9
  119. package/src/editor/lib/TopBar.svelte +18 -17
  120. package/src/editor/lib/UnsavedChanges.svelte +7 -6
  121. package/src/editor/lib/VisualEditor.svelte +14 -13
  122. package/src/editor/lib/calendar.ts +10 -4
  123. package/src/editor/lib/embed-preview.ts +23 -5
  124. package/src/editor/lib/extension-preview.ts +121 -0
  125. package/src/editor/lib/github-auth.ts +23 -22
  126. package/src/editor/lib/github-backend.ts +96 -91
  127. package/src/editor/lib/icons.ts +4 -0
  128. package/src/editor/lib/images.ts +33 -31
  129. package/src/editor/lib/menu-tree.ts +5 -4
  130. package/src/editor/lib/preview-frame.ts +4 -3
  131. package/src/editor/lib/schema-edit.ts +3 -2
  132. package/src/editor/lib/settings.ts +5 -3
  133. package/src/editor/lib/slash-command.ts +1 -0
  134. package/src/editor/lib/tooltip.ts +1 -1
  135. package/src/editor/main.ts +9 -0
  136. package/src/editor/styles.css +6 -3
  137. package/src/env.d.ts +11 -1
  138. package/src/extensions.ts +160 -0
  139. package/src/manifest/collections.ts +6 -5
  140. package/src/manifest/menu.ts +12 -10
  141. package/src/manifest/navigation.ts +9 -8
  142. package/src/manifest/references.ts +8 -7
  143. package/src/manifest/seo.ts +4 -3
  144. package/src/manifest/types.ts +1 -0
  145. package/src/preview-urls.ts +6 -0
  146. package/src/preview.ts +38 -7
  147. package/src/routes/local-api.ts +5 -3
  148. package/src/routes/local-documents.ts +20 -16
  149. package/src/routes/local-media.ts +21 -16
  150. package/src/routes/local-menu.ts +9 -7
  151. package/src/routes/local-post.ts +18 -12
  152. package/src/routes/media-files.ts +10 -8
  153. package/src/routes/rigel-page.astro +2 -1
  154. package/src/schema/store.ts +22 -20
  155. package/src/schema/types.ts +1 -0
  156. package/src/schema/validate.ts +73 -69
  157. package/src/slugs.ts +12 -11
  158. package/src/vite/manifest-plugin.ts +33 -1
package/README.md CHANGED
@@ -1,18 +1,30 @@
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/)
24
+
25
+ ## Extensions
26
+
27
+ Register your own slash commands, toolbar buttons and saved article blocks with Svelte components. The [extension guide](docs/extensions.md) covers registration, editable blocks and published rendering.
16
28
 
17
29
  ## License
18
30
 
@@ -0,0 +1,194 @@
1
+ # Extensions
2
+
3
+ Extensions are developer-owned TypeScript modules selected in your Astro configuration. Rigel imports them at build time. Restart the dev server after changing the extension list.
4
+
5
+ Install `@astrojs/svelte` in your site to let `astro check` typecheck your Svelte components. Rigel already registers the Svelte compiler for the editor and extension modules.
6
+
7
+ ```ts
8
+ import { defineConfig } from 'astro/config';
9
+ import { rigel } from '@rigel-cms/rigel';
10
+
11
+ export default defineConfig({
12
+ integrations: [rigel({ extensions: ['./src/rigel/cta.ts'] })],
13
+ });
14
+ ```
15
+
16
+ ## Add a call-to-action block
17
+
18
+ Create `src/rigel/cta.ts`:
19
+
20
+ ```ts
21
+ import { defineExtension } from '@rigel-cms/rigel/extensions';
22
+ import CtaEditor from './CtaEditor.svelte';
23
+ import Cta from './Cta.svelte';
24
+ import CtaButton from './CtaButton.svelte';
25
+
26
+ export default defineExtension({
27
+ name: 'site-cta',
28
+ commands: [{
29
+ name: 'insert-cta',
30
+ label: 'Call to action',
31
+ slash: 'cta',
32
+ toolbar: true,
33
+ button: CtaButton,
34
+ run(context) {
35
+ context.insertBlock('cta', { label: 'Contact us', href: '/contact/' });
36
+ },
37
+ }],
38
+ blocks: [{
39
+ name: 'cta',
40
+ label: 'Call to action',
41
+ editor: CtaEditor,
42
+ render: Cta,
43
+ validate(data) {
44
+ if (typeof data.label !== 'string' || !data.label.trim()) {
45
+ throw new Error('Enter a button label.');
46
+ }
47
+ if (typeof data.href !== 'string' || !/^\/[a-z0-9/-]*$/.test(data.href)) {
48
+ throw new Error('Choose a local path such as /contact/.');
49
+ }
50
+ },
51
+ toMarkdown(data) {
52
+ const label = String(data.label).replace(/[\\[\]]/g, '\\$&');
53
+ return `[${label}](<${data.href}>)`;
54
+ },
55
+ }],
56
+ });
57
+ ```
58
+
59
+ Create `src/rigel/CtaEditor.svelte`:
60
+
61
+ ```svelte
62
+ <script lang="ts">
63
+ import type { ExtensionBlockProps } from '@rigel-cms/rigel/extensions';
64
+ let { data, onchange }: ExtensionBlockProps = $props();
65
+ </script>
66
+
67
+ <label>
68
+ Label
69
+ <input
70
+ value={String(data.label ?? '')}
71
+ oninput={(event) => onchange({ ...data, label: event.currentTarget.value })}
72
+ />
73
+ </label>
74
+ <label>
75
+ Local path
76
+ <input
77
+ value={String(data.href ?? '')}
78
+ oninput={(event) => onchange({ ...data, href: event.currentTarget.value })}
79
+ />
80
+ </label>
81
+ ```
82
+
83
+ Call `onchange` with the complete next data object rather than mutating `data`. Changes stay in the draft until the author clicks Save. Keep temporary input state inside your component if an incomplete value cannot pass your validator.
84
+
85
+ Create `src/rigel/Cta.svelte` for published HTML and read-only previews:
86
+
87
+ ```svelte
88
+ <script lang="ts">
89
+ import type { BlockData } from '@rigel-cms/rigel/extensions';
90
+ let { data }: { data: BlockData } = $props();
91
+ </script>
92
+
93
+ <aside class="cta">
94
+ <a href={String(data.href)}>{data.label}</a>
95
+ </aside>
96
+
97
+ <style>
98
+ .cta { padding: 1rem; border: 1px solid currentColor; }
99
+ </style>
100
+ ```
101
+
102
+ Create `src/rigel/CtaButton.svelte` to supply the command's toolbar button:
103
+
104
+ ```svelte
105
+ <script lang="ts">
106
+ import type { ExtensionButtonProps } from '@rigel-cms/rigel/extensions';
107
+ let { label, disabled, onclick }: ExtensionButtonProps = $props();
108
+ </script>
109
+
110
+ <button type="button" {disabled} {onclick} data-tooltip={label} aria-label={label} onpointerdown={(event) => event.preventDefault()}>
111
+ CTA
112
+ </button>
113
+ ```
114
+
115
+ Omit `button` to use Rigel's default toolbar button. `slash: 'cta'` exposes the same command as `/cta`. Names must be unique across the selected extensions. Commands can read `context.selection`, call `insertText(text)`, insert a registered block with `insertBlock(name, data)`, or replace a selected custom block's data with `updateBlock(data)`.
116
+
117
+ ## Group toolbar actions
118
+
119
+ Register a group and assign commands to it by name:
120
+
121
+ ```ts
122
+ export default defineExtension({
123
+ name: 'widgets',
124
+ groups: [{ name: 'widgets', label: 'Widgets', button: WidgetsButton }],
125
+ commands: [{
126
+ name: 'insert-callout',
127
+ label: 'Insert callout',
128
+ slash: 'callout',
129
+ group: 'widgets',
130
+ run: context => context.insertBlock('callout', {
131
+ title: 'Good to know', message: 'A note for readers.', tone: 'info',
132
+ }),
133
+ }],
134
+ blocks: [{
135
+ name: 'callout',
136
+ label: 'Callout',
137
+ editor: CalloutEditor,
138
+ render: Callout,
139
+ validate(data) {
140
+ if (typeof data.title !== 'string' || typeof data.message !== 'string' || data.tone !== 'info') {
141
+ throw new Error('Expected a title, message and info tone.');
142
+ }
143
+ },
144
+ }],
145
+ });
146
+ ```
147
+
148
+ The group occupies one toolbar button. Opening it shows only its assigned commands. A command's slash shortcut still works independently. Grouped commands appear in the toolbar automatically; set `toolbar: false` to keep a command out of the menu. Commands without a group keep their existing behavior.
149
+
150
+ A group's optional `button` uses the same `ExtensionButtonProps` as a command button. It can contain an SVG icon instead of text; keep its accessible label and call `onclick` to toggle the menu. Omit `button` for Rigel's default group trigger. Group names must be unique across registered modules. Commands can reference a group declared by another registered module, regardless of module order. An unknown group name is a configuration error. Empty groups do not create toolbar buttons.
151
+
152
+ The starter's `src/rigel/widgets.ts` is a complete second example: an icon-only Widgets group with callout and warning actions, an editable callout component, validation and a published renderer. The original CTA example remains a standalone button.
153
+
154
+ ## Render articles on your site
155
+
156
+ Use the public article entry in Astro pages:
157
+
158
+ ```astro
159
+ ---
160
+ import { renderArticle, articleMarkdown } from '@rigel-cms/rigel/article';
161
+ const { body } = Astro.props;
162
+ let head = '';
163
+ const html = renderArticle(body, { onHead: value => { head = value; } });
164
+ const markdown = articleMarkdown(body);
165
+ ---
166
+
167
+ <html lang="en">
168
+ <head><Fragment set:html={head} /></head>
169
+ <body><article data-rigel-richtext="$body" set:html={html} /></body>
170
+ </html>
171
+ ```
172
+
173
+ `renderArticle(body)` still returns an HTML string. It validates each registered custom block and server-renders its `render` component with Svelte. By default the returned string includes component head output, including CSS emitted by Svelte. Use `onHead` to put that output in your page's head instead. CSS extracted by the build remains the build tool's responsibility. Keep render components synchronous for this string-returning API.
174
+
175
+ `renderArticle(body, { preview: true })` returns escaped custom-block markers. Rigel sanitizes the article HTML first, then mounts the registered read-only render components. It does not run the editor component in a read-only preview.
176
+
177
+ `articleMarkdown(body)` calls a block's optional `toMarkdown`. Without one, it writes a fenced `rigel-extension` placeholder containing the block's name and complete JSON data. Unknown blocks also retain their data in Markdown. Published HTML throws for an unknown block rather than silently losing content. Keep the extension registered while articles still use it, or migrate those articles before removing it.
178
+
179
+ ## Data and trust
180
+
181
+ A custom block is a root-level article node with exactly this shape:
182
+
183
+ ```json
184
+ {
185
+ "type": "extension",
186
+ "version": 1,
187
+ "name": "cta",
188
+ "data": { "label": "Contact us", "href": "/contact/" }
189
+ }
190
+ ```
191
+
192
+ Custom blocks have no children. Data must be a bounded JSON object. Rigel checks that boundary and your block's `validate(data)` checks its schema and domain rules. Throw an error for invalid data. Validate URLs as URLs or restrict them to a known safe format; Svelte's attribute escaping does not make an unsafe URL safe.
193
+
194
+ Extension modules and Svelte components are trusted application code. Review them like any other dependency. Article content stores data and a registered name, never executable code or import locations. Rigel does not fetch modules, evaluate code from content, or install extensions at runtime. Use normal Svelte interpolation for content values; passing untrusted data to `{@html}` bypasses its escaping.
package/package.json CHANGED
@@ -1,11 +1,17 @@
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.1.0",
4
+ "description": "A CMS for Astro sites. You edit the text, Git keeps the history.",
5
+ "keywords": [
6
+ "astro",
7
+ "astro-integration",
8
+ "astro-component",
9
+ "withastro",
10
+ "cms",
11
+ "git-based-cms",
12
+ "svelte",
13
+ "typescript"
14
+ ],
9
15
  "author": {
10
16
  "name": "FreshJuice",
11
17
  "email": "contact@freshjuice.dev",
@@ -38,14 +44,16 @@
38
44
  ".": "./src/index.ts",
39
45
  "./content": "./src/content.ts",
40
46
  "./preview": "./src/preview.ts",
41
- "./article": "./src/article-render.ts",
47
+ "./article": "./src/article.ts",
48
+ "./extensions": "./src/extensions.ts",
42
49
  "./types": "./src/env.d.ts",
43
50
  "./routes/*": "./src/routes/*"
44
51
  },
45
52
  "files": [
46
53
  "src",
47
54
  "!src/**/*.test.ts",
48
- "README.md"
55
+ "README.md",
56
+ "docs/extensions.md"
49
57
  ],
50
58
  "peerDependencies": {
51
59
  "astro": "^7.3.0",
@@ -53,7 +61,6 @@
53
61
  "vite": "^8.0.0"
54
62
  },
55
63
  "devDependencies": {
56
-
57
64
  "@types/markdown-it": "^14.2.0",
58
65
  "@types/node": "^24.0.0",
59
66
  "astro": "^7.3.2",
@@ -1,6 +1,8 @@
1
1
  import type { SerializedEditorState } from "lexical";
2
+ import { validateBlockData, validateExtensionName } from "./extensions";
2
3
  import { safePreviewUrl } from "./preview-urls";
3
4
  import { safeEmbed } from "./editor/lib/embeds";
5
+ import { systemMessage, type Translator } from "./editor/i18n/format";
4
6
 
5
7
  export type ArticleBody = SerializedEditorState;
6
8
  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,56 +14,66 @@ export function emptyBody(): ArticleBody {
12
14
  return { root: { type: "root", version: 1, children: [paragraph], direction: null, format: "", indent: 0 } };
13
15
  }
14
16
 
15
- export function validateBody(value: unknown): asserts value is ArticleBody {
17
+ export function validateBody(value: unknown, message: Translator = systemMessage): asserts value is ArticleBody {
16
18
  const types = new Set(["root", "paragraph", "heading", "quote", "list", "listitem", "code", "text", "linebreak", "link", "image", "video", "embed", "divider", "table", "tablerow", "tablecell"]);
17
19
  let count = 0;
18
20
  let textSize = 0;
19
21
  function check(raw: unknown, parent = "", depth = 0) {
20
- if (++count > 10000 || depth > 32 || !raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid or oversized article block tree");
22
+ if (++count > 10000 || depth > 32 || !raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(message("validation.body.invalidTree"));
21
23
  const node = raw as ArticleNode;
22
- if (!types.has(node.type) || node.version !== 1 || (node.type === "root") !== !parent) throw new Error("Unsupported article block");
24
+ if (Object.hasOwn(node, "type") && node.type === "extension") {
25
+ const prototype = Object.getPrototypeOf(node);
26
+ if ((prototype !== Object.prototype && prototype !== null) || parent !== "root" || Reflect.ownKeys(node).length !== 4 || ["type", "version", "name", "data"].some(key => {
27
+ const descriptor = Object.getOwnPropertyDescriptor(node, key);
28
+ return !descriptor?.enumerable || !Object.hasOwn(descriptor, "value");
29
+ }) || node.version !== 1) throw new Error("rigel: invalid extension block envelope");
30
+ validateExtensionName(node.name);
31
+ validateBlockData(node.data);
32
+ return;
33
+ }
34
+ if (!types.has(node.type) || node.version !== 1 || (node.type === "root") !== !parent) throw new Error(message("validation.body.unsupportedBlock"));
23
35
  const inline = ["text", "linebreak", "link"];
24
- if (parent === "root" && (inline.includes(node.type) || ["listitem", "tablerow", "tablecell"].includes(node.type))) throw new Error("Expected an article block");
25
- if (node.type === "table" && parent !== "root") throw new Error("Nested tables are not supported");
26
- if (node.type === "tablerow" && parent !== "table" || node.type === "tablecell" && parent !== "tablerow") throw new Error("Invalid table structure");
27
- if (parent === "table" && node.type !== "tablerow" || parent === "tablerow" && node.type !== "tablecell" || parent === "tablecell" && node.type !== "paragraph") throw new Error("Invalid table content");
36
+ if (parent === "root" && (inline.includes(node.type) || ["listitem", "tablerow", "tablecell"].includes(node.type))) throw new Error(message("validation.body.expectedBlock"));
37
+ if (node.type === "table" && parent !== "root") throw new Error(message("validation.body.nestedTable"));
38
+ if (node.type === "tablerow" && parent !== "table" || node.type === "tablecell" && parent !== "tablerow") throw new Error(message("validation.body.tableStructure"));
39
+ if (parent === "table" && node.type !== "tablerow" || parent === "tablerow" && node.type !== "tablecell" || parent === "tablecell" && node.type !== "paragraph") throw new Error(message("validation.body.tableContent"));
28
40
  if (node.type === "tablecell") {
29
- if (![0, 1, 2, 3].includes(Number(node.headerState)) || typeof node.headerState !== "number" || (node.colSpan ?? 1) !== 1 || (node.rowSpan ?? 1) !== 1) throw new Error("Invalid table cell or unsupported merged cells");
30
- if (node.backgroundColor || node.verticalAlign) throw new Error("Custom table cell styles are not supported");
41
+ if (![0, 1, 2, 3].includes(Number(node.headerState)) || typeof node.headerState !== "number" || (node.colSpan ?? 1) !== 1 || (node.rowSpan ?? 1) !== 1) throw new Error(message("validation.body.tableCell"));
42
+ if (node.backgroundColor || node.verticalAlign) throw new Error(message("validation.body.tableCellStyles"));
31
43
  }
32
- for (const key of ["width", "height"]) if (node[key] !== undefined && (typeof node[key] !== "number" || !Number.isFinite(node[key]) || node[key] <= 0 || node[key] > 2000)) throw new Error("Invalid table dimensions");
33
- if (node.colWidths !== undefined && (!Array.isArray(node.colWidths) || node.colWidths.length > MAX_TABLE_COLUMNS || node.colWidths.some(width => typeof width !== "number" || !Number.isFinite(width) || width <= 0 || width > 2000))) throw new Error("Invalid table column widths");
34
- if (parent === "list" && node.type !== "listitem") throw new Error("Lists require list items");
35
- if (["paragraph", "heading", "link", "code"].includes(parent) && !inline.includes(node.type)) throw new Error("Expected inline article content");
36
- if (node.type === "heading" && !["h1", "h2", "h3", "h4", "h5", "h6"].includes(node.tag ?? "")) throw new Error("Use heading levels 1–6");
37
- if (node.type === "list" && !["bullet", "number"].includes(node.listType ?? "")) throw new Error("Unsupported list type");
38
- if (node.start !== undefined && (!Number.isInteger(node.start) || node.start < 1 || node.start > 1000000)) throw new Error("Invalid list start");
39
- if (node.type === "text" && (typeof node.text !== "string" || typeof node.format !== "number" || !Number.isInteger(node.format) || node.format < 0 || node.format > 255)) throw new Error("Invalid article text");
40
- if (node.type === "text" && (textSize += node.text!.length) > 1000000) throw new Error("Article text is too large");
41
- if (node.style || node.textStyle) throw new Error("Custom article styles are not supported");
42
- if (node.type === "image" && (typeof node.src !== "string" || typeof node.alt !== "string")) throw new Error("Images require a URL and alt text");
43
- if (node.type === "link" && typeof node.url !== "string") throw new Error("Links require a URL");
44
- if (node.type === "image" && !safePreviewUrl(node.src!, true)) throw new Error("Unsafe image URL");
45
- if (node.type === "image" && node.url !== undefined && (typeof node.url !== "string" || node.url && !safePreviewUrl(node.url))) throw new Error("Unsafe image link URL");
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");
53
- for (const key of ["credit", "creditURL"]) if (node[key] !== undefined && typeof node[key] !== "string") throw new Error("Invalid image credit");
54
- if (node.creditURL && !safePreviewUrl(node.creditURL)) throw new Error("Unsafe image credit URL");
44
+ for (const key of ["width", "height"]) if (node[key] !== undefined && (typeof node[key] !== "number" || !Number.isFinite(node[key]) || node[key] <= 0 || node[key] > 2000)) throw new Error(message("validation.body.tableDimensions"));
45
+ if (node.colWidths !== undefined && (!Array.isArray(node.colWidths) || node.colWidths.length > MAX_TABLE_COLUMNS || node.colWidths.some(width => typeof width !== "number" || !Number.isFinite(width) || width <= 0 || width > 2000))) throw new Error(message("validation.body.tableColumnWidths"));
46
+ if (parent === "list" && node.type !== "listitem") throw new Error(message("validation.body.listItems"));
47
+ if (["paragraph", "heading", "link", "code"].includes(parent) && !inline.includes(node.type)) throw new Error(message("validation.body.inlineContent"));
48
+ if (node.type === "heading" && !["h1", "h2", "h3", "h4", "h5", "h6"].includes(node.tag ?? "")) throw new Error(message("validation.body.headingLevel"));
49
+ if (node.type === "list" && !["bullet", "number"].includes(node.listType ?? "")) throw new Error(message("validation.body.listType"));
50
+ if (node.start !== undefined && (!Number.isInteger(node.start) || node.start < 1 || node.start > 1000000)) throw new Error(message("validation.body.listStart"));
51
+ if (node.type === "text" && (typeof node.text !== "string" || typeof node.format !== "number" || !Number.isInteger(node.format) || node.format < 0 || node.format > 255)) throw new Error(message("validation.body.invalidText"));
52
+ if (node.type === "text" && (textSize += node.text!.length) > 1000000) throw new Error(message("validation.body.textSize"));
53
+ if (node.style || node.textStyle) throw new Error(message("validation.body.customStyles"));
54
+ if (node.type === "image" && (typeof node.src !== "string" || typeof node.alt !== "string")) throw new Error(message("validation.body.imageRequired"));
55
+ if (node.type === "link" && typeof node.url !== "string") throw new Error(message("validation.body.linkRequired"));
56
+ if (node.type === "image" && !safePreviewUrl(node.src!, true)) throw new Error(message("validation.body.imageUrl"));
57
+ if (node.type === "image" && node.url !== undefined && (typeof node.url !== "string" || node.url && !safePreviewUrl(node.url))) throw new Error(message("validation.body.imageLinkUrl"));
58
+ if (node.type === "link" && !safePreviewUrl(node.url!)) throw new Error(message("validation.body.linkUrl"));
59
+ if (node.type === "video" && (typeof node.src !== "string" || !safePreviewUrl(node.src, true))) throw new Error(message("validation.body.videoUrl"));
60
+ if (node.type === "video" && node.poster !== undefined && (typeof node.poster !== "string" || !safePreviewUrl(node.poster, true))) throw new Error(message("validation.body.videoPosterUrl"));
61
+ if (node.type === "video" && node.caption !== undefined && typeof node.caption !== "string") throw new Error(message("validation.body.videoCaption"));
62
+ if (node.type === "video") for (const key of ["muted", "controls"]) if (node[key] !== undefined && typeof node[key] !== "boolean") throw new Error(message("validation.body.videoPlayback"));
63
+ if (node.type === "embed" && (!safeEmbed(node) || typeof node.privacy !== "boolean")) throw new Error(message("validation.body.videoEmbed"));
64
+ if (node.type === "embed" && node.title !== undefined && typeof node.title !== "string") throw new Error(message("validation.body.embedTitle"));
65
+ for (const key of ["credit", "creditURL"]) if (node[key] !== undefined && typeof node[key] !== "string") throw new Error(message("validation.body.imageCredit"));
66
+ if (node.creditURL && !safePreviewUrl(node.creditURL)) throw new Error(message("validation.body.imageCreditUrl"));
55
67
  if (!["text", "linebreak", "image", "video", "embed", "divider"].includes(node.type)) {
56
- if (!Array.isArray(node.children)) throw new Error("Article block requires children");
68
+ if (!Array.isArray(node.children)) throw new Error(message("validation.body.childrenRequired"));
57
69
  for (const child of node.children) check(child, node.type, depth + 1);
58
- if (["table", "tablerow", "tablecell"].includes(node.type) && !node.children.length) throw new Error("Table elements cannot be empty");
70
+ if (["table", "tablerow", "tablecell"].includes(node.type) && !node.children.length) throw new Error(message("validation.body.emptyTable"));
59
71
  if (node.type === "table") {
60
72
  const columns = node.children[0]?.children?.length ?? 0;
61
- if (node.children.length > MAX_TABLE_ROWS || columns > MAX_TABLE_COLUMNS || node.children.some(row => row.children?.length !== columns)) throw new Error("Table must be rectangular and at most 50 rows by 20 columns");
73
+ if (node.children.length > MAX_TABLE_ROWS || columns > MAX_TABLE_COLUMNS || node.children.some(row => row.children?.length !== columns)) throw new Error(message("validation.body.tableSize"));
62
74
  }
63
- } else if (node.children !== undefined) throw new Error("Unexpected article children");
75
+ } else if (node.children !== undefined) throw new Error(message("validation.body.unexpectedChildren"));
64
76
  }
65
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected a Lexical article body");
77
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(message("validation.body.expectedBody"));
66
78
  check((value as ArticleBody).root);
67
79
  }
@@ -11,13 +11,29 @@ function renderText(node: ArticleNode): string {
11
11
  return text;
12
12
  }
13
13
 
14
- export function renderArticle(body: ArticleBody, options: { preview?: boolean } = {}): string {
14
+ export interface ArticleRenderOptions {
15
+ preview?: boolean;
16
+ renderExtension?: (name: string, data: import("./extensions").BlockData) => string;
17
+ }
18
+
19
+ export interface ArticleMarkdownOptions {
20
+ extensionMarkdown?: (name: string, data: import("./extensions").BlockData) => string | undefined;
21
+ }
22
+
23
+ export function renderArticle(body: ArticleBody, options: ArticleRenderOptions = {}): string {
15
24
  validateBody(body);
16
25
  function walk(node: ArticleNode): string {
17
26
  const content = () => (node.children ?? []).map(walk).join("");
18
27
  if (node.type === "text") return renderText(node);
19
28
  if (node.type === "linebreak") return "<br>";
20
29
  if (node.type === "root") return content();
30
+ if (node.type === "extension") {
31
+ const name = node.name as string;
32
+ const data = node.data as import("./extensions").BlockData;
33
+ if (options.preview) return `<figure data-rigel-extension="${escape(name)}" data-rigel-extension-data="${escape(JSON.stringify(data))}"></figure>`;
34
+ if (!options.renderExtension) throw new Error(`No renderer registered for extension block "${name}"`);
35
+ return options.renderExtension(name, data);
36
+ }
21
37
  if (node.type === "paragraph") return `<p>${content()}</p>`;
22
38
  if (node.type === "heading") return `<${node.tag}>${content()}</${node.tag}>`;
23
39
  if (node.type === "quote") return `<blockquote>${content()}</blockquote>`;
@@ -62,11 +78,18 @@ function plain(node: ArticleNode): string {
62
78
  return node.type === "text" ? node.text ?? "" : node.type === "linebreak" ? "\n" : (node.children ?? []).map(plain).join("");
63
79
  }
64
80
 
65
- export function articleMarkdown(body: ArticleBody): string {
81
+ export function articleMarkdown(body: ArticleBody, options: ArticleMarkdownOptions = {}): string {
66
82
  validateBody(body);
67
83
  const escaped = (text: string) => text.replace(/[\\`*_[\]<>#~]/g, "\\$&");
68
84
  function walk(node: ArticleNode, depth = 0): string {
69
85
  const content = () => (node.children ?? []).map(child => walk(child, depth)).join("");
86
+ if (node.type === "extension") {
87
+ const markdown = options.extensionMarkdown?.(node.name as string, node.data as import("./extensions").BlockData);
88
+ if (markdown !== undefined) return markdown + "\n\n";
89
+ const serialized = JSON.stringify({ type: "extension", version: 1, name: node.name, data: node.data });
90
+ const fence = "`".repeat(Math.max(3, ...(serialized.match(/`+/g) ?? []).map(run => run.length + 1)));
91
+ return `${fence}rigel-extension\n${serialized}\n${fence}\n\n`;
92
+ }
70
93
  if (node.type === "text") {
71
94
  let text = escaped(node.text ?? "");
72
95
  const format = Number(node.format);
package/src/article.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { render } from "svelte/server";
2
+ import extensions from "virtual:rigel-extensions";
3
+ import { resolveExtensions, type BlockData } from "./extensions";
4
+ import { renderArticle as renderBody, articleMarkdown as markdownBody, type ArticleRenderOptions, type ArticleMarkdownOptions } from "./article-render";
5
+ import type { ArticleBody } from "./article-body";
6
+
7
+ export type { ArticleRenderOptions, ArticleMarkdownOptions } from "./article-render";
8
+
9
+ const registry = resolveExtensions(extensions);
10
+
11
+ function blockFor(name: string, data: BlockData) {
12
+ const block = registry.blocks.find(block => block.name === name);
13
+ if (!block) throw new Error(`No renderer registered for extension block "${name}"`);
14
+ block.validate(data);
15
+ return block;
16
+ }
17
+
18
+ export function renderArticle(body: ArticleBody, options: ArticleRenderOptions & { onHead?: (head: string) => void } = {}): string {
19
+ const head = new Set<string>();
20
+ let index = 0;
21
+ const html = renderBody(body, {
22
+ ...options,
23
+ renderExtension: (name, data) => {
24
+ const block = blockFor(name, data);
25
+ if (options.renderExtension) return options.renderExtension(name, data);
26
+ const output = render(block.render, { props: { data }, idPrefix: `rigel-extension-${index++}-` });
27
+ if (output.head) head.add(output.head);
28
+ return output.body;
29
+ },
30
+ });
31
+ const renderedHead = [...head].join("");
32
+ if (options.onHead) { options.onHead(renderedHead); return html; }
33
+ return renderedHead + html;
34
+ }
35
+
36
+ export function articleMarkdown(body: ArticleBody, options: ArticleMarkdownOptions = {}): string {
37
+ return markdownBody(body, {
38
+ extensionMarkdown: (name, data) => {
39
+ const block = registry.blocks.find(block => block.name === name);
40
+ block?.validate(data);
41
+ return options.extensionMarkdown ? options.extensionMarkdown(name, data) : block?.toMarkdown?.(data);
42
+ },
43
+ });
44
+ }
package/src/config.ts CHANGED
@@ -3,9 +3,11 @@ 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
+ import { systemMessage } from "./editor/i18n/format";
10
+ import { localeNames, type Locale } from "./editor/i18n/locales";
9
11
 
10
12
  export interface RigelConfig {
11
13
  copyDir: string;
@@ -15,6 +17,8 @@ export interface RigelConfig {
15
17
  backend: "local" | "github";
16
18
  jsonOutput?: JsonOutput;
17
19
  demo?: boolean;
20
+ languages?: Locale[];
21
+ extensions?: string[];
18
22
  collections?: CollectionConfig[];
19
23
  menus?: { folder: string; maxDepth?: number };
20
24
  schema?: Schema;
@@ -44,6 +48,12 @@ const DEFAULTS: Pick<RigelConfig, "copyDir" | "backend"> = {
44
48
 
45
49
  export function parseConfig(options: Partial<RigelConfig> = {}): RigelConfig {
46
50
  const cfg = { ...DEFAULTS, ...options };
51
+ if (cfg.extensions === undefined) cfg.extensions = [];
52
+ if (!Array.isArray(cfg.extensions) || Array.from(cfg.extensions).some(module => typeof module !== "string" || !module.startsWith("./") || !module.slice(2).split("/").every(part => /^[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$/.test(part) && part !== "node_modules" && part !== "." && part !== "..")) || new Set(cfg.extensions).size !== cfg.extensions.length) throw new Error("rigel: extensions must be unique explicit ./module paths inside the site project");
53
+ cfg.extensions = [...cfg.extensions];
54
+ if (cfg.languages === undefined) cfg.languages = ["en", "fr"];
55
+ if (!Array.isArray(cfg.languages) || Array.from(cfg.languages).some(language => typeof language !== "string" || !Object.hasOwn(localeNames, language))) throw new Error("rigel: languages must be an array of supported locale codes");
56
+ cfg.languages = [...new Set<Locale>(["en", ...cfg.languages])];
47
57
  if (cfg.maxFileSize === undefined) cfg.maxFileSize = DEFAULT_MAX_FILE_SIZE;
48
58
  if (!Number.isSafeInteger(cfg.maxFileSize) || cfg.maxFileSize <= 0) throw new Error("rigel: maxFileSize must be a positive integer in bytes");
49
59
  if (cfg.jsonOutput === undefined) cfg.jsonOutput = "pretty";
@@ -53,6 +63,11 @@ export function parseConfig(options: Partial<RigelConfig> = {}): RigelConfig {
53
63
  if (cfg.preview) {
54
64
  cfg.preview = { ...cfg.preview };
55
65
  for (const key of ["liveUrl", "draftUrl"] as const) if (cfg.preview[key] !== undefined) cfg.preview[key] = previewBaseUrl(cfg.preview[key]);
66
+ for (const mappings of [cfg.preview.pages, cfg.preview.documents, cfg.preview.collections]) {
67
+ for (const mapping of Object.values(mappings ?? {})) {
68
+ 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");
69
+ }
70
+ }
56
71
  }
57
72
  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
73
  if (cfg.backend === "github") {
@@ -87,8 +102,8 @@ export function parseConfig(options: Partial<RigelConfig> = {}): RigelConfig {
87
102
  const schema = cfg.demo ? declared : validateSchema({ ...declared, documents: declared.documents.filter(type => !type.demo) });
88
103
  cfg.schema = { ...schema, documents: schema.documents.map(type => {
89
104
  if (type.shared) return type;
90
- const fields = withSeoFields(type.fields.map(field => field.key === "title" && field.label === "Title" ? { ...field, label: "Page name" } : field));
91
- if (!fields.some(field => field.key === "draft")) fields.push({ key: "draft", label: "Draft", type: "boolean" });
105
+ const fields = withSeoFields(type.fields);
106
+ if (!fields.some(field => field.key === "draft")) fields.push({ key: "draft", label: systemMessage("defaults.draft"), labelKey: "defaults.draft", type: "boolean" });
92
107
  return { ...type, fields };
93
108
  }) };
94
109
  }