@rigel-cms/rigel 1.1.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 CHANGED
@@ -22,6 +22,10 @@ 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
+
25
29
  ## Extensions
26
30
 
27
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.
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigel-cms/rigel",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A CMS for Astro sites. You edit the text, Git keeps the history.",
5
5
  "keywords": [
6
6
  "astro",
@@ -53,7 +53,8 @@
53
53
  "src",
54
54
  "!src/**/*.test.ts",
55
55
  "README.md",
56
- "docs/extensions.md"
56
+ "docs/extensions.md",
57
+ "docs/content-locales.md"
57
58
  ],
58
59
  "peerDependencies": {
59
60
  "astro": "^7.3.0",
package/src/config.ts CHANGED
@@ -8,6 +8,12 @@ import type { JsonOutput } from "./json-output";
8
8
  import { DEFAULT_MAX_FILE_SIZE } from "./media-types";
9
9
  import { systemMessage } from "./editor/i18n/format";
10
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
+ }
11
17
 
12
18
  export interface RigelConfig {
13
19
  copyDir: string;
@@ -18,6 +24,7 @@ export interface RigelConfig {
18
24
  jsonOutput?: JsonOutput;
19
25
  demo?: boolean;
20
26
  languages?: Locale[];
27
+ contentLocales?: ContentLocalesConfig;
21
28
  extensions?: string[];
22
29
  collections?: CollectionConfig[];
23
30
  menus?: { folder: string; maxDepth?: number };
@@ -34,6 +41,7 @@ export interface RigelConfig {
34
41
 
35
42
  export interface CollectionConfig {
36
43
  name: string;
44
+ locale?: string;
37
45
  label?: string;
38
46
  listTitle?: string;
39
47
  folder: string;
@@ -48,6 +56,12 @@ const DEFAULTS: Pick<RigelConfig, "copyDir" | "backend"> = {
48
56
 
49
57
  export function parseConfig(options: Partial<RigelConfig> = {}): RigelConfig {
50
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);
51
65
  if (cfg.extensions === undefined) cfg.extensions = [];
52
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");
53
67
  cfg.extensions = [...cfg.extensions];
@@ -13,6 +13,9 @@
13
13
  import ImagePicker from "./lib/ImagePicker.svelte";
14
14
  import Settings from "./lib/Settings.svelte";
15
15
  import TopBar from "./lib/TopBar.svelte";
16
+ import Select from "./lib/Select.svelte";
17
+ import { contentForLocale, selectionInContent } from "../manifest/content-locales";
18
+ import { validateDocumentSlugs } from "../slugs";
16
19
  import Shortcuts from "./lib/Shortcuts.svelte";
17
20
  import CollectionPanel from "./lib/CollectionPanel.svelte";
18
21
  import { emptyBody, type ArticleBody } from "../article-body";
@@ -43,6 +46,10 @@
43
46
  type Sel = Selection;
44
47
 
45
48
  let manifest = $state<Manifest>({ pages: [] });
49
+ let selectedContentLocale = $state<string | undefined>(undefined);
50
+ let localeControlRevision = $state(0);
51
+ const contentLocale = $derived(selectedContentLocale ?? manifest.contentLocales?.defaultLocale);
52
+ const visibleContent = $derived(contentForLocale(manifest, contentLocale));
46
53
  let ready = $state(false);
47
54
  let blueprint: Manifest | null = null;
48
55
  let gitContext: GitContext | null = null;
@@ -134,6 +141,7 @@
134
141
  } finally { publishBusy = false; }
135
142
  }
136
143
  let pendingNavigation = $state<(() => void) | null>(null);
144
+ let pendingNavigationCancel: (() => void) | null = null;
137
145
  let actionDialog = $state<{ title: string; description: string; confirmLabel: string; fieldLabel?: string; danger?: boolean; onconfirm: (value: string) => void | Promise<void> } | null>(null);
138
146
  let newEntry = $state<{ collection: string; post: CollectionPost } | null>(null);
139
147
  let newDocument = $state<{ type: string; id: string; slug: string } | null>(null);
@@ -414,7 +422,7 @@
414
422
  });
415
423
 
416
424
  function findColl(name: string): ManifestCollection | undefined {
417
- return manifest.collections?.find((x) => x.name === name);
425
+ return visibleContent.collections?.find((x) => x.name === name);
418
426
  }
419
427
 
420
428
  function collName(s: Sel | null): string | null {
@@ -469,13 +477,13 @@
469
477
  });
