@rigel-cms/rigel 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/docs/content-locales.md +53 -0
- package/docs/extensions.md +194 -0
- package/package.json +16 -3
- package/src/article-body.ts +49 -37
- package/src/article-render.ts +25 -2
- package/src/article.ts +44 -0
- package/src/config.ts +26 -2
- package/src/editor/App.svelte +149 -107
- package/src/editor/blocks/extension.ts +29 -0
- package/src/editor/blocks/index.ts +17 -15
- package/src/editor/blocks/video.ts +2 -1
- package/src/editor/i18n/README.md +23 -0
- package/src/editor/i18n/de/connection.json +136 -0
- package/src/editor/i18n/de/defaults.json +6 -0
- package/src/editor/i18n/de/fields.json +130 -0
- package/src/editor/i18n/de/local.json +49 -0
- package/src/editor/i18n/de/media.json +166 -0
- package/src/editor/i18n/de/richtext.json +116 -0
- package/src/editor/i18n/de/shell.json +182 -0
- package/src/editor/i18n/de/system.json +14 -0
- package/src/editor/i18n/de/validation.json +82 -0
- package/src/editor/i18n/en/connection.json +136 -0
- package/src/editor/i18n/en/defaults.json +6 -0
- package/src/editor/i18n/en/fields.json +130 -0
- package/src/editor/i18n/en/local.json +49 -0
- package/src/editor/i18n/en/media.json +166 -0
- package/src/editor/i18n/en/richtext.json +116 -0
- package/src/editor/i18n/en/shell.json +182 -0
- package/src/editor/i18n/en/system.json +14 -0
- package/src/editor/i18n/en/validation.json +82 -0
- package/src/editor/i18n/es/connection.json +136 -0
- package/src/editor/i18n/es/defaults.json +6 -0
- package/src/editor/i18n/es/fields.json +130 -0
- package/src/editor/i18n/es/local.json +49 -0
- package/src/editor/i18n/es/media.json +166 -0
- package/src/editor/i18n/es/richtext.json +116 -0
- package/src/editor/i18n/es/shell.json +182 -0
- package/src/editor/i18n/es/system.json +14 -0
- package/src/editor/i18n/es/validation.json +82 -0
- package/src/editor/i18n/format.ts +26 -0
- package/src/editor/i18n/fr/connection.json +136 -0
- package/src/editor/i18n/fr/defaults.json +6 -0
- package/src/editor/i18n/fr/fields.json +130 -0
- package/src/editor/i18n/fr/local.json +49 -0
- package/src/editor/i18n/fr/media.json +166 -0
- package/src/editor/i18n/fr/richtext.json +116 -0
- package/src/editor/i18n/fr/shell.json +182 -0
- package/src/editor/i18n/fr/system.json +14 -0
- package/src/editor/i18n/fr/validation.json +82 -0
- package/src/editor/i18n/index.ts +18 -0
- package/src/editor/i18n/it/connection.json +136 -0
- package/src/editor/i18n/it/defaults.json +6 -0
- package/src/editor/i18n/it/fields.json +130 -0
- package/src/editor/i18n/it/local.json +49 -0
- package/src/editor/i18n/it/media.json +166 -0
- package/src/editor/i18n/it/richtext.json +116 -0
- package/src/editor/i18n/it/shell.json +182 -0
- package/src/editor/i18n/it/system.json +14 -0
- package/src/editor/i18n/it/validation.json +82 -0
- package/src/editor/i18n/ja/connection.json +136 -0
- package/src/editor/i18n/ja/defaults.json +6 -0
- package/src/editor/i18n/ja/fields.json +130 -0
- package/src/editor/i18n/ja/local.json +49 -0
- package/src/editor/i18n/ja/media.json +166 -0
- package/src/editor/i18n/ja/richtext.json +116 -0
- package/src/editor/i18n/ja/shell.json +182 -0
- package/src/editor/i18n/ja/system.json +14 -0
- package/src/editor/i18n/ja/validation.json +82 -0
- package/src/editor/i18n/locales.ts +13 -0
- package/src/editor/i18n/nl/connection.json +136 -0
- package/src/editor/i18n/nl/defaults.json +6 -0
- package/src/editor/i18n/nl/fields.json +130 -0
- package/src/editor/i18n/nl/local.json +49 -0
- package/src/editor/i18n/nl/media.json +166 -0
- package/src/editor/i18n/nl/richtext.json +116 -0
- package/src/editor/i18n/nl/shell.json +182 -0
- package/src/editor/i18n/nl/system.json +14 -0
- package/src/editor/i18n/nl/validation.json +82 -0
- package/src/editor/i18n/ru/connection.json +138 -0
- package/src/editor/i18n/ru/defaults.json +6 -0
- package/src/editor/i18n/ru/fields.json +132 -0
- package/src/editor/i18n/ru/local.json +49 -0
- package/src/editor/i18n/ru/media.json +166 -0
- package/src/editor/i18n/ru/richtext.json +116 -0
- package/src/editor/i18n/ru/shell.json +182 -0
- package/src/editor/i18n/ru/system.json +14 -0
- package/src/editor/i18n/ru/validation.json +82 -0
- package/src/editor/i18n/server.ts +13 -0
- package/src/editor/i18n/zh/connection.json +136 -0
- package/src/editor/i18n/zh/defaults.json +6 -0
- package/src/editor/i18n/zh/fields.json +130 -0
- package/src/editor/i18n/zh/local.json +49 -0
- package/src/editor/i18n/zh/media.json +166 -0
- package/src/editor/i18n/zh/richtext.json +116 -0
- package/src/editor/i18n/zh/shell.json +182 -0
- package/src/editor/i18n/zh/system.json +14 -0
- package/src/editor/i18n/zh/validation.json +82 -0
- package/src/editor/lib/ActionDialog.svelte +5 -4
- package/src/editor/lib/BlockEditor.svelte +305 -88
- package/src/editor/lib/CollectionPanel.svelte +17 -15
- package/src/editor/lib/DatePicker.svelte +15 -12
- package/src/editor/lib/ExtensionMount.svelte +12 -0
- package/src/editor/lib/Field.svelte +3 -2
- package/src/editor/lib/ImagePicker.svelte +82 -81
- package/src/editor/lib/MarkdownEditor.svelte +25 -24
- package/src/editor/lib/MediaPicker.svelte +6 -5
- package/src/editor/lib/MenuEditor.svelte +31 -30
- package/src/editor/lib/PreviewFrame.svelte +11 -6
- package/src/editor/lib/PropertiesTabs.svelte +3 -2
- package/src/editor/lib/PublishDialog.svelte +15 -14
- package/src/editor/lib/ReferenceSelect.svelte +3 -2
- package/src/editor/lib/SchemaField.svelte +15 -13
- package/src/editor/lib/SchemaPanel.svelte +20 -19
- package/src/editor/lib/Select.svelte +5 -4
- package/src/editor/lib/SeoGuidance.svelte +6 -5
- package/src/editor/lib/Settings.svelte +54 -54
- package/src/editor/lib/Shortcuts.svelte +7 -6
- package/src/editor/lib/SitePreview.svelte +10 -9
- package/src/editor/lib/TopBar.svelte +18 -17
- package/src/editor/lib/UnsavedChanges.svelte +7 -6
- package/src/editor/lib/VisualEditor.svelte +19 -18
- package/src/editor/lib/calendar.ts +10 -4
- package/src/editor/lib/embed-preview.ts +23 -5
- package/src/editor/lib/extension-preview.ts +121 -0
- package/src/editor/lib/github-auth.ts +23 -22
- package/src/editor/lib/github-backend.ts +96 -91
- package/src/editor/lib/images.ts +33 -31
- package/src/editor/lib/menu-tree.ts +5 -4
- package/src/editor/lib/preview-frame.ts +4 -3
- package/src/editor/lib/schema-edit.ts +3 -2
- package/src/editor/lib/settings.ts +5 -3
- package/src/editor/lib/slash-command.ts +1 -0
- package/src/editor/lib/tooltip.ts +1 -1
- package/src/editor/main.ts +9 -0
- package/src/editor/styles.css +15 -3
- package/src/env.d.ts +11 -1
- package/src/extensions.ts +160 -0
- package/src/index.ts +1 -1
- package/src/manifest/collections.ts +6 -5
- package/src/manifest/content-locales.ts +45 -0
- package/src/manifest/menu.ts +12 -10
- package/src/manifest/navigation.ts +13 -8
- package/src/manifest/references.ts +8 -7
- package/src/manifest/seo.ts +4 -3
- package/src/manifest/types.ts +4 -0
- package/src/preview.ts +36 -2
- package/src/routes/local-api.ts +5 -3
- package/src/routes/local-documents.ts +20 -16
- package/src/routes/local-media.ts +21 -16
- package/src/routes/local-menu.ts +9 -7
- package/src/routes/local-post.ts +18 -12
- package/src/routes/media-files.ts +10 -8
- package/src/routes/rigel-page.astro +2 -1
- package/src/schema/store.ts +22 -20
- package/src/schema/types.ts +2 -0
- package/src/schema/validate.ts +73 -69
- package/src/slugs.ts +11 -10
- package/src/vite/manifest-plugin.ts +38 -2
package/README.md
CHANGED
|
@@ -22,6 +22,14 @@ Use the [Astro starter](https://github.com/rigel-cms/rigel-starter), or follow t
|
|
|
22
22
|
|
|
23
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
24
|
|
|
25
|
+
## Content languages
|
|
26
|
+
|
|
27
|
+
Declare language-specific page types and article collections, then use an optional content-language selector in the editor. See [Content languages](docs/content-locales.md) for configuration. Website routing stays in your Astro templates.
|
|
28
|
+
|
|
29
|
+
## Extensions
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
25
33
|
## License
|
|
26
34
|
|
|
27
35
|
[Apache-2.0](LICENSE). Copyright 2026 [Alex Zappa](https://alex.zappa.dev/) ([FreshJuice](https://freshjuice.dev/)).
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Content languages
|
|
2
|
+
|
|
3
|
+
Content languages are optional. Configure them separately from `languages`, which selects the editor's interface translations.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
rigel({
|
|
7
|
+
contentLocales: {
|
|
8
|
+
defaultLocale: "en",
|
|
9
|
+
locales: [
|
|
10
|
+
{ code: "en", label: "English" },
|
|
11
|
+
{ code: "fr", label: "Français" },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
schema,
|
|
15
|
+
collections: [
|
|
16
|
+
{ name: "blogEn", label: "Blog", locale: "en", folder: "src/content/blog/en", fields },
|
|
17
|
+
{ name: "blogFr", label: "Articles", locale: "fr", folder: "src/content/blog/fr", fields },
|
|
18
|
+
],
|
|
19
|
+
preview: {
|
|
20
|
+
documents: {
|
|
21
|
+
pageEn: { url: "/en/{slug}/" },
|
|
22
|
+
pageFr: { url: "/fr/{slug}/" },
|
|
23
|
+
},
|
|
24
|
+
collections: {
|
|
25
|
+
blogEn: { url: "/en/blog/{slug}/", new: "/preview/blog-en/" },
|
|
26
|
+
blogFr: { url: "/fr/blog/{slug}/", new: "/preview/blog-fr/" },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Here `schema` and `fields` are your existing document schema and article fields. Add `locale` to each document type that belongs to one content language:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
defineDocument({
|
|
36
|
+
name: "pageFr",
|
|
37
|
+
title: "Pages françaises",
|
|
38
|
+
type: "document",
|
|
39
|
+
locale: "fr",
|
|
40
|
+
folder: "src/content/pages/fr",
|
|
41
|
+
fields: pageFields,
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Each type can have different fields, allowed sections and initial content. You can also annotate an existing `defineCopy` page with `locale`. Rigel reads the annotation, not the folder name.
|
|
46
|
+
|
|
47
|
+
With multiple configured locales, the editor shows a Content language dropdown. It filters navigation, page lists, collections and search. Switching away from unsaved changes offers the existing Save, Discard and Keep editing choices. New sessions start with `defaultLocale`.
|
|
48
|
+
|
|
49
|
+
Content without a `locale` remains visible in every language. Use this for common authors, media and other shared records. A shared document type can also have a locale when its records are language-specific. The language filter is an editing convenience, not an access-control boundary: the manifest and publishing batch still contain the site's complete content.
|
|
50
|
+
|
|
51
|
+
Without `contentLocales`, the editor keeps its existing navigation and shows no content-language dropdown. A single configured locale also needs no dropdown.
|
|
52
|
+
|
|
53
|
+
Your Astro routes and templates still own website localization, including public language links, HTML `lang`, translated navigation and fallback behavior. Rigel does not translate text, pair translated entries or generate localized routes. Keep each type's URL mapping consistent with the website routes, and prepare its preview bindings as usual.
|
|
@@ -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,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigel-cms/rigel",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
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
|
+
],
|
|
5
15
|
"author": {
|
|
6
16
|
"name": "FreshJuice",
|
|
7
17
|
"email": "contact@freshjuice.dev",
|
|
@@ -34,14 +44,17 @@
|
|
|
34
44
|
".": "./src/index.ts",
|
|
35
45
|
"./content": "./src/content.ts",
|
|
36
46
|
"./preview": "./src/preview.ts",
|
|
37
|
-
"./article": "./src/article
|
|
47
|
+
"./article": "./src/article.ts",
|
|
48
|
+
"./extensions": "./src/extensions.ts",
|
|
38
49
|
"./types": "./src/env.d.ts",
|
|
39
50
|
"./routes/*": "./src/routes/*"
|
|
40
51
|
},
|
|
41
52
|
"files": [
|
|
42
53
|
"src",
|
|
43
54
|
"!src/**/*.test.ts",
|
|
44
|
-
"README.md"
|
|
55
|
+
"README.md",
|
|
56
|
+
"docs/extensions.md",
|
|
57
|
+
"docs/content-locales.md"
|
|
45
58
|
],
|
|
46
59
|
"peerDependencies": {
|
|
47
60
|
"astro": "^7.3.0",
|
package/src/article-body.ts
CHANGED
|
@@ -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("
|
|
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 (
|
|
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("
|
|
25
|
-
if (node.type === "table" && parent !== "root") throw new Error("
|
|
26
|
-
if (node.type === "tablerow" && parent !== "table" || node.type === "tablecell" && parent !== "tablerow") throw new Error("
|
|
27
|
-
if (parent === "table" && node.type !== "tablerow" || parent === "tablerow" && node.type !== "tablecell" || parent === "tablecell" && node.type !== "paragraph") throw new Error("
|
|
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("
|
|
30
|
-
if (node.backgroundColor || node.verticalAlign) throw new Error("
|
|
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("
|
|
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("
|
|
34
|
-
if (parent === "list" && node.type !== "listitem") throw new Error("
|
|
35
|
-
if (["paragraph", "heading", "link", "code"].includes(parent) && !inline.includes(node.type)) throw new Error("
|
|
36
|
-
if (node.type === "heading" && !["h1", "h2", "h3", "h4", "h5", "h6"].includes(node.tag ?? "")) throw new Error("
|
|
37
|
-
if (node.type === "list" && !["bullet", "number"].includes(node.listType ?? "")) throw new Error("
|
|
38
|
-
if (node.start !== undefined && (!Number.isInteger(node.start) || node.start < 1 || node.start > 1000000)) throw new Error("
|
|
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("
|
|
40
|
-
if (node.type === "text" && (textSize += node.text!.length) > 1000000) throw new Error("
|
|
41
|
-
if (node.style || node.textStyle) throw new Error("
|
|
42
|
-
if (node.type === "image" && (typeof node.src !== "string" || typeof node.alt !== "string")) throw new Error("
|
|
43
|
-
if (node.type === "link" && typeof node.url !== "string") throw new Error("
|
|
44
|
-
if (node.type === "image" && !safePreviewUrl(node.src!, true)) throw new Error("
|
|
45
|
-
if (node.type === "image" && node.url !== undefined && (typeof node.url !== "string" || node.url && !safePreviewUrl(node.url))) throw new Error("
|
|
46
|
-
if (node.type === "link" && !safePreviewUrl(node.url!)) throw new Error("
|
|
47
|
-
if (node.type === "video" && (typeof node.src !== "string" || !safePreviewUrl(node.src, true))) throw new Error("
|
|
48
|
-
if (node.type === "video" && node.poster !== undefined && (typeof node.poster !== "string" || !safePreviewUrl(node.poster, true))) throw new Error("
|
|
49
|
-
if (node.type === "video" && node.caption !== undefined && typeof node.caption !== "string") throw new Error("
|
|
50
|
-
if (node.type === "video") for (const key of ["muted", "controls"]) if (node[key] !== undefined && typeof node[key] !== "boolean") throw new Error("
|
|
51
|
-
if (node.type === "embed" && (!safeEmbed(node) || typeof node.privacy !== "boolean")) throw new Error("
|
|
52
|
-
if (node.type === "embed" && node.title !== undefined && typeof node.title !== "string") throw new Error("
|
|
53
|
-
for (const key of ["credit", "creditURL"]) if (node[key] !== undefined && typeof node[key] !== "string") throw new Error("
|
|
54
|
-
if (node.creditURL && !safePreviewUrl(node.creditURL)) throw new Error("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
}
|
package/src/article-render.ts
CHANGED
|
@@ -11,13 +11,29 @@ function renderText(node: ArticleNode): string {
|
|
|
11
11
|
return text;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export
|
|
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
|
@@ -6,6 +6,14 @@ import { withSeoFields } from "./manifest/seo";
|
|
|
6
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";
|
|
11
|
+
import { validateContentLocales, validateContentLocale } from "./manifest/content-locales";
|
|
12
|
+
|
|
13
|
+
export interface ContentLocalesConfig {
|
|
14
|
+
defaultLocale: string;
|
|
15
|
+
locales: { code: string; label: string }[];
|
|
16
|
+
}
|
|
9
17
|
|
|
10
18
|
export interface RigelConfig {
|
|
11
19
|
copyDir: string;
|
|
@@ -15,6 +23,9 @@ export interface RigelConfig {
|
|
|
15
23
|
backend: "local" | "github";
|
|
16
24
|
jsonOutput?: JsonOutput;
|
|
17
25
|
demo?: boolean;
|
|
26
|
+
languages?: Locale[];
|
|
27
|
+
contentLocales?: ContentLocalesConfig;
|
|
28
|
+
extensions?: string[];
|
|
18
29
|
collections?: CollectionConfig[];
|
|
19
30
|
menus?: { folder: string; maxDepth?: number };
|
|
20
31
|
schema?: Schema;
|
|
@@ -30,6 +41,7 @@ export interface RigelConfig {
|
|
|
30
41
|
|
|
31
42
|
export interface CollectionConfig {
|
|
32
43
|
name: string;
|
|
44
|
+
locale?: string;
|
|
33
45
|
label?: string;
|
|
34
46
|
listTitle?: string;
|
|
35
47
|
folder: string;
|
|
@@ -44,6 +56,18 @@ const DEFAULTS: Pick<RigelConfig, "copyDir" | "backend"> = {
|
|
|
44
56
|
|
|
45
57
|
export function parseConfig(options: Partial<RigelConfig> = {}): RigelConfig {
|
|
46
58
|
const cfg = { ...DEFAULTS, ...options };
|
|
59
|
+
if (cfg.contentLocales !== undefined) {
|
|
60
|
+
validateContentLocales(cfg.contentLocales);
|
|
61
|
+
cfg.contentLocales = { ...cfg.contentLocales, locales: cfg.contentLocales.locales.map(locale => ({ ...locale })) };
|
|
62
|
+
}
|
|
63
|
+
for (const type of cfg.schema?.documents ?? []) validateContentLocale(type.locale, cfg.contentLocales, type.name);
|
|
64
|
+
for (const collection of cfg.collections ?? []) validateContentLocale(collection.locale, cfg.contentLocales, collection.name);
|
|
65
|
+
if (cfg.extensions === undefined) cfg.extensions = [];
|
|
66
|
+
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");
|
|
67
|
+
cfg.extensions = [...cfg.extensions];
|
|
68
|
+
if (cfg.languages === undefined) cfg.languages = ["en", "fr"];
|
|
69
|
+
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");
|
|
70
|
+
cfg.languages = [...new Set<Locale>(["en", ...cfg.languages])];
|
|
47
71
|
if (cfg.maxFileSize === undefined) cfg.maxFileSize = DEFAULT_MAX_FILE_SIZE;
|
|
48
72
|
if (!Number.isSafeInteger(cfg.maxFileSize) || cfg.maxFileSize <= 0) throw new Error("rigel: maxFileSize must be a positive integer in bytes");
|
|
49
73
|
if (cfg.jsonOutput === undefined) cfg.jsonOutput = "pretty";
|
|
@@ -92,8 +116,8 @@ export function parseConfig(options: Partial<RigelConfig> = {}): RigelConfig {
|
|
|
92
116
|
const schema = cfg.demo ? declared : validateSchema({ ...declared, documents: declared.documents.filter(type => !type.demo) });
|
|
93
117
|
cfg.schema = { ...schema, documents: schema.documents.map(type => {
|
|
94
118
|
if (type.shared) return type;
|
|
95
|
-
const fields = withSeoFields(type.fields
|
|
96
|
-
if (!fields.some(field => field.key === "draft")) fields.push({ key: "draft", label: "
|
|
119
|
+
const fields = withSeoFields(type.fields);
|
|
120
|
+
if (!fields.some(field => field.key === "draft")) fields.push({ key: "draft", label: systemMessage("defaults.draft"), labelKey: "defaults.draft", type: "boolean" });
|
|
97
121
|
return { ...type, fields };
|
|
98
122
|
}) };
|
|
99
123
|
}
|