Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/collection-permalinks-step3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@cloudhal/agent-runtime": patch
"@cloudhal/api": patch
"@cloudhal/mcp-server": patch
"@cloudhal/core-ts": patch
"@cloudhal/astro-renderer": patch
"@cloudhal/cli": patch
---

Collection permalinks, step 3 (RFC-0038 §5): Studio's Site settings has a Collections section, one row per collection with the URL prefix before `/{slug}` and a real entry's address; saving goes through the site update, which writes the permanent redirect, and a path conflict shows on the row. The new MCP tool `set_collection_permalink` (`site:write`) moves a collection's URLs and always waits for a person, in every mode, with a prompt listing the old and new template, example entries' old and new URLs and the redirect; `dryRun: true` returns that plan without changing anything, and overlaps are refused before anyone is asked. `get_site` documents `collections`. `SiteGraphStore.updateSite(site, { dryRun: true })` runs every check without writing. A menu item for a collection links to a published page at the collection's permalink prefix when there is one, before the page that lists the collection. The blog starter's README describes collection permalinks. Only someone who may change site settings (`site:settings`) approves it: tool policies take a new `approverScopes`.
232 changes: 232 additions & 0 deletions apps/studio/src/components/CollectionSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/**
* Site › Settings › Collections (RFC-0038 §5, ADR-0040): where each collection's entries live.
* One row per collection with the prefix before `/{slug}` and the address of one of its entries.
* Saved on the Site (PUT, CAS on `rev`); the store leaves a permanent redirect from the old
* addresses and refuses a prefix that overlaps a page, an archive or another collection.
*/
import type { ContentEntry, Site } from "@cloudhal/core-contracts";
import { Banner, Button, InputGroup, SectionEmpty, SkeletonLine } from "@cloudhal/ui";
import { FolderSimple, WarningCircle } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useT } from "../i18n/index.tsx";
import { api } from "../lib/api.ts";
import { apiErrorText, isConflict } from "../lib/api-errors.ts";
import {
changedKeys,
collectionKeys,
isPathRefusal,
type PrefixForm,
prefixForm,
previewUrls,
refusedKey,
sampleEntry,
validatePrefix,
withPrefixes,
} from "../lib/collection-permalinks.ts";
import { ConflictBanner } from "./ConflictBanner.tsx";
import { SettingsSection } from "./Settings.tsx";
import { useToast } from "./Toast.ts";
import { useGate } from "./useMe.ts";

export const COLLECTION_SETTINGS_SECTION = "collections";