470
478
  });
471
479
 
472
- const sidebar = $derived(buildSidebar(manifest, $t));
480
+ const sidebar = $derived(buildSidebar(visibleContent, $t));
473
481
  const pageCategory = $derived(sel?.kind === "pages" ? sel.category : undefined);
474
482
  const schema = $derived<Schema | undefined>(manifest.schema);
475
- const docTypes = $derived(schema?.documents ?? []);
483
+ const docTypes = $derived(visibleContent.schema?.documents ?? []);
476
484
 
477
485
  const pageDocType = $derived(docTypes.find((type) => !type.shared && !type.singleton));
478
- const pageDocuments = $derived((manifest.documents ?? []).filter((doc) => docTypes.some(type => type.name === doc.type && !type.shared)));
486
+ const pageDocuments = $derived((visibleContent.documents ?? []).filter((doc) => docTypes.some(type => type.name === doc.type && !type.shared)));
479
487
  function docTypeName(s: Sel | null): string | null {
480
488
  return s && (s.kind === "document" || s.kind === "documents") ? s.type : null;
481
489
  }
@@ -493,14 +501,15 @@
493
501
  });
494
502
  function documentPreview(mode: "draft" | "website") {
495
503
  if (sel?.kind !== "document" || (mode === "draft" && newDocument?.id === sel.id)) return null;
496
- return previewUrl(manifest.preview ?? {}, { kind: "document", type: sel.type, slug: sel.id.slice(sel.type.length + 1), urlSlug: Object.hasOwn(draft, "slug") ? String(draft.slug) : undefined }, mode);
504
+ const storedSlug = activeDocument ? effectiveSlug(activeDocument.data, activeDocument.slug) : sel.id.slice(sel.type.length + 1);
505
+ return previewUrl(manifest.preview ?? {}, { kind: "document", type: sel.type, slug: storedSlug, urlSlug: Object.hasOwn(draft, "slug") ? String(draft.slug) : undefined }, mode);
497
506
  }
498
507
  const standaloneDocument = $derived(sel?.kind === "document" && activeDocType?.shared === true);
499
508
  const visualSelection = $derived(sel?.kind === "post" || (sel?.kind === "document" && !standaloneDocument));
500
509
 
