@rigel-cms/rigel 0.0.0-stage → 0.1.3-beta.1

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 (120) hide show
  1. package/LICENSE +203 -0
  2. package/README.md +16 -2
  3. package/package.json +83 -4
  4. package/src/article-body.ts +60 -0
  5. package/src/article-render.ts +87 -0
  6. package/src/config.ts +103 -0
  7. package/src/content.ts +5 -0
  8. package/src/editor/App.svelte +981 -0
  9. package/src/editor/assets/rigelcms-icon.png +0 -0
  10. package/src/editor/assets/sidebar/LICENSE +21 -0
  11. package/src/editor/assets/sidebar/book-open.svg +1 -0
  12. package/src/editor/assets/sidebar/calendar.svg +1 -0
  13. package/src/editor/assets/sidebar/chart-bar.svg +1 -0
  14. package/src/editor/assets/sidebar/file-text.svg +1 -0
  15. package/src/editor/assets/sidebar/files.svg +1 -0
  16. package/src/editor/assets/sidebar/folder.svg +1 -0
  17. package/src/editor/assets/sidebar/folders.svg +1 -0
  18. package/src/editor/assets/sidebar/gear.svg +1 -0
  19. package/src/editor/assets/sidebar/globe.svg +1 -0
  20. package/src/editor/assets/sidebar/heart.svg +1 -0
  21. package/src/editor/assets/sidebar/house.svg +1 -0
  22. package/src/editor/assets/sidebar/images.svg +1 -0
  23. package/src/editor/assets/sidebar/layout.svg +1 -0
  24. package/src/editor/assets/sidebar/list.svg +1 -0
  25. package/src/editor/assets/sidebar/magnifying-glass.svg +1 -0
  26. package/src/editor/assets/sidebar/megaphone.svg +1 -0
  27. package/src/editor/assets/sidebar/newspaper.svg +1 -0
  28. package/src/editor/assets/sidebar/pencil-simple.svg +1 -0
  29. package/src/editor/assets/sidebar/shopping-bag.svg +1 -0
  30. package/src/editor/assets/sidebar/sidebar-simple.svg +1 -0
  31. package/src/editor/assets/sidebar/square-half.svg +1 -0
  32. package/src/editor/assets/sidebar/squares-four.svg +1 -0
  33. package/src/editor/assets/sidebar/tag.svg +1 -0
  34. package/src/editor/assets/sidebar/user.svg +1 -0
  35. package/src/editor/assets/sidebar/users.svg +1 -0
  36. package/src/editor/blocks/bulleted-list.ts +2 -0
  37. package/src/editor/blocks/code.ts +1 -0
  38. package/src/editor/blocks/divider.ts +14 -0
  39. package/src/editor/blocks/h1.ts +2 -0
  40. package/src/editor/blocks/h2.ts +2 -0
  41. package/src/editor/blocks/h3.ts +2 -0
  42. package/src/editor/blocks/h4.ts +2 -0
  43. package/src/editor/blocks/h5.ts +2 -0
  44. package/src/editor/blocks/h6.ts +2 -0
  45. package/src/editor/blocks/image.ts +34 -0
  46. package/src/editor/blocks/index.ts +40 -0
  47. package/src/editor/blocks/numbered-list.ts +2 -0
  48. package/src/editor/blocks/paragraph.ts +1 -0
  49. package/src/editor/blocks/quote.ts +1 -0
  50. package/src/editor/blocks/table.ts +5 -0
  51. package/src/editor/lib/ActionDialog.svelte +51 -0
  52. package/src/editor/lib/BlockEditor.svelte +614 -0
  53. package/src/editor/lib/CollectionPanel.svelte +103 -0
  54. package/src/editor/lib/DatePicker.svelte +106 -0
  55. package/src/editor/lib/Field.svelte +80 -0
  56. package/src/editor/lib/Icon.svelte +34 -0
  57. package/src/editor/lib/ImagePicker.svelte +264 -0
  58. package/src/editor/lib/MarkdownEditor.svelte +206 -0
  59. package/src/editor/lib/MenuEditor.svelte +209 -0
  60. package/src/editor/lib/PreviewFrame.svelte +45 -0
  61. package/src/editor/lib/PropertiesTabs.svelte +15 -0
  62. package/src/editor/lib/ReferenceSelect.svelte +17 -0
  63. package/src/editor/lib/SchemaField.svelte +156 -0
  64. package/src/editor/lib/SchemaPanel.svelte +189 -0
  65. package/src/editor/lib/Select.svelte +138 -0
  66. package/src/editor/lib/SeoGuidance.svelte +21 -0
  67. package/src/editor/lib/Settings.svelte +183 -0
  68. package/src/editor/lib/Shortcuts.svelte +29 -0
  69. package/src/editor/lib/SidebarIcon.svelte +8 -0
  70. package/src/editor/lib/SitePreview.svelte +51 -0
  71. package/src/editor/lib/TopBar.svelte +165 -0
  72. package/src/editor/lib/UnsavedChanges.svelte +38 -0
  73. package/src/editor/lib/VisualEditor.svelte +492 -0
  74. package/src/editor/lib/calendar.ts +55 -0
  75. package/src/editor/lib/github-auth.ts +117 -0
  76. package/src/editor/lib/github-backend.ts +263 -0
  77. package/src/editor/lib/icons.ts +111 -0
  78. package/src/editor/lib/images.ts +195 -0
  79. package/src/editor/lib/markdown.ts +71 -0
  80. package/src/editor/lib/media-library.ts +39 -0
  81. package/src/editor/lib/menu-tree.ts +37 -0
  82. package/src/editor/lib/preview-frame.ts +29 -0
  83. package/src/editor/lib/rich-markdown.ts +30 -0
  84. package/src/editor/lib/schema-edit.ts +110 -0
  85. package/src/editor/lib/settings.ts +103 -0
  86. package/src/editor/lib/slugify.ts +10 -0
  87. package/src/editor/lib/storage.ts +17 -0
  88. package/src/editor/lib/store.svelte.ts +33 -0
  89. package/src/editor/main.ts +9 -0
  90. package/src/editor/styles.css +1281 -0
  91. package/src/env.d.ts +22 -0
  92. package/src/index.ts +128 -0
  93. package/src/manifest/collections.ts +27 -0
  94. package/src/manifest/frontmatter.ts +56 -0
  95. package/src/manifest/loader.ts +14 -0
  96. package/src/manifest/menu.ts +46 -0
  97. package/src/manifest/navigation.ts +94 -0
  98. package/src/manifest/references.ts +39 -0
  99. package/src/manifest/seo.ts +15 -0
  100. package/src/manifest/types.ts +76 -0
  101. package/src/media-types.ts +21 -0
  102. package/src/preview-urls.ts +12 -0
  103. package/src/preview.ts +175 -0
  104. package/src/routes/local-api.ts +42 -0
  105. package/src/routes/local-documents.ts +83 -0
  106. package/src/routes/local-media.ts +124 -0
  107. package/src/routes/local-menu.ts +81 -0
  108. package/src/routes/local-post.ts +80 -0
  109. package/src/routes/manifest.json.ts +15 -0
  110. package/src/routes/media-files.ts +126 -0
  111. package/src/routes/media-validation.ts +37 -0
  112. package/src/routes/media.json.ts +12 -0
  113. package/src/routes/rigel-page.astro +25 -0
  114. package/src/routes/serialize.ts +139 -0
  115. package/src/schema/docs.ts +33 -0
  116. package/src/schema/paths.ts +136 -0
  117. package/src/schema/store.ts +141 -0
  118. package/src/schema/types.ts +81 -0
  119. package/src/schema/validate.ts +327 -0
  120. package/src/vite/manifest-plugin.ts +182 -0
