@rigel-cms/rigel 1.4.0-rc.1 → 1.4.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +4 -2
  2. package/docs/README.md +28 -0
  3. package/docs/authors.md +133 -0
  4. package/docs/content-locales.md +12 -3
  5. package/docs/content-model.md +131 -0
  6. package/docs/getting-started.md +139 -0
  7. package/docs/github.md +112 -0
  8. package/docs/media.md +75 -0
  9. package/docs/menus.md +95 -0
  10. package/docs/preview-hosts.md +78 -0
  11. package/docs/preview.md +100 -0
  12. package/docs/seo.md +87 -0
  13. package/docs/sidebar.md +97 -0
  14. package/package.json +2 -3
  15. package/src/article-render.ts +12 -5
  16. package/src/config.ts +5 -0
  17. package/src/editor/App.svelte +67 -21
  18. package/src/editor/i18n/de/fields.json +2 -0
  19. package/src/editor/i18n/de/shell.json +8 -2
  20. package/src/editor/i18n/en/fields.json +2 -0
  21. package/src/editor/i18n/en/shell.json +8 -2
  22. package/src/editor/i18n/es/fields.json +2 -0
  23. package/src/editor/i18n/es/shell.json +8 -2
  24. package/src/editor/i18n/fr/fields.json +2 -0
  25. package/src/editor/i18n/fr/shell.json +8 -2
  26. package/src/editor/i18n/it/fields.json +2 -0
  27. package/src/editor/i18n/it/shell.json +8 -2
  28. package/src/editor/i18n/ja/fields.json +2 -0
  29. package/src/editor/i18n/ja/shell.json +8 -2
  30. package/src/editor/i18n/nl/fields.json +2 -0
  31. package/src/editor/i18n/nl/shell.json +8 -2
  32. package/src/editor/i18n/ru/fields.json +2 -0
  33. package/src/editor/i18n/ru/shell.json +8 -2
  34. package/src/editor/i18n/zh/fields.json +2 -0
  35. package/src/editor/i18n/zh/shell.json +8 -2
  36. package/src/editor/lib/BlockEditor.svelte +4 -3
  37. package/src/editor/lib/CollectionPanel.svelte +10 -2
  38. package/src/editor/lib/MarkdownEditor.svelte +38 -5
  39. package/src/editor/lib/PropertiesTabs.svelte +8 -1
  40. package/src/editor/lib/SchemaField.svelte +16 -2
  41. package/src/editor/lib/SchemaPanel.svelte +9 -3
  42. package/src/editor/lib/Settings.svelte +37 -30
  43. package/src/editor/lib/TopBar.svelte +13 -49
  44. package/src/editor/lib/VisualEditor.svelte +64 -13
  45. package/src/editor/lib/markdown-blocks.ts +69 -0
  46. package/src/editor/lib/media-library.ts +2 -1
  47. package/src/editor/lib/settings.ts +2 -0
  48. package/src/editor/styles.css +17 -1
  49. package/src/index.ts +1 -1
  50. package/src/manifest/pages.ts +0 -4
  51. package/src/manifest/search.ts +41 -0
  52. package/src/manifest/types.ts +1 -0
  53. package/src/schema/types.ts +1 -0
  54. package/src/schema/validate.ts +1 -0
  55. package/src/vite/manifest-plugin.ts +2 -1
@@ -25,8 +25,9 @@
25
25
  import { articleMarkdown } from "../../article-render";
26
26
  import Icon from "./Icon.svelte";
27
27
  import Select from "./Select.svelte";
28
- let { value, oninput, editableRoot }: { value: ArticleBody; oninput: (value: ArticleBody) => void; editableRoot?: HTMLElement } = $props();
28
+ let { value, oninput, editableRoot, label }: { value: ArticleBody; oninput: (value: ArticleBody) => void; editableRoot?: HTMLElement; label?: string } = $props();
29
29
  const inline = $derived(!!editableRoot);
30
+ const regionLabel = $derived(label ?? $t("richtext.body"));
30
31
  let root: HTMLElement;
31
32
  let surface: HTMLDivElement;
32
33
  let menu: HTMLDivElement;
@@ -203,7 +204,7 @@
203
204
  onMount(() => {
204
205
  if (editableRoot) {
205
206
  root = editableRoot; root.classList.add("block-content", "rigel-article-content"); root.contentEditable = "true";
206
- root.setAttribute("role", "textbox"); root.setAttribute("aria-label", $t("richtext.body")); root.setAttribute("aria-multiline", "true");
207
+ root.setAttribute("role", "textbox"); root.setAttribute("aria-label", regionLabel); root.setAttribute("aria-multiline", "true");
207
208
  root.setAttribute("aria-keyshortcuts", "Alt+F10");
208
209
  }
209
210
  view = root.ownerDocument.defaultView!;
@@ -278,7 +279,7 @@
278
279
  };
279
280
  });
