diff --git a/.changeset/collection-permalinks-step3.md b/.changeset/collection-permalinks-step3.md new file mode 100644 index 00000000..b805892e --- /dev/null +++ b/.changeset/collection-permalinks-step3.md @@ -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`. diff --git a/apps/studio/src/components/CollectionSettings.tsx b/apps/studio/src/components/CollectionSettings.tsx new file mode 100644 index 00000000..87cfe575 --- /dev/null +++ b/apps/studio/src/components/CollectionSettings.tsx @@ -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({}); + 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 ( + + + + ); + 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 ( + + {dirty && ( + + )} + + + } + > + { + save.reset(); + void site.refetch(); + }} + reloading={site.isFetching} + /> + {keys.length === 0 ? ( + } + title={t("siteSettings.collections.empty")} + description={t("siteSettings.collections.emptyDescription")} + /> + ) : ( + keys.map((key) => ( + { + if (refused) save.reset(); + setForm((f) => ({ ...f, [key]: value })); + }} + onSubmit={submit} + /> + )) + )} + + ); +} + +/** 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 ; + return ( + } + 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 ( + + onChange((e.target as HTMLInputElement).value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onSubmit(); + } + }} + /> + {"/{slug}"} + + ); +} diff --git a/apps/studio/src/components/ConflictBanner.tsx b/apps/studio/src/components/ConflictBanner.tsx new file mode 100644 index 00000000..8de5b5f3 --- /dev/null +++ b/apps/studio/src/components/ConflictBanner.tsx @@ -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 ( +
+ } + title={t("siteSettings.conflictTitle")} + description={t("siteSettings.conflictDescription")} + action={ + } + onClick={onReload} + loading={reloading} + > + {t("siteSettings.loadLatest")} + + } + /> +
+ ); +} diff --git a/apps/studio/src/components/settings-sections.ts b/apps/studio/src/components/settings-sections.ts index 24e30284..b4d6d3c1 100644 --- a/apps/studio/src/components/settings-sections.ts +++ b/apps/studio/src/components/settings-sections.ts @@ -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"; @@ -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, diff --git a/apps/studio/src/i18n/ar.ts b/apps/studio/src/i18n/ar.ts index 07a8a218..dfc24f5d 100644 --- a/apps/studio/src/i18n/ar.ts +++ b/apps/studio/src/i18n/ar.ts @@ -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 = { @@ -658,6 +658,7 @@ export const messages: Messages = { unique: "يوجد بالفعل شيء بهذا الاسم أو العنوان.", slug_taken: "هذا العنوان مستخدم بالفعل.", path_conflict: "صفحة أو مقالة أخرى تستخدم هذا المسار بالفعل.", + reserved_path: "هذا المسار محجوز للموقع نفسه (البحث أو الوسائط أو بادئة لغة).", in_use: "لا يزال قيد الاستخدام، لذا لا يمكن إزالته.", quota_exceeded: "حصة التخزين ممتلئة.", too_large: "هذا التغيير كبير جدًا لعملية واحدة. قسّمه.", @@ -1215,6 +1216,7 @@ export const messages: Messages = { sections: { general: "عام", domains: "النطاقات", + collections: "المجموعات", appearance: "المظهر", design: "قواعد التصميم", previews: "روابط المعاينة", @@ -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: "حفظ شخص أو وكيل هذا الموقع بعد أن فتحته. حمّل أحدث نسخة، وتحقّق من تغييرك، ثم احفظ مجددًا.", diff --git a/apps/studio/src/i18n/de.ts b/apps/studio/src/i18n/de.ts index 8df371f3..21855a81 100644 --- a/apps/studio/src/i18n/de.ts +++ b/apps/studio/src/i18n/de.ts @@ -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 = { @@ -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.", @@ -1261,6 +1263,7 @@ export const messages: Messages = { sections: { general: "Allgemein", domains: "Domains", + collections: "Sammlungen", appearance: "Erscheinungsbild", design: "Designregeln", previews: "Vorschau-Links", @@ -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.", diff --git a/apps/studio/src/i18n/en.ts b/apps/studio/src/i18n/en.ts index 8e10e73e..ef10cfba 100644 --- a/apps/studio/src/i18n/en.ts +++ b/apps/studio/src/i18n/en.ts @@ -633,6 +633,8 @@ export const en = { unique: "Something with this name or address already exists.", slug_taken: "This address is already in use.", path_conflict: "Another page or article already uses this path.", + reserved_path: + "This path is reserved for the site itself (search, media or a language prefix).", in_use: "It's still in use, so it can't be removed.", quota_exceeded: "The storage quota is full.", too_large: "This change is too large for one operation. Split it up.", @@ -1177,6 +1179,7 @@ export const en = { sections: { general: "General", domains: "Domains", + collections: "Collections", appearance: "Appearance", design: "Design rules", previews: "Preview links", @@ -1203,6 +1206,28 @@ export const en = { saved: "Appearance saved", failed: "Could not save the appearance", }, + /** RFC-0038 §5: where each collection's entries live (the part of the address before the slug). */ + collections: { + description: + "Where each collection's entries live on the site. Changing a prefix moves the address of every entry in that collection; old addresses keep working through a permanent redirect. Leave a field empty for the default, the collection's own name. New addresses go live with the next publish.", + /** Shown under a row: one real entry's address (or an example). */ + preview: "Entries live at addresses like {url}", + /** Shown under a changed row before saving. */ + previewMoved: + "Entries will live at addresses like {url}. The old address {old} redirects there.", + /** An example slug when the collection has no entries yet: lower-case letters, digits and hyphens only. */ + exampleSlug: "my-article", + empty: "No collections yet", + emptyDescription: "Collections appear here once the site has articles or other entries.", + saved: "Collections saved", + failed: "Couldn't save the collections", + errors: { + slash: "Start with a slash, like /blog", + trailingSlash: "Leave out the slash at the end", + invalid: + "Use lower-case letters, digits and hyphens between slashes, like /blog or /docs/guides", + }, + }, conflictTitle: "This site changed somewhere else", conflictDescription: "A person or an agent saved this site after you opened it. Load the latest version, check your change and save again.", diff --git a/apps/studio/src/i18n/es.ts b/apps/studio/src/i18n/es.ts index 2534f2f5..824a698d 100644 --- a/apps/studio/src/i18n/es.ts +++ b/apps/studio/src/i18n/es.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): es core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: d29c27e165ec3375 +// hash: 849ee636ff4ae543 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -681,6 +681,8 @@ export const messages: Messages = { unique: "Ya existe algo con este nombre o esta dirección.", slug_taken: "Esta dirección ya está en uso.", path_conflict: "Otra página u otro artículo ya usa esta ruta.", + reserved_path: + "Esta ruta está reservada para el propio sitio (búsqueda, medios o un prefijo de idioma).", in_use: "Todavía está en uso, así que no se puede quitar.", quota_exceeded: "La cuota de almacenamiento está llena.", too_large: "Este cambio es demasiado grande para una sola operación. Divídelo.", @@ -1257,6 +1259,7 @@ export const messages: Messages = { sections: { general: "General", domains: "Dominios", + collections: "Colecciones", appearance: "Apariencia", design: "Reglas de diseño", previews: "Enlaces de vista previa", @@ -1283,6 +1286,24 @@ export const messages: Messages = { saved: "Apariencia guardada", failed: "No se pudo guardar la apariencia", }, + collections: { + description: + "Dónde viven las entradas de cada colección en el sitio. Cambiar un prefijo mueve la dirección de todas las entradas de esa colección; las direcciones antiguas siguen funcionando mediante una redirección permanente. Deja un campo vacío para usar el valor predeterminado, el nombre de la propia colección. Las nuevas direcciones se publican con la próxima publicación.", + preview: "Las entradas viven en direcciones como {url}", + previewMoved: + "Las entradas vivirán en direcciones como {url}. La dirección antigua {old} redirige allí.", + exampleSlug: "mi-articulo", + empty: "Aún no hay colecciones", + emptyDescription: + "Las colecciones aparecen aquí cuando el sitio tiene artículos u otras entradas.", + saved: "Colecciones guardadas", + failed: "No se pudieron guardar las colecciones", + errors: { + slash: "Empieza con una barra, como /blog", + trailingSlash: "Quita la barra del final", + invalid: "Usa minúsculas, dígitos y guiones entre barras, como /blog o /docs/guides", + }, + }, conflictTitle: "Este sitio cambió en otro lugar", conflictDescription: "Una persona o un agente guardó este sitio después de que lo abrieras. Carga la última versión, revisa tu cambio y vuelve a guardar.", diff --git a/apps/studio/src/i18n/fa.ts b/apps/studio/src/i18n/fa.ts index 39b2d870..9843af7f 100644 --- a/apps/studio/src/i18n/fa.ts +++ b/apps/studio/src/i18n/fa.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fa core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: cb94d2968657f128 +// hash: 8ec073f87312d817 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -663,6 +663,7 @@ export const messages: Messages = { unique: "چیزی با این نام یا نشانی از قبل وجود دارد.", slug_taken: "این نشانی از قبل در حال استفاده است.", path_conflict: "صفحه یا مقالهٔ دیگری از قبل از این مسیر استفاده می‌کند.", + reserved_path: "این مسیر برای خود سایت رزرو شده است (جستجو، رسانه یا پیشوند زبان).", in_use: "هنوز در حال استفاده است، بنابراین قابل حذف نیست.", quota_exceeded: "سهمیهٔ فضای ذخیره‌سازی پر است.", too_large: "این تغییر برای یک عملیات بیش از حد بزرگ است. آن را تقسیم کنید.", @@ -1237,6 +1238,7 @@ export const messages: Messages = { sections: { general: "عمومی", domains: "دامنه‌ها", + collections: "مجموعه‌ها", appearance: "ظاهر", design: "قواعد طراحی", previews: "پیوندهای پیش‌نمایش", @@ -1264,6 +1266,25 @@ 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: "یک شخص یا عامل پس از باز کردن این سایت توسط شما، آن را ذخیره کرده است. آخرین نسخه را بارگذاری کنید، تغییر خود را بررسی کنید و دوباره ذخیره کنید.", diff --git a/apps/studio/src/i18n/fr.ts b/apps/studio/src/i18n/fr.ts index 83675aae..40927d7c 100644 --- a/apps/studio/src/i18n/fr.ts +++ b/apps/studio/src/i18n/fr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fr core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: fc52ec12db474138 +// hash: 4d57345726542d46 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -688,6 +688,8 @@ export const messages: Messages = { unique: "Un élément avec ce nom ou cette adresse existe déjà.", slug_taken: "Cette adresse est déjà utilisée.", path_conflict: "Une autre page ou un autre article utilise déjà ce chemin.", + reserved_path: + "Ce chemin est réservé au site lui-même (recherche, médias ou préfixe de langue).", in_use: "Cet élément est encore utilisé et ne peut donc pas être retiré.", quota_exceeded: "Le quota de stockage est plein.", too_large: "Cette modification est trop volumineuse pour une seule opération. Divisez-la.", @@ -1273,6 +1275,7 @@ export const messages: Messages = { sections: { general: "Général", domains: "Domaines", + collections: "Collections", appearance: "Apparence", design: "Règles de design", previews: "Liens d’aperçu", @@ -1302,6 +1305,25 @@ export const messages: Messages = { saved: "Apparence enregistrée", failed: "Impossible d’enregistrer l’apparence", }, + collections: { + description: + "L'emplacement des entrées de chaque collection sur le site. Modifier un préfixe déplace l'adresse de chaque entrée de cette collection ; les anciennes adresses continuent de fonctionner grâce à une redirection permanente. Laissez un champ vide pour la valeur par défaut, le nom de la collection. Les nouvelles adresses sont en ligne à la prochaine publication.", + preview: "Les entrées se trouvent à des adresses comme {url}", + previewMoved: + "Les entrées se trouveront à des adresses comme {url}. L'ancienne adresse {old} y redirige.", + exampleSlug: "mon-article", + empty: "Aucune collection pour l'instant", + emptyDescription: + "Les collections apparaissent ici dès que le site a des articles ou d'autres entrées.", + saved: "Collections enregistrées", + failed: "Impossible d'enregistrer les collections", + errors: { + slash: "Commencez par une barre oblique, comme /blog", + trailingSlash: "Retirez la barre oblique finale", + invalid: + "Utilisez des minuscules, des chiffres et des tirets entre les barres obliques, comme /blog ou /docs/guides", + }, + }, conflictTitle: "Ce site a été modifié ailleurs", conflictDescription: "Une personne ou un agent a enregistré ce site après que vous l’avez ouvert. Chargez la dernière version, vérifiez votre modification et enregistrez de nouveau.", diff --git a/apps/studio/src/i18n/generated/params.ts b/apps/studio/src/i18n/generated/params.ts index 305529c0..f6948092 100644 --- a/apps/studio/src/i18n/generated/params.ts +++ b/apps/studio/src/i18n/generated/params.ts @@ -656,6 +656,8 @@ export interface MessageParams { "siteAccess.title": { name: string | number }; "siteSettings.appearance.description": { theme: string | number }; "siteSettings.appearance.none": { theme: string | number }; + "siteSettings.collections.preview": { url: string | number }; + "siteSettings.collections.previewMoved": { url: string | number; old: string | number }; "siteSettings.design.count": { count: string | number; max: string | number }; "siteSettings.design.noTheme": { theme: string | number }; "siteSettings.design.theme": { theme: string | number }; diff --git a/apps/studio/src/i18n/he.ts b/apps/studio/src/i18n/he.ts index bbe0796e..a785dbaa 100644 --- a/apps/studio/src/i18n/he.ts +++ b/apps/studio/src/i18n/he.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): he core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: a3fbc3dfc533431e +// hash: 1f619bd9babf939f import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -656,6 +656,7 @@ export const messages: Messages = { unique: "כבר קיים משהו עם השם או הכתובת האלה.", slug_taken: "הכתובת הזו כבר בשימוש.", path_conflict: "עמוד או מאמר אחר כבר משתמש בנתיב הזה.", + reserved_path: "הנתיב הזה שמור לאתר עצמו (חיפוש, מדיה או קידומת שפה).", in_use: "זה עדיין בשימוש, ולכן אי אפשר להסיר את זה.", quota_exceeded: "מכסת האחסון מלאה.", too_large: "השינוי הזה גדול מדי לפעולה אחת. יש לפצל אותו.", @@ -1213,6 +1214,7 @@ export const messages: Messages = { sections: { general: "כללי", domains: "דומיינים", + collections: "אוספים", appearance: "מראה", design: "כללי עיצוב", previews: "קישורי תצוגה מקדימה", @@ -1239,6 +1241,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: "אדם או סוכן שמרו את האתר הזה אחרי שפתחת אותו. יש לטעון את הגרסה העדכנית, לבדוק את השינוי ולשמור שוב.", diff --git a/apps/studio/src/i18n/hi.ts b/apps/studio/src/i18n/hi.ts index d9610548..38e6832e 100644 --- a/apps/studio/src/i18n/hi.ts +++ b/apps/studio/src/i18n/hi.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): hi core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 737592cf146bb7a3 +// hash: 676c28d94a84d681 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -652,6 +652,7 @@ export const messages: Messages = { unique: "इस नाम या पते वाला कुछ पहले से मौजूद है।", slug_taken: "यह पता पहले से उपयोग में है।", path_conflict: "कोई दूसरा पेज या लेख पहले से इस पाथ का उपयोग करता है।", + reserved_path: "यह पथ साइट के लिए आरक्षित है (खोज, मीडिया या भाषा उपसर्ग)।", in_use: "यह अब भी उपयोग में है, इसलिए हटाया नहीं जा सकता।", quota_exceeded: "स्टोरेज कोटा भर गया है।", too_large: "यह बदलाव एक ऑपरेशन के लिए बहुत बड़ा है। इसे बाँटें।", @@ -1209,6 +1210,7 @@ export const messages: Messages = { sections: { general: "सामान्य", domains: "डोमेन", + collections: "संग्रह", appearance: "रूप-रंग", design: "डिज़ाइन नियम", previews: "प्रीव्यू लिंक", @@ -1235,6 +1237,22 @@ 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: "आपके खोलने के बाद किसी व्यक्ति या एजेंट ने यह साइट सहेज दी। नवीनतम संस्करण लोड करें, अपना बदलाव जाँचें और फिर से सहेजें।", diff --git a/apps/studio/src/i18n/id.ts b/apps/studio/src/i18n/id.ts index fbc7719f..19c6ec65 100644 --- a/apps/studio/src/i18n/id.ts +++ b/apps/studio/src/i18n/id.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): id core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: dff29bcb0c59552a +// hash: 407fe5a24c0cf3e5 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -672,6 +672,8 @@ export const messages: Messages = { unique: "Sudah ada sesuatu dengan nama atau alamat ini.", slug_taken: "Alamat ini sudah digunakan.", path_conflict: "Halaman atau artikel lain sudah menggunakan path ini.", + reserved_path: + "Jalur ini dicadangkan untuk situs itu sendiri (pencarian, media, atau awalan bahasa).", in_use: "Masih digunakan, jadi tidak dapat dihapus.", quota_exceeded: "Kuota penyimpanan penuh.", too_large: "Perubahan ini terlalu besar untuk satu operasi. Pecah menjadi beberapa bagian.", @@ -1251,6 +1253,7 @@ export const messages: Messages = { sections: { general: "Umum", domains: "Domain", + collections: "Koleksi", appearance: "Tampilan", design: "Aturan desain", previews: "Tautan pratinjau", @@ -1278,6 +1281,24 @@ export const messages: Messages = { saved: "Tampilan disimpan", failed: "Tampilan tidak dapat disimpan", }, + collections: { + description: + "Tempat entri setiap koleksi berada di situs. Mengubah awalan memindahkan alamat setiap entri dalam koleksi itu; alamat lama tetap berfungsi melalui pengalihan permanen. Biarkan kolom kosong untuk bawaan, yaitu nama koleksi itu sendiri. Alamat baru tayang pada publikasi berikutnya.", + preview: "Entri berada di alamat seperti {url}", + previewMoved: + "Entri akan berada di alamat seperti {url}. Alamat lama {old} dialihkan ke sana.", + exampleSlug: "artikel-saya", + empty: "Belum ada koleksi", + emptyDescription: "Koleksi muncul di sini setelah situs memiliki artikel atau entri lain.", + saved: "Koleksi disimpan", + failed: "Tidak dapat menyimpan koleksi", + errors: { + slash: "Mulai dengan garis miring, seperti /blog", + trailingSlash: "Hapus garis miring di akhir", + invalid: + "Gunakan huruf kecil, angka, dan tanda hubung di antara garis miring, seperti /blog atau /docs/guides", + }, + }, conflictTitle: "Situs ini diubah di tempat lain", conflictDescription: "Seseorang atau agen menyimpan situs ini setelah Anda membukanya. Muat versi terbaru, periksa perubahan Anda, lalu simpan lagi.", diff --git a/apps/studio/src/i18n/it.ts b/apps/studio/src/i18n/it.ts index 269ca17d..12aa4afb 100644 --- a/apps/studio/src/i18n/it.ts +++ b/apps/studio/src/i18n/it.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): it core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 86b834295ccf9ee0 +// hash: 2392501373532dca import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -674,6 +674,8 @@ export const messages: Messages = { unique: "Esiste già qualcosa con questo nome o indirizzo.", slug_taken: "Questo indirizzo è già in uso.", path_conflict: "Un’altra pagina o un altro articolo usa già questo percorso.", + reserved_path: + "Questo percorso è riservato al sito stesso (ricerca, media o un prefisso di lingua).", in_use: "È ancora in uso, quindi non può essere rimosso.", quota_exceeded: "La quota di spazio è piena.", too_large: "Questa modifica è troppo grande per un’unica operazione. Dividila.", @@ -1253,6 +1255,7 @@ export const messages: Messages = { sections: { general: "Generale", domains: "Domini", + collections: "Raccolte", appearance: "Aspetto", design: "Regole di design", previews: "Link di anteprima", @@ -1280,6 +1283,23 @@ export const messages: Messages = { saved: "Aspetto salvato", failed: "Impossibile salvare l’aspetto", }, + collections: { + description: + "Dove si trovano sul sito le voci di ogni raccolta. Cambiare un prefisso sposta l'indirizzo di ogni voce di quella raccolta; i vecchi indirizzi continuano a funzionare tramite un reindirizzamento permanente. Lascia un campo vuoto per il valore predefinito, il nome della raccolta stessa. I nuovi indirizzi vanno online con la prossima pubblicazione.", + preview: "Le voci si trovano a indirizzi come {url}", + previewMoved: + "Le voci si troveranno a indirizzi come {url}. Il vecchio indirizzo {old} reindirizza lì.", + exampleSlug: "il-mio-articolo", + empty: "Ancora nessuna raccolta", + emptyDescription: "Le raccolte compaiono qui quando il sito ha articoli o altre voci.", + saved: "Raccolte salvate", + failed: "Impossibile salvare le raccolte", + errors: { + slash: "Inizia con una barra, come /blog", + trailingSlash: "Togli la barra finale", + invalid: "Usa lettere minuscole, cifre e trattini tra le barre, come /blog o /docs/guides", + }, + }, conflictTitle: "Questo sito è cambiato altrove", conflictDescription: "Una persona o un agente ha salvato questo sito dopo che l’hai aperto. Carica l’ultima versione, controlla la tua modifica e salva di nuovo.", diff --git a/apps/studio/src/i18n/ja.ts b/apps/studio/src/i18n/ja.ts index 8b53aee3..01fcad69 100644 --- a/apps/studio/src/i18n/ja.ts +++ b/apps/studio/src/i18n/ja.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ja core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 957c688122ebaca0 +// hash: 1929d0e3930a43eb import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -670,6 +670,8 @@ export const messages: Messages = { unique: "この名前またはアドレスのものがすでに存在します。", slug_taken: "このアドレスはすでに使われています。", path_conflict: "別のページまたは記事がすでにこのパスを使っています。", + reserved_path: + "このパスはサイト自体のために予約されています(検索、メディア、言語プレフィックス)。", in_use: "まだ使用中のため、削除できません。", quota_exceeded: "ストレージの容量が上限に達しています。", too_large: "1回の操作には大きすぎる変更です。分割してください。", @@ -1244,6 +1246,7 @@ export const messages: Messages = { sections: { general: "一般", domains: "ドメイン", + collections: "コレクション", appearance: "外観", design: "デザインルール", previews: "プレビューリンク", @@ -1271,6 +1274,25 @@ 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: "あなたが開いた後に、人またはエージェントがこのサイトを保存しました。最新バージョンを読み込み、変更を確認してから再度保存してください。", diff --git a/apps/studio/src/i18n/ko.ts b/apps/studio/src/i18n/ko.ts index 0b9800b5..79474619 100644 --- a/apps/studio/src/i18n/ko.ts +++ b/apps/studio/src/i18n/ko.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ko core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: bfe3acc7ecbda94b +// hash: 519ff20ac8c13982 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -654,6 +654,8 @@ export const messages: Messages = { unique: "같은 이름이나 주소가 이미 있어요.", slug_taken: "이 주소는 이미 사용 중이에요.", path_conflict: "다른 페이지나 글이 이미 이 경로를 사용하고 있어요.", + reserved_path: + "이 경로는 사이트 자체용으로 예약되어 있습니다(검색, 미디어 또는 언어 접두사).", in_use: "아직 사용 중이라 제거할 수 없어요.", quota_exceeded: "저장 공간 할당량이 가득 찼어요.", too_large: "이 변경은 한 번에 처리하기에 너무 커요. 나눠서 하세요.", @@ -1216,6 +1218,7 @@ export const messages: Messages = { sections: { general: "일반", domains: "도메인", + collections: "컬렉션", appearance: "디자인", design: "디자인 규칙", previews: "미리보기 링크", @@ -1242,6 +1245,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: "연 뒤에 사람이나 에이전트가 이 사이트를 저장했어요. 최신 버전을 불러와 변경 사항을 확인하고 다시 저장하세요.", diff --git a/apps/studio/src/i18n/nl.ts b/apps/studio/src/i18n/nl.ts index ce13936a..df1572d8 100644 --- a/apps/studio/src/i18n/nl.ts +++ b/apps/studio/src/i18n/nl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): nl core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: d697fbb570059445 +// hash: c5d5bf988b3add75 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -677,6 +677,8 @@ export const messages: Messages = { unique: "Er bestaat al iets met deze naam of dit adres.", slug_taken: "Dit adres is al in gebruik.", path_conflict: "Een andere pagina of een ander artikel gebruikt dit pad al.", + reserved_path: + "Dit pad is gereserveerd voor de site zelf (zoeken, media of een taalvoorvoegsel).", in_use: "Het is nog in gebruik, dus het kan niet worden verwijderd.", quota_exceeded: "Het opslagquotum is vol.", too_large: "Deze wijziging is te groot voor één bewerking. Splits haar op.", @@ -1254,6 +1256,7 @@ export const messages: Messages = { sections: { general: "Algemeen", domains: "Domeinen", + collections: "Collecties", appearance: "Weergave", design: "Ontwerpregels", previews: "Voorbeeldlinks", @@ -1281,6 +1284,25 @@ export const messages: Messages = { saved: "Weergave opgeslagen", failed: "Kan de weergave niet opslaan", }, + collections: { + description: + "Waar de items van elke collectie op de site staan. Een ander voorvoegsel verplaatst het adres van elk item in die collectie; oude adressen blijven werken via een permanente doorverwijzing. Laat een veld leeg voor de standaard, de naam van de collectie zelf. Nieuwe adressen gaan live bij de volgende publicatie.", + preview: "Items staan op adressen zoals {url}", + previewMoved: + "Items komen te staan op adressen zoals {url}. Het oude adres {old} verwijst daarheen door.", + exampleSlug: "mijn-artikel", + empty: "Nog geen collecties", + emptyDescription: + "Collecties verschijnen hier zodra de site artikelen of andere items heeft.", + saved: "Collecties opgeslagen", + failed: "De collecties konden niet worden opgeslagen", + errors: { + slash: "Begin met een schuine streep, zoals /blog", + trailingSlash: "Laat de schuine streep aan het eind weg", + invalid: + "Gebruik kleine letters, cijfers en koppeltekens tussen schuine strepen, zoals /blog of /docs/guides", + }, + }, conflictTitle: "Deze site is elders gewijzigd", conflictDescription: "Een persoon of agent heeft deze site opgeslagen nadat jij hem opende. Laad de nieuwste versie, controleer je wijziging en sla opnieuw op.", diff --git a/apps/studio/src/i18n/pl.ts b/apps/studio/src/i18n/pl.ts index ed78ddc4..fd289602 100644 --- a/apps/studio/src/i18n/pl.ts +++ b/apps/studio/src/i18n/pl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pl core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: f3746cd7722de2ac +// hash: 7429dfe5972d305f import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -666,6 +666,8 @@ export const messages: Messages = { unique: "Coś o tej nazwie lub adresie już istnieje.", slug_taken: "Ten adres jest już używany.", path_conflict: "Inna strona lub artykuł już używa tej ścieżki.", + reserved_path: + "Ta ścieżka jest zarezerwowana dla samej witryny (wyszukiwanie, multimedia lub prefiks języka).", in_use: "To jest nadal używane, więc nie można tego usunąć.", quota_exceeded: "Limit miejsca jest wyczerpany.", too_large: "Ta zmiana jest zbyt duża na jedną operację. Podziel ją.", @@ -1238,6 +1240,7 @@ export const messages: Messages = { sections: { general: "Ogólne", domains: "Domeny", + collections: "Kolekcje", appearance: "Wygląd", design: "Zasady projektu", previews: "Linki podglądu", @@ -1264,6 +1267,25 @@ export const messages: Messages = { saved: "Zapisano wygląd", failed: "Nie udało się zapisać wyglądu", }, + collections: { + description: + "Gdzie na stronie znajdują się wpisy każdej kolekcji. Zmiana prefiksu przenosi adres każdego wpisu w tej kolekcji; stare adresy nadal działają dzięki stałemu przekierowaniu. Zostaw pole puste, aby użyć domyślnego, czyli nazwy samej kolekcji. Nowe adresy zaczną działać po następnej publikacji.", + preview: "Wpisy są pod adresami takimi jak {url}", + previewMoved: + "Wpisy będą pod adresami takimi jak {url}. Stary adres {old} przekierowuje tam.", + exampleSlug: "moj-artykul", + empty: "Brak kolekcji", + emptyDescription: + "Kolekcje pojawią się tutaj, gdy witryna będzie mieć artykuły lub inne wpisy.", + saved: "Zapisano kolekcje", + failed: "Nie udało się zapisać kolekcji", + errors: { + slash: "Zacznij od ukośnika, np. /blog", + trailingSlash: "Usuń ukośnik na końcu", + invalid: + "Używaj małych liter, cyfr i łączników między ukośnikami, np. /blog lub /docs/guides", + }, + }, conflictTitle: "Tę witrynę zmieniono gdzie indziej", conflictDescription: "Osoba lub agent zapisał tę witrynę po tym, jak ją otwarto. Wczytaj najnowszą wersję, sprawdź swoją zmianę i zapisz ponownie.", diff --git a/apps/studio/src/i18n/pt-BR.ts b/apps/studio/src/i18n/pt-BR.ts index 5130260c..4c675775 100644 --- a/apps/studio/src/i18n/pt-BR.ts +++ b/apps/studio/src/i18n/pt-BR.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pt-BR core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: ea5c4aa9b29fe750 +// hash: bac0d5899741ff5e import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -672,6 +672,8 @@ export const messages: Messages = { unique: "Já existe algo com este nome ou endereço.", slug_taken: "Este endereço já está em uso.", path_conflict: "Outra página ou artigo já usa este caminho.", + reserved_path: + "Este caminho é reservado para o próprio site (busca, mídia ou um prefixo de idioma).", in_use: "Ainda está em uso, então não pode ser removido.", quota_exceeded: "A cota de armazenamento está cheia.", too_large: "Esta alteração é grande demais para uma operação. Divida-a.", @@ -1244,6 +1246,7 @@ export const messages: Messages = { sections: { general: "Geral", domains: "Domínios", + collections: "Coleções", appearance: "Aparência", design: "Regras de design", previews: "Links de pré-visualização", @@ -1270,6 +1273,23 @@ export const messages: Messages = { saved: "Aparência salva", failed: "Não foi possível salvar a aparência", }, + collections: { + description: + "Onde ficam as entradas de cada coleção no site. Alterar um prefixo move o endereço de todas as entradas dessa coleção; os endereços antigos continuam funcionando por meio de um redirecionamento permanente. Deixe um campo vazio para usar o padrão, o próprio nome da coleção. Os novos endereços entram no ar na próxima publicação.", + preview: "As entradas ficam em endereços como {url}", + previewMoved: + "As entradas ficarão em endereços como {url}. O endereço antigo {old} redireciona para lá.", + exampleSlug: "meu-artigo", + empty: "Nenhuma coleção ainda", + emptyDescription: "As coleções aparecem aqui quando o site tiver artigos ou outras entradas.", + saved: "Coleções salvas", + failed: "Não foi possível salvar as coleções", + errors: { + slash: "Comece com uma barra, como /blog", + trailingSlash: "Remova a barra do final", + invalid: "Use letras minúsculas, dígitos e hifens entre barras, como /blog ou /docs/guides", + }, + }, conflictTitle: "Este site foi alterado em outro lugar", conflictDescription: "Uma pessoa ou um agente salvou este site depois que você o abriu. Carregue a versão mais recente, confira sua alteração e salve de novo.", diff --git a/apps/studio/src/i18n/ru.ts b/apps/studio/src/i18n/ru.ts index fe5cc146..27fb8de9 100644 --- a/apps/studio/src/i18n/ru.ts +++ b/apps/studio/src/i18n/ru.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ru core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 8f3c67fcb371fe34 +// hash: 2b9b91e3ba845f7c import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -678,6 +678,8 @@ export const messages: Messages = { unique: "Что-то с таким названием или адресом уже существует.", slug_taken: "Этот адрес уже используется.", path_conflict: "Этот путь уже использует другая страница или статья.", + reserved_path: + "Этот путь зарезервирован для самого сайта (поиск, медиафайлы или языковой префикс).", in_use: "Это ещё используется, поэтому удалить нельзя.", quota_exceeded: "Квота хранилища исчерпана.", too_large: "Это изменение слишком велико для одной операции. Разделите его.", @@ -1260,6 +1262,7 @@ export const messages: Messages = { sections: { general: "Общие", domains: "Домены", + collections: "Коллекции", appearance: "Оформление", design: "Правила дизайна", previews: "Ссылки предпросмотра", @@ -1286,6 +1289,24 @@ 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: "Человек или агент сохранил этот сайт после того, как вы его открыли. Загрузите последнюю версию, проверьте своё изменение и сохраните снова.", diff --git a/apps/studio/src/i18n/tr.ts b/apps/studio/src/i18n/tr.ts index 5dd30b7f..b0e78e8c 100644 --- a/apps/studio/src/i18n/tr.ts +++ b/apps/studio/src/i18n/tr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): tr core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: a4c27f8e742ff095 +// hash: a2224e343d98566f import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -663,6 +663,7 @@ export const messages: Messages = { unique: "Bu ad ya da adresle başka bir kayıt zaten var.", slug_taken: "Bu adres zaten kullanılıyor.", path_conflict: "Bu yolu başka bir sayfa ya da yazı kullanıyor.", + reserved_path: "Bu yol sitenin kendisine ayrılmış (arama, medya ya da bir dil öneki).", in_use: "Hâlâ kullanıldığı için kaldırılamaz.", quota_exceeded: "Depolama kotası doldu.", too_large: "Bu değişiklik tek işlem için çok büyük. Parçalara böl.", @@ -1237,6 +1238,7 @@ export const messages: Messages = { sections: { general: "Genel", domains: "Alan adları", + collections: "Koleksiyonlar", appearance: "Görünüm", design: "Tasarım kuralları", previews: "Önizleme bağlantıları", @@ -1263,6 +1265,24 @@ export const messages: Messages = { saved: "Görünüm kaydedildi", failed: "Görünüm kaydedilemedi", }, + collections: { + description: + "Her koleksiyonun girdilerinin sitede nerede durduğu. Bir öneki değiştirmek o koleksiyondaki her girdinin adresini taşır; eski adresler kalıcı bir yönlendirmeyle çalışmaya devam eder. Varsayılan için, yani koleksiyonun kendi adı için alanı boş bırak. Yeni adresler bir sonraki yayında canlıya çıkar.", + preview: "Girdiler {url} gibi adreslerde duruyor", + previewMoved: "Girdiler {url} gibi adreslerde duracak. Eski adres {old} oraya yönlendirir.", + exampleSlug: "yazim", + empty: "Henüz koleksiyon yok", + emptyDescription: + "Sitede yazılar ya da başka girdiler olduğunda koleksiyonlar burada görünür.", + saved: "Koleksiyonlar kaydedildi", + failed: "Koleksiyonlar kaydedilemedi", + errors: { + slash: "/blog gibi bir eğik çizgiyle başla", + trailingSlash: "Sondaki eğik çizgiyi kaldır", + invalid: + "Eğik çizgiler arasında küçük harf, rakam ve kısa çizgi kullan; /blog ya da /docs/guides gibi", + }, + }, conflictTitle: "Bu site başka bir yerde değişti", conflictDescription: "Sen açtıktan sonra bir kişi ya da agent bu siteyi kaydetti. En son sürümü yükle, değişikliğini kontrol et ve yeniden kaydet.", diff --git a/apps/studio/src/i18n/uk.ts b/apps/studio/src/i18n/uk.ts index 28975935..6c9ddaf8 100644 --- a/apps/studio/src/i18n/uk.ts +++ b/apps/studio/src/i18n/uk.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): uk core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: cca2e874252f6006 +// hash: c59751eaf3e4657b import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -672,6 +672,7 @@ export const messages: Messages = { unique: "Щось із такою назвою чи адресою вже існує.", slug_taken: "Ця адреса вже використовується.", path_conflict: "Цей шлях уже використовує інша сторінка чи стаття.", + reserved_path: "Цей шлях зарезервовано для самого сайту (пошук, медіа або мовний префікс).", in_use: "Це досі використовується, тому вилучити його не можна.", quota_exceeded: "Квоту сховища вичерпано.", too_large: "Ця зміна завелика для однієї операції. Розділіть її.", @@ -1248,6 +1249,7 @@ export const messages: Messages = { sections: { general: "Загальні", domains: "Домени", + collections: "Колекції", appearance: "Вигляд", design: "Правила дизайну", previews: "Посилання для перегляду", @@ -1275,6 +1277,24 @@ 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: "Людина або агент зберегли цей сайт після того, як ви його відкрили. Завантажте останню версію, перевірте свою зміну та збережіть знову.", diff --git a/apps/studio/src/i18n/zh-Hans.ts b/apps/studio/src/i18n/zh-Hans.ts index 4de0ec5a..72d03651 100644 --- a/apps/studio/src/i18n/zh-Hans.ts +++ b/apps/studio/src/i18n/zh-Hans.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): zh-Hans core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 472b7197a3624bca +// hash: bc5750ee8cbc24aa import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -639,6 +639,7 @@ export const messages: Messages = { unique: "已存在同名或同地址的内容。", slug_taken: "此地址已被使用。", path_conflict: "已有其他页面或文章使用此路径。", + reserved_path: "此路径保留给站点自身使用(搜索、媒体或语言前缀)。", in_use: "它仍在使用中,因此无法移除。", quota_exceeded: "存储配额已满。", too_large: "此更改过大,无法一次完成。请拆分处理。", @@ -1179,6 +1180,7 @@ export const messages: Messages = { sections: { general: "常规", domains: "域名", + collections: "集合", appearance: "外观", design: "设计规范", previews: "预览链接", @@ -1205,6 +1207,22 @@ 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: "在你打开后,有人或智能体保存了此站点。请加载最新版本,检查你的更改后再次保存。", diff --git a/apps/studio/src/lib/collection-permalinks.ts b/apps/studio/src/lib/collection-permalinks.ts new file mode 100644 index 00000000..32583ebf --- /dev/null +++ b/apps/studio/src/lib/collection-permalinks.ts @@ -0,0 +1,108 @@ +/** + * Site › Settings › Collections (RFC-0038 §5): where each collection's entries live. Version 1 + * templates are a fixed prefix followed by `/{slug}`, so the form edits the prefix (`/blog`); + * an empty field is the default, `/`, and removes the setting. + */ +import type { ContentEntry, Site } from "@cloudhal/core-contracts"; +import { collectionPermalink, entryPath, permalinkPrefix } from "@cloudhal/core-ts/permalinks"; +import { ApiRequestError } from "./api/http.ts"; + +/** The schema's pattern without its `/{slug}` tail (`site.schema.json`, `CollectionSettings`). */ +const PREFIX = /^(\/[a-z0-9]+(?:-[a-z0-9]+)*)+$/; + +/** Prefix by collection key; `""` is the default. */ +export type PrefixForm = Record; + +export type PrefixError = "slash" | "trailingSlash" | "invalid"; + +/** The site's collections: the configured ones and the ones its entries use, sorted. */ +export function collectionKeys( + site: Pick, + entries: readonly Pick[], +): string[] { + return [ + ...new Set([...Object.keys(site.collections ?? {}), ...entries.map((e) => e.collection)]), + ].sort(); +} + +/** The form for a site: each collection's configured prefix, or `""`. */ +export function prefixForm(site: Pick, keys: readonly string[]): PrefixForm { + return Object.fromEntries( + keys.map((k) => { + const p = site.collections?.[k]?.permalink; + return [k, p ? permalinkPrefix(p) : ""]; + }), + ); +} + +/** Why a typed prefix is not a v1 template prefix; undefined when it is (or empty). */ +export function validatePrefix(value: string): PrefixError | undefined { + const v = value.trim(); + if (v === "") return undefined; + if (!v.startsWith("/")) return "slash"; + if (v.length > 1 && v.endsWith("/")) return "trailingSlash"; + return PREFIX.test(v) ? undefined : "invalid"; +} + +/** The site with the form applied. An empty prefix, or the default one, removes the setting. */ +export function withPrefixes(site: Site, form: PrefixForm): Site { + const collections: NonNullable = { ...site.collections }; + for (const [key, raw] of Object.entries(form)) { + const prefix = raw.trim(); + const { permalink: _old, ...rest } = collections[key] ?? {}; + if (prefix && prefix !== `/${key}`) + collections[key] = { ...rest, permalink: `${prefix}/{slug}` }; + else if (Object.keys(rest).length) collections[key] = rest; + else delete collections[key]; + } + const { collections: _drop, ...base } = site; + return Object.keys(collections).length ? { ...base, collections } : (base as Site); +} + +/** The collections whose effective template the form changes. */ +export function changedKeys(site: Site, form: PrefixForm): string[] { + const next = withPrefixes(site, form); + return Object.keys(form).filter( + (k) => collectionPermalink(site, k) !== collectionPermalink(next, k), + ); +} + +/** An entry to show the address of: published first, default locale first, newest first. */ +export function sampleEntry( + entries: readonly ContentEntry[], + collection: string, + defaultLocale: string, +): Pick | undefined { + const rank = (e: ContentEntry) => + (e.status === "published" ? 0 : 2) + (e.locale === defaultLocale ? 0 : 1); + return entries + .filter((e) => e.collection === collection) + .sort((a, b) => rank(a) - rank(b) || b.updatedAt.localeCompare(a.updatedAt))[0]; +} + +/** The entry's address now and with the form applied. */ +export function previewUrls( + site: Site, + form: PrefixForm, + entry: Pick, +): { now: string; next: string } { + return { now: entryPath(site, entry), next: entryPath(withPrefixes(site, form), entry) }; +} + +/** RFC-0038 invariant 1 refused the save (`path_conflict`, `reserved_path`). */ +export function isPathRefusal(error: unknown): boolean { + return ( + error instanceof ApiRequestError && + (error.body?.error === "path_conflict" || error.body?.error === "reserved_path") + ); +} + +/** + * The collection a refusal is about: the one its message names (`collection "posts" at …`), else + * the first changed one. + */ +export function refusedKey(error: unknown, changed: readonly string[]): string | undefined { + if (!isPathRefusal(error)) return undefined; + const named = /collection "([a-z0-9-]+)"/.exec((error as ApiRequestError).body?.message ?? ""); + return named?.[1] && changed.includes(named[1]) ? named[1] : changed[0]; +} diff --git a/apps/studio/src/routes/SiteSettingsPage.tsx b/apps/studio/src/routes/SiteSettingsPage.tsx index e6fc2d84..51f0e776 100644 --- a/apps/studio/src/routes/SiteSettingsPage.tsx +++ b/apps/studio/src/routes/SiteSettingsPage.tsx @@ -21,7 +21,6 @@ import { Tooltip, } from "@cloudhal/ui"; import { - ArrowClockwise, ArrowSquareOut, DotsThree, Globe, @@ -40,8 +39,10 @@ import { AiSettingsSection } from "../components/AiSettings.tsx"; import { AnalyticsSettingsSection } from "../components/AnalyticsSettings.tsx"; import { AppearanceSettingsSection } from "../components/AppearanceSettings.tsx"; import { ChromeSettingsSection } from "../components/ChromeSettings.tsx"; +import { CollectionSettingsSection } from "../components/CollectionSettings.tsx"; import { CommentSettingsSection } from "../components/CommentSettings.tsx"; import { ConfirmDialog } from "../components/ConfirmDialog.tsx"; +import { ConflictBanner } from "../components/ConflictBanner.tsx"; import { SiteCrumbs } from "../components/Crumbs.tsx"; import { DesignRulesSettingsSection } from "../components/DesignRulesSettings.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; @@ -92,31 +93,6 @@ function useSaveSite(siteId: string) { }); } -/** 409 from the CAS: explain and offer the latest version (DESIGN.md §5, Error). */ -function ConflictBanner({ onReload, reloading }: { onReload: () => void; reloading: boolean }) { - const t = useT(); - return ( -
- } - title={t("siteSettings.conflictTitle")} - description={t("siteSettings.conflictDescription")} - action={ - } - onClick={onReload} - loading={reloading} - > - {t("siteSettings.loadLatest")} - - } - /> -
- ); -} - function SaveError({ error, title, @@ -857,6 +833,8 @@ export function SiteSettingsPage() { + {/* RFC-0038 §5: where each collection's entries live; settings.write changes it. */} + {/* The theme's design settings (RFC-0017 §1): settings.write changes them. */} {/* The header, footer and brand (RFC-0025): settings.write changes them. */} diff --git a/apps/studio/test/collection-settings.test.tsx b/apps/studio/test/collection-settings.test.tsx new file mode 100644 index 00000000..c1034f9f --- /dev/null +++ b/apps/studio/test/collection-settings.test.tsx @@ -0,0 +1,130 @@ +/** + * Site settings › Collections (RFC-0038 §5): one row per collection with its prefix and an entry's + * address; saving writes `collections..permalink`, an empty field removes it, a bad prefix is + * caught before saving and the server's path conflict shows on the row. + */ +import type { ContentEntry, Site } from "@cloudhal/core-contracts"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CollectionSettingsSection } from "../src/components/CollectionSettings.tsx"; +import { + changedKeys, + collectionKeys, + prefixForm, + validatePrefix, + withPrefixes, +} from "../src/lib/collection-permalinks.ts"; +import { asRole, bodyOf, on, type Route, serve, wrap } from "./roles-fixtures.tsx"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const SITE = { + schemaVersion: 1, + id: "site_1", + projectId: "proj_1", + name: "Site", + slug: "site", + defaultLocale: "en", + locales: ["en"], + domains: [], + pageIds: [], + theme: { name: "blog" }, + rev: 4, + createdAt: "2026-10-18T10:00:00Z", + updatedAt: "2026-10-18T10:00:00Z", +} as unknown as Site; + +const entry = (collection: string, slug: string, status = "published") => + ({ + id: `entry_${slug}`, + siteId: "site_1", + collection, + slug, + status, + locale: "en", + title: slug, + updatedAt: "2026-10-18T10:00:00Z", + }) as unknown as ContentEntry; + +describe("collection permalinks (lib)", () => { + it("lists configured and used collections, edits prefixes and validates like the schema", () => { + const site = { ...SITE, collections: { docs: { permalink: "/handbook/{slug}" } } } as Site; + const keys = collectionKeys(site, [entry("posts", "a"), entry("posts", "b")]); + expect(keys).toEqual(["docs", "posts"]); + expect(prefixForm(site, keys)).toEqual({ docs: "/handbook", posts: "" }); + const next = withPrefixes(site, { docs: "", posts: "/blog" }); + expect(next.collections).toEqual({ posts: { permalink: "/blog/{slug}" } }); + // The default prefix is the same as no setting. + expect(withPrefixes(SITE, { posts: "/posts" }).collections).toBeUndefined(); + expect(changedKeys(site, { docs: "/handbook", posts: "/blog" })).toEqual(["posts"]); + expect(validatePrefix("")).toBeUndefined(); + expect(validatePrefix("/docs/guides")).toBeUndefined(); + expect(validatePrefix("blog")).toBe("slash"); + expect(validatePrefix("/blog/")).toBe("trailingSlash"); + expect(validatePrefix("/Blog")).toBe("invalid"); + expect(validatePrefix("/blog/{slug}")).toBe("invalid"); + }); +}); + +describe("Collection settings", () => { + const routes = (put: Parameters[2]) => { + const [me] = asRole("owner"); + return serve( + me as Route, + on("GET", "/api/sites/site_1", { status: 200, body: SITE }), + on("GET", "/api/sites/site_1/entries", { + status: 200, + body: [entry("posts", "hello-world")], + }), + on("PUT", "/api/sites/site_1", put), + ); + }; + + it("previews an entry's address and saves /blog as the posts permalink", async () => { + const fetch = routes((init) => ({ status: 200, body: JSON.parse(String(init?.body)) })); + render(wrap()); + const input = await screen.findByRole("textbox", { name: "posts" }); + expect( + screen.getByText("Entries live at addresses like /posts/hello-world"), + ).toBeInTheDocument(); + expect( + screen.getByText(/old addresses keep working through a permanent redirect/), + ).toBeTruthy(); + fireEvent.change(input, { target: { value: "/blog/" } }); + expect(await screen.findByText("Leave out the slash at the end")).toBeInTheDocument(); + fireEvent.change(input, { target: { value: "/blog" } }); + expect( + screen.getByText( + "Entries will live at addresses like /blog/hello-world. The old address /posts/hello-world redirects there.", + ), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await waitFor(() => + expect(bodyOf(fetch, "PUT", "/api/sites/site_1")).toMatchObject({ + rev: 4, + collections: { posts: { permalink: "/blog/{slug}" } }, + }), + ); + }); + + it("shows a path conflict on the row, not as a stale-site banner", async () => { + routes({ + status: 409, + body: { + error: "path_conflict", + message: 'collection "posts" at /about would cover page /about', + }, + }); + render(wrap()); + const input = await screen.findByRole("textbox", { name: "posts" }); + fireEvent.change(input, { target: { value: "/about" } }); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + expect( + await screen.findByText("Another page or article already uses this path."), + ).toBeInTheDocument(); + expect(screen.queryByText("This site changed somewhere else")).toBeNull(); + }); +}); diff --git a/docs/rfcs/0038-collection-permalinks.md b/docs/rfcs/0038-collection-permalinks.md index dc8f3d38..659226be 100644 --- a/docs/rfcs/0038-collection-permalinks.md +++ b/docs/rfcs/0038-collection-permalinks.md @@ -149,3 +149,5 @@ No new scopes: changing a template is a site update (`site:write`). In Studio a - **The reverse.** Page writes (`checkPagePath`/`pageGuard` in `store/site-permalinks.ts`) and taxonomy writes (`#checkTaxonomy`/`#archiveGuard`) refuse a path in a configured prefix; their SQL guards read the site row's `collections` (`configuredPrefixFree`). - **Search.** `planSearch` rewrites every entry row when the live site node's templates differ from the indexed snapshot's, so hrefs follow the move on the next publish. - **Rollback** restores the template with the site node; redirects stay, as invariant 5 says. + +**Who approves (added in review).** The tool needs `site:write` from the agent. The person who approves it must also hold `site:settings`, the scope Studio's Site settings needs, so an Editor cannot approve an agent's URL move that they could not make themselves. This is a new optional `ToolPolicy.approverScopes` (`@cloudhal/agent-runtime`), checked in the API's `canDecide`. It can hold person-only scopes, which never satisfy a tool's own `scopes`. diff --git a/packages/agent-runtime/src/policy.ts b/packages/agent-runtime/src/policy.ts index 1dad8c2a..940725db 100644 --- a/packages/agent-runtime/src/policy.ts +++ b/packages/agent-runtime/src/policy.ts @@ -236,6 +236,12 @@ export interface ToolPolicy { * needs `content:propose`, not the write it proposes. */ proposeScopes?: Scope[]; + /** + * Scopes the person who approves a call must hold besides the tool's own, person-only ones + * included: an agent with `site:write` may ask to move a collection's URLs, but only someone who + * could change site settings in Studio (`site:settings`) decides it (RFC-0038 §5). + */ + approverScopes?: Scope[]; } export interface Principal { diff --git a/packages/api/src/routes/approvals.ts b/packages/api/src/routes/approvals.ts index 82d332bb..9e10c102 100644 --- a/packages/api/src/routes/approvals.ts +++ b/packages/api/src/routes/approvals.ts @@ -276,6 +276,14 @@ export async function canDecide( .evaluate(asPerson, request.tool, { confirmed: true, args: input }); if (verdict.kind === "deny") return NO("missing_scope", `your role cannot ${request.tool}: ${verdict.reason}`); + // Some calls need more of the person deciding than of the agent asking (`approverScopes`). + const extra = c.get("policy")().tool(request.tool)?.approverScopes ?? []; + const lacking = extra.filter((s) => !holds(principal.scopes, s)); + if (lacking.length > 0) + return NO( + "missing_scope", + `your role cannot approve ${request.tool}: needs ${lacking.join(", ")}`, + ); if (RELEASE_TOOLS.has(request.tool)) return releaseDecideCheck(c, request, asPerson); const limit = verdict.limit; if (!limit) return { ok: true }; diff --git a/packages/api/src/testing-permalinks.ts b/packages/api/src/testing-permalinks.ts new file mode 100644 index 00000000..c2f8b326 --- /dev/null +++ b/packages/api/src/testing-permalinks.ts @@ -0,0 +1,109 @@ +/** + * Shared API suite for RFC-0038 step 3: a collection's permalink is set through the site update + * (`PUT /api/sites/:id`, `site:settings`), leaves a prefix redirect behind, and an overlap is a + * clear 409. Runs against any SiteGraphStore backend, like `testing.ts`. + */ +import type { Redirect, Site } from "@cloudhal/core-contracts"; +import { type SiteGraphStore, seedStarterSite } from "@cloudhal/core-ts"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { createApi } from "./app.ts"; +import type { ApiStoreFactory } from "./testing.ts"; + +const human = { kind: "human" as const, id: "user_apitest00001", name: "Tester" }; + +type Json = Record; + +export function describePermalinkApi(label: string, factory: ApiStoreFactory): void { + describe(`API collection permalinks: ${label}`, () => { + let store: SiteGraphStore; + let siteId: string; + let app: ReturnType; + const headers = { "content-type": "application/json", "x-test-user": "1" }; + const call = async (path: string, init: RequestInit = {}) => { + const res = await app.request(path, { headers, ...init }); + return { status: res.status, body: (await res.json()) as Json }; + }; + const site = async () => (await call(`/api/sites/${siteId}`)).body as unknown as Site; + const putSite = (body: Site) => + call(`/api/sites/${siteId}`, { method: "PUT", body: JSON.stringify(body) }); + + beforeEach(async () => { + store = await factory.create(); + siteId = (await seedStarterSite(store, { name: "Links", slug: "links" })).id; + app = createApi({ + store, + resolveActor: async (c) => (c.req.header("x-test-user") ? human : undefined), + }); + }); + + afterAll(async () => { + await factory.dispose?.(); + }); + + it("sets /blog/{slug} for posts and leaves a permanent prefix redirect", async () => { + const before = await site(); + const saved = await putSite({ + ...before, + collections: { posts: { permalink: "/blog/{slug}" } }, + }); + expect(saved.status, JSON.stringify(saved.body)).toBe(200); + expect((saved.body as unknown as Site).collections).toEqual({ + posts: { permalink: "/blog/{slug}" }, + }); + expect((await site()).collections?.posts?.permalink).toBe("/blog/{slug}"); + + const list = await call(`/api/sites/${siteId}/redirects`); + const moved = (list.body as unknown as Redirect[]).filter((r) => r.from === "/posts"); + expect(moved.map((r) => [r.from, r.to, r.match, r.status])).toEqual([ + ["/posts", "/blog", "prefix", 301], + ]); + + // Back to the default: the setting goes, the redirect that would loop goes with it. + const { collections: _drop, ...rest } = await site(); + const reset = await putSite(rest as Site); + expect(reset.status, JSON.stringify(reset.body)).toBe(200); + expect((reset.body as unknown as Site).collections?.posts).toBeUndefined(); + const after = (await call(`/api/sites/${siteId}/redirects`)).body as unknown as Redirect[]; + expect(after.some((r) => r.from === "/posts" && r.to === "/blog")).toBe(false); + }); + + it("refuses an overlap with a page (409 path_conflict) and a reserved prefix (409 reserved_path)", async () => { + const current = await site(); + const clash = await putSite({ + ...current, + collections: { posts: { permalink: "/about/{slug}" } }, + }); + expect(clash.status).toBe(409); + expect(clash.body.error).toBe("path_conflict"); + expect(clash.body.message).toContain("/about"); + + const archive = await putSite({ + ...current, + collections: { posts: { permalink: "/tag/{slug}" } }, + }); + expect(archive.status).toBe(409); + expect(archive.body.error).toBe("path_conflict"); + + const reserved = await putSite({ + ...current, + collections: { posts: { permalink: "/search/{slug}" } }, + }); + expect(reserved.status).toBe(409); + expect(reserved.body.error).toBe("reserved_path"); + + // Nothing was written. + expect((await site()).collections).toBeUndefined(); + const list = (await call(`/api/sites/${siteId}/redirects`)).body as unknown as Redirect[]; + expect(list.some((r) => r.from === "/posts")).toBe(false); + }); + + it("rejects a template outside the schema's pattern (400 validation)", async () => { + const r = await putSite({ + ...(await site()), + collections: { posts: { permalink: "/Blog/{slug}/" } }, + }); + expect(r.status).toBe(400); + expect(r.body.error).toBe("validation"); + }); + }); +} diff --git a/packages/api/test/approver-scopes.test.ts b/packages/api/test/approver-scopes.test.ts new file mode 100644 index 00000000..63eaa60b --- /dev/null +++ b/packages/api/test/approver-scopes.test.ts @@ -0,0 +1,64 @@ +/** + * `approverScopes` (RFC-0038 §5): an agent with `site:write` may ask to move a collection's URLs, + * but only a person who could change site settings in Studio decides it. `canDecide` is called + * directly with the context values it reads. + */ +import { + type Approval, + PolicyEngine, + type Role, + roleScopes, + type ToolPolicy, +} from "@cloudhal/agent-runtime"; +import type { Context } from "hono"; +import { describe, expect, it } from "vitest"; +import type { Env } from "../src/context.ts"; +import { canDecide } from "../src/routes/approvals.ts"; + +const TOOL: ToolPolicy = { + name: "set_collection_permalink", + scopes: ["site:write"], + annotations: { readOnlyHint: false, destructiveHint: false }, + requiresApproval: true, + approverScopes: ["site:settings"], +}; + +const request: Approval = { + id: "appr_test", + siteId: "site_one", + tool: TOOL.name, + input: { siteId: "site_one", collection: "posts", permalink: "/blog/{slug}" }, + actor: { kind: "agent", id: "agent_bot", name: "bot" }, + prompt: "Move the public URLs of posts", + stateHash: "0".repeat(64), + status: "pending", + createdAt: "2026-10-22T00:00:00.000Z", + expiresAt: "2026-10-23T00:00:00.000Z", +}; + +function as(role: Role): Context { + const values: Record = { + principal: { + actor: { kind: "human", id: `user_${role}`, name: role }, + scopes: roleScopes(role), + person: { id: `user_${role}`, userId: role, role }, + }, + staff: {}, + policy: () => new PolicyEngine([TOOL]), + ownership: { personOf: async () => "user_owner" }, + }; + return { get: (key: string) => values[key] } as unknown as Context; +} + +describe("approverScopes: who may approve a call (RFC-0038 §5)", () => { + it("an Editor holds the tool's scope but may not approve it", async () => { + expect(await canDecide(as("editor"), request, "approve")).toMatchObject({ + ok: false, + code: "missing_scope", + }); + }); + + it("an Administrator, who may change site settings, may", async () => { + expect(await canDecide(as("administrator"), request, "approve")).toEqual({ ok: true }); + }); +}); diff --git a/packages/api/test/permalinks-api.test.ts b/packages/api/test/permalinks-api.test.ts new file mode 100644 index 00000000..275a9a18 --- /dev/null +++ b/packages/api/test/permalinks-api.test.ts @@ -0,0 +1,32 @@ +import { SiteGraphStore } from "@cloudhal/core-ts"; +import { MemoryBlobStore } from "@cloudhal/storage"; +import { D1Driver } from "@cloudhal/storage/d1"; +import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; +import { createMiniflareD1, createTableReset } from "@cloudhal/storage/testing"; +import { describePermalinkApi } from "../src/testing-permalinks.ts"; + +describePermalinkApi("better-sqlite3", { + async create() { + const store = new SiteGraphStore({ + sql: await BetterSqlite3Driver.open(":memory:"), + blobs: new MemoryBlobStore(), + }); + await store.init(); + return store; + }, +}); + +let d1: Awaited> | undefined; +const reset = createTableReset(); +describePermalinkApi("Cloudflare D1 (Miniflare)", { + async create() { + d1 ??= await createMiniflareD1(); + const sql = new D1Driver(d1.db); + const store = new SiteGraphStore({ sql, blobs: new MemoryBlobStore() }); + await reset(sql, () => store.init()); + return store; + }, + async dispose() { + await d1?.dispose(); + }, +}); diff --git a/packages/astro-renderer/src/navigation.ts b/packages/astro-renderer/src/navigation.ts index d9246a1d..52ec2a6a 100644 --- a/packages/astro-renderer/src/navigation.ts +++ b/packages/astro-renderer/src/navigation.ts @@ -19,7 +19,13 @@ import type { Taxonomy, Term, } from "@cloudhal/core-contracts"; -import { entryPath, normalisePath, termName } from "@cloudhal/core-ts"; +import { + collectionPermalink, + entryPath, + normalisePath, + permalinkPrefix, + termName, +} from "@cloudhal/core-ts"; import { type MenuLookup, type MenuLookupNode, @@ -82,8 +88,9 @@ export interface LookupData { /** * A `MenuLookup` over a site's nodes (§7.1 table). Term items follow `archiveState` (200 only when * the archive lists entries the site serves in the locale); a collection index comes from the theme - * hook, else the first served page (in `Site.pageIds` order) of the locale with an unfiltered - * `contentList` of that collection (answer 5). + * hook, else a served page at the collection's permalink prefix (RFC-0038 §5), else the first + * served page (in `Site.pageIds` order) of the locale with an unfiltered `contentList` of that + * collection (answer 5). */ export function menuLookup(data: LookupData): MenuLookup { const served = (status: Page["status"]) => @@ -138,14 +145,19 @@ export function menuLookup(data: LookupData): MenuLookup { collectionIndex: (collection, locale) => { const hooked = data.collectionIndex?.(collection, locale); if (hooked !== undefined) return hooked; - return [...data.pages] + const shown = [...data.pages] .filter((p) => p.locale === locale && served(p.status)) - .sort((a, b) => (order.get(a.id) ?? 1e9) - (order.get(b.id) ?? 1e9)) - .find((p) => + .sort((a, b) => (order.get(a.id) ?? 1e9) - (order.get(b.id) ?? 1e9)); + // RFC-0038 §5: a page at the template's fixed prefix (`/docs` for `/docs/{slug}`) first. + const prefix = permalinkPrefix(collectionPermalink(data.site, collection)); + return ( + shown.find((p) => p.path === prefix) ?? + shown.find((p) => p.sections.some( (s) => s.kind === "contentList" && s.collection === collection && !s.filter, ), - )?.path; + ) + )?.path; }, }; } diff --git a/packages/astro-renderer/test/navigation.test.ts b/packages/astro-renderer/test/navigation.test.ts index a6a8a3c9..f9580c6a 100644 --- a/packages/astro-renderer/test/navigation.test.ts +++ b/packages/astro-renderer/test/navigation.test.ts @@ -175,6 +175,23 @@ describe("GraphSource.navigation (§7.1)", () => { expect(shape(dev)?.[2]).toEqual(["Draft", "/draft", [["About", "/about"]]]); }); + it("RFC-0038 §5: without a hook, a collection item links to a page at its permalink prefix", async () => { + await store.createMenu({ siteId: site.id, key: "primary", maxDepth: 3, items: items() }); + ({ site } = await store.createPage((await store.getSite(site.id)) as Site, { + siteId: site.id, + path: "/docs", + title: "Docs", + locale: "en", + status: "published", + sections: [], + })); + const [live] = await sources(); + const nav = await live?.navigation("primary"); + expect(nav?.items.find((i) => i.label === "Docs")?.href).toBe("/docs"); + // posts still falls back to the page that lists them (no page at /posts) + expect(nav?.items[0]?.href).toBe("/"); + }); + it("a deleted page or an emptied archive hides the item on the next render", async () => { await store.createMenu({ siteId: site.id, diff --git a/packages/core-ts/src/store.ts b/packages/core-ts/src/store.ts index 5d7c05b5..5bb2bd24 100644 --- a/packages/core-ts/src/store.ts +++ b/packages/core-ts/src/store.ts @@ -355,8 +355,8 @@ export class SiteGraphStore { return projectId ? sites.filter((s) => s.projectId === projectId) : sites; } - async updateSite(site: Site): Promise { - return this.#sites.update(site); + async updateSite(site: Site, options?: { dryRun?: boolean }): Promise { + return this.#sites.update(site, undefined, options); } /** diff --git a/packages/core-ts/src/store/sites.ts b/packages/core-ts/src/store/sites.ts index 235e0b29..72a6f10b 100644 --- a/packages/core-ts/src/store/sites.ts +++ b/packages/core-ts/src/store/sites.ts @@ -48,10 +48,16 @@ export class SiteEngine { return site; } - /** `updateSite` with statements in the same batch, guarded on the site's new hash. */ + /** + * `updateSite` with statements in the same batch, guarded on the site's new hash. `dryRun` + * runs every check (schema, hosts, RFC-0038 permalink overlaps) and returns the site that would + * be written, without writing it: the MCP tool's precheck, so a refused call never waits for a + * person. + */ async update( site: Site, extra?: (guard: SiteGuard, at: string) => SqlStatement[], + options: { dryRun?: boolean } = {}, ): Promise { const next: Site = structuredClone({ ...site, @@ -95,6 +101,7 @@ export class SiteEngine { const permalinks = await planPermalinks(this.#ctx, stored, next); const refs = await this.#ctx.assets.planRefs("site", site.id, next, stored); const written = refs.node; + if (options.dryRun) return written; const s = await casUpdateStatement( "site", written, diff --git a/packages/core-ts/src/testing-permalinks.ts b/packages/core-ts/src/testing-permalinks.ts index 81592f18..874d8f86 100644 --- a/packages/core-ts/src/testing-permalinks.ts +++ b/packages/core-ts/src/testing-permalinks.ts @@ -100,6 +100,18 @@ export function permalinksSuite(ctx: SuiteContext): void { expect(await redirects(site)).toHaveLength(2); }); + it("§5: a dry run checks the template and writes nothing", async () => { + const site = await setup(); + const s = await fresh(site); + const next = { ...s, collections: { posts: { permalink: "/blog/{slug}" } } }; + const dry = await ctx.store().updateSite(next, { dryRun: true }); + expect(dry).toMatchObject({ rev: s.rev + 1, collections: next.collections }); + expect((await fresh(site)).rev).toBe(s.rev); + expect(await redirects(site)).toEqual([]); + const clash = { ...s, collections: { posts: { permalink: "/about/{slug}" } } }; + expect(await code(ctx.store().updateSite(clash, { dryRun: true }))).toBe("path_conflict"); + }); + it("§3: moving again collapses the chain; moving back to the default leaves no loop", async () => { const site = await setup(); await move(site, "/blog/{slug}"); diff --git a/packages/mcp-server/src/common.ts b/packages/mcp-server/src/common.ts index 9a111932..8e67956f 100644 --- a/packages/mcp-server/src/common.ts +++ b/packages/mcp-server/src/common.ts @@ -71,6 +71,8 @@ export interface ToolDefinition { classify?: (args: Record) => CallClass | undefined; /** The scopes a `propose` call needs instead of `scopes` (ADR-0028). */ proposeScopes?: Scope[]; + /** Scopes the person approving the call must also hold (person-only ones included). */ + approverScopes?: Scope[]; /** * What the call would change, for a person deciding its approval request (RFC-0014 §5): Studio * lists it with the request. Computed against the current state when the request is listed. diff --git a/packages/mcp-server/src/page-tools.ts b/packages/mcp-server/src/page-tools.ts index 0d6cf4de..1c01f3f6 100644 --- a/packages/mcp-server/src/page-tools.ts +++ b/packages/mcp-server/src/page-tools.ts @@ -54,7 +54,8 @@ const listSitesTool: ToolDefinition = { const getSiteTool: ToolDefinition = { name: "get_site", title: "Get site", - description: "A site with its page order, locales, theme and published snapshot.", + description: + "A site with its page order, locales, theme, published snapshot and per-collection settings (`collections..permalink`, e.g. /blog/{slug}; a collection without one lives at //{slug}).", inputSchema: obj({ siteId: id }, ["siteId"]), outputSchema: obj({ site: SITE }, ["site"]), annotations: { diff --git a/packages/mcp-server/src/permalink-tools.ts b/packages/mcp-server/src/permalink-tools.ts new file mode 100644 index 00000000..9b37602b --- /dev/null +++ b/packages/mcp-server/src/permalink-tools.ts @@ -0,0 +1,243 @@ +/** + * RFC-0038 §5: `set_collection_permalink`, the one tool that changes where a collection's entries + * live. It moves public URLs, so it waits for a person in every mode (`bypass` included), and the + * person sees the old and new template, a few entries' old and new addresses and the redirect the + * store writes. `dryRun` returns the same plan without asking anyone or changing anything. + */ +import type { ContentEntry, Redirect, Site } from "@cloudhal/core-contracts"; +import { collectionPermalink, entryPath, permalinkPrefix } from "@cloudhal/core-ts/permalinks"; +import { + autoSnapshot, + id, + type JsonSchema, + obj, + requireSite, + SLUG_PATTERN, + str, + type ToolContext, + type ToolDefinition, + ToolError, + toToolError, +} from "./common.ts"; +import { SITE, SNAPSHOT } from "./content-shared.ts"; + +/** The schema's v1 pattern (`site.schema.json` → `CollectionSettings.permalink`). */ +export const PERMALINK_PATTERN = "^(/[a-z0-9]+(?:-[a-z0-9]+)*)+/\\{slug\\}$"; +const EXAMPLES = 3; + +/** What a call would do: templates, example URLs and redirects. */ +export interface PermalinkPlan { + collection: string; + from: string; + to: string; + /** The new template is the default `//{slug}` (the setting is removed). */ + isDefault: boolean; + /** Entries in the collection, every status and locale. */ + entries: number; + examples: { title: string; slug: string; status: string; from: string; to: string }[]; + redirects: { change: "add" | "repoint" | "remove"; from: string; to: string; note: string }[]; + /** Locales the prefix redirect is written for (the default locale and every other one). */ + locales: string[]; +} + +/** The site with `collection`'s permalink set (`null`: back to the default). */ +function nextSite(site: Site, collection: string, permalink: string | null): Site { + const { [collection]: current, ...others } = site.collections ?? {}; + const { permalink: _old, ...rest } = current ?? {}; + const settings = permalink ? { ...rest, permalink } : rest; + const collections = Object.keys(settings).length ? { ...others, [collection]: settings } : others; + const { collections: _drop, ...base } = site; + return Object.keys(collections).length ? { ...base, collections } : (base as Site); +} + +/** Published entries first, newest first; at most `EXAMPLES`, default locale preferred. */ +function examples(site: Site, entries: ContentEntry[], next: Site): PermalinkPlan["examples"] { + const rank = (e: ContentEntry) => + (e.status === "published" ? 0 : 2) + (e.locale === site.defaultLocale ? 0 : 1); + return [...entries] + .sort((a, b) => rank(a) - rank(b) || b.updatedAt.localeCompare(a.updatedAt)) + .slice(0, EXAMPLES) + .map((e) => ({ + title: e.title, + slug: e.slug, + status: e.status, + from: entryPath(site, e), + to: entryPath(next, e), + })); +} + +/** Mirrors `RedirectPlan.move` (prefix match): repointed targets, dropped loops, the new one. */ +function redirectPlan(existing: Redirect[], from: string, to: string): PermalinkPlan["redirects"] { + const out: PermalinkPlan["redirects"] = []; + for (const r of existing) { + if (r.locale) continue; + if (r.to !== from && !r.to.startsWith(`${from}/`)) continue; + const target = `${to}${r.to.slice(from.length)}`; + if (target === r.from) + out.push({ change: "remove", from: r.from, to: r.to, note: "it would point to itself" }); + else out.push({ change: "repoint", from: r.from, to: target, note: `was ${r.to}` }); + } + out.push({ + change: "add", + from, + to, + note: `301, prefix: every ${from}/ answers with ${to}/`, + }); + return out; +} + +async function plan(ctx: ToolContext, a: Record): Promise { + const site = await requireSite(ctx, a.siteId as string); + const collection = a.collection as string; + const permalink = (a.permalink ?? null) as string | null; + const next = nextSite(site, collection, permalink); + const from = collectionPermalink(site, collection); + const to = collectionPermalink(next, collection); + const entries = await ctx.store.listContentEntries(site.id, collection); + return { + collection, + from, + to, + isDefault: !next.collections?.[collection]?.permalink, + entries: entries.length, + examples: examples(site, entries, next), + redirects: + from === to + ? [] + : redirectPlan( + await ctx.store.listRedirects(site.id), + permalinkPrefix(from), + permalinkPrefix(to), + ), + locales: [...new Set([site.defaultLocale, ...site.locales])], + }; +} + +/** The approval text: what a person decides on. */ +export function describePlan(p: PermalinkPlan): string { + const head = `Move the public URLs of collection "${p.collection}" from ${p.from} to ${p.to}${p.isDefault ? " (the default)" : ""}? ${p.entries} entr${p.entries === 1 ? "y" : "ies"} move; old URLs keep working through a permanent redirect (301).`; + const lines = [ + ...p.examples.map((e) => ` ${e.from} → ${e.to} (${e.status}: "${e.title}")`), + ...p.redirects.map( + (r) => + `${{ add: "+", repoint: "~", remove: "−" }[r.change]} redirect ${r.from} → ${r.to} (${r.note})`, + ), + ]; + if (p.locales.length > 1) lines.push(` in every locale: ${p.locales.join(", ")}`); + return lines.length ? `${head}\n${lines.join("\n")}` : head; +} + +async function check(a: Record, ctx: ToolContext): Promise { + const permalink = a.permalink ?? null; + if (permalink !== null && !new RegExp(PERMALINK_PATTERN).test(String(permalink))) + throw new ToolError( + "invalid", + `permalink must be one or more lower-case path segments followed by /{slug}, e.g. /blog/{slug} (got ${JSON.stringify(permalink)}); null goes back to /${a.collection}/{slug}`, + ); + const site = await requireSite(ctx, a.siteId as string); + const collection = a.collection as string; + const next = nextSite(site, collection, permalink as string | null); + if (collectionPermalink(site, collection) === collectionPermalink(next, collection)) + throw new ToolError( + "invalid", + `collection "${collection}" already uses ${collectionPermalink(site, collection)}; nothing to change`, + ); + try { + await ctx.store.updateSite(next, { dryRun: true }); + } catch (e) { + throw withHint(e); + } +} + +function withHint(e: unknown): unknown { + const err = toToolError(e); + if (err instanceof ToolError && (err.code === "path_conflict" || err.code === "reserved_path")) + return new ToolError( + err.code, + `${err.message}. Choose another prefix: get_site shows the collections, list_pages the pages and list_taxonomies the archive base paths`, + { ...err.details }, + ); + return err; +} + +const PLAN_OUT: JsonSchema = obj( + { + collection: { type: "string" }, + from: { type: "string" }, + to: { type: "string" }, + isDefault: { type: "boolean" }, + entries: { type: "integer" }, + examples: { type: "array", items: { type: "object" } }, + redirects: { type: "array", items: { type: "object" } }, + locales: { type: "array", items: { type: "string" } }, + }, + ["collection", "from", "to", "isDefault", "entries", "examples", "redirects", "locales"], +); + +export function permalinkTools(): ToolDefinition[] { + return [ + { + name: "set_collection_permalink", + title: "Set collection permalink", + description: + 'Move where a collection\'s entries live (RFC-0038): permalink "/blog/{slug}" puts collection "posts" at /blog/; null goes back to the default //{slug}. The collection key never changes (filters and tools keep using it). Old URLs keep working: the store writes a permanent prefix redirect (301) in every locale. The template may not overlap a page, a taxonomy archive, another collection, /search or a locale prefix. get_site shows the current `collections`. It moves public URLs, so it always waits for a person, who sees the old and new URLs of a few entries and the redirect; call it with dryRun: true first to see that plan without asking anyone.', + inputSchema: obj( + { + siteId: id, + collection: str("Collection key (ContentEntry.collection), e.g. posts", { + pattern: SLUG_PATTERN, + }), + permalink: { + anyOf: [ + { + type: "string", + pattern: PERMALINK_PATTERN, + description: "Fixed lower-case segments followed by /{slug}, e.g. /blog/{slug}", + }, + { type: "null" }, + ], + description: "The new template, or null for the default //{slug}", + }, + dryRun: { + type: "boolean", + description: "Return the plan (example URLs, redirects) without changing anything", + }, + }, + ["siteId", "collection", "permalink"], + ), + outputSchema: obj( + { plan: PLAN_OUT, dryRun: { type: "boolean" }, site: SITE, snapshot: SNAPSHOT }, + ["plan", "dryRun"], + ), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + scopes: ["site:write"], + // Only someone who may change site settings in Studio decides it (an Editor may not). + approverScopes: ["site:settings"], + requiresApproval: true, + // Public URLs move: a person decides in every mode; a dry run changes nothing. + classify: (a) => (a.dryRun === true ? "read" : "person"), + precheck: check, + approvalPrompt: async (a, ctx) => describePlan(await plan(ctx, a)), + handler: async (a, ctx) => { + const p = await plan(ctx, a); + if (a.dryRun === true) return { plan: p, dryRun: true }; + const site = await requireSite(ctx, a.siteId as string); + let updated: Site; + try { + updated = await ctx.store.updateSite( + nextSite(site, a.collection as string, (a.permalink ?? null) as string | null), + ); + } catch (e) { + throw withHint(e); + } + const snapshot = await autoSnapshot(ctx, site.id, "set_collection_permalink"); + return { plan: p, dryRun: false, site: updated, snapshot }; + }, + }, + ]; +} diff --git a/packages/mcp-server/src/server-instructions.ts b/packages/mcp-server/src/server-instructions.ts index d4e7e350..1abdde9b 100644 --- a/packages/mcp-server/src/server-instructions.ts +++ b/packages/mcp-server/src/server-instructions.ts @@ -9,6 +9,8 @@ Building or redesigning pages: call get_design first and follow the site's desig Categories and tags: before you file an article, call list_taxonomies (which taxonomies are required, how many terms an article takes, who may add terms) and list_terms for each one (or resolve_terms for the names you have in mind). Reuse existing terms: set them on create_entry / update_entry with \`terms\`, e.g. {"category": {"primary": "News"}, "tag": {"add": ["AI"]}}; ids, slugs, names and synonyms all work, and nothing is ever created implicitly. Propose a new tag with propose_term only when no existing term fits; it stays private until a person approves it. Categories are added by people only: if none fits, say so and ask. A review or published article needs a primary term in every required taxonomy. Renaming, merging, retiring or moving terms, taxonomy settings and redirects (site paths only) always wait for a person; taxonomy_report suggests clean-ups to propose to them. +Entry URLs: an entry lives at its collection's permalink (get_site → \`collections\`; //{slug} by default). set_collection_permalink moves a whole collection (e.g. posts to /blog/{slug}) and leaves a 301 from the old URLs; it always waits for a person, so call it with dryRun: true first and show them the URLs that move. + Corrections: when a published article changes in substance, say so with add_correction (correction, clarification or update) instead of editing silently. Notices that are live cannot be removed or reworded by you, and publishing a new one always waits for a person. Modes: get_mode tells you how independently you work right now. If a task needs more autonomy for a while (e.g. publishing a series of small updates), call request_elevation with the mode, the minutes and a one-sentence reason; a person decides in Studio, and once granted your next call runs in that mode until the time is up. Ask only for what the task needs. diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 9ec2af24..73afd4f1 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -38,6 +38,7 @@ import { elevationTools } from "./elevation-tools.ts"; import { entryTools } from "./entry-tools.ts"; import { menuTools } from "./menu-tools.ts"; import { pageRemovalTools, siteAndPageTools } from "./page-tools.ts"; +import { permalinkTools } from "./permalink-tools.ts"; import { previewDoc } from "./preview-doc.ts"; import { ownOnlyPublish, publishTools } from "./publish-tools.ts"; import { renderTools } from "./render-tools.ts"; @@ -85,6 +86,8 @@ export function defineTools(): ToolDefinition[] { // Taxonomy, term and redirect tools (RFC-0002 §6.2), then menu tools (RFC-0003 §6.1) and // media tools (RFC-0004 §6.2), follow the article tools. ...taxonomyTools(), + // RFC-0038 §5: where a collection's entries live (always a person's decision). + ...permalinkTools(), ...menuTools(), ...assetTools(), ...publishTools(), @@ -111,6 +114,7 @@ export function toolPolicies(tools: ToolDefinition[]): ToolPolicy[] { ...(t.own ? { own: t.own } : {}), ...(t.classify ? { classify: t.classify } : {}), ...(t.proposeScopes ? { proposeScopes: t.proposeScopes } : {}), + ...(t.approverScopes ? { approverScopes: t.approverScopes } : {}), })); } diff --git a/packages/mcp-server/test/e2e.test.ts b/packages/mcp-server/test/e2e.test.ts index 5880fb38..cadfadd9 100644 --- a/packages/mcp-server/test/e2e.test.ts +++ b/packages/mcp-server/test/e2e.test.ts @@ -72,7 +72,7 @@ describe("MCP client ↔ Cloudhal server", () => { const { client } = await connect({ store, audit, elicitation: true }); const { tools } = await client.listTools(); expect(tools.map((t) => t.name).slice(0, 3)).toEqual(["list_sites", "get_site", "list_pages"]); - expect(tools).toHaveLength(101); + expect(tools).toHaveLength(102); const publish = tools.find((t) => t.name === "publish"); expect(publish?.annotations).toMatchObject({ destructiveHint: true, readOnlyHint: false }); expect(publish?.inputSchema).toMatchObject({ type: "object" }); diff --git a/packages/mcp-server/test/menu.test.ts b/packages/mcp-server/test/menu.test.ts index 3e828ada..d1ffd596 100644 --- a/packages/mcp-server/test/menu.test.ts +++ b/packages/mcp-server/test/menu.test.ts @@ -195,7 +195,8 @@ describe("RFC-0003 over MCP", () => { expect(names).toEqual( expect.arrayContaining(["list_menus", "get_menu", "update_menu", "set_menu", "delete_menu"]), ); - expect(names.indexOf("list_menus")).toBe(names.indexOf("delete_redirect") + 1); + // RFC-0038 §5's set_collection_permalink sits between the redirect and the menu tools. + expect(names.indexOf("list_menus")).toBe(names.indexOf("set_collection_permalink") + 1); for (const n of ["list_menus", "get_menu"]) expect(byName[n]).toMatchObject({ scopes: ["site:read"], diff --git a/packages/mcp-server/test/permalinks.test.ts b/packages/mcp-server/test/permalinks.test.ts new file mode 100644 index 00000000..b969a0fb --- /dev/null +++ b/packages/mcp-server/test/permalinks.test.ts @@ -0,0 +1,180 @@ +/** + * RFC-0038 §5 over MCP: `set_collection_permalink` waits for a person in every mode (bypass + * included), its prompt and dry run list the old and new URLs and the redirect, applying it writes + * the redirect, and an overlap is refused before anyone is asked. + */ +import { type AgentMode, ApprovalStore, AuditLog, PolicyEngine } from "@cloudhal/agent-runtime"; +import type { Site } from "@cloudhal/core-contracts"; +import { SiteGraphStore, seedStarterSite } from "@cloudhal/core-ts"; +import { MemoryBlobStore, migrate, migrations } from "@cloudhal/storage"; +import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { PermalinkPlan } from "../src/permalink-tools.ts"; +import { createCloudhalMcpServer } from "../src/server.ts"; +import { defineTools, runTool, type ToolContext, toolPolicies } from "../src/tools.ts"; + +const agent = { kind: "agent" as const, id: "agent_permalink1", name: "Mover" }; +const owner = { kind: "human" as const, id: "user_owner000001", name: "Owner" }; +const tools = defineTools(); +const tool = tools.find((t) => t.name === "set_collection_permalink") as (typeof tools)[number]; +const policy = new PolicyEngine(toolPolicies(tools)); + +interface World { + store: SiteGraphStore; + audit: AuditLog; + approvals: ApprovalStore; + site: Site; +} + +async function world(): Promise { + const sql = await BetterSqlite3Driver.open(":memory:"); + await migrate(sql, migrations); + const store = new SiteGraphStore({ sql, blobs: new MemoryBlobStore() }); + const site = await seedStarterSite(store, { name: "Links", slug: "links" }); + return { store, audit: new AuditLog(sql), approvals: new ApprovalStore(sql), site }; +} + +function direct(w: World, mode: AgentMode, local = false) { + const ctx: ToolContext = { + store: w.store, + principal: { actor: agent, scopes: ["*"], mode, ...(local ? { local: true } : {}) }, + }; + return (args: Record, confirmed = false) => + runTool(tool, { siteId: w.site.id, collection: "posts", ...args }, ctx, { + policy, + audit: w.audit, + confirmed, + }); +} + +describe("set_collection_permalink (RFC-0038 §5)", () => { + let w: World; + beforeEach(async () => { + w = await world(); + }); + + it("is registered with site:write and always needs a person, bypass included", async () => { + expect(tool.scopes).toEqual(["site:write"]); + for (const mode of ["edits", "auto", "bypass"] as const) { + const r = await direct(w, mode, mode === "bypass")({ permalink: "/blog/{slug}" }); + expect(r.decision.kind, mode).toBe("approval-required"); + expect(r.result).toBeUndefined(); + } + expect((await w.store.getSite(w.site.id))?.collections).toBeUndefined(); + // Without site:write the call is refused. + const reader = await runTool( + tool, + { siteId: w.site.id, collection: "posts", permalink: "/blog/{slug}" }, + { store: w.store, principal: { actor: agent, scopes: ["site:read"], mode: "edits" } }, + { policy }, + ); + expect(reader.decision.kind).toBe("deny"); + }); + + it("the prompt and the dry run list example URLs and the redirect", async () => { + const run = direct(w, "edits"); + const ask = await run({ permalink: "/blog/{slug}" }); + expect(ask.approvalPrompt).toMatch( + /^Move the public URLs of collection "posts" from \/posts\/\{slug\} to \/blog\/\{slug\}\?/, + ); + expect(ask.approvalPrompt).toContain("/posts/hello-world → /blog/hello-world"); + expect(ask.approvalPrompt).toContain("+ redirect /posts → /blog (301, prefix"); + expect(ask.approvalPrompt).toContain("permanent redirect (301)"); + + const dry = await run({ permalink: "/blog/{slug}", dryRun: true }); + expect(dry.decision.kind).toBe("allow"); + const plan = (dry.result as { plan: PermalinkPlan; dryRun: boolean }).plan; + expect(plan).toMatchObject({ from: "/posts/{slug}", to: "/blog/{slug}", isDefault: false }); + expect(plan.examples[0]).toMatchObject({ + from: "/posts/hello-world", + to: "/blog/hello-world", + }); + expect(plan.examples.length).toBeLessThanOrEqual(3); + expect(plan.redirects).toEqual([ + expect.objectContaining({ change: "add", from: "/posts", to: "/blog" }), + ]); + // A dry run changes nothing. + expect((await w.store.getSite(w.site.id))?.collections).toBeUndefined(); + expect(await w.store.listRedirects(w.site.id)).toEqual([]); + }); + + it("applying writes the template, the redirect and one snapshot; null goes back", async () => { + const run = direct(w, "edits"); + const before = (await w.store.listSnapshots(w.site.id, 100)).length; + const done = await run({ permalink: "/blog/{slug}" }, true); + const out = done.result as { site: Site; dryRun: boolean }; + expect(out.dryRun).toBe(false); + expect(out.site.collections).toEqual({ posts: { permalink: "/blog/{slug}" } }); + expect((await w.store.listSnapshots(w.site.id, 100)).length).toBe(before + 1); + expect( + (await w.store.listRedirects(w.site.id)) + .filter((r) => !r.locale) + .map((r) => [r.from, r.to, r.match, r.status]), + ).toEqual([["/posts", "/blog", "prefix", 301]]); + expect((await w.store.redirectFor(w.site.id, "/posts/hello-world"))?.location).toBe( + "/blog/hello-world", + ); + + // Back to the default: the prompt shows the loop the store drops. + const back = await run({ permalink: null }); + expect(back.approvalPrompt).toContain("/blog/{slug} to /posts/{slug} (the default)"); + expect(back.approvalPrompt).toContain("− redirect /posts → /blog (it would point to itself)"); + const reset = await run({ permalink: null }, true); + expect((reset.result as { site: Site }).site.collections).toBeUndefined(); + }); + + it("an overlap or a no-op is refused before a person is asked", async () => { + const run = direct(w, "edits"); + await expect(run({ permalink: "/about/{slug}" })).rejects.toMatchObject({ + code: "path_conflict", + message: expect.stringContaining("would cover page /about"), + }); + await expect(run({ permalink: "/tag/{slug}" })).rejects.toMatchObject({ + code: "path_conflict", + }); + await expect(run({ permalink: "/search/{slug}" })).rejects.toMatchObject({ + code: "reserved_path", + }); + await expect(run({ permalink: "/Blog/{slug}/" })).rejects.toMatchObject({ code: "invalid" }); + await expect(run({ permalink: null })).rejects.toMatchObject({ + code: "invalid", + message: expect.stringContaining("already uses /posts/{slug}"), + }); + const rows = await w.audit.list({ limit: 20 }); + expect(rows.filter((r) => r.tool === "set_collection_permalink").map((r) => r.outcome)).toEqual( + ["error", "error", "error", "error", "error"], + ); + expect(await w.store.listRedirects(w.site.id)).toEqual([]); + }); + + it("over MCP with out-of-band approvals: pending, approved in Studio, then applied", async () => { + const server = createCloudhalMcpServer({ + store: w.store, + audit: w.audit, + approvals: w.approvals, + principal: { actor: agent, scopes: ["*"], mode: "edits" }, + stateKey: "permalink-test-state-key-0123456789abcdef", + }); + const client = new Client({ name: "t", version: "1" }, { capabilities: {} }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await server.connect(st); + await client.connect(ct); + const args = { siteId: w.site.id, collection: "posts", permalink: "/blog/{slug}" }; + const call = async (a: Record) => { + const r = await client.callTool({ name: "set_collection_permalink", arguments: a }); + return { + isError: r.isError === true, + text: (r.content as { text?: string }[])[0]?.text ?? "", + }; + }; + const ask = await call(args); + expect(ask.text).toMatch(/^approval pending: Move the public URLs of collection "posts"/); + expect(ask.text).toContain("/posts/hello-world → /blog/hello-world"); + const approvalId = ask.text.match(/approvalId: (appr_\w+)/)?.[1] as string; + await w.approvals.decide(approvalId, "approved", owner); + const done = await call({ ...args, approvalId }); + expect(done.isError, done.text).toBe(false); + expect((await w.store.getSite(w.site.id))?.collections?.posts?.permalink).toBe("/blog/{slug}"); + }); +}); diff --git a/packages/mcp-server/test/tool-order.test.ts b/packages/mcp-server/test/tool-order.test.ts index a597ad77..e6dc2e57 100644 --- a/packages/mcp-server/test/tool-order.test.ts +++ b/packages/mcp-server/test/tool-order.test.ts @@ -55,6 +55,7 @@ const ORDER = [ "list_redirects", "create_redirect", "delete_redirect", + "set_collection_permalink", "list_menus", "get_menu", "update_menu", diff --git a/packages/mcp-server/test/tools.test.ts b/packages/mcp-server/test/tools.test.ts index d2ead7a9..d8327f8a 100644 --- a/packages/mcp-server/test/tools.test.ts +++ b/packages/mcp-server/test/tools.test.ts @@ -92,6 +92,7 @@ describe("Cloudhal tools (SDK-independent)", () => { "list_redirects", "create_redirect", "delete_redirect", + "set_collection_permalink", "list_menus", "get_menu", "update_menu", diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index ab955af6..dd4a060e 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -8,7 +8,7 @@ "apps/studio/src/routes/CommentThreadPage.tsx": 813, "apps/studio/src/routes/NavigationPage.tsx": 1171, "apps/studio/src/routes/SchedulesPage.tsx": 1296, - "apps/studio/src/routes/SiteSettingsPage.tsx": 881, + "apps/studio/src/routes/SiteSettingsPage.tsx": 859, "packages/api/src/build-turn.ts": 885, "packages/api/src/routes/assets.ts": 833, "packages/api/src/routes/comments.ts": 874, diff --git a/starters/blog/README.md b/starters/blog/README.md index 95994dfc..4d1d42a1 100644 --- a/starters/blog/README.md +++ b/starters/blog/README.md @@ -16,7 +16,7 @@ Everything but `.gitignore`'s list. `cloudhal.json` holds the project's settings `cloudhal.json` → `domains.site` (set with `cloudhal domain set `) is also the address a static build (`cloudhal build`) writes into canonical links, the sitemap, feeds and `robots.txt`. ## Routes -- `[...path].astro` — pages at their path, posts at `//`, category and tag archives at `/category/` and `/tag/` (`/page/` for older posts). Anything else follows the site's redirects (301), answers 410 for a retired term, or 404. +- `[...path].astro` — pages at their path, posts at their collection's permalink (`//` unless Studio's Site settings › Collections or the `set_collection_permalink` tool moves them, e.g. to `/blog/`, with a 301 from the old URLs), category and tag archives at `/category/` and `/tag/` (`/page/` for older posts). Anything else follows the site's redirects (301), answers 410 for a retired term, or 404. - `[...path]/rss.xml.ts` — a feed per category and tag (`/category//rss.xml`). - `rss.xml.ts`, `sitemap.xml.ts`, `robots.txt.ts` — the site feed, the sitemap (pages, posts and indexable archives) and robots rules. - `search.json.ts` — the search modal's answers (RFC-0029), where the deployment answers searches (`cloudhal dev`, the Cloudflare Site Worker). `components/SearchDialog.astro` is the modal's markup and words; Cloudhal's `/_cloudhal/search.1.js` wires it, and without it the header form opens `/search`. diff --git a/starters/blog/src/theme.ts b/starters/blog/src/theme.ts index 89662eda..6425d6d7 100644 --- a/starters/blog/src/theme.ts +++ b/starters/blog/src/theme.ts @@ -36,8 +36,9 @@ export const NAV_CATEGORIES = true; /** * Where this theme serves a collection's index, for menu items that link to a collection - * (RFC-0003 §7.1). Return `undefined` to use the default: the first published page that lists the - * collection (the blog's home page lists `posts`); without one the item is hidden. + * (RFC-0003 §7.1). Return `undefined` to use the default: a published page at the collection's + * permalink prefix (`/docs` for `/docs/{slug}`, RFC-0038 §5), else the first published page that + * lists the collection (the blog's home page lists `posts`); without one the item is hidden. */ export const collectionIndex: CollectionIndexHook = () => undefined;