@@ -0,0 +1,189 @@
1
+ <script lang="ts">
2
+ import SchemaField from "./SchemaField.svelte";
3
+ import Icon from "./Icon.svelte";
4
+ import PropertiesTabs from "./PropertiesTabs.svelte";
5
+ import SeoGuidance from "./SeoGuidance.svelte";
6
+ import Select from "./Select.svelte";
7
+ import { tick } from "svelte";
8
+ import {
9
+ blankItem,
10
+ duplicateItem,
11
+ fieldForPath,
12
+ moveItem,
13
+ replaceAt,
14
+ sectionLabel,
15
+ sectionList,
16
+ sectionTypeDef,
17
+ type SelectedField,
18
+ } from "./schema-edit";
19
+ import { resolvePath } from "../../schema/paths";
20
+ import { isSeoField } from "../../manifest/seo";
21
+ import type { ContentDocument, DocumentDef, Schema } from "../../schema/types";
22
+
23
+ let {
24
+ schema, docType, values, docId, selected, creating, pending, documents, focusToken, devMode, onchange, onselect, oneditdocument,
25
+ }: {
26
+ schema: Schema;
27
+ docType: DocumentDef;
28
+ values: Record<string, unknown>;
29
+ docId: string;
30
+ selected: SelectedField | null;
31
+ creating: boolean;
32
+ pending: string[];
33
+ documents: ContentDocument[];
34
+ focusToken: number;
35
+ devMode: boolean;
36
+ onchange: (next: Record<string, unknown>) => void;
37
+ onselect: (next: SelectedField | null) => void;
38
+ oneditdocument: (id: string) => void;
39
+ } = $props();
40
+
41
+ let host: HTMLDivElement;
42
+ let tab = $state<"content" | "seo">("content");
43
+ let addType = $state("");
44
+ const sections = $derived(sectionList(values));
45
+ const allowedTypes = $derived(schema.objects.filter((object) => object.type === "section"));
46
+ const selectedField = $derived(selected?.docId === docId ? fieldForPath(schema, docType, values, selected.path) : null);
47
+ const selectedSection = $derived(selected?.sectionKey ? sectionTypeDef(schema, sections.find((section) => section._key === selected?.sectionKey)?._type) : undefined);
48
+ const selectedValue = $derived(selected && selectedField ? resolvePath(values, selected.path).value : undefined);
49
+ const docFields = $derived(docType.fields.filter((field) => field.key !== "sections"));
50
+ const seoFields = $derived(docType.shared ? [] : docFields.filter(field => isSeoField(field.key)));
51
+
52
+ $effect(() => {
53
+ void focusToken;
54
+ const path = selected?.docId === docId ? selected.path : "";
55
+ const sectionKey = selected?.docId === docId ? selected.sectionKey : undefined;
56
+ tab = "content";
57
+ if (!focusToken || !host) return;
58
+ let cancelled = false;
59
+ void tick().then(() => {
60
+ if (cancelled) return;
61
+ const properties = host.querySelector(sectionKey ? ".section-properties" : ".document-properties");
62
+ const field = [...properties?.querySelectorAll<HTMLElement>("[data-schema-path]") ?? []].find((element) => element.dataset.schemaPath === path);
63
+ const scope = field ?? properties;
64
+ const target = scope?.querySelector<HTMLElement>('[contenteditable="true"]')
65
+ ?? scope?.querySelector<HTMLElement>('button[role="combobox"], input:not([type="hidden"]), textarea')
66
+ ?? scope?.querySelector<HTMLElement>('button:not(:disabled)');
67
+ target?.focus();
68
+ target?.scrollIntoView({ block: "nearest" });
69
+ });
70
+ return () => { cancelled = true; };
71
+ });
72
+
73
+ function setSections(next: Record<string, unknown>[]) {
74
+ onchange({ ...values, sections: next });
75
+ }
76
+
77
+ function addSection() {
78
+ const def = schema.objects.find((object) => object.name === addType && object.type === "section");
79
+ if (!def) return;
80
+ const section = blankItem(def.fields);
81
+ section._type = addType;
82
+ setSections([...sections, section]);
83
+ onselect({ docId, path: "", sectionKey: String(section._key) });
84
+ addType = "";
85
+ }
86
+
87
+ function removeSection(index: number) {
88
+ setSections(sections.filter((_, position) => position !== index));
89
+ }
90
+ </script>
91
+
92
+ <div class="schema-panel" bind:this={host}>
93
+ {#if docType.shared}
94
+ <p class="shared-badge"><Icon name="info" size={12} /> {docType.demo ? "Field examples for testing. These values are separate from your website settings." : "Shared content. Changes appear on every page that references it."}</p>
95
+ {/if}
96
+ <div class:page-properties-header={!docType.shared}>
97
+ {#if seoFields.length}<PropertiesTabs bind:tab />{/if}
98
+ <section class="document-properties" hidden={tab !== "content"}>
99
+ <h3>{docType.shared ? "Content" : "Page properties"}</h3>
100
+ {#each docFields.filter(field => docType.shared || !isSeoField(field.key)) as field (field.key)}
101
+ <div data-schema-path={field.key}>
102
+ <SchemaField {schema} {documents} {devMode} {field} base={field.key} value={values[field.key]} onvalue={(next) => onchange(replaceAt(values, field.key, next))} />
103
+ </div>
104
+ {/each}
105
+ </section>
106
+ </div>
107
+ <div id="properties-content" role={seoFields.length ? "tabpanel" : undefined} aria-labelledby={seoFields.length ? "properties-tab-content" : undefined} hidden={tab !== "content"}>
108
+ {#if creating}
109
+ <p class="panel-note">New page. Save writes the JSON file; the route appears once the site has the file.</p>
110
+ {/if}
111
+ {#if sections.length || values.sections !== undefined}
112
+ <section>
113
+ <h3>Structure</h3>
114
+ <ul class="structure-list">
115
+ {#each sections as section, index (section._key)}
116
+ <li class:active={selected?.sectionKey === section._key}>
117
+ <button type="button" class="structure-row" onclick={() => onselect({ docId, path: "", sectionKey: String(section._key) })}>
118
+ <span>{sectionLabel(schema, section._type)}</span>
119
+ {#if devMode}<span class="schema-key">{section._key}</span>{/if}
120
+ </button>
121
+ <div class="structure-actions">
122
+ <button type="button" aria-label={`Move ${sectionLabel(schema, section._type)} up`} title="Move up" disabled={index === 0} onclick={() => setSections(moveItem(sections, index, -1) as Record<string, unknown>[])}>↑</button>
123
+ <button type="button" aria-label={`Move ${sectionLabel(schema, section._type)} down`} title="Move down" disabled={index === sections.length - 1} onclick={() => setSections(moveItem(sections, index, 1) as Record<string, unknown>[])}>↓</button>
124
+ <button type="button" aria-label={`Duplicate ${sectionLabel(schema, section._type)}`} title="Duplicate" onclick={() => setSections(duplicateItem(sections, index) as Record<string, unknown>[])}>⧉</button>
125
+ <button type="button" aria-label={`Remove ${sectionLabel(schema, section._type)}`} title="Remove" onclick={() => removeSection(index)}>×</button>
126
+ </div>
127
+ </li>
128
+ {/each}
129
+ </ul>
130
+ <div class="add-section">
131
+ <Select ariaLabel="Section type to add" value={addType} onchange={next => { addType = String(next); }} options={[{ value: "", label: "Choose a section…" }, ...allowedTypes.map(type => ({ value: type.name, label: type.title }))]} />
132
+ <button type="button" class="outlined" disabled={!addType} onclick={addSection}>Add section</button>
133
+ </div>
134
+ {#if pending.length}<p class="panel-note" role="status">{pending.length} new section{pending.length === 1 ? "" : "s"} will appear after Save.</p>{/if}
135
+ </section>
136
+ {/if}
137
+
138
+ {#if selectedSection || (selectedField && selected?.path.includes("."))}
139
+ <section class="section-properties">
140
+ <h3>Section properties</h3>
141
+ {#if selectedSection && selected?.sectionKey}
142
+ <p class="selected-path">{selectedField ? `${selectedField.label} · ` : ""}{selectedSection.title}</p>
143
+ {#each selectedSection.fields as field (field.key)}
144
+ {@const reference = field.type === "reference" ? sections.find((section) => section._key === selected?.sectionKey)?.[field.key] as { _ref?: string } | undefined : undefined}
145
+ <div data-schema-path={`sections.${selected.sectionKey}.${field.key}`}>
146
+ <SchemaField
147
+ {schema}
148
+ {documents}
149
+ {devMode}
150
+ {field}
151
+ base={`sections.${selected.sectionKey}.${field.key}`}
152
+ value={sections.find((section) => section._key === selected?.sectionKey)?.[field.key]}
153
+ onvalue={(next) => onchange(replaceAt(values, `sections.${selected!.sectionKey}.${field.key}`, next))}
154
+ />
155
+ {#if reference?._ref && documents.some((doc) => doc.id === reference._ref)}
156
+ <button type="button" class="outlined" onclick={() => oneditdocument(reference._ref!)}>Edit {field.label.toLowerCase()} ↗</button>
157
+ {/if}
158
+ </div>
159
+ {/each}
160
+ {:else if selectedField && selected}
161
+ <p class="selected-path">{selectedField.label}{selected.sectionKey ? ` · ${sectionLabel(schema, sections.find((section) => section._key === selected.sectionKey)?._type)}` : ""}</p>
162
+ <SchemaField
163
+ {schema}
164
+ {documents}
165
+ {devMode}
166
+ field={selectedField}
167
+ base={selected.path}
168
+ value={selectedValue}
169
+ onvalue={(next) => onchange(replaceAt(values, selected.path, next))}
170
+ />
171
+
172
+ {/if}
173
+ </section>
174
+ {/if}
175
+ </div>
176
+ {#if seoFields.length}
177
+ <div id="properties-seo" role="tabpanel" aria-labelledby="properties-tab-seo" hidden={tab !== "seo"}>
178
+ <section class="seo-fields">
179
+ <h3>SEO</h3>
180
+ {#each seoFields as field (field.key)}
181
+ <div data-schema-path={field.key}>
182
+ <SchemaField {schema} {documents} {devMode} {field} base={field.key} value={values[field.key]} onvalue={(next) => onchange(replaceAt(values, field.key, next))} />
183
+ <SeoGuidance field={field.key} value={values[field.key]} />
184
+ </div>
185
+ {/each}
186
+ </section>
187
+ </div>
188
+ {/if}
189
+ </div>
@@ -0,0 +1,138 @@
1
+ <script lang="ts">
2
+ import { tick } from "svelte";
3
+ import Icon from "./Icon.svelte";
4
+ import type { ICONS } from "./icons";
5
+
6
+ let { label = "", ariaLabel = label, title, icon, value = "", options, multiple = false, disabled = false, onchange = () => {} }: {
7
+ label?: string;
8
+ ariaLabel?: string;
9
+ title?: string;
10
+ icon?: keyof typeof ICONS;
11
+ value?: string | string[];
12
+ options: { value: string; label: string; icon?: keyof typeof ICONS; disabled?: boolean }[];
13
+ multiple?: boolean;
14
+ disabled?: boolean;
15
+ onchange?: (value: string | string[]) => void;
16
+ } = $props();
17
+ const id = $props.id();
18
+ let trigger: HTMLButtonElement;
19
+ let menu: HTMLDivElement;
20
+ let open = $state(false);
21
+ let active = $state(-1);
22
+ let placement = $state("");
23
+ let search = "";
24
+ let lastKey = 0;
25
+ const selected = $derived(Array.isArray(value) ? value : [value]);
26
+ const text = $derived(options.filter(option => selected.includes(option.value)).map(option => option.label).join(", "));
27
+
28
+ function position() {
29
+ if (!open || !trigger || !menu) return;
30
+ const { innerWidth, innerHeight } = trigger.ownerDocument.defaultView!;
31
+ const rect = trigger.getBoundingClientRect();
32
+ const below = innerHeight - rect.bottom - 8;
33
+ const above = rect.top - 8;
34
+ const upward = below < 160 && above > below;
35
+ const height = Math.min(280, upward ? above : below);
36
+ const width = Math.min(Math.max(rect.width, 160), innerWidth - 16);
37
+ const left = Math.max(8, Math.min(rect.left, innerWidth - width - 8));
38
+ const top = upward ? Math.max(8, rect.top - Math.min(menu.scrollHeight + 2, height) - 4) : rect.bottom + 4;
39
+ placement = `left:${left}px;top:${top}px;width:${width}px;max-height:${Math.max(32, height)}px`;
40
+ }
41
+
42
+ function close() {
43
+ if (menu?.matches(":popover-open")) menu.hidePopover();
44
+ open = false;
45
+ }
46
+
47
+ async function reveal(last = false) {
48
+ if (disabled) return;
49
+ active = options.findIndex(option => !option.disabled && selected.includes(option.value));
50
+ if (active < 0) active = last ? options.map((option, index) => option.disabled ? -1 : index).filter(index => index >= 0).at(-1) ?? -1 : options.findIndex(option => !option.disabled);
51
+ open = true;
52
+ search = "";
53
+ menu.showPopover();
54
+ await tick();
55
+ position();
56
+ await tick();
57
+ position();
58
+ menu.querySelector(`#${CSS.escape(id)}-option-${active}`)?.scrollIntoView({ block: "nearest" });
59
+ }
60
+
61
+ function choose(index: number) {
62
+ const option = options[index];
63
+ if (!option || option.disabled) return;
64
+ active = index;
65
+ value = multiple ? selected.includes(option.value) ? selected.filter(item => item !== option.value) : [...selected, option.value] : option.value;
66
+ if (!multiple) close();
67
+ trigger.focus({ preventScroll: true });
68
+ onchange(value);
69
+ }
70
+
71
+ async function onkey(event: KeyboardEvent) {
72
+ const expanded = menu.matches(":popover-open");
73
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
74
+ event.preventDefault();
75
+ if (!expanded) await reveal(event.key === "ArrowUp" || event.key === "End");
76
+ if (expanded || event.key === "Home" || event.key === "End") {
77
+ const enabled = options.map((option, index) => option.disabled ? -1 : index).filter(index => index >= 0);
78
+ const current = enabled.indexOf(active);
79
+ active = (event.key === "Home" ? enabled[0] : event.key === "End" ? enabled.at(-1) : enabled[(current + (event.key === "ArrowDown" ? 1 : -1) + enabled.length) % enabled.length]) ?? -1;
80
+ }
81
+ } else if (event.key === "Enter" || event.key === " ") {
82
+ event.preventDefault();
83
+ if (expanded) choose(active); else await reveal();
84
+ } else if (event.key === "Escape" && expanded) {
85
+ event.preventDefault();
86
+ event.stopPropagation();
87
+ close();
88
+ } else if (event.key === "Tab") close();
89
+ else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
90
+ event.preventDefault();
91
+ if (!expanded) await reveal();
92
+ search = event.timeStamp - lastKey > 700 ? event.key : search + event.key;
93
+ lastKey = event.timeStamp;
94
+ let match = options.findIndex((option, index) => index > active && !option.disabled && option.label.toLocaleLowerCase().startsWith(search.toLocaleLowerCase()));
95
+ if (match < 0) match = options.findIndex(option => !option.disabled && option.label.toLocaleLowerCase().startsWith(search.toLocaleLowerCase()));
96
+ if (match >= 0) active = match;
97
+ } else return;
98
+ await tick();
99
+ menu.querySelector(`#${CSS.escape(id)}-option-${active}`)?.scrollIntoView({ block: "nearest" });
100
+ }
101
+
102
+ $effect(() => {
103
+ void options;
104
+ if (disabled) close();
105
+ });
106
+
107
+ $effect(() => {
108
+ if (!open) return;
109
+ const observer = new ResizeObserver(position);
110
+ observer.observe(trigger);
111
+ observer.observe(menu);
112
+ const dialog = trigger.closest("dialog");
113
+ if (dialog) observer.observe(dialog);
114
+ const view = trigger.ownerDocument.defaultView!;
115
+ const scroll = (event: Event) => { if (!(event.target as Node)?.nodeType || !menu.contains(event.target as Node)) position(); };
116
+ view.addEventListener("resize", position); view.addEventListener("scroll", scroll, true);
117
+ return () => { observer.disconnect(); view.removeEventListener("resize", position); view.removeEventListener("scroll", scroll, true); };
118
+ });
119
+ </script>
120
+
121
+ <div class="custom-select" onfocusout={() => { if (open) requestAnimationFrame(() => { if (trigger.ownerDocument.activeElement?.tagName === "IFRAME") close(); }); }}>
122
+ {#if label}<span class="select-label" id={`${id}-label`}>{label}</span>{/if}
123
+ <button bind:this={trigger} type="button" class="select-trigger" class:icon-only={!!icon} role="combobox" aria-label={label ? undefined : ariaLabel} aria-labelledby={label ? `${id}-label` : undefined} aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-list`} aria-activedescendant={open && active >= 0 ? `${id}-option-${active}` : undefined} popovertarget={`${id}-list`} {title} {disabled} onclick={event => { event.preventDefault(); menu.matches(":popover-open") ? close() : void reveal(); }} onkeydown={onkey}>
124
+ {#if icon}<Icon name={icon} size={18} />{:else}<span>{text || (multiple ? `Select ${label.toLowerCase() || "options"}…` : "Choose…")}</span><Icon name="caretDown" size={12} />{/if}
125
+ </button>
126
+ <select hidden aria-hidden="true" tabindex="-1" aria-label={ariaLabel} {multiple} {disabled} {value} onchange={event => { value = multiple ? Array.from(event.currentTarget.selectedOptions, option => option.value) : event.currentTarget.value; onchange(value); }}>
127
+ {#each options as option (option.value)}<option value={option.value} disabled={option.disabled}>{option.label}</option>{/each}
128
+ </select>
129
+ <div bind:this={menu} id={`${id}-list`} class="select-menu" popover="auto" role="listbox" aria-label={ariaLabel} aria-multiselectable={multiple || undefined} style={placement} ontoggle={event => { open = event.currentTarget.matches(":popover-open"); }} onkeydown={onkey}>
130
+ {#each options as option, index (option.value)}
131
+ <div id={`${id}-option-${index}`} class="select-option" class:active={active === index} role="option" tabindex="-1" aria-selected={selected.includes(option.value)} aria-disabled={option.disabled || undefined} onpointermove={() => { if (!option.disabled) active = index; }} onpointerdown={event => event.preventDefault()} onclick={event => { event.preventDefault(); choose(index); }}>
132
+ {#if option.icon}<Icon name={option.icon} size={16} />{/if}
133
+ <span>{option.label}</span><span class="select-check" aria-hidden="true">{selected.includes(option.value) ? "✓" : ""}</span>
134
+ </div>
135
+ {/each}
136
+ {#if !options.length}<p class="select-empty">No options available.</p>{/if}
137
+ </div>
138
+ </div>
@@ -0,0 +1,21 @@
1
+ <script lang="ts">
2
+ let { field, value }: { field: string; value: unknown } = $props();
3
+ const limits = $derived(field === "metaTitle" ? { min: 50, max: 60, label: "SEO title" } : { min: 120, max: 160, label: "SEO description" });
4
+ const count = $derived(typeof value === "string" ? [...value].length : 0);
5
+ const status = $derived(!count ? "Not set" : count < limits.min ? "Too short" : count > limits.max ? "Too long" : "Recommended length");
6
+ const tone = $derived(!count ? "empty" : count > limits.max || count < limits.min / 2 ? "danger" : count < limits.min ? "warning" : "success");
7
+ </script>
8
+
9
+ {#if field === "metaTitle" || field === "description"}
10
+ <div class="seo-length" data-seo-length={field} data-tone={tone}>
11
+ <div class="seo-length-track" role="meter" aria-label={`${limits.label} length`} aria-valuemin={0} aria-valuemax={Math.max(limits.max, count)} aria-valuenow={count} aria-valuetext={`${count} characters. ${status}. Recommended ${limits.min} to ${limits.max}.`} title={status}>
12
+ <span class="seo-length-fill" style:width={`${Math.min(count / limits.max * 100, 100)}%`}></span>
13
+ </div>
14
+ <div class="seo-length-summary hint">
15
+ <span>Recommended: {limits.min}–{limits.max} characters.</span>
16
+ <span class="seo-length-count" aria-hidden="true">{count}/{limits.max}</span>
17
+ </div>
18
+ </div>
19
+ {:else if field === "ogImage"}
20
+ <p class="hint">Recommended size: 1200 × 628 px.</p>
21
+ {/if}
@@ -0,0 +1,183 @@
1
+ <script lang="ts">
2
+ import { getKeys, setKeys, STOCK_PROVIDERS } from "./images";
3
+ import type { RigelSettings } from "./settings";
4
+ import Icon from "./Icon.svelte";
5
+
6
+ let { s = $bindable(), error, saving, onerase, onclose }: {
7
+ s: RigelSettings;
8
+ error: string;
9
+ saving: boolean;
10
+ onerase: () => void;
11
+ onclose: () => void;
12
+ } = $props();
13
+
14
+ type PaneId = "appearance" | "editor" | "media" | "language" | "advanced";
15
+ let active: PaneId = $state("appearance");
16
+
17
+ const providerIds = Object.keys(STOCK_PROVIDERS) as (keyof typeof STOCK_PROVIDERS)[];
18
+ let keys = $state(getKeys());
19
+ let revealed = $state<Partial<Record<keyof typeof STOCK_PROVIDERS, boolean>>>({});
20
+ let keysError = $state("");
21
+ let eraseError = $state("");
22
+
23
+ const panes: { id: PaneId; label: string }[] = [
24
+ { id: "appearance", label: "Appearance" },
25
+ { id: "editor", label: "Editor" },
26
+ { id: "media", label: "Media" },
27
+ { id: "language", label: "Language" },
28
+ { id: "advanced", label: "Advanced" },
29
+ ];
30
+
31
+ const PANE_ICONS = {
32
+ appearance: "palette",
33
+ editor: "textB",
34
+ media: "image",
35
+ language: "translate",
36
+ advanced: "wrench",
37
+ } as const;
38
+
39
+ $effect(() => {
40
+ try {
41
+ setKeys(Object.fromEntries(providerIds.map((id) => [id, keys[id]?.trim() || undefined])));
42
+ keysError = "";
43
+ } catch {
44
+ keysError = "API keys could not be saved in this browser. Allow browser storage and try again.";
45
+ }
46
+ });
47
+ </script>
48
+
49
+ <div
50
+ class="rigel-modal-backdrop rigel"
51
+ role="presentation"
52
+ onclick={onclose}
53
+ onkeydown={(e) => e.key === "Escape" && onclose()}
54
+ >
55
+ <div class="rigel-modal settings-modal" role="dialog" aria-label="Settings" onclick={(e) => e.stopPropagation()}>
56
+ <div class="settings-head">
57
+ <h2>Settings</h2>
58
+ <button class="close" onclick={onclose} aria-label="Close">
59
+ <Icon name="x" size={14} weight="bold" />
60
+ </button>
61
+ </div>
62
+ <div class="settings-body">
63
+ <nav class="settings-nav">
64
+ {#each panes as p (p.id)}
65
+ <button
66
+ class:active={active === p.id}
67
+ onclick={() => (active = p.id)}
68
+ >
69
+ <span class="pico"><Icon name={PANE_ICONS[p.id]} size={24} /></span>
70
+ {p.label}
71
+ </button>
72
+ {/each}
73
+ </nav>
74
+
75
+ <div class="settings-pane">
76
+ {#if active === "appearance"}
77
+ <h3>Appearance</h3>
78
+ <fieldset>
79
+ <legend>Theme</legend>
80
+ <div class="theme-options">
81
+ {#each ["system", "light", "dark"] as const as t}
82
+ <label class="theme-option">
83
+ <input type="radio" bind:group={s.theme} value={t} />
84
+ <span>{t[0].toUpperCase() + t.slice(1)}</span>
85
+ </label>
86
+ {/each}
87
+ </div>
88
+ </fieldset>
89
+ <fieldset class="accessibility-options">
90
+ <legend>Accessibility</legend>
91
+ <label class="inline">
92
+ <input type="checkbox" role="switch" class="switch" bind:checked={s.highContrast} aria-describedby="high-contrast-hint" />
93
+ <span>High contrast mode</span>
94
+ </label>
95
+ <p class="hint" id="high-contrast-hint">Stronger text and borders in both light and dark themes.</p>
96
+ </fieldset>
97
+
98
+ {:else if active === "editor"}
99
+ <h3>Editor experience</h3>
100
+ <fieldset>
101
+ <legend>Article body editing</legend>
102
+ <label class="radio"><input type="radio" bind:group={s.userPref.editorExperience} value="sidebar" /><span>Sidebar</span></label>
103
+ <label class="radio"><input type="radio" bind:group={s.userPref.editorExperience} value="inline" /><span>Inline preview</span></label>
104
+ </fieldset>
105
+ <p class="hint">Inline preview lets you click an article body and write directly on the page. Other fields stay in the sidebar. This preference saves in this browser; content still requires Save.</p>
106
+ {:else if active === "media"}
107
+
108
+ <section class="media-settings-section">
109
+ <h3>Stock Photo Service API Keys</h3>
110
+ <p class="hint">Manage API keys for stock photo services. Keys save automatically in this browser only.</p>
111
+ {#each providerIds as id (id)}
112
+ {@const provider = STOCK_PROVIDERS[id]}
113
+ {@const keyLabel = `${provider.label} ${id === "unsplash" ? "Access Key" : "API key"}`}
114
+ {@const invalid = !!keys[id]?.trim() && !new RegExp(`^${provider.pattern}$`).test(keys[id]!.trim())}
115
+ <div class="stock-key">
116
+ <div class="stock-key-head">
117
+ <h4><label for={`rigel-key-${id}`}>{keyLabel}</label></h4>
118
+ <a href={provider.keyURL} target="_blank" rel="noopener noreferrer">Get {id === "unsplash" ? "Access Key" : "API key"}</a>
119
+ </div>
120
+ <div class="secret-input">
121
+ <input
122
+ id={`rigel-key-${id}`}
123
+ type={revealed[id] ? "text" : "password"}
124
+ bind:value={keys[id]}
125
+ placeholder={provider.placeholder}
126
+ autocomplete="off"
127
+ spellcheck={false}
128
+ aria-label={keyLabel}
129
+ aria-invalid={invalid}
130
+ aria-describedby={invalid ? `rigel-key-${id}-error` : undefined}
131
+ />
132
+ <button type="button" aria-label={`${revealed[id] ? "Hide" : "Show"} ${keyLabel}`} aria-controls={`rigel-key-${id}`} aria-pressed={revealed[id] ?? false} onclick={() => revealed[id] = !revealed[id]}>
133
+ <Icon name={revealed[id] ? "eyeSlash" : "eye"} size={18} />
134
+ </button>
135
+ </div>
136
+ {#if invalid}<p class="error" id={`rigel-key-${id}-error`}>Check the {keyLabel} format.</p>{/if}
137
+ </div>
138
+ {/each}
139
+
140
+ </section>
141
+
142
+ {:else if active === "language"}
143
+ <h3>Language</h3>
144
+ <fieldset>
145
+ <legend>Editor interface language</legend>
146
+ {#each ["en", "es", "ru"] as const as lang}
147
+ <label class="radio">
148
+ <input type="radio" bind:group={s.language} value={lang} />
149
+ <span>{{ en: "English", es: "Español", ru: "Русский" }[lang]}{lang !== "en" ? " (soon)" : ""}</span>
150
+ </label>
151
+ {/each}
152
+ </fieldset>
153
+ <p class="hint">English is the only shipped locale today.</p>
154
+
155
+ {:else}
156
+ <h3>Developer Mode</h3>
157
+ <label class="inline">
158
+ <input type="checkbox" role="switch" class="switch" bind:checked={s.devMode} />
159
+ <span>Enable dev mode</span>
160
+ </label>
161
+ <p class="hint">Shows raw JSON of the current draft + field keys next to labels.</p>
162
+ <div class="clear-data">
163
+ <h3>Clear Data</h3>
164
+ <p class="hint">Rigel does not currently store file contents or thumbnails in a persistent browser cache.</p>
165
+ <button type="button" class="danger-btn" disabled>Clear File Cache</button>
166
+ <p class="hint">Erase Rigel settings, API keys and unsaved drafts from this browser. Files already saved to your repository are not affected.</p>
167
+ <button type="button" class="danger-btn" disabled={saving} onclick={() => {
168
+ eraseError = "";
169
+ try { onerase(); }
170
+ catch { eraseError = "Could not erase browser data. Check browser storage permissions and try again."; }
171
+ }}>Erase All Data</button>
172
+ {#if saving}<p class="hint" role="status">Wait for the current save to finish.</p>{/if}
173
+ {#if eraseError}<p class="error" role="alert">{eraseError}</p>{/if}
174
+ </div>
175
+ {/if}
176
+
177
+ {#if error || keysError}
178
+ <p class="error" role="alert">{error || keysError}</p>
179
+ {/if}
180
+ </div>
181
+ </div>
182
+ </div>
183
+ </div>
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ let { onclose }: { onclose: () => void } = $props();
3
+
4
+ const rows: [string, string][] = [
5
+ ["Search", "⌘/Ctrl + K"],
6
+ ["Close dialog", "Esc"],
7
+ ];
8
+ </script>
9
+
10
+ <div
11
+ class="rigel-modal-backdrop rigel"
12
+ role="presentation"
13
+ onclick={onclose}
14
+ onkeydown={(e) => e.key === "Escape" && onclose()}
15
+ >
16
+ <div class="rigel-modal" role="dialog" aria-label="Keyboard shortcuts" onclick={(e) => e.stopPropagation()}>
17
+ <h2>Keyboard shortcuts</h2>
18
+ <table class="keys">
19
+ <tbody>
20
+ {#each rows as [action, key]}
21
+ <tr>
22
+ <td>{action}</td>
23
+ <td><kbd>{key}</kbd></td>
24
+ </tr>
25
+ {/each}
26
+ </tbody>
27
+ </table>
28
+ </div>
29
+ </div>
@@ -0,0 +1,8 @@
1
+ <script lang="ts">
2
+ import { isSidebarIconPath } from "../../manifest/navigation";
3
+ let { name }: { name: string } = $props();
4
+ const assets = import.meta.glob<string>("../assets/sidebar/*.svg", { eager: true, query: "?url&no-inline", import: "default" });
5
+ const url = $derived(isSidebarIconPath(name) ? name : assets[`../assets/sidebar/${name}.svg`] ?? assets["../assets/sidebar/squares-four.svg"]);
6
+ </script>
7
+
8
+ <span class="sidebar-asset-icon" aria-hidden="true" style={`--sidebar-icon: url("${url}")`}></span>
@@ -0,0 +1,51 @@
1
+ <script lang="ts">
2
+ import { untrack } from "svelte";
3
+ import Icon from "./Icon.svelte";
4
+ import { patchPreview, previewUrl, type PreviewConfig, type PreviewTarget } from "../../preview";
5
+ import PreviewFrame from "./PreviewFrame.svelte";
6
+ let { target, config, values, body, isNew = false, devMode = false }: { target: PreviewTarget; config: PreviewConfig; values: Record<string, unknown>; body: "" | import("../../article-body").ArticleBody; isNew?: boolean; devMode?: boolean } = $props();
7
+
8
+ const livePath = $derived(previewUrl(config, target, "live"));
9
+ const key = $derived(`${target.kind}:${target.collection ?? ""}:${target.slug}`);
10
+ let frame: HTMLIFrameElement | undefined = $state();
11
+ let loaded = $state(false);
12
+ let error = $state("");
13
+ let path = $state<string | null>(null);
14
+ $effect(() => {
15
+ void key;
16
+ loaded = false;
17
+ error = "";
18
+ path = untrack(() => previewUrl(config, target, "draft", isNew));
19
+ });
20
+ function patch() {
21
+ if (!loaded || !frame?.contentDocument) return;
22
+ patchPreview(frame.contentDocument, JSON.parse(JSON.stringify(values)), body);
23
+ }
24
+ function onload() {
25
+ try {
26
+ const doc = frame?.contentDocument;
27
+ doc?.addEventListener("contextmenu", event => { if (!devMode) event.preventDefault(); }, true);
28
+ if (!doc) throw new Error("The prepared page could not be loaded.");
29
+ if (!doc.querySelector('[data-rigel-text], [data-rigel-richtext], [data-rigel-image]')) throw new Error("This page has no prepared Rigel anchors.");
30
+ doc.addEventListener("click", event => event.preventDefault(), true);
31
+ doc.addEventListener("submit", event => event.preventDefault(), true);
32
+ loaded = true;
33
+ patch();
34
+ } catch (cause) { error = cause instanceof Error ? cause.message : "Preview unavailable"; }
35
+ }
36
+ $effect(() => { JSON.stringify(values); void body; patch(); });
37
+ </script>
38
+
39
+ <div class="site-preview" aria-label="Website preview">
40
+ <div class="preview-heading">
41
+ <strong>Website preview</strong>
42
+ {#if livePath}<a class="icon-btn visual-live" href={livePath} target="_blank" rel="noopener noreferrer" aria-label="View live page" title="View live page"><Icon name="external" size={18} /></a>{/if}
43
+ </div>
44
+ {#if error}<p role="status" class="preview-error">{error}</p>{/if}
45
+ {#if path}
46
+ {#key key}<PreviewFrame bind:frame url={path} title="Live website draft preview" {onload} onloading={() => { loaded = false; error = ""; }} onerror={(message) => { loaded = false; error = message; }} />{/key}
47
+ <small>Prepared template · Scripts, navigation and forms disabled. Unanchored fields and lists are not previewed.</small>
48
+ {:else}
49
+ <p role="status">{target.kind === "menu" ? "Menu preview is not supported." : "No prepared preview URL is mapped to this content."}</p>
50
+ {/if}
51
+ </div>