280
281
 
281
- $effect(() => { if (editableRoot) editableRoot.setAttribute("aria-label", $t("richtext.body")); });
282
+ $effect(() => { if (editableRoot) editableRoot.setAttribute("aria-label", regionLabel); });
282
283
 
283
284
  $effect(() => { if (!linkOpen) view?.CSS.highlights?.delete("rigel-link-selection"); });
284
285
 
@@ -16,14 +16,16 @@
16
16
  import type { ContentDocument } from "../../schema/types";
17
17
  import { slugify } from "../../slugs";
18
18
 
19
- let { fields, values, storageSlug, body, selected, focusToken, devMode, documents = [], bodyInline = false, bodyLoading = false, oneditbody = () => {}, onchange, onbody }: {
19
+ let { fields, values, storageSlug, body, selected, focusToken, devMode, documents = [], bodyInline = false, bodyLoading = false, inlineFields = [], oneditbody = () => {}, oneditfield = () => {}, onchange, onbody }: {
20
20
  fields: CollectionField[];
21
21
  values: Record<string, unknown>;
22
22
  storageSlug: string;
23
23
  body: ArticleBody;
24
24
  bodyInline?: boolean;
25
25
  bodyLoading?: boolean;
26
+ inlineFields?: string[];
26
27
  oneditbody?: () => void;
28
+ oneditfield?: (path: string) => void;
27
29
  selected: SelectedField | null;
28
30
  focusToken: number;
29
31
  devMode: boolean;
@@ -66,7 +68,13 @@
66
68
  <span>{label}{devMode ? ` (${field.key})` : ""}</span>
67
69
  </label>
68
70
  {:else if field.type === "markdown"}
69
- <MarkdownEditor {label} value={String(values[field.key] ?? "")} oninput={(value) => onchange(field.key, value)} />
71
+ {#if inlineFields.includes(field.key)}
72
+ <span class="schema-label">{label}{devMode ? ` (${field.key})` : ""}</span>
73
+ <button type="button" class="outlined" onclick={() => oneditfield(field.key)}>{$t("fields.editRichText")}</button>
74
+ <p class="hint">{$t("fields.editRichTextHint")}</p>
75
+ {:else}
76
+ <MarkdownEditor {label} value={String(values[field.key] ?? "")} oninput={(value) => onchange(field.key, value)} />
77
+ {/if}
70
78
  {:else if field.type === "image"}
71
79
  <label><span>{label}{devMode ? ` (${field.key})` : ""}</span><ImagePicker value={String(values[field.key] ?? "")} onpick={(value) => onchange(field.key, value)} /></label>
72
80
  {:else if field.type === "tags"}
@@ -28,6 +28,28 @@
28
28
  let view: EditorView | undefined;
29
29
  let lastValue: string | undefined;
30
30
  let imageSelection: SelectionBookmark | undefined;
31
+ let toolbar: HTMLDivElement | undefined;
32
+ let floating = $state("");
33
+ let linkPosition = $state("");
34
+ let releaseFloating: (() => void) | undefined;
35
+ const inline = $derived(!!editableRoot);
36
+ function positionFloating() {
37
+ if (!inline || !editableRoot || !view || mode !== "visual") { floating = ""; linkPosition = ""; return; }
38
+ const owner = editableRoot.ownerDocument;
39
+ const active = owner.activeElement;
40
+ if (!editableRoot.contains(active) && !toolbar?.contains(active)) { floating = ""; linkPosition = ""; return; }
41
+ const anchor = view.state.selection.$from;
42
+ const block = anchor.depth > 0 ? view.nodeDOM(anchor.before(1)) : null;
43
+ const rect = block instanceof HTMLElement ? block.getBoundingClientRect() : editableRoot.getBoundingClientRect();
44
+ const { innerWidth, innerHeight } = owner.defaultView!;
45
+ if (rect.bottom < 0 || rect.top > innerHeight) { floating = ""; linkPosition = ""; return; }
46
+ const size = toolbar?.getBoundingClientRect();
47
+ const height = size?.height || 44, width = size?.width || 360;
48
+ const top = Math.max(8, Math.min(rect.top >= height + 12 ? rect.top - height - 8 : rect.bottom + 8, innerHeight - height - 8));
49
+ const left = Math.max(8, Math.min(rect.left, innerWidth - width - 8));
50
+ floating = `left:${left}px;top:${top}px`;
51
+ linkPosition = `left:${Math.max(8, Math.min(left, innerWidth - 356))}px;top:${Math.max(8, Math.min(top + height + 4, innerHeight - 128))}px`;
52
+ }
31
53
  function insertImage(url: string, alt: string, credit?: StockResult) {
32
54
  if (!view || !imageSelection || !safeImageUrl(url)) return;
33
55
  let tr = view.state.tr.setSelection(imageSelection.resolve(view.state.doc));
@@ -76,6 +98,7 @@
76
98
  state = view!.state.apply(transaction);
77
99
  view!.updateState(state);
78
100
  if (transaction.docChanged) emit(serializeMarkdown(state.doc));
101
+ positionFloating();
79
102
  },
80
103
  handlePaste(editor, event) {
81
104
  event.preventDefault();
@@ -85,8 +108,14 @@
85
108
  },
86
109
  handleDrop(_editor, _event, _slice, moved) { return !moved; },
87
110
  handleClickOn(_editor, _pos, node) { return node.type.name === "image"; },
88
- handleDOMEvents: { click: (_editor, event) => { if ((event.target as Element).closest("a")) { event.preventDefault(); return true; } return false; } },
111
+ handleDOMEvents: { click: (_editor, event) => { if ((event.target as Element).closest("a")) { event.preventDefault(); return true; } return false; }, focus: () => { positionFloating(); return false; }, blur: () => { setTimeout(positionFloating); return false; } },
89
112
  });
113
+ const target = editableRoot ?? host;
114
+ const owner = target.ownerDocument;
115
+ const reposition = () => positionFloating();
116
+ owner.defaultView?.addEventListener("scroll", reposition, true);
117
+ owner.defaultView?.addEventListener("resize", reposition);
118
+ releaseFloating = () => { owner.defaultView?.removeEventListener("scroll", reposition, true); owner.defaultView?.removeEventListener("resize", reposition); };
90
119
  }