export function CollectionSettingsSection({ siteId }: { siteId: string }) {
const t = useT();
const qc = useQueryClient();
const toast = useToast();
const gate = useGate("settings.write");
const site = useQuery({ queryKey: ["site", siteId], queryFn: () => api.sites.get(siteId) });
const entries = useQuery({
queryKey: ["entries", siteId, "all"],
queryFn: () => api.entries.list(siteId),
});
const keys = site.data ? collectionKeys(site.data, entries.data ?? []) : [];
const keysId = keys.join(",");
const [form, setForm] = useState<PrefixForm>({});
const [attempted, setAttempted] = useState(false);
// The form follows the saved site (our save, or a reload after a conflict).
useEffect(() => {
if (site.data) setForm(prefixForm(site.data, keysId ? keysId.split(",") : []));
}, [site.data, keysId]);
const save = useMutation({
mutationFn: (f: PrefixForm) => api.sites.update(withPrefixes(site.data as Site, f)),
onSuccess: async (saved) => {
setAttempted(false);
qc.setQueryData(["site", siteId], saved);
await qc.invalidateQueries({ queryKey: ["sites"] });
toast.success(t("siteSettings.collections.saved"), t("common.rev", { rev: saved.rev }));
},
});
const title = t("siteSettings.sections.collections");
if (!site.data || entries.isPending)
return (
<SettingsSection id={COLLECTION_SETTINGS_SECTION} title={title}>
<SkeletonLine />
</SettingsSection>
);
const current = site.data;
const changed = changedKeys(current, form);
const dirty = changed.length > 0;
const invalid = keys.some((k) => validatePrefix(form[k] ?? ""));
const refused = refusedKey(save.error, changed);
const submit = () => {
setAttempted(true);
if (!dirty || invalid) return;
save.mutate(form);
};
const discard = () => {
setForm(prefixForm(current, keys));
setAttempted(false);
save.reset();
};
return (
<SettingsSection
id={COLLECTION_SETTINGS_SECTION}
title={title}
description={t("siteSettings.collections.description")}
footer={
<>
{dirty && (
<Button variant="ghost" onClick={discard} disabled={save.isPending}>
{t("common.discard")}
</Button>
)}
<Button
variant="primary"
disabled={!dirty || !gate.allowed}
title={gate.reason}
loading={save.isPending}
onClick={submit}
>
{t("common.saveChanges")}
</Button>
</>
}
>
<SaveProblem
error={isPathRefusal(save.error) ? undefined : save.error}
onReload={() => {
save.reset();
void site.refetch();
}}
reloading={site.isFetching}
/>
{keys.length === 0 ? (
<SectionEmpty
icon={<FolderSimple size={32} className="text-kumo-inactive" />}
title={t("siteSettings.collections.empty")}
description={t("siteSettings.collections.emptyDescription")}
/>
) : (
keys.map((key) => (
<CollectionRow
key={key}
collection={key}
site={current}
form={form}
entries={entries.data ?? []}
disabled={!gate.allowed || save.isPending}
title={gate.reason}
showInvalid={attempted || (form[key] ?? "") !== prefixForm(current, [key])[key]}
serverError={refused === key ? apiErrorText(t, save.error) : undefined}
onChange={(value) => {
if (refused) save.reset();
setForm((f) => ({ ...f, [key]: value }));
}}
onSubmit={submit}
/>
))
)}
</SettingsSection>
);
}

/** A failed save that is not about a path: the CAS conflict (reload) or the API's sentence. */
function SaveProblem({
error,
onReload,
reloading,
}: {
error: unknown;
onReload: () => void;
reloading: boolean;
}) {
const t = useT();
if (!error) return null;
if (isConflict(error)) return <ConflictBanner onReload={onReload} reloading={reloading} />;
return (
<Banner
variant="error"
size="sm"
icon={<WarningCircle weight="fill" />}
title={t("siteSettings.collections.failed")}
description={apiErrorText(t, error)}
/>
);
}

/** One collection: its prefix before `/{slug}` and where one of its entries lives. */
function CollectionRow({
collection,
site,
form,
entries,
disabled,
title,
showInvalid,
serverError,
onChange,
onSubmit,
}: {
collection: string;
site: Site;
form: PrefixForm;
entries: readonly ContentEntry[];
disabled: boolean;
/** Why the field is read-only (the gate's reason). */
title: string | undefined;
showInvalid: boolean;
serverError: string | undefined;
onChange: (value: string) => void;
onSubmit: () => void;
}) {
const t = useT();
const value = form[collection] ?? "";
const problem = showInvalid ? validatePrefix(value) : undefined;
const error = problem ? t(`siteSettings.collections.errors.${problem}`) : serverError;
const sample = sampleEntry(entries, collection, site.defaultLocale) ?? {
collection,
slug: t("siteSettings.collections.exampleSlug"),
};
const urls = previewUrls(site, problem ? { ...form, [collection]: "" } : form, sample);
const description =
urls.now === urls.next
? t("siteSettings.collections.preview", { url: urls.next })
: t("siteSettings.collections.previewMoved", { url: urls.next, old: urls.now });
return (
<InputGroup
label={collection}
description={description}
error={error ? { message: error, match: true } : undefined}
disabled={disabled}
title={title}
>
<InputGroup.Input
value={value}
placeholder={`/${collection}`}
spellCheck={false}
autoComplete="off"
aria-invalid={error ? true : undefined}
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSubmit();
}
}}
/>
<InputGroup.Addon align="end">{"/{slug}"}</InputGroup.Addon>
</InputGroup>
);
}
35 changes: 35 additions & 0 deletions apps/studio/src/components/ConflictBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/** Site › Settings: a save refused by the site's CAS (409 `conflict`). */
import { Banner } from "@cloudhal/ui";
import { ArrowClockwise, Warning } from "@phosphor-icons/react";
import { useT } from "../i18n/index.tsx";

