@rigel-cms/rigel 0.1.0-beta.1 → 0.1.4-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.
@@ -0,0 +1,120 @@
1
+ export function tooltipPosition(rect: Pick<DOMRect, "left" | "right" | "top" | "bottom">, width: number, height: number, viewportWidth: number, viewportHeight: number) {
2
+ return {
3
+ left: Math.max(8, Math.min((rect.left + rect.right - width) / 2, viewportWidth - width - 8)),
4
+ top: Math.max(8, Math.min(rect.top >= height + 14 ? rect.top - height - 6 : rect.bottom + 6, viewportHeight - height - 8)),
5
+ };
6
+ }
7
+
8
+ let provider: ReturnType<typeof createTooltips> | undefined;
9
+
10
+ export function tooltipRoot(root: HTMLElement) {
11
+ provider ??= createTooltips();
12
+ const remove = provider.register(root);
13
+ return { destroy: remove };
14
+ }
15
+
16
+ function createTooltips() {
17
+ let tip: HTMLDivElement | undefined;
18
+ let anchor: HTMLElement | null = null;
19
+ let timer: ReturnType<typeof setTimeout> | undefined;
20
+ let opening: ReturnType<typeof setTimeout> | undefined;
21
+ let frame = 0;
22
+ let view: Window | null = null;
23
+ let pointerTime = 0;
24
+ let users = 0;
25
+ const id = "rigel-tooltip";
26
+
27
+ function hide() {
28
+ clearTimeout(timer);
29
+ clearTimeout(opening);
30
+ if (view) view.cancelAnimationFrame(frame);
31
+ if (anchor) {
32
+ const ids = (anchor.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(value => value && value !== id);
33
+ if (ids.length) anchor.setAttribute("aria-describedby", ids.join(" ")); else anchor.removeAttribute("aria-describedby");
34
+ }
35
+ anchor = null;
36
+ if (tip?.matches(":popover-open")) tip.hidePopover();
37
+ tip?.remove();
38
+ }
39
+
40
+ function update() {
41
+ if (!anchor || !tip || !view) return;
42
+ if (!anchor.isConnected || !anchor.checkVisibility() || !anchor.dataset.tooltip) { hide(); return; }
43
+ if (tip.textContent !== anchor.dataset.tooltip) tip.textContent = anchor.dataset.tooltip;
44
+ const position = tooltipPosition(anchor.getBoundingClientRect(), tip.offsetWidth, tip.offsetHeight, view.innerWidth, view.innerHeight);
45
+ tip.style.left = `${position.left}px`;
46
+ tip.style.top = `${position.top}px`;
47
+ frame = view.requestAnimationFrame(update);
48
+ }
49
+
50
+ function show(target: HTMLElement) {
51
+ hide();
52
+ if (!target.isConnected || !target.checkVisibility() || !target.dataset.tooltip) return;
53
+ const doc = target.ownerDocument;
54
+ tip ??= doc.createElement("div");
55
+ tip.id = id;
56
+ tip.className = "rigel-tooltip";
57
+ tip.setAttribute("role", "tooltip");
58
+ tip.popover = "manual";
59
+ anchor = target;
60
+ view = doc.defaultView;
61
+ const scope = target.closest("dialog, .rigel") ?? target.parentElement!;
62
+ scope.append(tip);
63
+ tip.textContent = target.dataset.tooltip;
64
+ target.setAttribute("aria-describedby", [...new Set([...(target.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(Boolean), id])].join(" "));
65
+ try { tip.showPopover(); update(); }
66
+ catch (error) {
67
+ if ((error as DOMException)?.name !== "InvalidStateError") throw error;
68
+ opening = setTimeout(() => { if (anchor !== target) return; if (!target.isConnected || !target.checkVisibility()) { hide(); return; } tip!.showPopover(); update(); }, 0);
69
+ }
70
+ }
71
+
72
+ function register(root: HTMLElement) {
73
+ users++;
74
+ const doc = root.ownerDocument;
75
+ const win = doc.defaultView!;
76
+ const target = (event: Event) => {
77
+ const element = (event.target as Element)?.closest?.<HTMLElement>("[data-tooltip]");
78
+ return element && root.contains(element) && element.closest(".rigel") ? element : null;
79
+ };
80
+ const over = (event: PointerEvent) => {
81
+ if (event.pointerType === "touch") return;
82
+ if (event.target === tip) { clearTimeout(timer); return; }
83
+ const element = target(event);
84
+ if (!element || element.contains(event.relatedTarget as Node | null) || element === anchor || element === doc.activeElement) return;
85
+ hide(); timer = setTimeout(() => show(element), 450);
86
+ };
87
+ const out = (event: PointerEvent) => {
88
+ const element = target(event);
89
+ if (element?.contains(event.relatedTarget as Node | null)) return;
90
+ if (event.relatedTarget === tip || anchor?.contains(event.relatedTarget as Node | null)) return;
91
+ if (anchor === doc.activeElement) return;
92
+ clearTimeout(timer); timer = setTimeout(hide, 150);
93
+ };
94
+ const focus = (event: FocusEvent) => {
95
+ const element = target(event);
96
+ if (element && Date.now() - pointerTime > 700) show(element);
97
+ };
98
+ const down = () => { pointerTime = Date.now(); hide(); };
99
+ const key = (event: KeyboardEvent) => {
100
+ pointerTime = 0;
101
+ if (event.key !== "Escape") return;
102
+ const visible = !!anchor;
103
+ hide();
104
+ if (visible) { event.preventDefault(); event.stopImmediatePropagation(); }
105
+ };
106
+ const listeners = [["pointerover", over], ["pointerout", out], ["focusin", focus], ["focusout", hide], ["pointerdown", down], ["click", hide], ["keydown", key]] as const;
107
+ for (const [name, listener] of listeners) root.addEventListener(name, listener as EventListener, true);
108
+ win.addEventListener("blur", hide);
109
+ win.addEventListener("resize", hide);
110
+ win.addEventListener("scroll", hide, true);
111
+ return () => {
112
+ hide();
113
+ for (const [name, listener] of listeners) root.removeEventListener(name, listener as EventListener, true);
114
+ win.removeEventListener("blur", hide); win.removeEventListener("resize", hide);
115
+ win.removeEventListener("scroll", hide, true);
116
+ if (!--users) { tip = undefined; provider = undefined; }
117
+ };
118
+ }
119
+ return { register };
120
+ }
@@ -1,4 +1,7 @@
1
1
  .rigel, .rigel *, .rigel *::before, .rigel *::after { box-sizing: border-box; }
2
+ .rigel .rigel-tooltip { position: fixed; pointer-events: auto; inset: auto; margin: 0; padding: 0.375rem 0.625rem; max-width: min(20rem, calc(100vw - 16px)); max-height: calc(100dvh - 16px); overflow: auto; border: 1px solid var(--border-control); border-radius: var(--radius); background: var(--text); color: var(--panel); box-shadow: var(--shadow-md); font: 500 0.75rem/1.5 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; overflow-wrap: anywhere; text-align: start; white-space: normal; }
3
+ .rigel .rigel-tooltip::backdrop { background: transparent; pointer-events: none; }
4
+ @media (forced-colors: active) { .rigel .rigel-tooltip { background: Canvas; color: CanvasText; border-color: CanvasText; } }
2
5
  .rigel {
3
6
  --rigel-50: #EFFBFF;
4
7
  --rigel-100: #D9F4FF;
@@ -26,6 +29,9 @@
26
29
 
27
30
  .rigel.connection-shell { align-items: center; justify-content: center; padding: 1.5rem; min-height: 100dvh; }
28
31
  .rigel .connection-card { flex: none; width: 100%; max-width: 560px; margin: 0; padding: 2rem; background: var(--panel); border: 1px solid var(--border); overflow-wrap: anywhere; }
32
+ .rigel .connection-brand { display: flex; align-items: center; gap: 0.75rem; font-size: 1.5rem; font-weight: 650; letter-spacing: -0.04em; }
33
+ .rigel .brand-suffix { font-weight: 450; }
34
+ .rigel .connection-brand img { flex-shrink: 0; }
29
35
  .rigel .connection-card h1 { margin: 1rem 0; font-size: 1.75rem; line-height: 1.2; }
30
36
  .rigel .connection-card p { margin: 1rem 0; }
31
37
  .rigel .connection-card :is(button, a.primary) { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0.625rem 1rem; font: inherit; text-decoration: none; text-align: center; }
@@ -263,6 +269,7 @@
263
269
  justify-content: center;
264
270
  flex-shrink: 0;
265
271
  }
272
+ .rigel .avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
266
273
  .rigel .uname { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
267
274
  .rigel .user-details { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 0.125rem; }
268
275
  .rigel .user-details small { color: var(--text-muted); font-size: 0.75rem; font-weight: 400; }
@@ -958,6 +965,10 @@
958
965
  font-size: 0.875rem; font-weight: 600; text-transform: none;
959
966
  letter-spacing: 0; color: var(--text); margin: 0 0 0.75rem;
960
967
  }
968
+ .rigel .connection-details { margin: 0; }
969
+ .rigel .connection-details dt { margin-top: 1rem; font-size: 0.75rem; font-weight: 600; color: var(--text-2); }
970
+ .rigel .connection-details dd { margin: 0.375rem 0 0; font-size: 0.875rem; overflow-wrap: anywhere; }
971
+ .rigel .connection-details small { display: block; margin-top: 0.125rem; color: var(--text-muted); }
961
972
  .rigel .settings-pane fieldset { border: none; padding: 0; margin: 0 0 1rem; }
962
973
  .rigel .settings-pane fieldset.accessibility-options { margin-top: 1.5rem; }
963
974
  .rigel .accessibility-options label.inline { margin-top: 0; }
@@ -0,0 +1,27 @@
1
+ import type { CollectionField } from "./types";
2
+ import type { ContentDocument } from "../schema/types";
3
+ import { validateCollectionReferences } from "./references";
4
+
5
+ export function initialCollectionValues(fields: CollectionField[], title: string): Record<string, unknown> {
6
+ return Object.fromEntries(fields.map(field => [field.key,
7
+ field.type === "boolean" ? field.key === "draft" :
8
+ field.type === "tags" || (field.type === "reference" && field.multiple) ? [] :
9
+ field.type === "date" ? new Date().toISOString().slice(0, 10) :
10
+ field.key === "title" && field.type === "string" ? title : "",
11
+ ]));
12
+ }
13
+
14
+ export function validateCollectionValues(fields: CollectionField[], values: Record<string, unknown>, documents: ContentDocument[] = []): void {
15
+ if (!values || typeof values !== "object" || Array.isArray(values)) throw new Error("Invalid article frontmatter");
16
+ for (const field of fields) {
17
+ const value = values[field.key];
18
+ if (field.required && (value === undefined || value === null || (typeof value === "string" && !value.trim()) || (Array.isArray(value) && !value.length))) throw new Error(`${field.label}: required`);
19
+ if (value === undefined || field.type === "reference") continue;
20
+ const valid = field.type === "boolean" ? typeof value === "boolean" :
21
+ field.type === "tags" ? Array.isArray(value) && value.every(tag => typeof tag === "string") :
22
+ field.type === "date" ? typeof value === "string" && (value === "" || (/^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value)) :
23
+ typeof value === "string";
24
+ if (!valid) throw new Error(`${field.label}: expected ${field.type}`);
25
+ }
26
+ validateCollectionReferences(fields, values, documents);
27
+ }
@@ -52,6 +52,7 @@ export interface CollectionField {
52
52
  type: "string" | "text" | "boolean" | "date" | "tags" | "markdown" | "image" | "reference";
53
53
  to?: string;
54
54
  multiple?: boolean;
55
+ required?: boolean;
55
56
  options?: import("./references").ReferenceOption[];
56
57
  }
57
58
 
@@ -1,3 +1,12 @@
1
1
  export function safePreviewUrl(value: string, image = false): boolean {
2
2
  return !!value && !/[ \t\r\n\f\v\u0000-\u001f\u007f\\]/.test(value) && !value.startsWith("//") && (!/^[a-z][a-z\d+.-]*:/i.test(value) || (image ? /^https?:/i : /^(https?:|mailto:)/i).test(value));
3
3
  }
4
+
5
+ export function previewBaseUrl(value: unknown): string {
6
+ try {
7
+ if (typeof value !== "string" || !/^https?:\/\/[^/@]+(?:\/|$)/.test(value) || /[\s\u0000-\u001f\u007f\\%?#]/.test(value) || value.split("/").some(part => part === "." || part === "..")) throw new Error();
8
+ const url = new URL(value);
9
+ if (url.username || url.password || (url.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) throw new Error();
10
+ return `${url.origin}${url.pathname.replace(/\/$/, "")}/`;
11
+ } catch { throw new Error("rigel: preview.liveUrl and preview.draftUrl must be absolute HTTPS base URLs without credentials, query or fragment (HTTP allowed on loopback)"); }
12
+ }
package/src/preview.ts CHANGED
@@ -2,7 +2,7 @@ import MarkdownIt from "markdown-it";
2
2
  import DOMPurify from "dompurify";
3
3
  import { pageRoute } from "./schema/paths";
4
4
  import { renderArticle } from "./article-render";
5
- import { safePreviewUrl } from "./preview-urls";
5
+ import { safePreviewUrl, previewBaseUrl } from "./preview-urls";
6
6
  export { safePreviewUrl } from "./preview-urls";
7
7
 
8
8
  export interface PreviewDocumentMapping {
@@ -11,6 +11,8 @@ export interface PreviewDocumentMapping {
11
11
  }
12
12
 
13
13
  export interface PreviewConfig {
14
+ liveUrl?: string;
15
+ draftUrl?: string;
14
16
  pages?: Record<string, string>;
15
17
  collections?: Record<string, { url: string; new: string }>;
16
18
  documents?: Record<string, PreviewDocumentMapping>;
@@ -23,10 +25,16 @@ export interface PreviewTarget {
23
25
  }
24
26
 
25
27
  export function previewPath(config: PreviewConfig, target: PreviewTarget, isNew = false): string | null {
26
- if (target.kind === "document") return pageRoute(target.slug, config.documents?.[target.type ?? ""]);
27
28
  const mapping = target.kind === "post" ? config.collections?.[target.collection ?? ""] : undefined;
28
- const path = target.kind === "page" ? config.pages?.[target.slug] : mapping ? (isNew ? mapping.new : mapping.url.replaceAll("{slug}", encodeURIComponent(target.slug))) : undefined;
29
- return typeof path === "string" && /^\/(?!\/)/.test(path) && !/[\\\s?#]/.test(path) && !path.split("/").includes("..") && !/^\/rigel(?:\/|$)/.test(path) ? path : null;
29
+ const path = target.kind === "document" ? pageRoute(target.slug, config.documents?.[target.type ?? ""]) : target.kind === "page" ? config.pages?.[target.slug] : mapping ? (isNew ? mapping.new : mapping.url.replaceAll("{slug}", encodeURIComponent(target.slug))) : undefined;
30
+ return typeof path === "string" && /^\/(?!\/)/.test(path) && !/[\\\s?#]/.test(path) && !/%(?:2e|2f|5c)/i.test(path) && !path.split("/").some(part => part === "." || part === "..") && !/^\/rigel(?:\/|$)/.test(path) ? path : null;
31
+ }
32
+
33
+ export function previewUrl(config: PreviewConfig, target: PreviewTarget, mode: "live" | "draft" | "website" = "draft", isNew = false): string | null {
34
+ const path = previewPath(config, target, mode === "draft" && (target.kind === "post" || isNew));
35
+ const base = mode === "live" ? config.liveUrl : config.draftUrl;
36
+ if (!path || base === undefined) return path;
37
+ try { return new URL(path.slice(1), previewBaseUrl(base)).href; } catch { return null; }
30
38
  }
31
39
 
32
40
  export function anchorValue(values: unknown, path: string): { found: boolean; value: unknown } {
@@ -1,11 +1,11 @@
1
1
  import type { APIRoute } from "astro";
2
- import { writeFile, mkdir, rm, readFile, rename } from "node:fs/promises";
2
+ import { writeFile, mkdir, rm, readFile, rename, link } from "node:fs/promises";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import path from "node:path";
5
5
 
6
6
  import { validateBody, type ArticleBody } from "../article-body";
7
7
  import config from "virtual:rigel-config";
8
- import { validateCollectionReferences } from "../manifest/references";
8
+ import { validateCollectionValues } from "../manifest/collections";
9
9
  import { readDocuments } from "../schema/store";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import type { CollectionConfig } from "../config";
@@ -46,16 +46,22 @@ export const POST: APIRoute = async ({ request, params }) => {
46
46
  validateBody(body.body);
47
47
  const fields = config.collections?.find((collection: CollectionConfig) => collection.name === params.collection)?.fields ?? [];
48
48
  const documents = config.schema && config.root ? await readDocuments(config.schema, fileURLToPath(config.root)) : [];
49
- validateCollectionReferences(fields, body.frontmatter, documents);
49
+ validateCollectionValues(fields, body.frontmatter, documents);
50
50
  } catch (error) {
51
- return new Response(error instanceof Error ? error.message : "Invalid references", { status: 400 });
51
+ return new Response(error instanceof Error ? error.message : "Invalid article", { status: 400 });
52
52
  }
53
53
  await mkdir(path.dirname(file), { recursive: true });
54
54
  const fm = { ...body.frontmatter, slug };
55
55
  const temporary = `${file}.${randomUUID()}.tmp`;
56
56
  try {
57
57
  await writeFile(temporary, JSON.stringify({ frontmatter: fm, body: body.body }, null, 2) + "\n", { flag: "wx" });
58
- await rename(temporary, file);
58
+ if (request.headers.get("If-None-Match") === "*") {
59
+ try { await link(temporary, file); }
60
+ catch (error) {
61
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return new Response("A post with that slug already exists. Reload the collection and create the draft again.", { status: 409 });
62
+ throw error;
63
+ }
64
+ } else await rename(temporary, file);
59
65
  } finally { await rm(temporary, { force: true }); }
60
66
  return new Response(null, { status: 204 });
61
67
  };
@@ -50,6 +50,7 @@ const __rigelReservedSlugs = new Set(documents.filter((doc) => !documentTypes.fi
50
50
  ).map(([path, raw]) => {
51
51
  const post = JSON.parse(raw);
52
52
  __rigelValidateBody(post.body);
53
+ __rigelValidateCollectionValues(${JSON.stringify(c.fields)}, post.frontmatter, documents);
53
54
  post.slug = path.split("/").pop().replace(/\\.json$/, "");
54
55
  return post;
55
56
  }),
@@ -165,6 +166,7 @@ export function derefDocument(value) { return deref(value, __rigelIndex); }
165
166
  }
166
167
  return `
167
168
  import { validateBody as __rigelValidateBody } from ${JSON.stringify(fileURLToPath(new URL("../article-body.ts", import.meta.url)))};
169
+ import { validateCollectionValues as __rigelValidateCollectionValues } from ${JSON.stringify(fileURLToPath(new URL("../manifest/collections.ts", import.meta.url)))};
168
170
  const modules = import.meta.glob(${JSON.stringify(pattern)}, { eager: true });
169
171
  const loaded = Object.values(modules).map((m) => m.default);
170
172
  ${documentsSrc}