91
120
  if (value !== lastValue) {
92
121
  lastValue = value;
@@ -94,7 +123,8 @@
94
123
  }
95
124
  });
96
125
  $effect(() => { const editable = mode === "visual"; view?.setProps({ editable: () => editable }); });
97
- onDestroy(() => view?.destroy());
126
+ $effect(() => { void mode; void inline; positionFloating(); });
127
+ onDestroy(() => { releaseFloating?.(); view?.destroy(); });
98
128
 
99
129
  function activeMark(type: string) {
100
130
  if (!state) return false;
@@ -164,7 +194,8 @@
164
194
 
165
195
  <svelte:window onpointerdown={event => { if (linkOpen && !document.getElementById(`${id}-link`)?.contains(event.target as Node) && !linkButton?.contains(event.target as Node)) linkOpen = false; }} />
166
196
 
167
- <div class="markdown-editor body-editor" class:markdown-inline={!!editableRoot}>
197
+ <div class="markdown-editor body-editor" class:markdown-inline={inline} class:markdown-floating={inline && mode === "visual"}>
198
+ {#if !inline || mode === "source"}
168
199
  <div class="markdown-heading">
169
200
  <div class="field-label" id={`${id}-label`}>{label}</div>
170
201
  <div class="markdown-modes" role="group" aria-label={$t("richtext.editingMode", { label })}>
@@ -172,9 +203,10 @@
172
203
  <button type="button" aria-pressed={mode === "source"} onclick={() => { mode = "source"; linkOpen = false; }}>{$t("richtext.markdown")}</button>
173
204
  </div>
174
205
  </div>
206
+ {/if}
175
207
  {#if warning}<p class="markdown-warning" role="status">{warning}</p>{/if}
176
208
  <div class="markdown-visual" hidden={mode !== "visual"}>
177
- <div class="body-toolbar formatting-toolbar" role="group" aria-label={$t("richtext.formatting", { label })}>
209
+ <div class="body-toolbar formatting-toolbar" bind:this={toolbar} role="group" aria-label={$t("richtext.formatting", { label })} style={inline ? floating : undefined} hidden={inline && !floating}>
178
210
  <Select ariaLabel={$t("richtext.textStyle")} data-tooltip={$t("richtext.textStyle")} value={format} options={[{ value: "paragraph", label: $t("richtext.paragraph") }, ...[1, 2, 3, 4, 5, 6].map(level => ({ value: `h${level}`, label: $t("richtext.heading", { level }) })), { value: "code_block", label: $t("richtext.codeBlock") }].map(option => ({ ...option, disabled: !!state && format !== option.value && !formatCommand(option.value)(state) }))} onchange={next => command(formatCommand(String(next)))} />
179
211
  <div class="format-group" role="group" aria-label={$t("richtext.textFormatting")}>
180
212
  {#each marks as item}
@@ -188,9 +220,10 @@
188
220
  </div>
189
221
  <button bind:this={linkButton} type="button" aria-label={$t("richtext.link")} data-tooltip={$t("richtext.link")} aria-pressed={activeMark("link")} aria-expanded={linkOpen} aria-controls={`${id}-link`} disabled={!state || !toggleMark(schema.marks.link)(state)} onmousedown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
190
222
  <ImagePicker toolbar onopen={() => { imageSelection = view?.state.selection.getBookmark(); linkOpen = false; }} onpick={insertImage} onclose={() => { imageSelection = undefined; }} />
223
+ {#if inline}<button type="button" class="markdown-toggle" aria-label={$t("richtext.markdown")} data-tooltip={$t("richtext.markdown")} onmousedown={event => event.preventDefault()} onclick={() => { mode = "source"; linkOpen = false; }}>{$t("richtext.markdown")}</button>{/if}
191
224
  </div>
192
225
  {#if linkOpen}
193
- <div class="markdown-link" id={`${id}-link`} role="group" aria-label={$t("richtext.editLink")} onkeydown={event => { if (event.key === "Escape") { event.stopPropagation(); linkOpen = false; linkButton.focus(); } if (event.key === "Enter") { event.preventDefault(); link(); } }}>
226
+ <div class="markdown-link" id={`${id}-link`} role="group" aria-label={$t("richtext.editLink")} style={inline ? linkPosition : undefined} onkeydown={event => { if (event.key === "Escape") { event.stopPropagation(); linkOpen = false; linkButton.focus(); } if (event.key === "Enter") { event.preventDefault(); link(); } }}>
194
227
  <label for={`${id}-url`}>{$t("richtext.linkUrl")}</label>
195
228
  <div class="markdown-link-controls">
196
229
  <input id={`${id}-url`} type="text" placeholder="https://…" bind:this={linkInput} bind:value={linkUrl} aria-invalid={!!linkError} aria-describedby={linkError ? `${id}-error` : undefined} />
@@ -1,6 +1,13 @@
1
1
  <script lang="ts">
2
2
  import { t } from "../i18n";
3
3
  let { tab = $bindable("content") }: { tab?: "content" | "seo" } = $props();
4
+ let tabs: HTMLDivElement;
5
+ let height = $state(0);
6
+ $effect(() => {
7
+ const panel = tabs?.parentElement;
8
+ panel?.style.setProperty("--properties-tabs-height", `${height}px`);
9
+ return () => panel?.style.removeProperty("--properties-tabs-height");
10
+ });
4
11
  function onkey(event: KeyboardEvent) {
5
12
  if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
6
13
  event.preventDefault();
@@ -9,7 +16,7 @@
9
16
  }
10
17
  </script>
11
18
 
12
- <div class="panel-tabs" role="tablist" aria-label={$t("fields.properties")}>
19
+ <div class="panel-tabs" role="tablist" aria-label={$t("fields.properties")} bind:this={tabs} bind:offsetHeight={height}>
13
20
  {#each ["content", "seo"] as value}
14
21
  <button type="button" role="tab" id={`properties-tab-${value}`} aria-controls={`properties-${value}`} aria-selected={tab === value} tabindex={tab === value ? 0 : -1} data-tab={value} onclick={() => (tab = value as "content" | "seo")} onkeydown={onkey}>{$t(value === "content" ? "fields.content" : "fields.seo")}</button>
15
22
  {/each}
@@ -18,7 +18,7 @@
18
18
  import type { ContentDocument, FieldDef, Schema } from "../../schema/types";
19
19
 
20
20
  let {
21
- schema, field, value, base, documents, devMode = false, onvalue,
21
+ schema, field, value, base, documents, devMode = false, inlineFields = [], oneditfield = () => {}, onvalue,
22
22
  }: {
23
23
  schema: Schema;
24
24
  field: FieldDef;
@@ -26,6 +26,8 @@
26
26
  base: string;
27
27
  documents: ContentDocument[];
28
28
  devMode?: boolean;
29
+ inlineFields?: string[];
30
+ oneditfield?: (path: string) => void;
29
31
  onvalue: (next: unknown) => void;
30
32
  } = $props();
31
33
 
@@ -85,7 +87,15 @@
85
87
  <textarea rows="3" value={typeof value === "string" ? value : ""} oninput={(event) => onvalue(event.currentTarget.value)}></textarea>
86
88
  </label>
87
89
  {:else if field.type === "markdown"}
88
- <MarkdownEditor label={fieldLabel} value={typeof value === "string" ? value : ""} oninput={(next) => onvalue(next)} />
90
+ {#if !field.sidebarOnly && inlineFields.includes(base)}
91
+ <div class="schema-field">
92
+ <span class="schema-label">{label}</span>
93
+ <button type="button" class="outlined" onclick={() => oneditfield(base)}>{$t("fields.editRichText")}</button>
94
+ <p class="hint">{$t("fields.editRichTextHint")}</p>
95
+ </div>
96
+ {:else}
97
+ <MarkdownEditor label={fieldLabel} value={typeof value === "string" ? value : ""} oninput={(next) => onvalue(next)} />
98
+ {/if}
89
99
  {:else if field.type === "image" || field.type === "file"}
90
100
  <div class="schema-field">
91
101
  <span class="schema-label">{label}</span>
@@ -113,6 +123,8 @@
113
123
  {schema}
114
124
  {documents}
115
125
  {devMode}
126
+ {inlineFields}
127
+ {oneditfield}
116
128
  field={sub}
117
129
  base={`${base}.${sub.key}`}
118
130
  value={value && typeof value === "object" ? (value as Record<string, unknown>)[sub.key] : undefined}
@@ -141,6 +153,8 @@
141
153
  {schema}
142
154
  {documents}
143
155
  {devMode}
156
+ {inlineFields}
157
+ {oneditfield}
144
158
  field={sub}
145
159
  base={`${base}.${(item as Record<string, unknown>)._key}.${sub.key}`}
146
160
  value={item && typeof item === "object" ? (item as Record<string, unknown>)[sub.key] : undefined}
@@ -23,7 +23,7 @@
23
23
  import { slugify } from "../../slugs";
24
24
 
25
25
  let {
26
- schema, docType, values, docId, selected, creating, pending, documents, focusToken, devMode, onchange, onselect, oneditdocument,
26
+ schema, docType, values, docId, selected, creating, pending, documents, focusToken, devMode, inlineFields = [], oneditfield = () => {}, onchange, onselect, oneditdocument,
27
27
  }: {
28
28
  schema: Schema;
29
29
  docType: DocumentDef;
@@ -35,6 +35,8 @@
35
35
  documents: ContentDocument[];
36
36
  focusToken: number;
37
37
  devMode: boolean;
38
+ inlineFields?: string[];
39
+ oneditfield?: (path: string) => void;
38
40
  onchange: (next: Record<string, unknown>) => void;
39
41
  onselect: (next: SelectedField | null) => void;
40
42
  oneditdocument: (id: string) => void;
@@ -176,7 +178,7 @@
176
178
  {/if}
177
179
  {#each docFields.filter(field => docType.shared || !isSeoField(field.key)) as field (field.key)}
178
180
  <div data-schema-path={field.key}>
179
- <SchemaField {schema} {documents} {devMode} {field} base={field.key} value={values[field.key]} onvalue={(next) => onchange(replaceAt(values, field.key, next))} />
181
+ <SchemaField {schema} {documents} {devMode} {inlineFields} {oneditfield} {field} base={field.key} value={values[field.key]} onvalue={(next) => onchange(replaceAt(values, field.key, next))} />
180
182
  </div>
181
183
  {/each}
182
184
  </section>
@@ -223,6 +225,8 @@
223
225
  {schema}
224
226
  {documents}
225
227
  {devMode}
228
+ {inlineFields}
229
+ {oneditfield}
226
230
  {field}
227
231
  base={`sections.${selected.sectionKey}.${field.key}`}
228
232
  value={sections.find((section) => section._key === selected?.sectionKey)?.[field.key]}
@@ -239,6 +243,8 @@
239
243
  {schema}
240
244
  {documents}
241
245
  {devMode}
246
+ {inlineFields}
247
+ {oneditfield}
242
248
  field={selectedField}
243
249
  base={selected.path}
244
250
  value={selectedValue}
@@ -255,7 +261,7 @@
255
261
  <h3>{$t("fields.seo")}</h3>
256
262
  {#each seoFields as field (field.key)}
257
263
  <div data-schema-path={field.key}>
258
- <SchemaField {schema} {documents} {devMode} {field} base={field.key} value={values[field.key]} onvalue={(next) => onchange(replaceAt(values, field.key, next))} />
264
+ <SchemaField {schema} {documents} {devMode} {inlineFields} {oneditfield} {field} base={field.key} value={values[field.key]} onvalue={(next) => onchange(replaceAt(values, field.key, next))} />
259
265
  <SeoGuidance field={field.key} value={values[field.key]} />
260
266
  </div>
261
267
  {/each}
@@ -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>