501
510
  const sortedPages = $derived.by(() => {
502
511
  const rows = [
503
- ...manifest.pages.filter((p) => !p.shared && (pageCategory === undefined || p.category === pageCategory))
512
+ ...visibleContent.pages.filter((p) => !p.shared && (pageCategory === undefined || p.category === pageCategory))
504
513
  .map((p) => ({ slug: p.slug, title: p.label, category: p.category, draft: false, target: { kind: "page", slug: p.slug } as Sel })),
505
514
  ...(pageCategory === undefined ? pageDocuments
506
515
  .map((doc) => ({ slug: effectiveSlug(doc.data, doc.slug), title: String(doc.data.title ?? doc.slug), category: undefined, draft: doc.data.draft === true, target: { kind: "document", type: doc.type, id: doc.id } as Sel })) : []),
@@ -511,7 +520,7 @@
511
520
  function documentCell(doc: ContentDocument, key: string): string {
512
521
  return String(key === "title" ? doc.data.title ?? doc.data.name ?? doc.slug : key === "slug" ? activeDocType?.shared ? doc.slug : effectiveSlug(doc.data, doc.slug) : doc.data[key] ?? "");
513
522
  }
514
- const typeDocuments = $derived((manifest.documents ?? []).filter((doc) => doc.type === docTypeName(sel)).sort((a, b) => documentCell(a, sortBy).localeCompare(documentCell(b, sortBy)) * sortDir));
523
+ const typeDocuments = $derived((visibleContent.documents ?? []).filter((doc) => doc.type === docTypeName(sel)).sort((a, b) => documentCell(a, sortBy).localeCompare(documentCell(b, sortBy)) * sortDir));
515
524
 
516
525
 
517
526
  const menu = $derived.by(() => { const slug = sel?.kind === "menu" ? sel.slug : null; return manifest.menus?.entries.find((m) => m.slug === slug); });
@@ -587,22 +596,31 @@
587
596
  return false;
588
597
  }
589
598
 
590
- function requestNavigation(action: () => void) {
599
+ function requestNavigation(action: () => void, oncancel: (() => void) | null = null) {
591
600
  if (publishOpen || publishBusy) return;
601
+ pendingNavigationCancel = oncancel;
592
602
  if (dirty || saveState === "saving") pendingNavigation = action;
593
603
  else action();
594
604
  }
595
605
 
606
+ function cancelNavigation() {
607
+ pendingNavigation = null;
608
+ pendingNavigationCancel?.();
609
+ pendingNavigationCancel = null;
610
+ }
611
+
596
612
  async function saveAndLeave() {
597
613
  if (!(await save())) return;
598
614
  const action = pendingNavigation;
599
615
  pendingNavigation = null;
616
+ pendingNavigationCancel = null;
600
617
  action?.();
601
618
  }
602
619
 
603
620
  function discardAndLeave() {
604
621
  const action = pendingNavigation;
605
622
  pendingNavigation = null;
623
+ pendingNavigationCancel = null;
606
624
  const fallback: Sel | null = newEntry ? { kind: "collection", name: newEntry.collection } : newDocument ? { kind: "pages" } : sel;
607
625
  newEntry = null;
608
626
  newDocument = null;
@@ -611,6 +629,7 @@
611
629
  }
612
630
 
613
631
  function select(next: Sel | null) {
632
+ if (manifest.contentLocales && !selectionInContent(visibleContent, next)) return;
614
633
  if (sel && next && sameSel(sel, next)) return;
615
634
  requestNavigation(() => openSelection(next));
616
635
  }
@@ -655,6 +674,21 @@
655
674
 
656
675
  }
657
676
 
677
+ function focusContentLocale() {
678
+ void tick().then(() => document.querySelector<HTMLButtonElement>(".content-language button[role=combobox]")?.focus({ preventScroll: true }));
679
+ }
680
+
681
+ function switchContentLocale(value: string | string[]) {
682
+ localeControlRevision += 1;
683
+ if (typeof value !== "string" || value === contentLocale || !manifest.contentLocales?.locales.some(locale => locale.code === value)) return;
684
+ requestNavigation(() => {
685
+ const destination = contentForLocale(manifest, value);
686
+ selectedContentLocale = value;
687
+ if (!selectionInContent(destination, sel)) openSelection(null);
688
+ focusContentLocale();
689
+ }, focusContentLocale);
690
+ }
691
+
658
692
  function sort(key: string) {
659
693
  if (sortBy === key) sortDir = sortDir === 1 ? -1 : 1;
660
694
  else {
@@ -697,10 +731,10 @@
697
731
  onconfirm: (title) => {
698
732
  const slug = slugify(title);
699
733
  if (!slug) throw new Error($t("shell.titleRequired"));
700
- const taken = [...(manifest.documents ?? []).filter(doc => !type.shared || doc.type === type.name).flatMap((doc) => [doc.slug, ...(type.shared || docTypes.find(type => type.name === doc.type)?.shared ? [] : [effectiveSlug(doc.data, doc.slug)])]), ...(!type.shared ? manifest.pages.map((page) => page.slug) : [])];
701
- if (taken.includes(slug)) throw new Error($t("shell.slugAlreadyUsed", { slug }));
734
+ if (manifest.documents?.some(doc => doc.type === type.name && doc.slug === slug)) throw new Error($t("shell.slugAlreadyUsed", { slug }));
702
735
  const id = `${type.name}:${slug}`;
703
736
  const data = newDocumentFromTemplate(schema, type.name, title, slug);
737
+ validateDocumentSlugs(schema, [...(manifest.documents ?? []), { id, type: type.name, slug, data }], manifest.preview, $t);
704
738
  newDocument = { type: type.name, id, slug };
705
739
  draft = JSON.parse(JSON.stringify(data));
706
740
  docBase = null;
@@ -821,7 +855,14 @@
821
855
  <img src={logo} alt="" width="32" height="32" />
822
856
  <span>Rigel <span class="brand-suffix">CMS</span></span>
823
857
  </a>
824
- <TopBar {manifest} {sel} onselect={select} />
858
+ <TopBar manifest={visibleContent} {sel} onselect={select} />
859
+ {#if manifest.contentLocales && manifest.contentLocales.locales.length > 1}
860
+ <div class="content-language" data-locale={contentLocale}>
861
+ {#key localeControlRevision}
862
+ <Select label={$t("shell.contentLanguage")} value={contentLocale} options={manifest.contentLocales.locales.map(locale => ({ value: locale.code, label: locale.label }))} disabled={publishOpen || publishBusy} onchange={switchContentLocale} />
863
+ {/key}
864
+ </div>
865
+ {/if}
825
866
 
826
867
  <nav id="rigel-sidebar-navigation" class="rail-scroll" aria-label={$t("shell.content")}>
827
868
  {#each sidebar as node}
@@ -1107,7 +1148,7 @@
1107
1148
  <Shortcuts onclose={() => (shortcutsOpen = false)} />
1108
1149
  {/if}
1109
1150
  {#if pendingNavigation}
1110
- <UnsavedChanges busy={saveState === "saving"} error={saveError} onsave={saveAndLeave} ondiscard={discardAndLeave} oncancel={() => { pendingNavigation = null; }} />
1151
+ <UnsavedChanges busy={saveState === "saving"} error={saveError} onsave={saveAndLeave} ondiscard={discardAndLeave} oncancel={cancelNavigation} />
1111
1152
  {/if}
1112
1153
  {#if actionDialog}
1113
1154
  <ActionDialog {...actionDialog} onclose={() => { actionDialog = null; }} />
@@ -139,6 +139,7 @@
139
139
  "shell.privacyPolicy": "Datenschutzerklärung",
140
140
  "shell.welcome": "Willkommen",
141
141
  "shell.content": "Inhalte",
142
+ "shell.contentLanguage": "Inhaltssprache",
142
143
  "shell.publish": "Veröffentlichen",
143
144
  "shell.saveBeforePublishing": "Änderungen vor dem Veröffentlichen speichern",
144
145
  "shell.publishSavedChanges": "Gespeicherte Branch-Änderungen veröffentlichen",
@@ -139,6 +139,7 @@
139
139
  "shell.privacyPolicy": "Privacy Policy",
140
140
  "shell.welcome": "Welcome",
141
141
  "shell.content": "Content",
142
+ "shell.contentLanguage": "Content language",
142
143
  "shell.publish": "Publish",
143
144
  "shell.saveBeforePublishing": "Save your edits before publishing",
144
145
  "shell.publishSavedChanges": "Publish saved branch changes",
@@ -139,6 +139,7 @@
139
139
  "shell.privacyPolicy": "Política de privacidad",
140
140
  "shell.welcome": "Bienvenido",
141
141
  "shell.content": "Contenido",
142
+ "shell.contentLanguage": "Idioma del contenido",
142
143
  "shell.publish": "Publicar",
143
144
  "shell.saveBeforePublishing": "Guardar los cambios antes de publicar",
144
145
  "shell.publishSavedChanges": "Publicar los cambios guardados de la rama",
@@ -139,6 +139,7 @@
139
139
  "shell.privacyPolicy": "Politique de confidentialité",
140
140
  "shell.welcome": "Bienvenue",
141
141
  "shell.content": "Contenu",
142
+ "shell.contentLanguage": "Langue du contenu",
142
143
  "shell.publish": "Publier",
143
144
  "shell.saveBeforePublishing": "Enregistrer vos modifications avant de publier",
144
145
  "shell.publishSavedChanges": "Publier les modifications enregistrées de la branche",
@@ -139,6 +139,7 @@
139
139
  "shell.privacyPolicy": "Informativa sulla privacy",
140
140
  "shell.welcome": "Benvenuto",
141
141
  "shell.content": "Contenuti",
142
+ "shell.contentLanguage": "Lingua dei contenuti",
142
143
  "shell.publish": "Pubblica",
143
144
  "shell.saveBeforePublishing": "Salva le modifiche prima di pubblicare",
144
145
  "shell.publishSavedChanges": "Pubblica le modifiche salvate nel branch",
@@ -139,6 +139,7 @@
139
139
  "shell.privacyPolicy": "プライバシーポリシー",
140
140
  "shell.welcome": "ようこそ",
141
141
  "shell.content": "コンテンツ",
142
+ "shell.contentLanguage": "コンテンツの言語",
142
143
  "shell.publish": "公開",
143
144
  "shell.saveBeforePublishing": "公開する前に変更を保存してください",
144
145
  "shell.publishSavedChanges": "ブランチに保存済みの変更を公開",
@@ -139,6 +139,7 @@
139
139
  "shell.privacyPolicy": "Privacybeleid",
140
140
  "shell.welcome": "Welkom",
141
141
  "shell.content": "Inhoud",
142
+ "shell.contentLanguage": "Inhoudstaal",
142
143
  "shell.publish": "Publiceren",
143
144
  "shell.saveBeforePublishing": "Sla je wijzigingen op voordat je publiceert",
144
145
  "shell.publishSavedChanges": "Opgeslagen branchwijzigingen publiceren",
@@ -139,6 +139,7 @@
139
139
  "shell.privacyPolicy": "Политика конфиденциальности",
140
140
  "shell.welcome": "Добро пожаловать",
141
141
  "shell.content": "Содержимое",
142
+ "shell.contentLanguage": "Язык содержимого",
142
143
  "shell.publish": "Опубликовать",
143
144
  "shell.saveBeforePublishing": "Сохраните правки перед публикацией",
144
145
  "shell.publishSavedChanges": "Опубликовать сохранённые изменения ветки",
@@ -139,6 +139,7 @@
139
139
  "shell.privacyPolicy": "隐私政策",
140
140
  "shell.welcome": "欢迎",
141
141
  "shell.content": "内容",
142
+ "shell.contentLanguage": "内容语言",
142
143
  "shell.publish": "发布",
143
144
  "shell.saveBeforePublishing": "请先保存修改,再发布",
144
145
  "shell.publishSavedChanges": "发布分支中已保存的更改",
@@ -60,7 +60,7 @@
60
60
  position();
61
61
  await tick();
62
62
  position();
63
- menu.querySelector(`#${CSS.escape(id)}-option-${active}`)?.scrollIntoView({ block: "nearest" });
63
+ menu?.querySelector(`#${CSS.escape(id)}-option-${active}`)?.scrollIntoView({ block: "nearest" });
64
64
  }
65
65
 
66
66
  function choose(index: number) {
@@ -101,7 +101,7 @@
101
101
  if (match >= 0) active = match;
102
102
  } else return;
103
103
  await tick();
104
- menu.querySelector(`#${CSS.escape(id)}-option-${active}`)?.scrollIntoView({ block: "nearest" });
104
+ menu?.querySelector(`#${CSS.escape(id)}-option-${active}`)?.scrollIntoView({ block: "nearest" });
105
105
  }
106
106
 
107
107
  $effect(() => {
@@ -94,7 +94,7 @@
94
94
 
95
95
  function anchorSelector(path: string) {
96
96
  const escaped = CSS.escape(path);
97
- return `[data-rigel-text="${escaped}"],[data-rigel-richtext="${escaped}"],[data-rigel-image="${escaped}"],[data-rigel-alt="${escaped}"],[data-rigel-href="${escaped}"]`;
97
+ return `[data-rigel-tags="${escaped}"],[data-rigel-text="${escaped}"],[data-rigel-richtext="${escaped}"],[data-rigel-image="${escaped}"],[data-rigel-alt="${escaped}"],[data-rigel-href="${escaped}"]`;
98
98
  }
99
99
 
100
100
  function applySelection(scroll = false) {
@@ -166,7 +166,7 @@
166
166
  }
167
167
 
168
168
  function fieldPathFrom(element: HTMLElement): { path: string; doc: string } | null {
169
- for (const attr of ["data-rigel-text", "data-rigel-richtext", "data-rigel-image", "data-rigel-alt", "data-rigel-href"]) {
169
+ for (const attr of ["data-rigel-tags", "data-rigel-text", "data-rigel-richtext", "data-rigel-image", "data-rigel-alt", "data-rigel-href"]) {
170
170
  const value = element.getAttribute(attr);
171
171
  if (value) return { path: value, doc: element.getAttribute("data-rigel-doc") ?? docId };
172
172
  }
@@ -264,7 +264,7 @@
264
264
  if (!target) return;
265
265
  const articleBody = target.closest<HTMLElement>('[data-rigel-richtext="$body"]');
266
266
  if (inlineBodyEnabled && articleBody) { void startBodyEdit(articleBody, event); return; }
267
- const element = target.closest<HTMLElement>("[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
267
+ const element = target.closest<HTMLElement>("[data-rigel-tags],[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
268
268
  if (element) selectFromElement(element);
269
269
  }
270
270
 
@@ -315,7 +315,7 @@
315
315
  const target = eventElement(event.target);
316
316
  if (target && (editingBody?.contains(target) || bodyScope?.contains(target))) return;
317
317
  if (!target) return;
318
- const element = target.closest<HTMLElement>("[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
318
+ const element = target.closest<HTMLElement>("[data-rigel-tags],[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
319
319
  if (!element || element === highlighted) return;
320
320
  if (hovered && hovered !== element) clearHover();
321
321
  hovered = element;
@@ -325,7 +325,7 @@
325
325
  function onOut(event: MouseEvent) {
326
326
  const target = eventElement(event.target);
327
327
  if (!target) return;
328
- const element = target.closest<HTMLElement>("[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
328
+ const element = target.closest<HTMLElement>("[data-rigel-tags],[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
329
329
  if (element && element === hovered) clearHover();
330
330
  }
331
331
 
@@ -349,7 +349,7 @@
349
349
  doc.addEventListener("mouseout", onOut, true);
350
350
  doc.addEventListener("submit", (event) => event.preventDefault(), true);
351
351
  doc.defaultView?.addEventListener("scroll", () => closeInline(), { passive: true });
352
- if (!doc.querySelector(schema ? `[data-rigel-doc="${docId}"]` : "[data-rigel-text],[data-rigel-richtext],[data-rigel-image]")) throw new Error($t("richtext.liveEditingUnavailable"));
352
+ if (!doc.querySelector(schema ? `[data-rigel-doc="${docId}"]` : "[data-rigel-tags],[data-rigel-text],[data-rigel-richtext],[data-rigel-image]")) throw new Error($t("richtext.liveEditingUnavailable"));
353
353
  loaded = true;
354
354
  bodyAvailable = !schema && !!body && !!doc.querySelector('[data-rigel-richtext="$body"]');
355
355
  patch();
@@ -1161,6 +1161,15 @@
1161
1161
  .rigel .schema-panel { border-left: 1px solid var(--border); overflow-y: auto; min-height: 0; padding: 1rem; background: var(--panel); }
1162
1162
  .rigel .schema-panel section { border: 0; padding: 0 0 1rem; }
1163
1163
  .rigel .custom-select { min-width: 0; max-width: 100%; }
1164
+ .rigel .content-language { position: relative; margin: -0.75rem 0.75rem 1.5rem; flex-shrink: 0; }
1165
+ .rigel .content-language .select-label { margin-top: 0; }
1166
+ .rigel .content-language .select-trigger { min-height: 36px; }
1167
+ @media (min-width: 768px) {
1168
+ .rigel.sidebar-collapsed .content-language .select-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
1169
+ .rigel.sidebar-collapsed .content-language .select-trigger > * { visibility: hidden; }
1170
+ .rigel.sidebar-collapsed .content-language .select-trigger { gap: 0; padding-inline: 0; }
1171
+ .rigel.sidebar-collapsed .content-language::after { content: attr(data-locale); position: absolute; inset: 0; overflow: hidden; line-height: 36px; text-align: center; text-transform: uppercase; font-size: 0.6875rem; pointer-events: none; }
1172
+ }
1164
1173
  .rigel .block-editor { position: relative; margin-top: 1rem; border: 1px solid var(--border-control); }
1165
1174
  .rigel .block-editor [hidden] { display: none !important; }
1166
1175
  .rigel.rigel-inline { display: block; position: fixed; top: 0; left: 0; width: 100%; min-height: 0; height: 0; padding: 0; background: transparent; pointer-events: none; z-index: 120; }
package/src/index.ts CHANGED
@@ -115,7 +115,7 @@ export { validateMenu } from "./manifest/menu";
115
115
  export type { MenuItem, MenuDocument } from "./manifest/menu";
116
116
  export { defineCopy } from "./manifest/types";
117
117
  export type { CopyField, CopyGroup, CopyPage, Manifest, CollectionField } from "./manifest/types";
118
- export type { RigelConfig, CollectionConfig } from "./config";
118
+ export type { RigelConfig, CollectionConfig, ContentLocalesConfig } from "./config";
119
119
  export type { JsonOutput } from "./json-output";
120
120
  export type { ReferenceOption } from "./manifest/references";
121
121
  export type { SidebarEntry, SidebarTarget, SidebarIconName } from "./manifest/navigation";
@@ -0,0 +1,45 @@
1
+ import type { ContentLocalesConfig } from "../config";
2
+ import type { Manifest } from "./types";
3
+ import type { Selection } from "./navigation";
4
+
5
+ const CODE = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/;
6
+
7
+ export function validateContentLocales(value: unknown): asserts value is ContentLocalesConfig {
8
+ const config = value as ContentLocalesConfig | null;
9
+ if (!config || typeof config !== "object" || !Array.isArray(config.locales) || !config.locales.length || Array.from(config.locales).some(locale => !locale || typeof locale.code !== "string" || !CODE.test(locale.code) || typeof locale.label !== "string" || !locale.label.trim()) || new Set(config.locales.map(locale => locale.code.toLowerCase())).size !== config.locales.length || !config.locales.some(locale => locale.code === config.defaultLocale)) {
10
+ throw new Error("rigel: contentLocales requires unique locale codes, non-empty labels and a defaultLocale matching a configured code");
11
+ }
12
+ }
13
+
14
+ export function validateContentLocale(locale: unknown, config: ContentLocalesConfig | undefined, scope: string): void {
15
+ if (locale === undefined) return;
16
+ if (typeof locale !== "string" || !CODE.test(locale)) throw new Error(`rigel: ${scope}: invalid content locale`);
17
+ if (config && !config.locales.some(entry => entry.code === locale)) throw new Error(`rigel: ${scope}: unknown content locale ${locale}`);
18
+ }
19
+
20
+ export function contentForLocale(manifest: Manifest, locale: string | undefined): Manifest {
21
+ if (!manifest.contentLocales) return manifest;
22
+ const selected = locale ?? manifest.contentLocales.defaultLocale;
23
+ const visible = (entry: { locale?: string }) => entry.locale === undefined || entry.locale === selected;
24
+ const types = manifest.schema?.documents.filter(visible);
25
+ return {
26
+ ...manifest,
27
+ pages: manifest.pages.filter(visible),
28
+ ...(manifest.collections ? { collections: manifest.collections.filter(visible) } : {}),
29
+ ...(manifest.schema ? { schema: { ...manifest.schema, documents: types! } } : {}),
30
+ ...(manifest.documents ? { documents: manifest.documents.filter(doc => types?.some(type => type.name === doc.type)) } : {}),
31
+ };
32
+ }
33
+
34
+ export function selectionInContent(manifest: Manifest, selection: Selection | null): boolean {
35
+ if (!selection) return true;
36
+ switch (selection.kind) {
37
+ case "page": return manifest.pages.some(page => page.slug === selection.slug);
38
+ case "pages": return !selection.category || manifest.pages.some(page => !page.shared && page.category === selection.category);
39
+ case "document":
40
+ case "documents": return !!manifest.schema?.documents.some(type => type.name === selection.type);
41
+ case "post": return !!manifest.collections?.some(collection => collection.name === selection.collection);
42
+ case "collection": return !!manifest.collections?.some(collection => collection.name === selection.name);
43
+ default: return true;
44
+ }
45
+ }
@@ -54,6 +54,10 @@ export function buildSidebar(manifest: Manifest, message: Translator = systemMes
54
54
  ];
55
55
  if (!manifest.sidebar) return defaults;
56
56
  const available = new Map(defaults.filter((node): node is SidebarLink => node.kind === "link").map(node => [navigationKey(node.target), node]));
57
+ for (const type of types) {
58
+ const target: SidebarTarget = { kind: "documents", type: type.name };
59
+ available.set(navigationKey(target), link(target, type.title));
60
+ }
57
61
  for (const page of manifest.pages) available.set(navigationKey({ kind: "page", slug: page.slug }), link({ kind: "page", slug: page.slug }, page.label));
58
62
  for (const doc of manifest.documents ?? []) {
59
63
  const target: SidebarTarget = { kind: "document", type: doc.type, id: doc.id };
@@ -15,6 +15,7 @@ export interface CopyGroup {
15
15
 
16
16
  export interface CopyPage {
17
17
  slug: string;
18
+ locale?: string;
18
19
  label: string;
19
20
  shared?: boolean;
20
21
  category?: string;
@@ -36,6 +37,7 @@ export interface GitHubBackendConfig {
36
37
  }
37
38
 
38
39
  export interface Manifest {
40
+ contentLocales?: import("../config").ContentLocalesConfig;
39
41
  backend?: "github";
40
42
  github?: GitHubBackendConfig;
41
43
  sidebar?: import("./navigation").SidebarEntry[];
@@ -66,6 +68,7 @@ export interface CollectionPost {
66
68
 
67
69
  export interface ManifestCollection {
68
70
  name: string;
71
+ locale?: string;
69
72
  label: string;
70
73
  listTitle?: string;
71
74
  fields: CollectionField[];
package/src/preview.ts CHANGED
@@ -100,6 +100,7 @@ function rebindTree(root: Element | DocumentFragment, update: (element: Element)
100
100
  }
101
101
 
102
102
  export function patchPreview(doc: Document, values: Record<string, unknown>, body: "" | import("./article-body").ArticleBody, options: PatchDocuments = {}) {
103
+ if (!doc.documentElement) return;
103
104
  let previews = extensionPreviews.get(doc);
104
105
  if (!previews) {
105
106
  previews = new Map();
@@ -172,6 +173,19 @@ export function patchPreview(doc: Document, values: Record<string, unknown>, bod
172
173
  const result = get(element, element.getAttribute("data-rigel-if")!);
173
174
  if (result.found) element.hidden = !result.value;
174
175
  }
176
+ for (const el of doc.querySelectorAll<HTMLElement>("[data-rigel-tags]")) {
177
+ if (el.tagName !== "UL" && el.tagName !== "OL") continue;
178
+ const result = get(el, el.dataset.rigelTags!);
179
+ if (!result.found) continue;
180
+ const source = typeof result.value === "string" ? result.value : "";
181
+ if (rendered.get(el) === source) continue;
182
+ el.replaceChildren(...source.split(",").map(tag => tag.trim()).filter(Boolean).map(tag => {
183
+ const item = doc.createElement("li");
184
+ item.textContent = tag;
185
+ return item;
186
+ }));
187
+ rendered.set(el, source);
188
+ }
175
189
  for (const el of doc.querySelectorAll<HTMLElement>("[data-rigel-text]")) {
176
190
  const result = get(el, el.dataset.rigelText!);
177
191
  if (!result.found) continue;
@@ -35,6 +35,7 @@ export interface DocumentTemplateSection {
35
35
 
36
36
  export interface DocumentDef {
37
37
  name: string;
38
+ locale?: string;
38
39
  title: string;
39
40
  type: "document";
40
41
  folder: string;
@@ -44,6 +44,7 @@ const __rigelReservedSlugs = new Set(documents.filter((doc) => !documentTypes.fi
44
44
  .map(
45
45
  (c) => ` {
46
46
  name: ${JSON.stringify(c.name)},
47
+ ${c.locale !== undefined ? `locale: ${JSON.stringify(c.locale)},` : ""}
47
48
  label: ${JSON.stringify(c.label ?? c.name)},
48
49
  listTitle: ${JSON.stringify(c.listTitle)},
49
50
  fields: ${JSON.stringify(c.fields)},
@@ -167,6 +168,7 @@ export const menus = {
167
168
  jsonOutput: cfg.jsonOutput,
168
169
  demo: cfg.demo,
169
170
  languages: cfg.languages,
171
+ contentLocales: cfg.contentLocales,
170
172
  publicDir: publicDir?.href,
171
173
  mediaDir: cfg.mediaDir,
172
174
  maxFileSize: cfg.maxFileSize,
@@ -205,8 +207,10 @@ export function derefDocument(value) { return deref(value, __rigelIndex); }
205
207
  return `
206
208
  import { validateBody as __rigelValidateBody } from ${JSON.stringify(fileURLToPath(new URL("../article-body.ts", import.meta.url)))};
207
209
  import { validateCollectionValues as __rigelValidateCollectionValues } from ${JSON.stringify(fileURLToPath(new URL("../manifest/collections.ts", import.meta.url)))};
210
+ import { validateContentLocale as __rigelValidateContentLocale } from ${JSON.stringify(fileURLToPath(new URL("../manifest/content-locales.ts", import.meta.url)))};
208
211
  const modules = import.meta.glob(${JSON.stringify(pattern)}, { eager: true });
209
212
  const loaded = Object.values(modules).map((m) => m.default);
213
+ for (const page of loaded) __rigelValidateContentLocale(page.locale, ${JSON.stringify(cfg.contentLocales)}, page.slug);
210
214
  ${documentsSrc}
211
215
  export const pages = loaded.filter((page) => !__rigelReservedSlugs.has(page.slug)).sort((a, b) => a.slug.localeCompare(b.slug));
212
216
  export const collections = [
@@ -214,7 +218,7 @@ ${collectionCode}
214
218
  ];
215
219
  for (const collection of collections) __rigelAssertUniqueUrlSlugs(collection.posts.map(post => ({ slug: post.slug, values: post.frontmatter })));
216
220
  ${menuCode}
217
- 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) : ""} };
221
+ export default { pages, collections, documents${cfg.contentLocales ? ", contentLocales: " + JSON.stringify(cfg.contentLocales) : ""}${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) : ""} };
218
222
  `;
219
223
  },
220
224
  };