/** 409 from the CAS: explain and offer the latest version (DESIGN.md §5, Error). */
export function ConflictBanner({
onReload,
reloading,
}: {
onReload: () => void;
reloading: boolean;
}) {
const t = useT();
return (
<div role="alert">
<Banner
variant="alert"
icon={<Warning weight="fill" />}
title={t("siteSettings.conflictTitle")}
description={t("siteSettings.conflictDescription")}
action={
<Banner.Action
variant="secondary"
icon={<ArrowClockwise />}
onClick={onReload}
loading={reloading}
>
{t("siteSettings.loadLatest")}
</Banner.Action>
}
/>
</div>
);
}
2 changes: 2 additions & 0 deletions apps/studio/src/components/settings-sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AI_SETTINGS_SECTION } from "./AiSettings.tsx";
import { ANALYTICS_SETTINGS_SECTION } from "./AnalyticsSettings.tsx";
import { APPEARANCE_SETTINGS_SECTION } from "./AppearanceSettings.tsx";
import { CHROME_SETTINGS_SECTION } from "./ChromeSettings.tsx";
import { COLLECTION_SETTINGS_SECTION } from "./CollectionSettings.tsx";
import { COMMENT_SETTINGS_SECTION } from "./CommentSettings.tsx";
import { DESIGN_RULES_SECTION } from "./DesignRulesSettings.tsx";
import { GLOSSARY_SETTINGS_SECTION } from "./GlossarySettings.tsx";
Expand All @@ -12,6 +13,7 @@ import { SCHEDULE_SETTINGS_SECTION } from "./ScheduleSettings.tsx";
export const SETTINGS_SECTIONS = [
"general",
"domains",
COLLECTION_SETTINGS_SECTION,
APPEARANCE_SETTINGS_SECTION,
CHROME_SETTINGS_SECTION,
DESIGN_RULES_SECTION,
Expand Down
21 changes: 20 additions & 1 deletion apps/studio/src/i18n/ar.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ar core messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: 46a568922c0a4e81
// hash: be115bdef78fb7c2
import type { Messages } from "./en.ts";

export const messages: Messages = {
Expand Down Expand Up @@ -658,6 +658,7 @@ export const messages: Messages = {
unique: "يوجد بالفعل شيء بهذا الاسم أو العنوان.",
slug_taken: "هذا العنوان مستخدم بالفعل.",
path_conflict: "صفحة أو مقالة أخرى تستخدم هذا المسار بالفعل.",
reserved_path: "هذا المسار محجوز للموقع نفسه (البحث أو الوسائط أو بادئة لغة).",
in_use: "لا يزال قيد الاستخدام، لذا لا يمكن إزالته.",
quota_exceeded: "حصة التخزين ممتلئة.",
too_large: "هذا التغيير كبير جدًا لعملية واحدة. قسّمه.",
Expand Down Expand Up @@ -1215,6 +1216,7 @@ export const messages: Messages = {
sections: {
general: "عام",
domains: "النطاقات",
collections: "المجموعات",
appearance: "المظهر",
design: "قواعد التصميم",
previews: "روابط المعاينة",
Expand All @@ -1241,6 +1243,23 @@ export const messages: Messages = {
saved: "تم حفظ المظهر",
failed: "تعذّر حفظ المظهر",
},
collections: {
description:
"مكان إدخالات كل مجموعة في الموقع. يؤدي تغيير البادئة إلى نقل عنوان كل إدخال في تلك المجموعة، وتبقى العناوين القديمة تعمل عبر إعادة توجيه دائمة. اترك الحقل فارغًا لاستخدام الافتراضي، أي اسم المجموعة نفسه. تصبح العناوين الجديدة متاحة مع النشر التالي.",
preview: "توجد الإدخالات في عناوين مثل {url}",
previewMoved: "ستوجد الإدخالات في عناوين مثل {url}. يعيد العنوان القديم {old} التوجيه إليها.",
exampleSlug: "my-article",
empty: "لا توجد مجموعات بعد",
emptyDescription: "تظهر المجموعات هنا عندما يحتوي الموقع على مقالات أو إدخالات أخرى.",
saved: "تم حفظ المجموعات",
failed: "تعذّر حفظ المجموعات",
errors: {
slash: "ابدأ بشرطة مائلة، مثل /blog",
trailingSlash: "احذف الشرطة المائلة في النهاية",
invalid:
"استخدم أحرفًا لاتينية صغيرة وأرقامًا وشرطات بين الشرطات المائلة، مثل /blog أو /docs/guides",
},
},
conflictTitle: "تغيّر هذا الموقع في مكان آخر",
conflictDescription:
"حفظ شخص أو وكيل هذا الموقع بعد أن فتحته. حمّل أحدث نسخة، وتحقّق من تغييرك، ثم احفظ مجددًا.",
Expand Down
24 changes: 23 additions & 1 deletion apps/studio/src/i18n/de.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): de core messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: d5dca37f709f1733
// hash: 93337839544425aa
import type { Messages } from "./en.ts";

export const messages: Messages = {
Expand Down Expand Up @@ -682,6 +682,8 @@ export const messages: Messages = {
unique: "Etwas mit diesem Namen oder dieser Adresse existiert bereits.",
slug_taken: "Diese Adresse wird bereits verwendet.",
path_conflict: "Eine andere Seite oder ein anderer Artikel verwendet diesen Pfad bereits.",
reserved_path:
"Dieser Pfad ist für die Website selbst reserviert (Suche, Medien oder ein Sprachpräfix).",
in_use: "Es wird noch verwendet und kann daher nicht entfernt werden.",
quota_exceeded: "Das Speicherkontingent ist voll.",
too_large: "Diese Änderung ist für einen Vorgang zu groß. Teile sie auf.",
Expand Down Expand Up @@ -1261,6 +1263,7 @@ export const messages: Messages = {
sections: {
general: "Allgemein",
domains: "Domains",
collections: "Sammlungen",
appearance: "Erscheinungsbild",
design: "Designregeln",
previews: "Vorschau-Links",
Expand Down Expand Up @@ -1290,6 +1293,25 @@ export const messages: Messages = {
saved: "Erscheinungsbild gespeichert",
failed: "Das Erscheinungsbild konnte nicht gespeichert werden",
},
collections: {
description:
"Wo die Einträge jeder Sammlung auf der Website liegen. Ein geändertes Präfix verschiebt die Adresse jedes Eintrags dieser Sammlung; alte Adressen funktionieren über eine dauerhafte Weiterleitung weiter. Lass ein Feld leer für den Standard, den Namen der Sammlung selbst. Neue Adressen gehen mit der nächsten Veröffentlichung live.",
preview: "Einträge liegen unter Adressen wie {url}",
previewMoved:
"Einträge werden unter Adressen wie {url} liegen. Die alte Adresse {old} leitet dorthin weiter.",
exampleSlug: "mein-artikel",
empty: "Noch keine Sammlungen",
emptyDescription:
"Sammlungen erscheinen hier, sobald die Website Artikel oder andere Einträge hat.",
saved: "Sammlungen gespeichert",
failed: "Die Sammlungen konnten nicht gespeichert werden",
errors: {
slash: "Beginne mit einem Schrägstrich, etwa /blog",
trailingSlash: "Lass den Schrägstrich am Ende weg",
invalid:
"Verwende Kleinbuchstaben, Ziffern und Bindestriche zwischen Schrägstrichen, etwa /blog oder /docs/guides",
},
},
conflictTitle: "Diese Website wurde anderswo geändert",
conflictDescription:
"Eine Person oder ein Agent hat diese Website gespeichert, nachdem du sie geöffnet hast. Lade die neueste Version, prüfe deine Änderung und speichere erneut.",
Expand Down
Loading
Loading