@rigel-cms/rigel 1.3.0 → 1.4.0-rc.2

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.
@@ -4,6 +4,7 @@ Content languages are optional. Configure them separately from `languages`, whic
4
4
 
5
5
  ```js
6
6
  rigel({
7
+ search: { scope: "all" },
7
8
  contentLocales: {
8
9
  defaultLocale: "en",
9
10
  locales: [
@@ -44,10 +45,26 @@ defineDocument({
44
45
 
45
46
  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
 
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
+ With multiple configured locales, the editor shows a Content language dropdown. It filters navigation, page lists and collections. Switching away from unsaved changes offers the existing Save, Discard and Keep editing choices. New sessions start with `defaultLocale`.
49
+
50
+ ## Global search
51
+
52
+ Sidebar search (⌘/Ctrl + K) searches **all content languages** by default. Set `search: { scope: "currentLocale" }` in your Astro integration to default to the selected content language plus shared resources instead. Missing or invalid scope values fall back to `"all"`. This setting does not change the Pages table's language filter or its live search.
53
+
54
+ In **Settings → Content → Search content languages**, editors can choose **Website default**, **All languages**, or **Current content language**, alongside the article editing preferences. The override applies immediately and persists in `rigelcms.settings.userPref.searchScope` in this browser. Website default removes the override; Erase All Data resets it with the other browser preferences. It is independent of the editor interface language, which lives in **Settings → Interface** after Theme and Accessibility. Current content language is disabled when there are fewer than two configured content languages.
55
+
56
+ Results include legacy pages, native documents, collections, posts and menus. Shared resources appear once. Localized results show two-character language codes when multiple content languages exist, with the full configured label available on hover and to screen readers. Opening another language's result switches content language and opens that exact resource through the existing unsaved-changes guard; Keep editing leaves the language and draft untouched. Search still shows at most 12 results.
48
57
 
49
58
  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
59
 
51
60
  Without `contentLocales`, the editor keeps its existing navigation and shows no content-language dropdown. A single configured locale also needs no dropdown.
52
61
 
53
62
  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.
63
+
64
+ ## Page categories and templates
65
+
66
+ Set `category` on a nonshared document definition to group its pages, for example `category: "Articles"`. This is schema metadata, not an editable field in stored documents. Uncategorised native documents and legacy copy pages remain in **Core** without a migration. Pages initially shows **All pages**. When categories exist, the table offers Core, All pages and each category visible in the selected content language. The live search matches title/name, slug and category substrings, ignores case and surrounding whitespace, and combines with the selected category. Its clear button restores input focus. Back preserves both the query and originating table/filter, not a filter inferred from the opened page. Content-language switches retain the query and reset unavailable categories to All pages.
67
+
68
+ Add new asks for a page name and a category from the locale-eligible, creatable document types. When a category contains multiple types, the editor also requires a Template choice using each definition's title. Shared, singleton and `creatable: false` types are not creation choices. A single uncategorised type keeps the name-only dialog. Creation uses the selected type's identity, initial values and section template; nothing is written until Save.
69
+
70
+ A visual variant can be another document definition with its own category, defaults and template—for example a Resources category with a dark-theme initial value. Its native type, folder, preview mapping and host renderer must be configured normally. A category does not change the editor theme, rewrite existing document types or generate website routes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigel-cms/rigel",
3
- "version": "1.3.0",
3
+ "version": "1.4.0-rc.2",
4
4
  "description": "A CMS for Astro sites. You edit the text, Git keeps the history.",
5
5
  "keywords": [
6
6
  "astro",
package/src/config.ts CHANGED
@@ -9,12 +9,15 @@ 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
11
  import { validateContentLocales, validateContentLocale } from "./manifest/content-locales";
12
+ import { effectiveSearchScope } from "./manifest/search";
12
13
 
13
14
  export interface ContentLocalesConfig {
14
15
  defaultLocale: string;
15
16
  locales: { code: string; label: string }[];
16
17
  }
17
18
 
19
+ export type SearchScope = "all" | "currentLocale";
20
+
18
21
  export interface RigelConfig {
19
22
  copyDir: string;
20
23
  mediaDir?: string;
@@ -25,6 +28,7 @@ export interface RigelConfig {
25
28
  demo?: boolean;
26
29
  languages?: Locale[];
27
30
  contentLocales?: ContentLocalesConfig;
31
+ search?: { scope: SearchScope };
28
32
  extensions?: string[];
29
33
  collections?: CollectionConfig[];
30
34
  menus?: { folder: string; maxDepth?: number };
@@ -56,6 +60,7 @@ const DEFAULTS: Pick<RigelConfig, "copyDir" | "backend"> = {
56
60
 
57
61
  export function parseConfig(options: Partial<RigelConfig> = {}): RigelConfig {
58
62
  const cfg = { ...DEFAULTS, ...options };
63
+ cfg.search = { scope: effectiveSearchScope(cfg.search, undefined) };
59
64
  if (cfg.contentLocales !== undefined) {
60
65
  validateContentLocales(cfg.contentLocales);
61
66
  cfg.contentLocales = { ...cfg.contentLocales, locales: cfg.contentLocales.locales.map(locale => ({ ...locale })) };
@@ -12,9 +12,11 @@
12
12
  import SitePreview from "./lib/SitePreview.svelte";
13
13
  import ImagePicker from "./lib/ImagePicker.svelte";
14
14
  import Settings from "./lib/Settings.svelte";
15
+ import { effectiveSearchScope } from "../manifest/search";
15
16
  import TopBar from "./lib/TopBar.svelte";
16
17
  import Select from "./lib/Select.svelte";
17
18
  import { contentForLocale, selectionInContent } from "../manifest/content-locales";
19
+ import { pageCategories, pageTemplates, inPageCategory } from "../manifest/pages";
18
20
  import { validateDocumentSlugs } from "../slugs";
19
21
  import Shortcuts from "./lib/Shortcuts.svelte";
20
22
  import CollectionPanel from "./lib/CollectionPanel.svelte";
@@ -38,7 +40,7 @@
38
40
  import VisualEditor from "./lib/VisualEditor.svelte";
39
41
  import { newDocumentFromTemplate } from "../schema/paths";
40
42
  import { previewUrl, previewPath } from "../preview";
41
- import type { ContentDocument, Schema } from "../schema/types";
43
+ import type { ContentDocument, DocumentDef, Schema } from "../schema/types";
42
44
  import { buildSidebar, navigationKey, type Selection } from "../manifest/navigation";
43
45
  import { startGitHubLogin, checkGitHubAccess, type GitHubAccess } from "./lib/github-auth";
44
46
  import { loadManifestRemote, saveRemoteSelection, deleteRemotePost, reviewRemotePublish, confirmRemotePublish, PublishReviewChangedError, type GitContext, type PublishReview } from "./lib/github-backend";
@@ -142,7 +144,7 @@
142
144
  }
143
145
  let pendingNavigation = $state<(() => void) | null>(null);
144
146
  let pendingNavigationCancel: (() => void) | null = null;
145
- let actionDialog = $state<{ title: string; description: string; confirmLabel: string; fieldLabel?: string; danger?: boolean; onconfirm: (value: string) => void | Promise<void> } | null>(null);
147
+ let actionDialog = $state<{ title: string; description: string; confirmLabel: string; fieldLabel?: string; danger?: boolean; templates?: DocumentDef[]; templateCategory?: string; onconfirm: (value: string, type?: string) => void | Promise<void> } | null>(null);
146
148
  let newEntry = $state<{ collection: string; post: CollectionPost } | null>(null);
147
149
  let newDocument = $state<{ type: string; id: string; slug: string } | null>(null);
148
150
  let docBase = $state<Record<string, unknown> | null>(null);
@@ -159,6 +161,7 @@
159
161
  if (!Array.isArray(data.pages) || (data.backend === "github" && !data.github)) throw new Error($t("shell.configurationIncomplete"));
160
162
  blueprint = data;
161
163
  manifest = data;
164
+ pagesFilter = null;
162
165
  ready = true;
163
166
  if (data.backend === "github" && data.github) {
164
167
  const session = readSession(data.github);
@@ -478,11 +481,20 @@
478
481
  });
479
482
 
480
483
  const sidebar = $derived(buildSidebar(visibleContent, $t));
481
- const pageCategory = $derived(sel?.kind === "pages" ? sel.category : undefined);
484
+ let pagesFilter = $state<string | null>(null);
485
+ let pagesQuery = $state("");
486
+ let pagesSearch: HTMLInputElement;
487
+ let entryList = $state<Sel | null>(null);
488
+ const categories = $derived(pageCategories(visibleContent));
489
+ const pageCategory = $derived(sel?.kind === "pages" && sel.category !== undefined ? sel.category : pagesFilter);
482
490
  const schema = $derived<Schema | undefined>(manifest.schema);
483
491
  const docTypes = $derived(visibleContent.schema?.documents ?? []);
484
492
 
485
493
  const pageDocType = $derived(docTypes.find((type) => !type.shared && !type.singleton));
494
+ const templates = $derived(pageTemplates(visibleContent));
495
+ const pageBack = $derived<Extract<Sel, { kind: "pages" }>>(entryList?.kind === "pages" ? entryList : { kind: "pages" });
496
+ const documentBack = $derived<Sel | null>(entryList?.kind === "documents" && entryList.type === activeDocType?.name ? entryList : !activeDocType?.shared ? pageBack : activeDocType.singleton ? null : { kind: "documents", type: activeDocType.name });
497
+ const pageBackLabel = $derived((pageBack.category ?? pagesFilter) || (pagesFilter === "" && categories.length ? $t("shell.coreCategory") : $t("shell.pages")));
486
498
  const pageDocuments = $derived((visibleContent.documents ?? []).filter((doc) => docTypes.some(type => type.name === doc.type && !type.shared)));
487
499
  function docTypeName(s: Sel | null): string | null {
488
500
  return s && (s.kind === "document" || s.kind === "documents") ? s.type : null;
@@ -509,14 +521,15 @@
509
521
 
510
522
  const sortedPages = $derived.by(() => {
511
523
  const rows = [
512
- ...visibleContent.pages.filter((p) => !p.shared && (pageCategory === undefined || p.category === pageCategory))
524
+ ...visibleContent.pages.filter((p) => !p.shared && inPageCategory(p.category, pageCategory))
513
525
  .map((p) => ({ slug: p.slug, path: previewPath(manifest.preview ?? {}, { kind: "page", slug: p.slug }), title: p.label, category: p.category, draft: false, target: { kind: "page", slug: p.slug } as Sel })),
514
526
  ...pageDocuments
515
- .map((doc) => ({ slug: effectiveSlug(doc.data, doc.slug), path: previewPath(manifest.preview ?? {}, { kind: "document", type: doc.type, slug: effectiveSlug(doc.data, doc.slug) }), title: String(doc.data.title ?? doc.slug), category: docTypes.find(type => type.name === doc.type)?.category, draft: doc.data.draft === true, target: { kind: "document", type: doc.type, id: doc.id } as Sel }))
516
- .filter(doc => pageCategory === undefined || doc.category === pageCategory),
527
+ .map((doc) => ({ slug: effectiveSlug(doc.data, doc.slug), path: previewPath(manifest.preview ?? {}, { kind: "document", type: doc.type, slug: effectiveSlug(doc.data, doc.slug) }), title: String(doc.data.title ?? doc.data.name ?? doc.slug), category: docTypes.find(type => type.name === doc.type)?.category, draft: doc.data.draft === true, target: { kind: "document", type: doc.type, id: doc.id } as Sel }))
528
+ .filter(doc => inPageCategory(doc.category, pageCategory)),
517
529
  ];
518
530
  const key = sortBy === "slug" || sortBy === "category" || sortBy === "path" ? sortBy : "title";
519
- return rows.sort((a, b) => String(a[key] ?? "").localeCompare(String(b[key] ?? "")) * sortDir);
531
+ const query = pagesQuery.trim().toLocaleLowerCase();
532
+ return rows.filter(row => !query || [row.title, row.slug, row.category ?? (categories.length ? $t("shell.coreCategory") : "")].some(value => value.toLocaleLowerCase().includes(query))).sort((a, b) => String(a[key] ?? "").localeCompare(String(b[key] ?? "")) * sortDir);
520
533
  });
521
534
  function documentCell(doc: ContentDocument, key: string): string {
522
535
  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] ?? "");
@@ -526,13 +539,13 @@
526
539
 
527
540
  const menu = $derived.by(() => { const slug = sel?.kind === "menu" ? sel.slug : null; return manifest.menus?.entries.find((m) => m.slug === slug); });
528
541
  const sortedMenus = $derived([...(manifest.menus?.entries ?? [])].sort((a, b) => (sortBy === "slug" ? a.slug.localeCompare(b.slug) : a.title.localeCompare(b.title)) * sortDir));
529
- const listTitle = $derived(sel?.kind === "menus" ? $t("shell.allMenus") : sel?.kind === "documents" ? activeDocType?.title ?? $t("shell.documents") : sel?.kind === "pages" ? pageCategory !== undefined ? $t("shell.allNamedEntries", { name: pageCategory }) : $t("shell.allPages") : coll?.listTitle?.trim() || (coll?.label !== undefined ? $t("shell.allNamedEntries", { name: coll.label }) : $t("shell.allEntries")));
542
+ const listTitle = $derived(sel?.kind === "menus" ? $t("shell.allMenus") : sel?.kind === "documents" ? activeDocType?.title ?? $t("shell.documents") : sel?.kind === "pages" ? pageCategory !== null && categories.length ? $t("shell.allNamedEntries", { name: pageCategory || $t("shell.coreCategory") }) : $t("shell.allPages") : coll?.listTitle?.trim() || (coll?.label !== undefined ? $t("shell.allNamedEntries", { name: coll.label }) : $t("shell.allEntries")));
530
543
  const listRows = $derived(sel?.kind === "menus"
531
544
  ? sortedMenus.map((m) => ({ slug: m.slug, cells: { slug: m.slug, title: m.title } as Record<string, string>, draft: false, target: { kind: "menu", slug: m.slug } as Sel }))
532
545
  : sel?.kind === "documents"
533
546
  ? typeDocuments.map((doc) => ({ slug: doc.slug, cells: Object.fromEntries(columns.map(column => [column.key, documentCell(doc, column.key)])), draft: doc.data.draft === true, target: { kind: "document", type: doc.type, id: doc.id } as Sel }))
534
547
  : sel?.kind === "pages"
535
- ? sortedPages.map((p) => ({ slug: p.slug, cells: { label: p.title, slug: p.slug, path: p.path ?? "—", category: p.category ?? "—" } as Record<string, string>, draft: p.draft, target: p.target }))
548
+ ? sortedPages.map((p) => ({ slug: p.slug, cells: { label: p.title, slug: p.slug, path: p.path ?? "—", category: p.category ?? (categories.length ? $t("shell.coreCategory") : "—") } as Record<string, string>, draft: p.draft, target: p.target }))
536
549
  : sortedPosts.map((p) => ({ slug: p.slug, cells: Object.fromEntries(columns.map((c) => [c.key, cellValue(p, c.key)])), draft: Boolean(p.frontmatter.draft), target: { kind: "post", collection: coll!.name, slug: p.slug } as Sel })));
537
550
  const titleColumn = $derived(sel?.kind === "pages" ? "label" : "title");
538
551
 
@@ -637,6 +650,11 @@
637
650
  }
638
651
 
639
652
  function openSelection(next: Sel | null) {
653
+ if (next?.kind === "page" || next?.kind === "document") {
654
+ if (sel?.kind === "pages" || sel?.kind === "documents") entryList = sel;
655
+ else if (sel?.kind !== "page" && sel?.kind !== "document") entryList = null;
656
+ }
657
+ if (next?.kind === "pages" && next.category !== undefined) pagesFilter = next.category;
640
658
  sel = next;
641
659
  saveError = "";
642
660
  sortBy = "";
@@ -680,17 +698,25 @@
680
698
  void tick().then(() => document.querySelector<HTMLButtonElement>(".content-language button[role=combobox]")?.focus({ preventScroll: true }));
681
699
  }
682
700
 
683
- function switchContentLocale(value: string | string[]) {
701
+ function switchContentLocale(value: string | string[], target?: Sel) {
684
702
  localeControlRevision += 1;
685
703
  if (typeof value !== "string" || value === contentLocale || !manifest.contentLocales?.locales.some(locale => locale.code === value)) return;
686
704
  requestNavigation(() => {
687
705
  const destination = contentForLocale(manifest, value);
688
706
  selectedContentLocale = value;
707
+ if (pagesFilter && !pageCategories(destination).includes(pagesFilter)) pagesFilter = null;
708
+ if (!selectionInContent(destination, entryList)) entryList = null;
689
709
  if (!selectionInContent(destination, sel)) openSelection(null);
710
+ if (target && selectionInContent(destination, target)) openSelection(target);
690
711
  focusContentLocale();
691
712
  }, focusContentLocale);
692
713
  }
693
714
 
715
+ function selectSearch(next: Sel, locale?: string) {
716
+ if (locale && locale !== contentLocale && manifest.contentLocales?.locales.some(entry => entry.code === locale)) switchContentLocale(locale, next);
717
+ else select(next);
718
+ }
719
+
694
720
  function sort(key: string) {
695
721
  if (sortBy === key) sortDir = sortDir === 1 ? -1 : 1;
696
722
  else {
@@ -726,21 +752,25 @@
726
752
  return String(v);
727
753
  }
728
754
 
729
- function newPage(type = pageDocType) {
755
+ function newPage(type = pageDocType, chooseTemplate = false) {
756
+ if (chooseTemplate) type = templates.find(type => inPageCategory(type.category, pageCategory)) ?? templates[0];
730
757
  if (!type || !schema || type.creatable === false || type.singleton) return;
731
758
  actionDialog = {
732
- title: $t("shell.addDocument", { name: type.title }), description: $t("shell.newDocumentDescription"), fieldLabel: type.shared ? $t("shell.name") : $t("shell.pageName"), confirmLabel: $t("shell.createDraft"),
733
- onconfirm: (title) => {
759
+ title: $t("shell.addDocument", { name: chooseTemplate ? $t("shell.pages") : type.title }), description: $t("shell.newDocumentDescription"), fieldLabel: type.shared ? $t("shell.name") : $t("shell.pageName"), confirmLabel: $t("shell.createDraft"),
760
+ ...(chooseTemplate ? { templates, templateCategory: type.category ?? "" } : {}),
761
+ onconfirm: (title, selectedType) => {
762
+ const selected = chooseTemplate ? templates.find(entry => entry.name === selectedType) : type;
763
+ if (!selected) throw new Error($t("shell.chooseTemplate"));
734
764
  const slug = slugify(title);
735
765
  if (!slug) throw new Error($t("shell.titleRequired"));
736
- if (manifest.documents?.some(doc => doc.type === type.name && doc.slug === slug)) throw new Error($t("shell.slugAlreadyUsed", { slug }));
737
- const id = `${type.name}:${slug}`;
738
- const data = newDocumentFromTemplate(schema, type.name, title, slug);
739
- validateDocumentSlugs(schema, [...(manifest.documents ?? []), { id, type: type.name, slug, data }], manifest.preview, $t);
740
- newDocument = { type: type.name, id, slug };
766
+ if (manifest.documents?.some(doc => doc.type === selected.name && doc.slug === slug)) throw new Error($t("shell.slugAlreadyUsed", { slug }));
767
+ const id = `${selected.name}:${slug}`;
768
+ const data = newDocumentFromTemplate(schema, selected.name, title, slug);
769
+ validateDocumentSlugs(schema, [...(manifest.documents ?? []), { id, type: selected.name, slug, data }], manifest.preview, $t);
770
+ newDocument = { type: selected.name, id, slug };
741
771
  draft = JSON.parse(JSON.stringify(data));
742
772
  docBase = null;
743
- openSelection({ kind: "document", type: type.name, id });
773
+ openSelection({ kind: "document", type: selected.name, id });
744
774
  },
745
775
  };
746
776
  }
@@ -857,7 +887,7 @@
857
887
  <img src={logo} alt="" width="32" height="32" />
858
888
  <span>Rigel <span class="brand-suffix">CMS</span></span>
859
889
  </a>
860
- <TopBar manifest={visibleContent} {sel} onselect={select} />
890
+ <TopBar {manifest} {sel} {contentLocale} scope={effectiveSearchScope(manifest.search, settings.userPref.searchScope)} onselect={selectSearch} />
861
891
  {#if manifest.contentLocales && manifest.contentLocales.locales.length > 1}
862
892
  <div class="content-language" data-locale={contentLocale}>
863
893
  {#key localeControlRevision}
@@ -957,10 +987,9 @@
957
987
  {#if sel.kind === "page" || sel.kind === "post" || sel.kind === "menu" || sel.kind === "document"}
958
988
  <div class="editor-actions">
959
989
  {#if sel.kind === "page" && page}
960
- <p class="back"><button onclick={() => select(page.shared ? null : { kind: "pages", category: page.category })}>{$t("shell.backTo", { name: page.shared ? $t("shell.welcome") : page.category ?? $t("shell.pages") })}</button></p>
990
+ <p class="back"><button onclick={() => select(page.shared ? null : pageBack)}>{$t("shell.backTo", { name: page.shared ? $t("shell.welcome") : pageBackLabel })}</button></p>
961
991
  {:else if sel.kind === "document"}
962
- {@const backSel: Sel | null = activeDocType?.singleton ? null : sel.type === pageDocType?.name ? { kind: "pages" } : { kind: "documents", type: sel.type }}
963
- <p class="back"><button onclick={() => select(backSel)}>{$t("shell.backTo", { name: activeDocType?.singleton ? $t("shell.welcome") : sel.type === pageDocType?.name ? $t("shell.pages") : activeDocType?.title ?? $t("shell.documents") })}</button></p>
992
+ <p class="back"><button onclick={() => select(documentBack)}>{$t("shell.backTo", { name: !documentBack ? $t("shell.welcome") : documentBack.kind === "pages" ? pageBackLabel : activeDocType?.title ?? $t("shell.documents") })}</button></p>
964
993
  {:else if sel.kind === "post" && coll}
965
994
  <p class="back"><button onclick={() => select({ kind: "collection", name: coll.name })}>{$t("shell.backTo", { name: coll.label })}</button></p>
966
995
  {:else if sel.kind === "menu"}
@@ -976,8 +1005,25 @@
976
1005
  {/if}
977
1006
  <div class="titlebar">
978
1007
  <h2>{sel.kind === "page" ? page?.label : sel.kind === "menu" ? String(draft.title || menu?.slug || "") : sel.kind === "post" ? String(draft.title || post?.slug || "") : sel.kind === "document" ? activeDocType?.singleton ? activeDocType.title : String(draft.title || draft.name || activeDocument?.slug || sel.id) : listTitle}</h2>
979
- {#if (sel?.kind === "collection" && coll) || (sel?.kind === "pages" && pageDocType?.creatable !== false && pageDocType) || (sel?.kind === "documents" && activeDocType && activeDocType.creatable !== false && !activeDocType.singleton)}
980
- <button class="primary" onclick={() => (sel?.kind === "collection" ? newPost(coll!) : newPage(sel?.kind === "documents" ? activeDocType : pageDocType))}>{$t("shell.addNew")}</button>
1008
+ {#if sel.kind === "pages"}
1009
+ {#if templates.length}
1010
+ <button class="primary" onclick={() => newPage(pageDocType, true)}>{$t("shell.addNew")}</button>
1011
+ {/if}
1012
+ <div class="pages-controls">
1013
+ {#if categories.length}
1014
+ <Select ariaLabel={$t("shell.category")} value={JSON.stringify(pageCategory)} options={[{ value: "null", label: $t("shell.allPages") }, { value: JSON.stringify(""), label: $t("shell.coreCategory") }, ...categories.map(category => ({ value: JSON.stringify(category), label: category }))]} onchange={(value) => { if (typeof value === "string") { pagesFilter = JSON.parse(value); select({ kind: "pages" }); } }} />
1015
+ {/if}
1016
+ <div class="pages-search">
1017
+ <Icon name="magnifier" size={18} />
1018
+ <input type="search" aria-label={$t("shell.searchPages")} placeholder={$t("shell.searchPages")} bind:value={pagesQuery} bind:this={pagesSearch} />
1019
+ {#if pagesQuery}
1020
+ <button type="button" class="icon-btn" aria-label={$t("fields.clear")} onclick={() => { pagesQuery = ""; pagesSearch.focus(); }}><Icon name="x" size={16} /></button>
1021
+ {/if}
1022
+ </div>
1023
+ </div>
1024
+ {/if}
1025
+ {#if (sel?.kind === "collection" && coll) || (sel?.kind === "documents" && activeDocType && activeDocType.creatable !== false && !activeDocType.singleton)}
1026
+ <button class="primary" onclick={() => (sel?.kind === "collection" ? newPost(coll!) : newPage(sel?.kind === "documents" ? activeDocType : pageDocType, sel?.kind === "pages"))}>{$t("shell.addNew")}</button>
981
1027
  {/if}
982
1028
  {#if sel?.kind === "post" && post}
983
1029
  <button class="icon-btn" aria-label={$t("shell.deletePost")} data-tooltip={$t("shell.deletePost")} disabled={saveState === "saving"} onclick={deletePost}>
@@ -1003,6 +1049,7 @@
1003
1049
  {:else if sel?.kind === "media"}
1004
1050
  <ImagePicker inline />
1005
1051
  {:else if sel?.kind === "pages" || sel?.kind === "documents" || sel?.kind === "menus" || (sel?.kind === "collection" && coll)}
1052
+
1006
1053
  <div class="table-card">
1007
1054
  <table>
1008
1055
  <thead>
@@ -1039,7 +1086,7 @@
1039
1086
  {/each}
1040
1087
  </tr>
1041
1088
  {:else}
1042
- <tr><td colspan={columns.length}>{$t("shell.noEntries")}</td></tr>
1089
+ <tr><td colspan={columns.length}>{$t(sel?.kind === "pages" && pagesQuery.trim() ? "shell.noResults" : "shell.noEntries")}</td></tr>
1043
1090
  {/each}
1044
1091
  </tbody>
1045
1092
  </table>
@@ -1144,7 +1191,7 @@
1144
1191
  {/if}
1145
1192
 
1146
1193
  {#if settingsOpen}
1147
- <Settings bind:s={settings} github={githubMode ? manifest.github : undefined} user={githubAccess?.user} error={settingsError} saving={saveState === "saving"} onerase={eraseBrowserData} onclose={() => (settingsOpen = false)} />
1194
+ <Settings bind:s={settings} search={manifest.search} contentLocales={manifest.contentLocales} github={githubMode ? manifest.github : undefined} user={githubAccess?.user} error={settingsError} saving={saveState === "saving"} onerase={eraseBrowserData} onclose={() => (settingsOpen = false)} />
1148
1195
  {/if}
1149
1196
  {#if shortcutsOpen}
1150
1197
  <Shortcuts onclose={() => (shortcutsOpen = false)} />
@@ -1,4 +1,12 @@
1
1
  {
2
+ "shell.searchScope": "Inhaltssprachen durchsuchen",
3
+ "shell.searchScopeDefault": "Website-Standard ({scope})",
4
+ "shell.searchScopeAll": "Alle Sprachen",
5
+ "shell.searchScopeCurrent": "Aktuelle Inhaltssprache",
6
+ "shell.coreCategory": "Standard",
7
+ "shell.searchPages": "Seiten suchen",
8
+ "shell.template": "Vorlage",
9
+ "shell.chooseTemplate": "Vorlage auswählen",
2
10
  "shell.search": "Suchen",
3
11
  "shell.searchShortcut": "⌘/Ctrl + K",
4
12
  "shell.closeDialog": "Dialog schließen",
@@ -21,7 +29,7 @@
21
29
  "shell.searchNavigate": "navigieren",
22
30
  "shell.searchOpen": "öffnen",
23
31
  "shell.searchClose": "schließen",
24
- "shell.appearance": "Darstellung",
32
+ "shell.interface": "Benutzeroberfläche",
25
33
  "shell.editor": "Editor",
26
34
  "shell.connection": "Verbindung",
27
35
  "shell.media": "Medien",
@@ -1,4 +1,12 @@
1
1
  {
2
+ "shell.searchScope": "Search content languages",
3
+ "shell.searchScopeDefault": "Website default ({scope})",
4
+ "shell.searchScopeAll": "All languages",
5
+ "shell.searchScopeCurrent": "Current content language",
6
+ "shell.coreCategory": "Core",
7
+ "shell.searchPages": "Search pages",
8
+ "shell.template": "Template",
9
+ "shell.chooseTemplate": "Choose a template",
2
10
  "shell.search": "Search",
3
11
  "shell.searchShortcut": "⌘/Ctrl + K",
4
12
  "shell.closeDialog": "Close dialog",
@@ -21,7 +29,7 @@
21
29
  "shell.searchNavigate": "navigate",
22
30
  "shell.searchOpen": "open",
23
31
  "shell.searchClose": "close",
24
- "shell.appearance": "Appearance",
32
+ "shell.interface": "Interface",
25
33
  "shell.editor": "Editor",
26
34
  "shell.connection": "Connection",
27
35
  "shell.media": "Media",
@@ -1,4 +1,12 @@
1
1
  {
2
+ "shell.searchScope": "Idiomas del contenido de búsqueda",
3
+ "shell.searchScopeDefault": "Predeterminado del sitio ({scope})",
4
+ "shell.searchScopeAll": "Todos los idiomas",
5
+ "shell.searchScopeCurrent": "Idioma actual del contenido",
6
+ "shell.coreCategory": "Principales",
7
+ "shell.searchPages": "Buscar páginas",
8
+ "shell.template": "Plantilla",
9
+ "shell.chooseTemplate": "Elige una plantilla",
2
10
  "shell.search": "Buscar",
3
11
  "shell.searchShortcut": "⌘/Ctrl + K",
4
12
  "shell.closeDialog": "Cerrar diálogo",
@@ -21,7 +29,7 @@
21
29
  "shell.searchNavigate": "navegar",
22
30
  "shell.searchOpen": "abrir",
23
31
  "shell.searchClose": "cerrar",
24
- "shell.appearance": "Apariencia",
32
+ "shell.interface": "Interfaz",
25
33
  "shell.editor": "Editor",
26
34
  "shell.connection": "Conexión",
27
35
  "shell.media": "Archivos multimedia",
@@ -1,4 +1,12 @@
1
1
  {
2
+ "shell.searchScope": "Langues du contenu recherché",
3
+ "shell.searchScopeDefault": "Par défaut du site ({scope})",
4
+ "shell.searchScopeAll": "Toutes les langues",
5
+ "shell.searchScopeCurrent": "Langue actuelle du contenu",
6
+ "shell.coreCategory": "Principales",
7
+ "shell.searchPages": "Rechercher des pages",
8
+ "shell.template": "Modèle",
9
+ "shell.chooseTemplate": "Choisir un modèle",
2
10
  "shell.search": "Rechercher",
3
11
  "shell.searchShortcut": "⌘/Ctrl + K",
4
12
  "shell.closeDialog": "Fermer la boîte de dialogue",
@@ -21,7 +29,7 @@
21
29
  "shell.searchNavigate": "parcourir",
22
30
  "shell.searchOpen": "ouvrir",
23
31
  "shell.searchClose": "fermer",
24
- "shell.appearance": "Apparence",
32
+ "shell.interface": "Interface",
25
33
  "shell.editor": "Éditeur",
26
34
  "shell.connection": "Connexion",
27
35
  "shell.media": "Médias",
@@ -1,4 +1,12 @@
1
1
  {
2
+ "shell.searchScope": "Lingue dei contenuti da cercare",
3
+ "shell.searchScopeDefault": "Predefinito del sito ({scope})",
4
+ "shell.searchScopeAll": "Tutte le lingue",
5
+ "shell.searchScopeCurrent": "Lingua attuale del contenuto",
6
+ "shell.coreCategory": "Principali",
7
+ "shell.searchPages": "Cerca pagine",
8
+ "shell.template": "Modello",
9
+ "shell.chooseTemplate": "Scegli un modello",
2
10
  "shell.search": "Cerca",
3
11
  "shell.searchShortcut": "⌘/Ctrl + K",
4
12
  "shell.closeDialog": "Chiudi finestra",
@@ -21,7 +29,7 @@
21
29
  "shell.searchNavigate": "naviga",
22
30
  "shell.searchOpen": "apri",
23
31
  "shell.searchClose": "chiudi",
24
- "shell.appearance": "Aspetto",
32
+ "shell.interface": "Interfaccia",
25
33
  "shell.editor": "Editor",
26
34
  "shell.connection": "Connessione",
27
35
  "shell.media": "Media",
@@ -1,4 +1,12 @@
1
1
  {
2
+ "shell.searchScope": "検索するコンテンツの言語",
3
+ "shell.searchScopeDefault": "サイトの既定値({scope})",
4
+ "shell.searchScopeAll": "すべての言語",
5
+ "shell.searchScopeCurrent": "現在のコンテンツ言語",
6
+ "shell.coreCategory": "基本",
7
+ "shell.searchPages": "ページを検索",
8
+ "shell.template": "テンプレート",
9
+ "shell.chooseTemplate": "テンプレートを選択",
2
10
  "shell.search": "検索",
3
11
  "shell.searchShortcut": "⌘/Ctrl + K",
4
12
  "shell.closeDialog": "ダイアログを閉じる",
@@ -21,7 +29,7 @@
21
29
  "shell.searchNavigate": "移動",
22
30
  "shell.searchOpen": "開く",
23
31
  "shell.searchClose": "閉じる",
24
- "shell.appearance": "外観",
32
+ "shell.interface": "インターフェース",
25
33
  "shell.editor": "エディター",
26
34
  "shell.connection": "接続",
27
35
  "shell.media": "メディア",
@@ -1,4 +1,12 @@
1
1
  {
2
+ "shell.searchScope": "Inhoudstalen doorzoeken",
3
+ "shell.searchScopeDefault": "Websitestandaard ({scope})",
4
+ "shell.searchScopeAll": "Alle talen",
5
+ "shell.searchScopeCurrent": "Huidige inhoudstaal",
6
+ "shell.coreCategory": "Standaard",
7
+ "shell.searchPages": "Pagina’s zoeken",
8
+ "shell.template": "Sjabloon",
9
+ "shell.chooseTemplate": "Kies een sjabloon",
2
10
  "shell.search": "Zoeken",
3
11
  "shell.searchShortcut": "⌘/Ctrl + K",
4
12
  "shell.closeDialog": "Dialoogvenster sluiten",
@@ -21,7 +29,7 @@
21
29
  "shell.searchNavigate": "navigeren",
22
30
  "shell.searchOpen": "openen",
23
31
  "shell.searchClose": "sluiten",
24
- "shell.appearance": "Weergave",
32
+ "shell.interface": "Interface",
25
33
  "shell.editor": "Editor",
26
34
  "shell.connection": "Verbinding",
27
35
  "shell.media": "Media",
@@ -1,4 +1,12 @@
1
1
  {
2
+ "shell.searchScope": "Языки содержимого для поиска",
3
+ "shell.searchScopeDefault": "По умолчанию на сайте ({scope})",
4
+ "shell.searchScopeAll": "Все языки",
5
+ "shell.searchScopeCurrent": "Текущий язык содержимого",
6
+ "shell.coreCategory": "Основные",
7
+ "shell.searchPages": "Поиск страниц",
8
+ "shell.template": "Шаблон",
9
+ "shell.chooseTemplate": "Выберите шаблон",
2
10
  "shell.search": "Поиск",
3
11
  "shell.searchShortcut": "⌘/Ctrl + K",
4
12
  "shell.closeDialog": "Закрыть диалог",
@@ -21,7 +29,7 @@
21
29
  "shell.searchNavigate": "перемещение",
22
30
  "shell.searchOpen": "открыть",
23
31
  "shell.searchClose": "закрыть",
24
- "shell.appearance": "Оформление",
32
+ "shell.interface": "Интерфейс",
25
33
  "shell.editor": "Редактор",
26
34
  "shell.connection": "Подключение",
27
35
  "shell.media": "Медиафайлы",
@@ -1,4 +1,12 @@
1
1
  {
2
+ "shell.searchScope": "搜索内容语言",
3
+ "shell.searchScopeDefault": "网站默认({scope})",
4
+ "shell.searchScopeAll": "所有语言",
5
+ "shell.searchScopeCurrent": "当前内容语言",
6
+ "shell.coreCategory": "基础",
7
+ "shell.searchPages": "搜索页面",
8
+ "shell.template": "模板",
9
+ "shell.chooseTemplate": "选择模板",
2
10
  "shell.search": "搜索",
3
11
  "shell.searchShortcut": "⌘/Ctrl + K",
4
12
  "shell.closeDialog": "关闭对话框",
@@ -21,7 +29,7 @@
21
29
  "shell.searchNavigate": "切换",
22
30
  "shell.searchOpen": "打开",
23
31
  "shell.searchClose": "关闭",
24
- "shell.appearance": "外观",
32
+ "shell.interface": "界面",
25
33
  "shell.editor": "编辑器",
26
34
  "shell.connection": "连接",
27
35
  "shell.media": "媒体",
@@ -1,9 +1,13 @@
1
1
  <script lang="ts">
2
2
  import { t } from "../i18n";
3
3
  import Icon from "./Icon.svelte";
4
- let { title, description, confirmLabel, fieldLabel, danger = false, onconfirm, onclose }: {
4
+ import Select from "./Select.svelte";
5
+ import { untrack } from "svelte";
6
+ import type { DocumentDef } from "../../schema/types";
7
+ let { title, description, confirmLabel, fieldLabel, danger = false, templates, templateCategory, onconfirm, onclose }: {
5
8
  title: string; description: string; confirmLabel: string; fieldLabel?: string; danger?: boolean;
6
- onconfirm: (value: string) => void | Promise<void>; onclose: () => void;
9
+ templates?: DocumentDef[]; templateCategory?: string;
10
+ onconfirm: (value: string, type?: string) => void | Promise<void>; onclose: () => void;
7
11
  } = $props();
8
12
  let dialog: HTMLDialogElement;
9
13
  let cancel: HTMLButtonElement;
@@ -11,6 +15,11 @@
11
15
  let value = $state("");
12
16
  let busy = $state(false);
13
17
  let error = $state("");
18
+ let category = $state(untrack(() => templateCategory ?? ""));
19
+ let selectedTemplate = $state("");
20
+ const categories = $derived([...new Set((templates ?? []).map(type => type.category ?? ""))].sort());
21
+ const candidates = $derived((templates ?? []).filter(type => (type.category ?? "") === category));
22
+ const template = $derived(candidates.length === 1 ? candidates[0].name : selectedTemplate);
14
23
  $effect(() => {
15
24
  dialog.showModal();
16
25
  (fieldLabel ? input : cancel).focus();
@@ -20,7 +29,10 @@
20
29
  if (busy) return;
21
30
  busy = true;
22
31
  error = "";
23
- try { await onconfirm(value.trim()); onclose(); }
32
+ try {
33
+ if (templates && !candidates.some(type => type.name === template)) throw new Error($t("shell.chooseTemplate"));
34
+ await onconfirm(value.trim(), template); onclose();
35
+ }
24
36
  catch (cause) { error = cause instanceof Error ? cause.message : $t("connection.actionFailed"); }
25
37
  finally { busy = false; }
26
38
  }
@@ -31,6 +43,14 @@
31
43
  <div class="action-head"><h2 id="action-title">{title}</h2><button type="button" class="icon-btn" aria-label={$t("connection.cancel")} disabled={busy} onclick={onclose}><Icon name="x" size={16} /></button></div>
32
44
  <p id="action-description">{description}</p>
33
45
  {#if fieldLabel}<label><span>{fieldLabel}</span><input type="text" bind:this={input} bind:value required disabled={busy} /></label>{/if}
46
+ {#if templates}
47
+ {#if categories.some(category => category !== "")}
48
+ <Select label={$t("shell.category")} value={category} options={categories.map(value => ({ value, label: value || $t("shell.coreCategory") }))} disabled={busy} onchange={(value) => { if (typeof value === "string") { category = value; selectedTemplate = ""; error = ""; } }} />
49
+ {/if}
50
+ {#if candidates.length > 1}
51
+ <Select label={$t("shell.template")} value={selectedTemplate} options={[{ value: "", label: $t("shell.chooseTemplate"), disabled: true }, ...candidates.map(type => ({ value: type.name, label: type.title }))]} disabled={busy} onchange={(value) => { if (typeof value === "string") selectedTemplate = value; }} />
52
+ {/if}
53
+ {/if}
34
54
  {#if error}<p class="error" role="alert">{error}</p>{/if}
35
55
  <div class="action-actions">
36
56
  <button type="button" bind:this={cancel} disabled={busy} onclick={onclose}>{$t("connection.cancel")}</button>
@@ -14,7 +14,7 @@
14
14
  import type { SelectionBookmark } from "prosemirror-state";
15
15
  import { parseVisualMarkdown, serializeMarkdown, safeMarkdownUrl, schema } from "./rich-markdown";
16
16
 
17
- let { value, label, oninput }: { value: string; label: string; oninput: (value: string) => void } = $props();
17
+ let { value, label, oninput, editableRoot }: { value: string; label: string; oninput: (value: string) => void; editableRoot?: HTMLElement } = $props();
18
18
  const id = $props.id();
19
19
  let host: HTMLDivElement;
20
20
  let mode = $state<"visual" | "source">("visual");
@@ -66,9 +66,10 @@
66
66
  }
67
67
 
68
68
  $effect(() => {
69
- if (!host) return;
69
+ if (!editableRoot && !host) return;
70
70
  if (!view) {
71
- view = new EditorView(host, {
71
+ if (editableRoot) editableRoot.classList.add("rigel-markdown-content");
72
+ view = new EditorView(editableRoot ? { mount: editableRoot } : host, {
72
73
  state: EditorState.create({ schema }),
73
74
  attributes: { role: "textbox", "aria-label": label, "aria-multiline": "true" },
74
75
  dispatchTransaction(transaction) {
@@ -92,6 +93,7 @@
92
93
  load(value);
93
94
  }
94
95
  });
96
+ $effect(() => { const editable = mode === "visual"; view?.setProps({ editable: () => editable }); });
95
97
  onDestroy(() => view?.destroy());
96
98
 
97
99
  function activeMark(type: string) {
@@ -162,7 +164,7 @@
162
164
 
163
165
  <svelte:window onpointerdown={event => { if (linkOpen && !document.getElementById(`${id}-link`)?.contains(event.target as Node) && !linkButton?.contains(event.target as Node)) linkOpen = false; }} />
164
166
 
165
- <div class="markdown-editor body-editor">
167
+ <div class="markdown-editor body-editor" class:markdown-inline={!!editableRoot}>
166
168
  <div class="markdown-heading">
167
169
  <div class="field-label" id={`${id}-label`}>{label}</div>
168
170
  <div class="markdown-modes" role="group" aria-label={$t("richtext.editingMode", { label })}>
@@ -199,7 +201,7 @@
199
201
  {#if linkError}<p id={`${id}-error`} role="alert">{linkError}</p>{/if}
200
202
  </div>
201
203
  {/if}
202
- <div class="md-preview rich-text" bind:this={host}></div>
204
+ {#if !editableRoot}<div class="md-preview rich-text" bind:this={host}></div>{/if}
203
205
  </div>
204
206
  {#if mode === "source"}
205
207
  <textarea class="mono" rows="14" aria-label={$t("richtext.markdownSource", { label })} value={value} oninput={(event) => { warning = ""; emit(event.currentTarget.value); }}></textarea>
@@ -5,9 +5,13 @@
5
5
  import type { Manifest } from "../../manifest/types";
6
6
  import type { GitHubAccess } from "./github-auth";
7
7
  import Icon from "./Icon.svelte";
8
+ import Select from "./Select.svelte";
9
+ import { effectiveSearchScope } from "../../manifest/search";
8
10
 
9
- let { s = $bindable(), github, user, error, saving, onerase, onclose }: {
11
+ let { s = $bindable(), search, contentLocales, github, user, error, saving, onerase, onclose }: {
10
12
  s: RigelSettings;
13
+ search?: Manifest["search"];
14
+ contentLocales?: Manifest["contentLocales"];
11
15
  github?: Manifest["github"];
12
16
  user?: GitHubAccess["user"];
13
17
  error: string;
@@ -16,8 +20,8 @@
16
20
  onclose: () => void;
17
21
  } = $props();
18
22
 
19
- type PaneId = "appearance" | "editor" | "connection" | "media" | "language" | "advanced";
20
- let active: PaneId = $state("appearance");
23
+ type PaneId = "interface" | "content" | "connection" | "media" | "advanced";
24
+ let active: PaneId = $state("interface");
21
25
 
22
26
  const providerIds = Object.keys(STOCK_PROVIDERS) as (keyof typeof STOCK_PROVIDERS)[];
23
27
  let keys = $state(getKeys());
@@ -26,20 +30,20 @@
26
30
  let eraseError = $state("");
27
31
 
28
32
  const panes: { id: PaneId; label: string }[] = $derived([
29
- { id: "appearance", label: $t("shell.appearance") },
30
- { id: "editor", label: $t("shell.editor") },
33
+ { id: "interface", label: $t("shell.interface") },
34
+ { id: "content", label: $t("shell.content") },
31
35
  { id: "connection", label: $t("shell.connection") },
32
36
  { id: "media", label: $t("shell.media") },
33
- { id: "language", label: $t("shell.language") },
37
+
34
38
  { id: "advanced", label: $t("shell.advanced") },
35
39
  ]);
36
40
 
37
41
  const PANE_ICONS = {
38
- appearance: "palette",
39
- editor: "textB",
42
+ interface: "palette",
43
+ content: "fileText",
40
44
  connection: "user",
41
45
  media: "image",
42
- language: "translate",
46
+
43
47
  advanced: "wrench",
44
48
  } as const;
45
49
 
@@ -71,6 +75,7 @@
71
75
  {#each panes as p (p.id)}
72
76
  <button
73
77
  class:active={active === p.id}
78
+ aria-current={active === p.id ? "true" : undefined}
74
79
  onclick={() => (active = p.id)}
75
80
  >
76
81
  <span class="pico"><Icon name={PANE_ICONS[p.id]} size={24} /></span>
@@ -79,9 +84,9 @@
79
84
  {/each}
80
85
  </nav>
81
86
 
82
- <div class="settings-pane">
83
- {#if active === "appearance"}
84
- <h3>{$t("shell.appearance")}</h3>
87
+ <div class="settings-pane" role="region" aria-label={panes.find(p => p.id === active)?.label}>
88
+ {#if active === "interface"}
89
+
85
90
  <fieldset>
86
91
  <legend>{$t("shell.theme")}</legend>
87
92
  <div class="theme-options">
@@ -101,17 +106,30 @@
101
106
  </label>
102
107
  <p class="hint" id="high-contrast-hint">{$t("shell.highContrastHint")}</p>
103
108
  </fieldset>
104
-
105
- {:else if active === "editor"}
106
- <h3>{$t("shell.editorExperience")}</h3>
107
109
  <fieldset>
108
- <legend>{$t("shell.articleBodyEditing")}</legend>
109
- <label class="radio"><input type="radio" bind:group={s.userPref.editorExperience} value="sidebar" /><span>{$t("shell.sidebar")}</span></label>
110
- <label class="radio"><input type="radio" bind:group={s.userPref.editorExperience} value="inline" /><span>{$t("shell.inlinePreview")}</span></label>
110
+ <legend>{$t("shell.editorInterfaceLanguage")}</legend>
111
+ {#each availableLocales as lang}
112
+ <label class="radio">
113
+ <input type="radio" bind:group={s.language} value={lang} />
114
+ <span>{localeNames[lang]}</span>
115
+ </label>
116
+ {/each}
111
117
  </fieldset>
118
+
119
+ {:else if active === "content"}
120
+
121
+ <Select label={$t("shell.articleBodyEditing")} value={s.userPref.editorExperience ?? "inline"} options={[
122
+ { value: "sidebar", label: $t("shell.sidebar") },
123
+ { value: "inline", label: $t("shell.inlinePreview") },
124
+ ]} onchange={value => s.userPref.editorExperience = value === "sidebar" ? "sidebar" : "inline"} />
112
125
  <p class="hint">{$t("shell.editorExperienceHint")}</p>
126
+ <Select label={$t("shell.searchScope")} value={s.userPref.searchScope ?? "inherit"} options={[
127
+ { value: "inherit", label: $t("shell.searchScopeDefault", { scope: $t(effectiveSearchScope(search, undefined) === "all" ? "shell.searchScopeAll" : "shell.searchScopeCurrent") }) },
128
+ { value: "all", label: $t("shell.searchScopeAll") },
129
+ { value: "currentLocale", label: $t("shell.searchScopeCurrent"), disabled: !contentLocales || contentLocales.locales.length < 2 },
130
+ ]} onchange={value => s.userPref.searchScope = value === "all" || value === "currentLocale" ? value : undefined} />
113
131
  {:else if active === "connection"}
114
- <h3>{$t("shell.connection")}</h3>
132
+
115
133
  {#if github}
116
134
  <dl class="connection-details">
117
135
  <dt>{$t("shell.githubAccount")}</dt>
@@ -165,17 +183,6 @@
165
183
 
166
184
  </section>
167
185
 
168
- {:else if active === "language"}
169
- <h3>{$t("shell.language")}</h3>
170
- <fieldset>
171
- <legend>{$t("shell.editorInterfaceLanguage")}</legend>
172
- {#each availableLocales as lang}
173
- <label class="radio">
174
- <input type="radio" bind:group={s.language} value={lang} />
175
- <span>{localeNames[lang]}</span>
176
- </label>
177
- {/each}
178
- </fieldset>
179
186
 
180
187
  {:else}
181
188
  <h3>{$t("shell.developerMode")}</h3>
@@ -4,6 +4,8 @@
4
4
  import SidebarIcon from "./SidebarIcon.svelte";
5
5
  import { tick } from "svelte";
6
6
  import type { Manifest } from "../../manifest/types";
7
+ import { searchIndex, type SearchHit } from "../../manifest/search";
8
+ import type { SearchScope } from "../../config";
7
9
 
8
10
  type Sel =
9
11
  | { kind: "media" }
@@ -20,10 +22,14 @@
20
22
  manifest,
21
23
  sel,
22
24
  onselect,
25
+ scope = "all",
26
+ contentLocale,
23
27
  }: {
24
28
  manifest: Manifest;
25
29
  sel: Sel | null;
26
- onselect: (s: Sel) => void;
30
+ onselect: (s: Sel, locale?: string) => void;
31
+ scope?: SearchScope;
32
+ contentLocale?: string;
27
33
  } = $props();
28
34
 
29
35
  let open = $state(false);
@@ -35,53 +41,11 @@
35
41
  if (open) inputEl?.focus();
36
42
  });
37
43
 
38
- interface Hit {
39
- label: string;
40
- hint: string;
41
- action: () => void;
42
- }
43
-
44
- const hits = $derived.by((): Hit[] => {
45
- const q = query.trim().toLowerCase();
46
- const all: Hit[] = [];
47
- for (const p of manifest.pages) {
48
- all.push({
49
- label: p.label,
50
- hint: $t("shell.page"),
51
- action: () => onselect({ kind: "page", slug: p.slug }),
52
- });
53
- }
54
- for (const doc of manifest.documents ?? []) {
55
- all.push({
56
- label: String(doc.data.title ?? doc.data.name ?? doc.slug),
57
- hint: $t("shell.searchItemHint", { name: doc.type, slug: doc.slug }),
58
- action: () => onselect({ kind: "document", type: doc.type, id: doc.id }),
59
- });
60
- }
61
- for (const c of manifest.collections ?? []) {
62
- all.push({
63
- label: c.label,
64
- hint: $t("shell.collection"),
65
- action: () => onselect({ kind: "collection", name: c.name }),
66
- });
67
- for (const po of c.posts) {
68
- all.push({
69
- label: String(po.frontmatter.title ?? po.slug),
70
- hint: $t("shell.searchItemHint", { name: c.label, slug: po.slug }),
71
- action: () => onselect({ kind: "post", collection: c.name, slug: po.slug }),
72
- });
73
- }
74
- }
75
- if (manifest.menus) {
76
- all.push({ label: $t("shell.menus"), hint: $t("shell.collection"), action: () => onselect({ kind: "menus" }) });
77
- for (const m of manifest.menus.entries) all.push({ label: m.title, hint: $t("shell.searchMenuHint", { slug: m.slug }), action: () => onselect({ kind: "menu", slug: m.slug }) });
78
- }
79
- if (!q) return all.slice(0, 12);
80
- return all.filter((h) => h.label.toLowerCase().includes(q) || h.hint.toLowerCase().includes(q)).slice(0, 12);
81
- });
44
+ const index = $derived(searchIndex(manifest, scope, contentLocale, $t));
45
+ const hits = $derived(index.filter(hit => [hit.label, hit.hint, hit.language ?? ""].some(value => value.toLowerCase().includes(query.trim().toLowerCase()))).slice(0, 12));
82
46
 
83
- function trigger(h: Hit) {
84
- h.action();
47
+ function trigger(h: SearchHit) {
48
+ onselect(h.selection, h.locale);
85
49
  open = false;
86
50
  query = "";
87
51
  active = -1;
@@ -145,10 +109,10 @@
145
109
  <kbd>{$t("shell.escapeKeyLowercase")}</kbd>
146
110
  </div>
147
111
  <ul id="rigel-search-results" role="listbox" aria-label={$t("shell.searchResults")}>
148
- {#each hits as h, i (h.hint + h.label)}
112
+ {#each hits as h, i (h.id)}
149
113
  <li role="presentation">
150
114
  <button id={`rigel-search-hit-${i}`} role="option" aria-selected={active === i} onfocus={() => active = i} onclick={() => trigger(h)}>
151
- <span>{h.label}</span>
115
+ <span>{h.label}{#if h.language}<span class="badge search-language" title={h.language} aria-label={h.language}>{h.locale?.slice(0, 2).toUpperCase()}</span>{/if}</span>
152
116
  <span class="hint">{h.hint}</span>
153
117
  </button>
154
118
  </li>
@@ -52,7 +52,7 @@
52
52
  let bodyScope: HTMLElement | null = null;
53
53
  let removeTooltips: (() => void) | undefined;
54
54
  let mode = $state<"browse" | "edit">("edit");
55
- let panelOpen = $state(true);
55
+ let panelOpen = $state(false);
56
56
  let editorWidth = $state(0);
57
57
  let requestedWidth = $state(340);
58
58
  let resizing = $state(false);
@@ -262,19 +262,21 @@
262
262
  clearHover(); clearHighlight();
263
263
  editingBody = element; bodyContents = [...element.childNodes];
264
264
  const scope = doc.createElement("div");
265
+ bodyAttributes = ["class", "style", "role", "aria-label", "aria-multiline", "contenteditable", "tabindex", "translate"].map(name => [name, element.getAttribute(name)]);
265
266
  const syncTheme = () => { scope.dataset.theme = document.documentElement.dataset.theme; scope.dataset.contrast = document.documentElement.dataset.contrast; };
266
267
  syncTheme();
267
268
  const host = doc.createElement("div"); host.className = "rigel rigel-inline";
268
- scope.append(host); element.replaceChildren(scope); bodyScope = scope;
269
+ scope.append(host); doc.body.append(scope); bodyScope = scope;
269
270
  removeTooltips = tooltipRoot(host).destroy;
270
271
  bodyEditor = mount(MarkdownEditor, { target: host, props: {
272
+ editableRoot: element,
271
273
  label: def.labelKey ? $t(def.labelKey) : def.label,
272
274
  get value() { return String(anchorValue(values, field.path).value ?? ""); },
273
275
  oninput: (next: string) => onchange(replaceAt(values, field.path, next)),
274
276
  } });
275
277
  bodyThemeObserver = new MutationObserver(syncTheme);
276
278
  bodyThemeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-contrast"] });
277
- void tick().then(() => host.querySelector<HTMLElement>('[contenteditable="true"], textarea')?.focus());
279
+ void tick().then(() => (element.isContentEditable ? element : host.querySelector<HTMLTextAreaElement>("textarea"))?.focus());
278
280
  return true;
279
281
  }
280
282
 
@@ -393,7 +395,7 @@
393
395
 
394
396
  function setMode(next: "browse" | "edit") {
395
397
  mode = next;
396
- panelOpen = next === "edit";
398
+ panelOpen = false;
397
399
  if (next === "browse") {
398
400
  clearHighlight();
399
401
  clearHover();
@@ -3,6 +3,7 @@ import type { StockKeys } from "./images";
3
3
  import { availableLocales, setLocale, type Locale } from "../i18n";
4
4
 
5
5
  export interface RigelUserPref {
6
+ searchScope?: "all" | "currentLocale";
6
7
  sidebarCollapsed?: boolean;
7
8
  editorExperience?: "sidebar" | "inline";
8
9
  previewSplit: number;
@@ -46,6 +47,7 @@ export function getSettings(): RigelSettings {
46
47
  highContrast: typeof saved?.highContrast === "boolean" ? saved.highContrast : DEFAULTS.highContrast,
47
48
  apiKeys: mergedKeys,
48
49
  userPref: {
50
+ searchScope: saved?.userPref?.searchScope === "all" || saved?.userPref?.searchScope === "currentLocale" ? saved.userPref.searchScope : undefined,
49
51
  sidebarCollapsed: saved?.userPref?.sidebarCollapsed === true,
50
52
  editorExperience: saved?.userPref?.editorExperience === "sidebar" ? "sidebar" : DEFAULTS.userPref.editorExperience,
51
53
  previewSplit: typeof saved?.userPref?.previewSplit === "number" && Number.isFinite(saved.userPref.previewSplit) && saved.userPref.previewSplit >= 0.2 && saved.userPref.previewSplit <= 0.8
@@ -408,6 +408,15 @@
408
408
  .rigel .titlebar { display: flex; align-items: center; flex-wrap: wrap; gap: 0.75rem 1.5rem; }
409
409
  .rigel .titlebar h2 { min-width: 0; overflow-wrap: anywhere; }
410
410
  .rigel .titlebar > button { flex-shrink: 0; }
411
+ .rigel .pages-controls button[role="combobox"], .rigel .pages-search input[type="search"] { padding-top: 0.375rem; padding-bottom: 0.375rem; font-size: 0.8125rem; line-height: normal; }
412
+ .rigel .pages-controls { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 0.75rem; margin-left: auto; min-width: 0; max-width: 100%; }
413
+ .rigel .pages-controls .custom-select { max-width: 100%; }
414
+ .rigel .pages-search { position: relative; width: 240px; max-width: 100%; color: var(--text-muted); }
415
+ .rigel .pages-search > svg { position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); pointer-events: none; }
416
+ .rigel .pages-search input[type="search"] { padding-left: 2.25rem; padding-right: 2.75rem; }
417
+ .rigel .pages-search input::-webkit-search-cancel-button { appearance: none; }
418
+ .rigel .pages-search .icon-btn { position: absolute; right: 0; top: 0; height: 100%; width: 2.5rem; }
419
+
411
420
  .rigel .editor-actions { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem; }
412
421
  .rigel .editor-actions .back { display: flex; min-width: 0; margin: 0; }
413
422
  .rigel .editor-actions .back button, .rigel .editor-actions .save-button { height: 2.25rem; padding: 0.5rem 0.75rem; border-width: 1px; border-style: solid; font-family: inherit; font-size: 0.8125rem; font-weight: 500; line-height: 1.25; }
@@ -567,6 +576,7 @@
567
576
  .rigel .palette li button:hover,
568
577
  .rigel .palette li button:focus-visible { background: var(--accent-soft); color: var(--on-accent-soft); }
569
578
  .rigel .palette .hint { color: var(--text-muted); font-size: 0.75rem; font-weight: 400; }
579
+ .rigel .palette .search-language { margin-left: 0.5rem; }
570
580
  .rigel .palette li button[aria-selected="true"] .hint,
571
581
  .rigel .palette li button:hover .hint,
572
582
  .rigel .palette li button:focus-visible .hint { color: var(--on-accent-soft); }
@@ -1183,6 +1193,9 @@
1183
1193
  .rigel.rigel-inline .markdown-link, .rigel .block-editor .markdown-link { position: fixed; inset: auto; width: 340px; max-width: calc(100vw - 16px); z-index: 121; }
1184
1194
  .rigel.rigel-inline .block-markdown-view { position: fixed; inset: 12px; max-height: calc(100vh - 24px); overflow: auto; padding: 0.5rem; border: 1px solid var(--border-control); background: var(--panel); }
1185
1195
  .rigel-article-content:focus { outline: 0; }
1196
+ .rigel.rigel-inline .markdown-inline { position: fixed; top: 8px; left: 8px; width: max-content; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); overflow: auto; background: var(--panel); border: 1px solid var(--border-control); box-shadow: var(--shadow-lg); pointer-events: auto; }
1197
+ .rigel .markdown-editor [hidden] { display: none !important; }
1198
+ .rigel-markdown-content { white-space: pre-wrap; overflow-wrap: anywhere; }
1186
1199
  :is(.rigel .block-content, .rigel-article-content)::highlight(rigel-link-selection), :is(.rigel .block-content, .rigel-article-content) ::highlight(rigel-link-selection) { background-color: Highlight; color: HighlightText; }
1187
1200
  .rigel-article-content:focus-visible > [data-rigel-active-block] { outline: 3px solid Highlight; outline-offset: 3px; }
1188
1201
  .rigel-article-content .block-bold { font-weight: 700; }
@@ -1316,6 +1329,7 @@
1316
1329
  .rigel .reorder-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
1317
1330
  .rigel .structure-list li.active { border-color: var(--accent); }
1318
1331
  .rigel .structure-row { flex: 1; min-width: 0; display: flex; justify-content: space-between; gap: 0.5rem; align-items: center; border: 0; border-radius: 0; background: transparent; color: var(--text); padding: 0.5rem; text-align: left; cursor: pointer; font-size: 0.8125rem; }
1332
+ .rigel .structure-row > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1319
1333
  .rigel .structure-actions, .rigel .schema-item-actions { display: flex; align-items: center; }
1320
1334
  .rigel .structure-actions button, .rigel .schema-item-actions button { border: 0; border-left: 1px solid var(--border); border-radius: 0; background: transparent; color: var(--text-2); cursor: pointer; }
1321
1335
  .rigel .structure-actions button:disabled, .rigel .schema-item-actions button:disabled { opacity: 0.35; cursor: default; }
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, ContentLocalesConfig } from "./config";
118
+ export type { RigelConfig, CollectionConfig, ContentLocalesConfig, SearchScope } 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";
@@ -1,4 +1,5 @@
1
1
  import type { Manifest } from "./types";
2
+ import { pageCategories } from "./pages";
2
3
  import { systemMessage, type Translator } from "../editor/i18n/format";
3
4
 
4
5
  export type Selection =
@@ -31,8 +32,7 @@ export function navigationKey(target: Selection): string {
31
32
  export function buildSidebar(manifest: Manifest, message: Translator = systemMessage): SidebarNode[] {
32
33
  const link = (target: SidebarTarget, label: string): SidebarLink => ({ kind: "link", target, label });
33
34
  const types = manifest.schema?.documents ?? [];
34
- const pages = manifest.pages.filter(page => !page.shared);
35
- const categories = [...new Set([...pages, ...types.filter(type => !type.shared)].map(entry => entry.category).filter((category): category is string => !!category))].sort();
35
+ const categories = pageCategories(manifest);
36
36
  const defaults: SidebarNode[] = [
37
37
  { kind: "heading", label: message("system.sidebar.website") },
38
38
  link({ kind: "pages" }, message("system.sidebar.pages")),
@@ -0,0 +1,13 @@
1
+ import type { Manifest } from "./types";
2
+
3
+ export function pageCategories(manifest: Manifest): string[] {
4
+ return [...new Set([...manifest.pages, ...(manifest.schema?.documents ?? [])].filter(entry => !entry.shared).map(entry => entry.category).filter((category): category is string => !!category))].sort();
5
+ }
6
+
7
+ export function pageTemplates(manifest: Manifest) {
8
+ return (manifest.schema?.documents ?? []).filter(type => !type.shared && !type.singleton && type.creatable !== false);
9
+ }
10
+
11
+ export function inPageCategory(category: string | undefined, filter: string | null): boolean {
12
+ return filter === null || (category ?? "") === filter;
13
+ }
@@ -0,0 +1,41 @@
1
+ import type { SearchScope } from "../config";
2
+ import type { Manifest } from "./types";
3
+ import type { Selection } from "./navigation";
4
+ import { contentForLocale } from "./content-locales";
5
+
6
+ export interface SearchHit {
7
+ id: string;
8
+ label: string;
9
+ hint: string;
10
+ locale?: string;
11
+ language?: string;
12
+ selection: Selection;
13
+ }
14
+
15
+ export function effectiveSearchScope(config: Manifest["search"], override: unknown): SearchScope {
16
+ return override === "all" || override === "currentLocale" ? override : config?.scope === "currentLocale" ? "currentLocale" : "all";
17
+ }
18
+
19
+ export function searchIndex(manifest: Manifest, scope: SearchScope, locale: string | undefined, t: (key: string, params?: Record<string, string>) => string): SearchHit[] {
20
+ const content = scope === "currentLocale" ? contentForLocale(manifest, locale) : manifest;
21
+ const hits = new Map<string, SearchHit>();
22
+ function add(selection: Selection, label: string, hint: string, code?: string) {
23
+ const id = JSON.stringify(selection);
24
+ const language = manifest.contentLocales && manifest.contentLocales.locales.length > 1 ? manifest.contentLocales.locales.find(entry => entry.code === code)?.label : undefined;
25
+ if (!hits.has(id)) hits.set(id, { id, label, hint, selection, locale: code, language });
26
+ }
27
+ for (const page of content.pages) add({ kind: "page", slug: page.slug }, page.label, t("shell.page"), page.locale);
28
+ for (const doc of content.documents ?? []) {
29
+ const type = content.schema?.documents.find(type => type.name === doc.type);
30
+ add({ kind: "document", type: doc.type, id: doc.id }, String(doc.data.title ?? doc.data.name ?? doc.slug), t("shell.searchItemHint", { name: doc.type, slug: doc.slug }), type?.locale);
31
+ }
32
+ for (const collection of content.collections ?? []) {
33
+ add({ kind: "collection", name: collection.name }, collection.label, t("shell.collection"), collection.locale);
34
+ for (const post of collection.posts) add({ kind: "post", collection: collection.name, slug: post.slug }, String(post.frontmatter.title ?? post.slug), t("shell.searchItemHint", { name: collection.label, slug: post.slug }), collection.locale);
35
+ }
36
+ if (content.menus) {
37
+ add({ kind: "menus" }, t("shell.menus"), t("shell.collection"));
38
+ for (const menu of content.menus.entries) add({ kind: "menu", slug: menu.slug }, menu.title, t("shell.searchMenuHint", { slug: menu.slug }));
39
+ }
40
+ return [...hits.values()];
41
+ }
@@ -38,6 +38,7 @@ export interface GitHubBackendConfig {
38
38
 
39
39
  export interface Manifest {
40
40
  contentLocales?: import("../config").ContentLocalesConfig;
41
+ search?: { scope: import("../config").SearchScope };
41
42
  backend?: "github";
42
43
  github?: GitHubBackendConfig;
43
44
  sidebar?: import("./navigation").SidebarEntry[];
@@ -169,6 +169,7 @@ export const menus = {
169
169
  demo: cfg.demo,
170
170
  languages: cfg.languages,
171
171
  contentLocales: cfg.contentLocales,
172
+ search: cfg.search,
172
173
  publicDir: publicDir?.href,
173
174
  mediaDir: cfg.mediaDir,
174
175
  maxFileSize: cfg.maxFileSize,
@@ -218,7 +219,7 @@ ${collectionCode}
218
219
  ];
219
220
  for (const collection of collections) __rigelAssertUniqueUrlSlugs(collection.posts.map(post => ({ slug: post.slug, values: post.frontmatter })));
220
221
  ${menuCode}
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) : ""} };
222
+ export default { pages, collections, documents, search: ${JSON.stringify(cfg.search)}${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) : ""} };
222
223
  `;
223
224
  },
224
225
  };