diff --git a/.changeset/collections-stage3-studio.md b/.changeset/collections-stage3-studio.md
new file mode 100644
index 00000000..1837238a
--- /dev/null
+++ b/.changeset/collections-stage3-studio.md
@@ -0,0 +1,13 @@
+---
+"@cloudhal/cli": minor
+"@cloudhal/api": minor
+---
+
+Collections stage 3, step 5 (RFC-0042 §7 / ADR-0044): Studio.
+
+- **Collection settings** get three cards, each saved on its own: **Listing fields** (up to four fields, ordered; a field content lists still use names those pages), **Expiry** (a date or date-and-time field, "Leave lists" or "Hide the page" explained in words, a grace in minutes, and how many published entries are already past it; a site Worker that does not apply expiry yet says to deploy first) and **Structured data** (Event, Job posting, Product or Recipe; every property offers only the sources that fit it, required ones are marked, problems show while editing, and a chosen entry's JSON-LD is previewed with a link to Google's Rich Results Test).
+- **Create listing page** makes a draft page at the collection's address with a content list of it (upcoming first for a collection with a date listing field and an expiry) and opens it; when a page already lives there, the action opens that page.
+- **Collection lists** show listing fields as columns, filter by them and sort by number and date fields. Filters and sort live in the URL and ask the API by field, one page at a time.
+- **Page editor**: content list sections get conditions and an order by the collection's listing fields; refused conditions show on their row.
+- Words for `invalid_query`, `structured_data_invalid`, `invalid_expiry`, `list_field_in_use` and `gates_unsupported`, the `preview_structured_data` tool and the expiry auto-publish check. Translations are AI drafts awaiting review.
+- `@cloudhal/api`: new read-only `GET /api/sites/:siteId/entries/:entryId/structured-data` (`content:read`), the REST twin of MCP's `preview_structured_data`: the preset document an entry's page emits, its issues and the preset's warnings.
diff --git a/apps/studio/bundle-budget.ts b/apps/studio/bundle-budget.ts
index d3cc669c..70dcaa4c 100644
--- a/apps/studio/bundle-budget.ts
+++ b/apps/studio/bundle-budget.ts
@@ -34,7 +34,9 @@ export const BUNDLE_BUDGET = {
* `loadLocale`) and the `menus` words moved to a lazy namespace, so the budget came down from
* 455 kB. 387.6 kB on 2026-10-22 (was 419.9 kB) once the bell's items, the elevation and
* agent-mode UI and the taxonomy model left the shell (`LAZY_ONLY`); the budget stays at
- * 420 kB as headroom for shell features.
+ * 420 kB as headroom for shell features. 390.0 kB on 2026-10-23 with collections stage 3 (RFC-0042):
+ * its screens are lazy; the shell gained the router's list-field search check, five error
+ * sentences and the expiry auto-check words, and Kumo's shared chunks split once more (+0.8 kB).
*/
initialGzipBytes: 420_000,
};
@@ -83,6 +85,13 @@ export const LAZY_ONLY: readonly RegExp[] = [
/[\\/]packages[\\/]core-ts[\\/]src[\\/]fields[\\/]/,
/[\\/]apps[\\/]studio[\\/]src[\\/]components[\\/]fields[\\/]/,
/[\\/]apps[\\/]studio[\\/]src[\\/]lib[\\/](fields|field-form)\.ts$/,
+ // Collections stage 3 (RFC-0042 §7): the listing, expiry and structured-data cards, the list's
+ // field columns and filters, "Create listing page", the content list conditions, their model and
+ // client, and the preset table load with their screens (the shell keeps `listing-search.ts`).
+ /[\\/]packages[\\/]core-ts[\\/]src[\\/]structured-data[\\/]/,
+ /[\\/]apps[\\/]studio[\\/]src[\\/]components[\\/]listings[\\/]/,
+ /[\\/]apps[\\/]studio[\\/]src[\\/]components[\\/]useCollectionEntries\.ts$/,
+ /[\\/]apps[\\/]studio[\\/]src[\\/]lib[\\/](listings|listing-query|api[\\/]listings)\.ts$/,
// Lazy message namespaces: their words load with the screens that show them.
/[\\/]apps[\\/]studio[\\/]src[\\/]i18n[\\/]lazy[\\/]/,
// Core catalogs other than English load with their language (`loadLocale`), never with English.
diff --git a/apps/studio/src/components/ErrorBanner.tsx b/apps/studio/src/components/ErrorBanner.tsx
index 24af1322..0ada606f 100644
--- a/apps/studio/src/components/ErrorBanner.tsx
+++ b/apps/studio/src/components/ErrorBanner.tsx
@@ -2,7 +2,7 @@ import { Banner } from "@cloudhal/ui";
import { ArrowClockwise, WarningCircle } from "@phosphor-icons/react";
import { useT } from "../i18n/index.tsx";
import { ApiRequestError } from "../lib/api.ts";
-import { apiErrorText } from "../lib/api-errors.ts";
+import { apiErrorText, isWordedCode } from "../lib/api-errors.ts";
/**
* Page/section error (DESIGN.md §5): plain-language message (`apiErrorText`), optional retry
@@ -24,7 +24,8 @@ export function ErrorBanner({
const t = useT();
if (!error) return null;
const message =
- error instanceof ApiRequestError && error.status === 409
+ // A 409 is someone else's change, unless its code has words of its own (`list_field_in_use`).
+ error instanceof ApiRequestError && error.status === 409 && !isWordedCode(error.body?.error)
? t("errors.conflict")
: apiErrorText(t, error);
return (
diff --git a/apps/studio/src/components/listings/ContentListQuery.tsx b/apps/studio/src/components/listings/ContentListQuery.tsx
new file mode 100644
index 00000000..2d6db38e
--- /dev/null
+++ b/apps/studio/src/components/listings/ContentListQuery.tsx
@@ -0,0 +1,341 @@
+import type {
+ ContentListSection,
+ FieldCondition,
+ FieldDefinition,
+ FieldOrder,
+} from "@cloudhal/core-contracts";
+import {
+ FIELD_QUERY_LIMITS,
+ FIELD_QUERY_OPS,
+ type FieldQueryOp,
+ fieldLabel,
+ NOW,
+ optionLabel,
+} from "@cloudhal/core-ts/fields";
+import { Button, Checkbox, Input, Link, Select, Text } from "@cloudhal/ui";
+import { Plus, Trash } from "@phosphor-icons/react";
+import { useQuery } from "@tanstack/react-query";
+import { useI18n } from "../../i18n/index.tsx";
+import { api } from "../../lib/api.ts";
+import { listFieldDefs, sortable, timeKind } from "../../lib/listings.ts";
+
+const NONE = "__none";
+
+/** A condition's defaults when its field or operator changes. */
+function freshCondition(def: FieldDefinition, op?: FieldQueryOp): FieldCondition {
+ const chosen = op ?? FIELD_QUERY_OPS[def.type][0] ?? "set";
+ if (chosen === "set" || chosen === "unset") return { field: def.key, op: chosen };
+ if (def.type === "date" || def.type === "datetime")
+ return { field: def.key, op: chosen, value: NOW };
+ if (def.type === "boolean") return { field: def.key, op: chosen, value: "true" };
+ if (def.type === "select") {
+ const first = def.options[0]?.value ?? "";
+ return chosen === "in"
+ ? { field: def.key, op: chosen, values: [first] }
+ : { field: def.key, op: chosen, value: first };
+ }
+ return { field: def.key, op: chosen, value: "" };
+}
+
+/** The section with `where` / `orderBy` replaced (empty ones left out, as the contract asks). */
+function withQuery(
+ section: ContentListSection,
+ where: FieldCondition[],
+ orderBy: FieldOrder | undefined,
+): ContentListSection {
+ const { where: _w, orderBy: _o, ...rest } = section;
+ return {
+ ...rest,
+ ...(where.length ? { where: where as ContentListSection["where"] } : {}),
+ ...(orderBy ? { orderBy } : {}),
+ };
+}
+
+/**
+ * The page editor's content list conditions (RFC-0042 §4): "only entries where" rows over the
+ * listed collection's listing fields — each with the operators its type allows and a value control
+ * for that type (`now` or a date for dates, the options of a choice, yes / no) — and an order by a
+ * number or date field. The save's `invalid_query` issues show under their row.
+ */
+export function ContentListQuery({
+ siteId,
+ section,
+ onChange,
+ issues,
+}: {
+ siteId: string;
+ section: ContentListSection;
+ onChange: (section: ContentListSection) => void;
+ /** The last save's `invalid_query` issues of this section (`where/0/value`, `orderBy/field`). */
+ issues: readonly { path: string; message: string }[];
+}) {
+ const { t, locale } = useI18n("listings");
+ const c = "listings.contentList";
+ const site = useQuery({ queryKey: ["site", siteId], queryFn: () => api.sites.get(siteId) });
+ const settings = site.data?.collections?.[section.collection];
+ const defs = listFieldDefs(settings);
+ const where = section.where ?? [];
+ const label = (d: FieldDefinition) => fieldLabel(d, locale, site.data?.defaultLocale ?? "en");
+ const issueAt = (prefix: string) =>
+ issues.filter((i) => i.path === prefix || i.path.startsWith(`${prefix}/`));
+ if (!site.data) return null;
+ if (defs.length === 0)
+ return (
+
+ {t(`${c}.noListFields`)}{" "}
+
+ {t(`${c}.settingsLink`)}
+
+
+ );
+ const setWhere = (next: FieldCondition[]) => onChange(withQuery(section, next, section.orderBy));
+ const sortDefs = defs.filter(sortable);
+ const orderDef = defs.find((d) => d.key === section.orderBy?.field);
+ return (
+
+
+
+ {t(`${c}.where`)}
+
+ {where.map((cond, i) => (
+
x.message)}
+ onChange={(next) => setWhere(where.map((w, j) => (j === i ? next : w)))}
+ onRemove={() => setWhere(where.filter((_, j) => j !== i))}
+ />
+ ))}
+
+ }
+ disabled={where.length >= FIELD_QUERY_LIMITS.conditions}
+ onClick={() => defs[0] && setWhere([...where, freshCondition(defs[0])])}
+ data-testid="add-condition"
+ >
+ {t(`${c}.add`)}
+
+
+
+ {sortDefs.length > 0 && (
+
+
+ onChange(
+ withQuery(
+ section,
+ where,
+ v === NONE
+ ? undefined
+ : { field: v as string, direction: section.orderBy?.direction ?? "asc" },
+ ),
+ )
+ }
+ items={{
+ [NONE]: t(`${c}.orderNone`),
+ ...Object.fromEntries(sortDefs.map((d) => [d.key, label(d)])),
+ }}
+ error={issueAt("orderBy")[0]?.message}
+ data-testid="order-by-field"
+ />
+ {section.orderBy && (
+
+ section.orderBy &&
+ onChange(
+ withQuery(section, where, {
+ field: section.orderBy.field,
+ direction: v === "desc" ? "desc" : "asc",
+ }),
+ )
+ }
+ items={{
+ asc: t(`${c}.asc`, { kind: timeKind(orderDef) }),
+ desc: t(`${c}.desc`, { kind: timeKind(orderDef) }),
+ }}
+ data-testid="order-by-direction"
+ />
+ )}
+
+ )}
+
+ );
+}
+
+/** One condition: field, operator (only its type's), the value control, remove. */
+function ConditionRow({
+ cond,
+ defs,
+ label,
+ defaultLocale,
+ errors,
+ onChange,
+ onRemove,
+}: {
+ cond: FieldCondition;
+ defs: readonly FieldDefinition[];
+ label: (d: FieldDefinition) => string;
+ defaultLocale: string;
+ errors: readonly string[];
+ onChange: (cond: FieldCondition) => void;
+ onRemove: () => void;
+}) {
+ const { t } = useI18n("listings");
+ const c = "listings.contentList";
+ const def = defs.find((d) => d.key === cond.field);
+ const ops = def ? FIELD_QUERY_OPS[def.type] : [];
+ return (
+
+
+ {
+ const next = defs.find((d) => d.key === v);
+ if (next) onChange(freshCondition(next));
+ }}
+ items={Object.fromEntries(defs.map((d) => [d.key, label(d)]))}
+ />
+ def && onChange(freshCondition(def, v as FieldQueryOp))}
+ items={Object.fromEntries(
+ ops.map((op) => [op, t(`${c}.ops.${op}`, { kind: timeKind(def) })]),
+ )}
+ data-testid={`condition-op-${cond.field}`}
+ />
+ {def && (
+
+ )}
+ }
+ aria-label={t(`${c}.remove`)}
+ title={t(`${c}.remove`)}
+ onClick={onRemove}
+ />
+
+ {errors.map((e) => (
+
+ {e}
+
+ ))}
+
+ );
+}
+
+/** The value of a condition, by its field's type; none for "has a value" / "is empty". */
+function ValueControl({
+ cond,
+ def,
+ defaultLocale,
+ onChange,
+}: {
+ cond: FieldCondition;
+ def: FieldDefinition;
+ defaultLocale: string;
+ onChange: (cond: FieldCondition) => void;
+}) {
+ const { t, locale } = useI18n("listings");
+ const c = "listings.contentList";
+ const label = t(`${c}.value`);
+ if (cond.op === "set" || cond.op === "unset") return ;
+ const value = cond.value ?? "";
+ const set = (v: string) => onChange({ field: cond.field, op: cond.op, value: v });
+ if (def.type === "select") {
+ const items = Object.fromEntries(
+ def.options.map((o) => [o.value, optionLabel(def, o.value, locale, defaultLocale)]),
+ );
+ if (cond.op === "in")
+ return (
+ o.value)}
+ onValueChange={(next: string[]) => {
+ const values = def.options
+ .map((o) => o.value)
+ .filter((v) => next.includes(v))
+ .slice(0, FIELD_QUERY_LIMITS.values);
+ onChange(
+ values.length
+ ? { field: cond.field, op: "in", values: values as FieldCondition["values"] }
+ : { field: cond.field, op: "in" },
+ );
+ }}
+ >
+ {def.options.map((o) => (
+
+ ))}
+
+ );
+ return (
+ set(v as string)}
+ items={items}
+ />
+ );
+ }
+ if (def.type === "boolean")
+ return (
+ set(v as string)}
+ items={{ true: t(`${c}.yes`), false: t(`${c}.no`) }}
+ />
+ );
+ if (def.type === "date" || def.type === "datetime")
+ return (
+
+ set(v === NOW ? NOW : "")}
+ items={{ [NOW]: t(`${c}.now`), date: t(`${c}.aDate`) }}
+ data-testid={`condition-when-${cond.field}`}
+ />
+ {value !== NOW && (
+
+ )}
+
+ );
+ return (
+
+ );
+}
diff --git a/apps/studio/src/components/listings/CreateListingPage.tsx b/apps/studio/src/components/listings/CreateListingPage.tsx
new file mode 100644
index 00000000..3f3c4221
--- /dev/null
+++ b/apps/studio/src/components/listings/CreateListingPage.tsx
@@ -0,0 +1,83 @@
+import type { Site } from "@cloudhal/core-contracts";
+import { collectionPermalink, permalinkPrefix } from "@cloudhal/core-ts/permalinks";
+import { Button, LinkButton } from "@cloudhal/ui";
+import { ListBullets } from "@phosphor-icons/react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useNavigate } from "@tanstack/react-router";
+import { useI18n } from "../../i18n/index.tsx";
+import { api } from "../../lib/api.ts";
+import { apiErrorText } from "../../lib/api-errors.ts";
+import { listingSection, listingTitle } from "../../lib/listings.ts";
+import { sectionId } from "../page-editor/sections.ts";
+import { useToast } from "../Toast.ts";
+import { useCan } from "../useMe.ts";
+
+/**
+ * RFC-0042 §5 "Create listing page": a draft page at the collection's address (`/events`), titled
+ * with its plural name, holding one `contentList` section of the collection (upcoming first when it
+ * has a date list field and an expiry). It goes through the ordinary page write, then opens in the
+ * page editor. When a page already lives at that address, the action opens it instead.
+ */
+export function CreateListingPage({ site, collection }: { site: Site; collection: string }) {
+ const { t } = useI18n("listings");
+ const qc = useQueryClient();
+ const navigate = useNavigate();
+ const toast = useToast();
+ const canWrite = useCan("pages.write");
+ const pages = useQuery({
+ queryKey: ["pages", site.id],
+ queryFn: () => api.pages.list(site.id),
+ enabled: canWrite,
+ });
+ const path = permalinkPrefix(collectionPermalink(site, collection));
+ const existing = pages.data?.find((p) => p.path === path && p.locale === site.defaultLocale);
+ const create = useMutation({
+ mutationFn: () =>
+ api.pages.create(site.id, {
+ path,
+ title: listingTitle(site, collection),
+ locale: site.defaultLocale,
+ status: "draft",
+ sections: [listingSection(sectionId(), collection, site.collections?.[collection] ?? {})],
+ }),
+ onSuccess: async ({ page, site: next }) => {
+ qc.setQueryData(["site", site.id], next);
+ await qc.invalidateQueries({ queryKey: ["pages", site.id] });
+ toast.success(t("listings.createPage.created"), page.path);
+ await navigate({
+ to: "/sites/$siteId/pages/$pageId",
+ params: { siteId: site.id, pageId: page.id },
+ });
+ },
+ onError: (error) => {
+ toast.error(t("errors.title"), apiErrorText(t, error));
+ // Someone may have made a page at that address meanwhile: the action then opens it.
+ void pages.refetch();
+ },
+ });
+ if (!canWrite) return null;
+ if (existing)
+ return (
+
+ {t("listings.createPage.open")}
+
+ );
+ return (
+ }
+ onClick={() => create.mutate()}
+ loading={create.isPending}
+ disabled={pages.isPending}
+ data-testid="create-listing-page"
+ >
+ {t("listings.createPage.action")}
+
+ );
+}
diff --git a/apps/studio/src/components/listings/ExpirySection.tsx b/apps/studio/src/components/listings/ExpirySection.tsx
new file mode 100644
index 00000000..6d368b5e
--- /dev/null
+++ b/apps/studio/src/components/listings/ExpirySection.tsx
@@ -0,0 +1,199 @@
+import type { CollectionExpiry, CollectionSettings, Site } from "@cloudhal/core-contracts";
+import { fieldGateInstant, fieldLabel } from "@cloudhal/core-ts/fields";
+import { Banner, Button, Input, Radio, Select, Text } from "@cloudhal/ui";
+import { CloudArrowUp } from "@phosphor-icons/react";
+import { useQuery } from "@tanstack/react-query";
+import { useEffect, useMemo, useState } from "react";
+import { useI18n } from "../../i18n/index.tsx";
+import { api, errorCode } from "../../lib/api.ts";
+import { dateFields, liveFields } from "../../lib/listings.ts";
+import { ErrorBanner } from "../ErrorBanner.tsx";
+import { SettingsSection } from "../Settings.tsx";
+import { SwitchField } from "../TaxonomySettingsDialog.tsx";
+import { useNow } from "../useNow.ts";
+import { LISTING_SECTIONS } from "./sections.ts";
+import { same, useSaveSetting } from "./useSaveSetting.ts";
+
+/** The contract's longest grace: one week. */
+const MAX_GRACE = 10_080;
+
+interface Form {
+ on: boolean;
+ field: string;
+ action: CollectionExpiry["action"];
+ grace: string;
+}
+
+const formOf = (expiry: CollectionExpiry | undefined, first: string): Form => ({
+ on: expiry !== undefined,
+ field: expiry?.field ?? first,
+ action: expiry?.action ?? "unlist",
+ grace: expiry?.afterMinutes ? String(expiry.afterMinutes) : "",
+});
+
+/** The rule the form describes; `undefined` when off (or without a field). */
+export function expiryOf(form: Form): CollectionExpiry | undefined {
+ if (!form.on || !form.field) return undefined;
+ const grace = Math.min(MAX_GRACE, Math.max(0, Math.trunc(Number(form.grace) || 0)));
+ return { field: form.field, action: form.action, ...(grace > 0 ? { afterMinutes: grace } : {}) };
+}
+
+/** Published entries of the collection already past the rule (counted in Studio, RFC-0042 §7). */
+function usePastCount(site: Site, collection: string, rule: CollectionExpiry | undefined) {
+ const now = useNow(60_000);
+ const published = useQuery({
+ queryKey: ["entries", site.id, collection, "published"],
+ queryFn: () => api.entries.list(site.id, { collection, status: ["published"] }),
+ enabled: rule !== undefined,
+ });
+ return useMemo(() => {
+ const def =
+ rule && liveFields(site.collections?.[collection] ?? {}).find((d) => d.key === rule.field);
+ if (!def || !published.data) return undefined;
+ return published.data.filter((e) => {
+ const at = fieldGateInstant(e.fields?.[def.key], def, site, rule?.afterMinutes ?? 0);
+ return at !== undefined && at <= now;
+ }).length;
+ }, [rule, published.data, site, collection, now]);
+}
+
+/**
+ * A collection › Settings › Expiry (RFC-0042 §3, §7): the date field entries end on (dates and
+ * dates with times only), what happens then (leave lists, or hide the page) in words, the grace,
+ * and how many published entries are already past it. A site Worker that does not apply derived
+ * gates refuses the rule (`gates_unsupported`): the card says to deploy first.
+ */
+export function ExpirySection({
+ site,
+ collection,
+ settings,
+ readOnlyReason,
+}: {
+ site: Site;
+ collection: string;
+ settings: CollectionSettings;
+ readOnlyReason: string | undefined;
+}) {
+ const { t, locale } = useI18n("listings");
+ const dates = dateFields(liveFields(settings));
+ const first = dates[0]?.key ?? "";
+ const saved = settings.expiry;
+ const [form, setForm] = useState(() => formOf(saved, first));
+ const savedKey = JSON.stringify(saved ?? null);
+ useEffect(() => setForm(formOf(saved, first)), [savedKey, first]);
+ const save = useSaveSetting(site, collection, "expiry", t("listings.expiry.saved"));
+ const rule = expiryOf(form);
+ const past = usePastCount(site, collection, rule);
+ const dirty = !same(rule, saved);
+ const canEdit = readOnlyReason === undefined;
+ const set = (k: K, v: Form[K]) => setForm((f) => ({ ...f, [k]: v }));
+ const unsupported = errorCode(save.error) === "gates_unsupported";
+ return (
+
+ {dirty && (
+ setForm(formOf(saved, first))}
+ disabled={save.isPending}
+ >
+ {t("common.discard")}
+
+ )}
+ save.mutate(rule)}
+ loading={save.isPending}
+ disabled={!canEdit || !dirty}
+ title={readOnlyReason}
+ data-testid="save-expiry"
+ >
+ {t("listings.expiry.save")}
+
+ >
+ }
+ >
+
+ {unsupported ? (
+ }
+ title={t("errors.title")}
+ description={t("listings.expiry.gatesUnsupported")}
+ data-testid="expiry-gates-unsupported"
+ />
+ ) : (
+
+ )}
+ {dates.length === 0 && !saved ? (
+ {t("listings.expiry.noDateFields")}
+ ) : (
+ <>
+ set("on", v)}
+ disabled={!canEdit}
+ testId="expiry-on"
+ />
+ {form.on && (
+ <>
+ set("field", v as string)}
+ items={Object.fromEntries(
+ dates.map((d) => [d.key, fieldLabel(d, locale, site.defaultLocale)]),
+ )}
+ data-testid="expiry-field"
+ />
+ set("action", v === "hide" ? "hide" : "unlist")}
+ >
+
+
+
+ set("grace", v)}
+ required={false}
+ data-testid="expiry-grace"
+ />
+ {past !== undefined && (
+
+ {t("listings.expiry.past", { count: past })}
+
+ )}
+ >
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/studio/src/components/listings/ListFieldColumns.tsx b/apps/studio/src/components/listings/ListFieldColumns.tsx
new file mode 100644
index 00000000..df08f25f
--- /dev/null
+++ b/apps/studio/src/components/listings/ListFieldColumns.tsx
@@ -0,0 +1,160 @@
+/**
+ * RFC-0042 §4 "Studio": a collection list's listing-field columns, filters and sort, loaded only
+ * for a collection with `listFields` (`LAZY_ONLY`). Values show as readers would read them
+ * (`formatFieldValue` in the Studio language); filters and sort live in the URL, and the list asks
+ * the API with them (`useCollectionEntries`).
+ */
+import type { ContentEntry, FieldDefinition, FieldOrder, Site } from "@cloudhal/core-contracts";
+import { fieldLabel, formatFieldValue, optionLabel } from "@cloudhal/core-ts/fields";
+import { Button, Select, Table, Text, Toolbar } from "@cloudhal/ui";
+import { ArrowDown, ArrowsDownUp, ArrowUp } from "@phosphor-icons/react";
+import { useI18n } from "../../i18n/index.tsx";
+import { listFieldDefs, sortable } from "../../lib/listings.ts";
+
+const ANY = "__any";
+
+/** The filter tokens a field type offers (`lib/listing-query.ts` turns them into conditions). */
+function tokensOf(def: FieldDefinition): string[] {
+ switch (def.type) {
+ case "date":
+ case "datetime":
+ return ["upcoming", "past", "set", "unset"];
+ case "boolean":
+ return ["yes", "no"];
+ case "select":
+ return [...def.options.map((o) => `is:${o.value}`), "unset"];
+ default:
+ return ["set", "unset"];
+ }
+}
+
+function useDefs(site: Site, collection: string) {
+ const { locale } = useI18n();
+ const defs = listFieldDefs(site.collections?.[collection]);
+ const label = (def: FieldDefinition) => fieldLabel(def, locale, site.defaultLocale);
+ return { defs, label, locale };
+}
+
+/** One `Select` per listing field: "Starts: upcoming", "Format: Workshop", "Seats: empty". */
+export function ListFieldFilters({
+ site,
+ collection,
+ filters,
+ onFilter,
+}: {
+ site: Site;
+ collection: string;
+ filters: Readonly>;
+ onFilter: (field: string, token: string | undefined) => void;
+}) {
+ const { t } = useI18n("listings");
+ const { defs, label, locale } = useDefs(site, collection);
+ const words = (def: FieldDefinition, token: string) => {
+ const l = label(def);
+ if (token.startsWith("is:"))
+ return t("listings.filters.is", {
+ label: l,
+ value: optionLabel(def, token.slice(3), locale, site.defaultLocale),
+ });
+ return t(`listings.filters.${token as "set"}`, { label: l });
+ };
+ return (
+
+ {defs.map((def) => (
+ onFilter(def.key, v === ANY ? undefined : (v as string))}
+ items={Object.fromEntries([
+ [ANY, t("listings.filters.any", { label: label(def) })],
+ ...tokensOf(def).map((token) => [token, words(def, token)]),
+ ])}
+ render={ }
+ data-testid={`list-filter-${def.key}`}
+ />
+ ))}
+
+ );
+}
+
+/** The columns' headers; number and date columns sort (ascending, descending, off). */
+export function ListFieldHeads({
+ site,
+ collection,
+ orderBy,
+ onSort,
+}: {
+ site: Site;
+ collection: string;
+ orderBy: FieldOrder | undefined;
+ onSort: (orderBy: FieldOrder | undefined) => void;
+}) {
+ const { t } = useI18n("listings");
+ const { defs, label } = useDefs(site, collection);
+ return defs.map((def) => {
+ if (!sortable(def)) return {label(def)} ;
+ const direction = orderBy?.field === def.key ? orderBy.direction : undefined;
+ const next: FieldOrder | undefined =
+ direction === undefined
+ ? { field: def.key, direction: "asc" }
+ : direction === "asc"
+ ? { field: def.key, direction: "desc" }
+ : undefined;
+ const Icon = direction === "asc" ? ArrowUp : direction === "desc" ? ArrowDown : ArrowsDownUp;
+ return (
+
+ }
+ onClick={() => onSort(next)}
+ title={t("listings.filters.sortBy", { label: label(def) })}
+ data-testid={`list-sort-${def.key}`}
+ >
+
+ {label(def)}
+
+
+
+ );
+ });
+}
+
+/** One entry's listing-field values, formatted in the Studio language. */
+export function ListFieldCells({
+ site,
+ collection,
+ entry,
+}: {
+ site: Site;
+ collection: string;
+ entry: ContentEntry;
+}) {
+ const { t, locale } = useI18n("listings");
+ const { defs } = useDefs(site, collection);
+ const words = { yes: t("listings.contentList.yes"), no: t("listings.contentList.no") };
+ return defs.map((def) => {
+ const text = formatFieldValue(entry.fields?.[def.key], def, {
+ locale,
+ defaultLocale: site.defaultLocale,
+ words,
+ });
+ return (
+
+ {text ? (
+ {text}
+ ) : (
+
+ {t("articles.none")}
+
+ )}
+
+ );
+ });
+}
diff --git a/apps/studio/src/components/listings/ListFieldsSection.tsx b/apps/studio/src/components/listings/ListFieldsSection.tsx
new file mode 100644
index 00000000..449c60a9
--- /dev/null
+++ b/apps/studio/src/components/listings/ListFieldsSection.tsx
@@ -0,0 +1,207 @@
+import type { CollectionSettings, FieldDefinition, Site } from "@cloudhal/core-contracts";
+import { fieldLabel } from "@cloudhal/core-ts/fields";
+import { Badge, Button, Link, SectionEmpty, Select, Text } from "@cloudhal/ui";
+import { ArrowDown, ArrowUp, Columns, Trash } from "@phosphor-icons/react";
+import { type ReactNode, useEffect, useState } from "react";
+import { useI18n } from "../../i18n/index.tsx";
+import { liveFields, pagesInUse, sortable } from "../../lib/listings.ts";
+import { ErrorBanner } from "../ErrorBanner.tsx";
+import { SettingsSection } from "../Settings.tsx";
+import { LISTING_SECTIONS } from "./sections.ts";
+import { same, useSaveSetting } from "./useSaveSetting.ts";
+
+const MAX = 4;
+
+/**
+ * A collection › Settings › Listing fields (RFC-0042 §4, §7): an ordered choice of up to four of
+ * its fields — the list's columns and what filters, sorting and `contentList` conditions may use.
+ * Saved alone (`listFields`); a 409 `list_field_in_use` names the pages whose sections still use a
+ * field taken out.
+ */
+export function ListFieldsSection({
+ site,
+ collection,
+ settings,
+ readOnlyReason,
+}: {
+ site: Site;
+ collection: string;
+ settings: CollectionSettings;
+ readOnlyReason: string | undefined;
+}) {
+ const { t, locale } = useI18n("listings", "fields");
+ const saved = settings.listFields ?? [];
+ const [draft, setDraft] = useState([...saved]);
+ const savedKey = JSON.stringify(saved);
+ useEffect(() => setDraft(JSON.parse(savedKey) as string[]), [savedKey]);
+ const save = useSaveSetting(site, collection, "listFields", t("listings.listFields.saved"));
+ const defs = liveFields(settings);
+ const byKey = new Map(defs.map((d) => [d.key, d]));
+ const dirty = !same(draft, saved);
+ const canEdit = readOnlyReason === undefined;
+ const label = (key: string) => {
+ const def = byKey.get(key);
+ return def ? fieldLabel(def, locale, site.defaultLocale) : key;
+ };
+ const free = defs.filter((d) => !draft.includes(d.key));
+ const move = (i: number, by: -1 | 1) => {
+ const next = [...draft];
+ const j = i + by;
+ [next[i], next[j]] = [next[j] as string, next[i] as string];
+ setDraft(next);
+ };
+ const pages = pagesInUse(save.error);
+ return (
+
+ {dirty && (
+ setDraft([...saved])} disabled={save.isPending}>
+ {t("common.discard")}
+
+ )}
+ save.mutate(draft as CollectionSettings["listFields"])}
+ loading={save.isPending}
+ disabled={!canEdit || !dirty}
+ title={readOnlyReason}
+ data-testid="save-list-fields"
+ >
+ {t("listings.listFields.save")}
+
+ >
+ }
+ >
+
+
+ {pages.length > 0 && (
+
+
{t("listings.listFields.inUse")}
+
+ {pages.map((p) => (
+
+ {p.path}
+
+ ))}
+
+
+ )}
+ {draft.length === 0 ? (
+
}
+ title={t("listings.listFields.empty")}
+ description={
+ defs.length === 0
+ ? t("listings.listFields.noFields")
+ : t("listings.listFields.emptyDescription")
+ }
+ />
+ ) : (
+
+ {draft.map((key, i) => (
+ move(i, by)}
+ onRemove={() => setDraft(draft.filter((k) => k !== key))}
+ />
+ ))}
+
+ )}
+ {defs.length > 0 && (
+
= MAX ? t("listings.listFields.full") : undefined}
+ placeholder={t("listings.listFields.choose")}
+ value={null as string | null}
+ onValueChange={(v) => v && setDraft([...draft, v as string])}
+ items={Object.fromEntries(free.map((d) => [d.key, label(d.key)]))}
+ disabled={!canEdit || draft.length >= MAX || free.length === 0}
+ data-testid="add-list-field"
+ />
+ )}
+
+
+ );
+}
+
+/** One chosen field: label, key, type and "Sortable"; up, down and remove. */
+function ListFieldRow({
+ fieldKey,
+ def,
+ label,
+ first,
+ last,
+ canEdit,
+ onMove,
+ onRemove,
+}: {
+ fieldKey: string;
+ def: FieldDefinition | undefined;
+ label: string;
+ first: boolean;
+ last: boolean;
+ canEdit: boolean;
+ onMove: (by: -1 | 1) => void;
+ onRemove: () => void;
+}) {
+ const { t } = useI18n("listings", "fields");
+ const iconButton = (text: string, icon: ReactNode, onClick: () => void, off: boolean) => (
+
+ );
+ return (
+
+
+
+ {label}
+
+
+
+ {fieldKey}
+
+ {def && {t(`fields.types.${def.type}`)} }
+ {def && sortable(def) && (
+ {t("listings.listFields.sortable")}
+ )}
+
+
+
+ {iconButton(
+ t("listings.listFields.moveUp", { label }),
+ ,
+ () => onMove(-1),
+ first,
+ )}
+ {iconButton(
+ t("listings.listFields.moveDown", { label }),
+ ,
+ () => onMove(1),
+ last,
+ )}
+ {iconButton(t("listings.listFields.remove", { label }), , onRemove, false)}
+
+
+ );
+}
diff --git a/apps/studio/src/components/listings/ListingSections.tsx b/apps/studio/src/components/listings/ListingSections.tsx
new file mode 100644
index 00000000..15447fe7
--- /dev/null
+++ b/apps/studio/src/components/listings/ListingSections.tsx
@@ -0,0 +1,26 @@
+import type { CollectionSettings, Site } from "@cloudhal/core-contracts";
+
+export { CreateListingPage } from "./CreateListingPage.tsx";
+
+import { ExpirySection } from "./ExpirySection.tsx";
+import { ListFieldsSection } from "./ListFieldsSection.tsx";
+import { StructuredDataSection } from "./StructuredDataSection.tsx";
+
+/**
+ * RFC-0042 §7: collection settings' stage 3 cards, one lazy chunk with the settings route —
+ * Listing fields, Expiry and Structured data, each saved on its own.
+ */
+export function ListingSections(props: {
+ site: Site;
+ collection: string;
+ settings: CollectionSettings;
+ readOnlyReason: string | undefined;
+}) {
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/apps/studio/src/components/listings/MappingRow.tsx b/apps/studio/src/components/listings/MappingRow.tsx
new file mode 100644
index 00000000..e3e9cb97
--- /dev/null
+++ b/apps/studio/src/components/listings/MappingRow.tsx
@@ -0,0 +1,139 @@
+import type {
+ EntryValueProperty,
+ FieldDefinition,
+ SiteValueProperty,
+ StructuredDataSource,
+} from "@cloudhal/core-contracts";
+import { fieldLabel } from "@cloudhal/core-ts/fields";
+import type { PathSpec } from "@cloudhal/core-ts/structured-data";
+import { Badge, Input, Select, Text } from "@cloudhal/ui";
+import { useI18n } from "../../i18n/index.tsx";
+import { type SourceOption, sourceOptions, vocabularyKeys } from "../../lib/listings.ts";
+
+/** The words of one source option ("Field: Starts", "Entry: Title", "Default: Entry: Summary"). */
+export function useSourceLabel(defs: readonly FieldDefinition[], defaultLocale: string) {
+ const { t, locale } = useI18n("listings");
+ const sd = "listings.structuredData";
+ const of = (source: StructuredDataSource | SourceOption): string => {
+ switch (source.kind) {
+ case "field": {
+ const key = "field" in source ? source.field : (source.key ?? "");
+ const def = defs.find((d) => d.key === key);
+ return t(`${sd}.sources.field`, {
+ label: def ? fieldLabel(def, locale, defaultLocale) : key,
+ });
+ }
+ case "entry":
+ return t(`${sd}.sources.entry`, {
+ property: t(
+ `${sd}.entryProps.${("property" in source ? source.property : source.key) as EntryValueProperty}`,
+ ),
+ });
+ case "site":
+ return t(`${sd}.sources.site`, {
+ property: t(
+ `${sd}.siteProps.${("property" in source ? source.property : source.key) as SiteValueProperty}`,
+ ),
+ });
+ case "constant":
+ return t(`${sd}.sources.constant`);
+ default:
+ return t(`${sd}.sources.unmapped`);
+ }
+ };
+ return of;
+}
+
+/**
+ * One property of a structured-data preset: its path in mono, Required / One of these, a `Select`
+ * offering only the sources that fit (its default first, fields of a fitting type, entry and site
+ * properties, a fixed value where allowed), the fixed value (a vocabulary `Select` on enum paths),
+ * and this path's issues.
+ */
+export function MappingRow({
+ path,
+ spec,
+ defs,
+ defaultLocale,
+ choice,
+ constant,
+ issues,
+ disabled,
+ onChange,
+}: {
+ path: string;
+ spec: PathSpec;
+ defs: readonly FieldDefinition[];
+ defaultLocale: string;
+ choice: string;
+ constant: string;
+ issues: readonly string[];
+ disabled: boolean;
+ onChange: (choice: string, constant: string) => void;
+}) {
+ const { t } = useI18n("listings");
+ const sd = "listings.structuredData";
+ const label = useSourceLabel(defs, defaultLocale);
+ const options = sourceOptions(spec, defs);
+ const items = options.map((o) => ({
+ value: o.value,
+ label:
+ o.kind === "default" && spec.default
+ ? t(`${sd}.sources.default`, { source: label(spec.default) })
+ : label(o),
+ }));
+ const vocabulary = vocabularyKeys(spec);
+ return (
+
+
+
+ {path}
+
+
+ {spec.required && {t(`${sd}.required`)} }
+ {spec.group && {t(`${sd}.oneOf`)} }
+
+
+
+ onChange(v as string, constant)}
+ items={items}
+ disabled={disabled}
+ data-testid={`source-${path}`}
+ />
+ {choice === "constant" &&
+ (vocabulary.length > 0 ? (
+ onChange(choice, v as string)}
+ items={Object.fromEntries(vocabulary.map((k) => [k, k]))}
+ disabled={disabled}
+ />
+ ) : (
+ onChange(choice, v)}
+ description={spec.kind === "currency" ? t(`${sd}.currencyHint`) : undefined}
+ disabled={disabled}
+ />
+ ))}
+ {issues.map((text) => (
+
+ {text}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/studio/src/components/listings/StructuredDataPreview.tsx b/apps/studio/src/components/listings/StructuredDataPreview.tsx
new file mode 100644
index 00000000..d0617890
--- /dev/null
+++ b/apps/studio/src/components/listings/StructuredDataPreview.tsx
@@ -0,0 +1,108 @@
+import type { Site } from "@cloudhal/core-contracts";
+import { entryPath } from "@cloudhal/core-ts/permalinks";
+import { DirectionalIcon, InsetPanel, LinkButton, Loader, Select, Text } from "@cloudhal/ui";
+import { ArrowSquareOut } from "@phosphor-icons/react";
+import { useQuery } from "@tanstack/react-query";
+import { useState } from "react";
+import { useI18n } from "../../i18n/index.tsx";
+import { listingsApi } from "../../lib/api/listings.ts";
+import { api } from "../../lib/api.ts";
+import { CodeHighlighted } from "../CodeHighlighted.tsx";
+import { ErrorBanner } from "../ErrorBanner.tsx";
+
+const RICH_RESULTS = "https://search.google.com/test/rich-results?url=";
+const LOCAL = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
+
+/**
+ * The JSON-LD a chosen entry's page emits with the saved preset (`GET …/structured-data`, the
+ * renderer's own builder), why it emits none, and — for a published entry on a site with a domain —
+ * a link to Google's Rich Results Test for its public address.
+ */
+export function StructuredDataPreview({ site, collection }: { site: Site; collection: string }) {
+ const { t } = useI18n("listings");
+ const sd = "listings.structuredData";
+ const entries = useQuery({
+ queryKey: ["entries", site.id, collection],
+ queryFn: () => api.entries.list(site.id, collection),
+ });
+ const [entryId, setEntryId] = useState(null);
+ const preview = useQuery({
+ queryKey: ["structured-data", site.id, entryId, site.rev],
+ queryFn: () => listingsApi.structuredData(site.id, entryId ?? ""),
+ enabled: entryId !== null,
+ });
+ const entry = entries.data?.find((e) => e.id === entryId);
+ const list = (entries.data ?? []).slice(0, 100);
+ const origin = preview.data?.origin;
+ const testUrl =
+ entry && origin && !LOCAL.test(origin) && entry.status === "published"
+ ? `${RICH_RESULTS}${encodeURIComponent(`${origin}${entryPath(site, entry)}`)}`
+ : undefined;
+ const issues = preview.data?.issues ?? [];
+ return (
+
+
+
+
+ {t(`${sd}.preview`)}
+
+
+ {t(`${sd}.previewDescription`)}
+
+
+ {entries.data && list.length === 0 ? (
+
{t(`${sd}.previewEmpty`)}
+ ) : (
+
setEntryId(v as string)}
+ items={Object.fromEntries(list.map((e) => [e.id, `${e.title} · ${e.locale}`]))}
+ loading={entries.isPending}
+ data-testid="preview-entry"
+ />
+ )}
+
+ {preview.isFetching && }
+ {preview.data && (
+
+ {issues.map((i) => (
+
+ {t(`${sd}.issues.${i.code}` as `${typeof sd}.issues.required_value_missing`, {
+ path: i.path.split("/").pop() ?? i.path,
+ })}
+
+ ))}
+ {preview.data.document ? (
+
+ ) : (
+
{t(`${sd}.noDocument`)}
+ )}
+ {testUrl && (
+
+ }
+ >
+ {t(`${sd}.richResults`)}
+
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/apps/studio/src/components/listings/StructuredDataSection.tsx b/apps/studio/src/components/listings/StructuredDataSection.tsx
new file mode 100644
index 00000000..68883020
--- /dev/null
+++ b/apps/studio/src/components/listings/StructuredDataSection.tsx
@@ -0,0 +1,158 @@
+import type { CollectionSettings, Site } from "@cloudhal/core-contracts";
+import { checkPreset, PRESET_TYPES, type PresetType } from "@cloudhal/core-ts/structured-data";
+import { Button, Select, Text } from "@cloudhal/ui";
+import { useEffect, useMemo, useState } from "react";
+import { useI18n } from "../../i18n/index.tsx";
+import {
+ issuesOf,
+ liveFields,
+ type MappingDraft,
+ mappingDraft,
+ presetOf,
+ presetRows,
+ suggestRequired,
+} from "../../lib/listings.ts";
+import { ErrorBanner } from "../ErrorBanner.tsx";
+import { SettingsSection } from "../Settings.tsx";
+import { MappingRow } from "./MappingRow.tsx";
+import { StructuredDataPreview } from "./StructuredDataPreview.tsx";
+import { LISTING_SECTIONS } from "./sections.ts";
+import { same, useSaveSetting } from "./useSaveSetting.ts";
+
+const NONE = "__none";
+
+/** The row an issue belongs to: `…/properties/[/…]`, or a group's prefix (its first row). */
+function rowOf(issuePath: string, paths: readonly string[]): string | undefined {
+ const at = issuePath.indexOf("properties/");
+ if (at < 0) return undefined;
+ const rest = issuePath.slice(at + "properties/".length).split("/")[0] ?? "";
+ return paths.includes(rest) ? rest : paths.find((p) => p.startsWith(`${rest}.`));
+}
+
+/**
+ * A collection › Settings › Structured data (RFC-0042 §2, §7): a type (Event, Job posting, Product,
+ * Recipe, or none for today's blog post), then each of its properties — required first — with only
+ * the sources that fit it. The same check the server runs (`checkPreset`) marks problems while
+ * editing; a refused save (`structured_data_invalid`) shows its issues on their rows. Below, the
+ * JSON-LD a chosen entry's page emits with the saved settings.
+ */
+export function StructuredDataSection({
+ site,
+ collection,
+ settings,
+ readOnlyReason,
+}: {
+ site: Site;
+ collection: string;
+ settings: CollectionSettings;
+ readOnlyReason: string | undefined;
+}) {
+ const { t } = useI18n("listings");
+ const sd = "listings.structuredData";
+ const saved = settings.structuredData;
+ const [type, setType] = useState(saved?.type);
+ const [draft, setDraft] = useState(() => mappingDraft(saved));
+ const savedKey = JSON.stringify(saved ?? null);
+ useEffect(() => {
+ setType(saved?.type);
+ setDraft(mappingDraft(saved));
+ }, [savedKey]);
+ const save = useSaveSetting(site, collection, "structuredData", t(`${sd}.saved`));
+ const defs = liveFields(settings);
+ const preset = presetOf(type, draft);
+ const dirty = !same(preset, saved);
+ const canEdit = readOnlyReason === undefined;
+ const rows = type ? presetRows(type) : [];
+ const paths = rows.map((r) => r.path);
+ const issues = useMemo(() => {
+ const live = preset ? checkPreset(preset, defs, "preset") : [];
+ const all = [...live, ...issuesOf(save.error)];
+ const byRow = new Map();
+ for (const i of all) {
+ const row = rowOf(i.path, paths);
+ if (!row) continue;
+ const text = t(`${sd}.issues.${i.code}` as `${typeof sd}.issues.required_value_missing`, {
+ path: row,
+ });
+ const list = byRow.get(row) ?? [];
+ if (!list.includes(text)) byRow.set(row, [...list, text]);
+ }
+ return byRow;
+ }, [preset, defs, save.error, paths.join()]);
+ const suggest = suggestRequired(preset, defs);
+ return (
+
+ {dirty && (
+ {
+ setType(saved?.type);
+ setDraft(mappingDraft(saved));
+ }}
+ disabled={save.isPending}
+ >
+ {t("common.discard")}
+
+ )}
+ save.mutate(preset)}
+ loading={save.isPending}
+ disabled={!canEdit || !dirty}
+ title={readOnlyReason}
+ data-testid="save-structured-data"
+ >
+ {t(`${sd}.save`)}
+
+ >
+ }
+ >
+
+
+
setType(v === NONE ? undefined : (v as PresetType))}
+ items={{
+ [NONE]: t(`${sd}.none`),
+ ...Object.fromEntries(PRESET_TYPES.map((p) => [p, t(`${sd}.types.${p}`)])),
+ }}
+ data-testid="preset-type"
+ />
+ {type && (
+
+ {rows.map(({ path, spec }) => (
+
+ setDraft((d) => ({ ...d, [path]: { choice, constant } }))
+ }
+ />
+ ))}
+
+ )}
+ {type && (
+
+ {suggest.length > 0 && {t(`${sd}.suggestRequired`)} }
+ {t(`${sd}.themeReminder`)}
+
+ )}
+ {saved && }
+
+
+ );
+}
diff --git a/apps/studio/src/components/listings/sections.ts b/apps/studio/src/components/listings/sections.ts
new file mode 100644
index 00000000..b9039b51
--- /dev/null
+++ b/apps/studio/src/components/listings/sections.ts
@@ -0,0 +1,6 @@
+/** The stage 3 cards' ids in collection settings' table of contents (RFC-0042 §7). */
+export const LISTING_SECTIONS = {
+ listFields: "listing-fields",
+ expiry: "expiry",
+ structuredData: "structured-data",
+} as const;
diff --git a/apps/studio/src/components/listings/useSaveSetting.ts b/apps/studio/src/components/listings/useSaveSetting.ts
new file mode 100644
index 00000000..dd46c7e5
--- /dev/null
+++ b/apps/studio/src/components/listings/useSaveSetting.ts
@@ -0,0 +1,50 @@
+/**
+ * RFC-0042 §7: one stage 3 card's save — `PUT …/collections/:key` with the collection's current
+ * settings and only this card's setting replaced, so the cards never undo each other.
+ */
+import type { CollectionSettings, Site } from "@cloudhal/core-contracts";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useT } from "../../i18n/index.tsx";
+import { api } from "../../lib/api.ts";
+import { withSetting } from "../../lib/listings.ts";
+import { useToast } from "../Toast.ts";
+
+type StageKey = "listFields" | "expiry" | "structuredData";
+
+export function useSaveSetting(
+ site: Site,
+ collection: string,
+ key: K,
+ savedText: string,
+) {
+ const t = useT();
+ const qc = useQueryClient();
+ const toast = useToast();
+ return useMutation({
+ mutationFn: (value: CollectionSettings[K] | undefined) => {
+ const current = site.collections?.[collection] ?? {};
+ return api.collections.save(site, collection, withSetting(current, key, value));
+ },
+ onSuccess: async ({ site: saved }) => {
+ qc.setQueryData(["site", site.id], saved);
+ await qc.invalidateQueries({ queryKey: ["collections", site.id] });
+ toast.success(savedText, t("common.rev", { rev: saved.rev }));
+ },
+ });
+}
+
+/** JSON with object keys sorted, so the server's key order does not read as a change. */
+function canonical(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(canonical);
+ if (value && typeof value === "object")
+ return Object.fromEntries(
+ Object.keys(value)
+ .sort()
+ .map((k) => [k, canonical((value as Record)[k])]),
+ );
+ return value ?? null;
+}
+
+/** The draft equals the saved value (key order, `undefined` and absent alike). */
+export const same = (a: unknown, b: unknown) =>
+ JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
diff --git a/apps/studio/src/components/page-editor/SectionForm.tsx b/apps/studio/src/components/page-editor/SectionForm.tsx
index 8a24a87e..874299b0 100644
--- a/apps/studio/src/components/page-editor/SectionForm.tsx
+++ b/apps/studio/src/components/page-editor/SectionForm.tsx
@@ -2,6 +2,7 @@
import type { Section } from "@cloudhal/core-contracts";
import type { BlockType } from "@cloudhal/core-ts/blocks";
import { Grid, Input, InputArea, Select } from "@cloudhal/ui";
+import { lazy, Suspense } from "react";
import { useT } from "../../i18n/index.tsx";
import { validateImageRef } from "../../lib/seo.ts";
import { setOptional } from "../../lib/util.ts";
@@ -11,6 +12,11 @@ import { ImageRefField } from "../MediaPicker.tsx";
import { SurfaceSectionEditor } from "../SurfaceSectionEditor.tsx";
import { FIELD_ROW } from "./sections.ts";
+// RFC-0042 §4: conditions and order by the listed collection's listing fields (lazy).
+const ContentListQuery = lazy(() =>
+ import("../listings/ContentListQuery.tsx").then((m) => ({ default: m.ContentListQuery })),
+);
+
/** Where a section's image comes from: the site (media library, primary domain). */
export interface MediaContext {
siteId: string;
@@ -27,6 +33,7 @@ export function SectionForm({
media,
blocks,
readOnly,
+ queryIssues = [],
}: {
section: Section;
onChange: (s: Section) => void;
@@ -35,6 +42,8 @@ export function SectionForm({
blocks: readonly BlockType[];
/** The fieldset turns off native fields; the rich body editor needs telling. */
readOnly: boolean;
+ /** A content list's `invalid_query` issues from the last save (RFC-0042). */
+ queryIssues?: readonly { path: string; message: string }[];
}) {
const t = useT();
switch (section.kind) {
@@ -139,7 +148,19 @@ export function SectionForm({
>
);
case "contentList":
- return ;
+ return (
+ <>
+
+
+
+
+ >
+ );
}
}
diff --git a/apps/studio/src/components/page-editor/SectionList.tsx b/apps/studio/src/components/page-editor/SectionList.tsx
index 37aa4710..eb75a751 100644
--- a/apps/studio/src/components/page-editor/SectionList.tsx
+++ b/apps/studio/src/components/page-editor/SectionList.tsx
@@ -16,7 +16,7 @@ import { useT } from "../../i18n/index.tsx";
import { blockIcon, newBlockSection } from "../BlockSection.tsx";
import { BodyProposals } from "../BodyProposals.tsx";
import { SectionForm } from "./SectionForm.tsx";
-import { KIND_ICON, KINDS, newSection, sectionId } from "./sections.ts";
+import { KIND_ICON, KINDS, newSection, sectionId, sectionQueryIssues } from "./sections.ts";
import type { PageEditorState } from "./usePageEditor.ts";
export function SectionList({
@@ -123,6 +123,7 @@ export function SectionList({
}}
blocks={blockTypes}
readOnly={readOnly}
+ queryIssues={sectionQueryIssues(ed.save.error, i)}
/>
{s.kind === "richText" && s.format === "markdown" && (
= {
@@ -60,3 +60,22 @@ export function duplicateOf(page: Page, title: string): NewPage {
/** Field rows inside a section card: stacked on phones, side by side when there is room. */
export const FIELD_ROW = "items-start gap-x-4 gap-y-5";
+
+/**
+ * RFC-0042: a refused save's `invalid_query` issues of section `index` (`sections//where/0/value`
+ * → `where/0/value`), for the content list editor to show on their row.
+ */
+export function sectionQueryIssues(
+ error: unknown,
+ index: number,
+): { path: string; message: string }[] {
+ if (!(error instanceof ApiRequestError) || error.body.error !== "invalid_query") return [];
+ const issues = (error.body.details as { issues?: unknown } | undefined)?.issues;
+ if (!Array.isArray(issues)) return [];
+ const prefix = `sections/${index}/`;
+ return issues.flatMap((i: { path?: unknown; message?: unknown }) =>
+ typeof i?.path === "string" && i.path.startsWith(prefix)
+ ? [{ path: i.path.slice(prefix.length), message: String(i.message ?? "") }]
+ : [],
+ );
+}
diff --git a/apps/studio/src/components/useCollectionEntries.ts b/apps/studio/src/components/useCollectionEntries.ts
new file mode 100644
index 00000000..bd764171
--- /dev/null
+++ b/apps/studio/src/components/useCollectionEntries.ts
@@ -0,0 +1,88 @@
+/**
+ * A collection list's entries (RFC-0040 §4, RFC-0042 §4): the whole collection newest-updated first,
+ * filtered by the category / tag facets — or, while the URL filters or sorts by list fields, one
+ * server page at a time (`GET …/entries` with `where`, `orderBy`, `limit`, `cursor`) in the
+ * server's order, with previous / next through the cursors seen so far.
+ */
+import type { ContentEntry } from "@cloudhal/core-contracts";
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { useEffect, useState } from "react";
+import { listingsApi } from "../lib/api/listings.ts";
+import { api } from "../lib/api.ts";
+import type { ListingQuery } from "../lib/listing-query.ts";
+
+/** Entries per server page (as the client-side pages). */
+export const LISTING_PAGE_SIZE = 25;
+
+export interface ServerPaging {
+ page: number;
+ perPage: number;
+ total: number;
+ setPage: (page: number) => void;
+}
+
+export function useCollectionEntries({
+ siteId,
+ collection,
+ filter,
+ listing,
+ enabled,
+}: {
+ siteId: string;
+ collection: string;
+ filter: string | undefined;
+ listing: ListingQuery;
+ enabled: boolean;
+}): {
+ data: ContentEntry[] | undefined;
+ isPending: boolean;
+ error: unknown;
+ server: ServerPaging | undefined;
+} {
+ const key = JSON.stringify([filter ?? null, listing.where, listing.orderBy ?? null]);
+ // cursors[i] opens page i + 2; going back drops the last one.
+ const [cursors, setCursors] = useState([]);
+ useEffect(() => setCursors([]), [key]);
+ const cursor = cursors.at(-1);
+ const plain = useQuery({
+ queryKey: filter ? ["entries", siteId, collection, filter] : ["entries", siteId, collection],
+ queryFn: () => api.entries.list(siteId, { collection, filter }),
+ enabled: enabled && !listing.active,
+ });
+ const paged = useQuery({
+ queryKey: ["entries", siteId, collection, "fields", key, cursor ?? ""],
+ queryFn: () =>
+ listingsApi.entries(siteId, {
+ collection,
+ filter,
+ where: listing.where,
+ orderBy: listing.orderBy,
+ limit: LISTING_PAGE_SIZE,
+ cursor,
+ }),
+ enabled: enabled && listing.active,
+ placeholderData: keepPreviousData,
+ });
+ if (!listing.active) {
+ const sorted = plain.data
+ ? [...plain.data].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
+ : undefined;
+ return { data: sorted, isPending: plain.isPending, error: plain.error, server: undefined };
+ }
+ const page = cursors.length + 1;
+ return {
+ data: paged.data?.entries,
+ isPending: paged.isPending,
+ error: paged.error,
+ server: {
+ page,
+ perPage: LISTING_PAGE_SIZE,
+ total: paged.data?.total ?? 0,
+ setPage: (to) => {
+ if (to < page) setCursors(cursors.slice(0, Math.max(0, to - 1)));
+ else if (to === page + 1 && paged.data?.nextCursor)
+ setCursors([...cursors, paged.data.nextCursor]);
+ },
+ },
+ };
+}
diff --git a/apps/studio/src/i18n/ar.ts b/apps/studio/src/i18n/ar.ts
index 4077b76b..d7a220dd 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: 65a0c65b290e3ce9
+// hash: 97f41c0c8d7f8084
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -688,6 +688,15 @@ export const messages: Messages = {
field_values_unconvertible:
"بعض القيم لا يمكن تحويلها. اسمح بمسحها أو غيّر تلك الإدخالات أولًا.",
field_operation_incomplete: "تغيير الحقل هذا لم يكتمل بعد، أو تم التراجع عنه بالفعل.",
+ invalid_query:
+ "عامل التصفية أو الترتيب هذا غير مناسب: استخدم حقول القوائم الخاصة بالمجموعة وشروطها.",
+ structured_data_invalid:
+ "إعدادات البيانات المنظمة لا تناسب حقول هذه المجموعة. راجع الخصائص المحددة.",
+ invalid_expiry: "يحتاج الانتهاء إلى حقل تاريخ أو تاريخ ووقت من هذه المجموعة.",
+ list_field_in_use:
+ "لا تزال أقسام قوائم المحتوى تصفّي أو ترتّب حسب هذا الحقل. غيّر تلك الصفحات أولًا.",
+ gates_unsupported:
+ "لا يدعم الخادم المباشر لهذا الموقع هذا بعد. وزّع الموقع أولًا، ثم أعد المحاولة.",
network: "تعذّر الوصول إلى الخادم. تحقّق من اتصالك وأعد المحاولة.",
status: "فشل الطلب (HTTP {status}).",
},
@@ -1581,6 +1590,7 @@ export const messages: Messages = {
media: "يضيف وسائط أو يغيّرها خارج تغيير مقالة",
"outside-window": "سيعمل بعد انتهاء استقلالية الوكيل",
correction: "يضيف تصحيحًا إلى مقالة منشورة",
+ expires: "يجعل إدخالًا منشورًا ينتهي أبكر بموجب إعداد الانتهاء في مجموعته",
},
noHistory: "لا قرارات بعد",
noHistoryDescription: "تُسرد هنا الطلبات الموافق عليها والمرفوضة والمستخدمة ومنتهية الصلاحية.",
@@ -1709,6 +1719,7 @@ export const messages: Messages = {
list_snapshots: "اسرد اللقطات",
diff_snapshots: "قارن اللقطات",
preview_page: "عاين صفحة",
+ preview_structured_data: "عاين البيانات المنظمة",
publish: "انشر الموقع",
rollback: "استرجع الموقع",
list_menus: "اسرد القوائم",
diff --git a/apps/studio/src/i18n/de.ts b/apps/studio/src/i18n/de.ts
index 739339e0..07605dc8 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: 0dd62c9d2969748b
+// hash: 8fbb985a874ba058
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -715,6 +715,16 @@ export const messages: Messages = {
"Einige Werte lassen sich nicht umwandeln. Erlaube das Leeren oder ändere zuerst diese Einträge.",
field_operation_incomplete:
"Diese Feldänderung ist noch nicht fertig oder wurde schon rückgängig gemacht.",
+ invalid_query:
+ "Dieser Filter oder diese Sortierung passt nicht: Verwende die Listenfelder der Sammlung und ihre Bedingungen.",
+ structured_data_invalid:
+ "Die Einstellungen für strukturierte Daten passen nicht zu den Feldern dieser Sammlung. Prüf die markierten Eigenschaften.",
+ invalid_expiry:
+ "Der Ablauf braucht ein Feld für Datum oder Datum und Uhrzeit dieser Sammlung.",
+ list_field_in_use:
+ "Inhaltslisten-Abschnitte filtern oder sortieren noch nach diesem Feld. Ändere zuerst diese Seiten.",
+ gates_unsupported:
+ "Der Live-Server dieser Website unterstützt das noch nicht. Deploye zuerst die Website und versuche es dann erneut.",
network: "Der Server ist nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.",
status: "Die Anfrage ist fehlgeschlagen (HTTP {status}).",
},
@@ -1651,6 +1661,8 @@ export const messages: Messages = {
media: "es Medien hinzufügt oder ändert, die nicht Teil einer Artikeländerung sind",
"outside-window": "es nach dem Ende der Autonomie des Agents laufen würde",
correction: "es einem veröffentlichten Artikel eine Korrektur hinzufügt",
+ expires:
+ "es einen veröffentlichten Eintrag durch den Ablauf seiner Sammlung früher enden lässt",
},
noHistory: "Noch keine Entscheidungen",
noHistoryDescription:
@@ -1790,6 +1802,7 @@ export const messages: Messages = {
list_snapshots: "Snapshots auflisten",
diff_snapshots: "Snapshots vergleichen",
preview_page: "Seitenvorschau",
+ preview_structured_data: "Vorschau strukturierter Daten",
publish: "Website veröffentlichen",
rollback: "Website zurücksetzen",
list_menus: "Menüs auflisten",
diff --git a/apps/studio/src/i18n/en.ts b/apps/studio/src/i18n/en.ts
index 0ba22906..8fce98ca 100644
--- a/apps/studio/src/i18n/en.ts
+++ b/apps/studio/src/i18n/en.ts
@@ -669,6 +669,15 @@ export const en = {
field_values_unconvertible:
"Some values can't be converted. Allow clearing them, or change those entries first.",
field_operation_incomplete: "That field change hasn't finished, or was already reverted.",
+ invalid_query:
+ "This filter or sort doesn't fit: use the collection's listing fields and their conditions.",
+ structured_data_invalid:
+ "The structured data settings don't fit this collection's fields. Check the marked properties.",
+ invalid_expiry: "Expiry needs a date or date-and-time field of this collection.",
+ list_field_in_use:
+ "Content list sections still filter or sort by this field. Change those pages first.",
+ gates_unsupported:
+ "This site's live server doesn't support this yet. Deploy the site first, then try again.",
network: "Can't reach the server. Check your connection and try again.",
status: "The request failed (HTTP {status}).",
},
@@ -1543,6 +1552,7 @@ export const en = {
media: "it adds or changes media that is not part of an article change",
"outside-window": "it would run after the agent's autonomy ends",
correction: "it adds a correction to a published article",
+ expires: "it makes a published entry end sooner under its collection's expiry",
},
noHistory: "No decisions yet",
noHistoryDescription: "Approved, denied, used and expired requests are listed here.",
@@ -1675,6 +1685,7 @@ export const en = {
list_snapshots: "List snapshots",
diff_snapshots: "Compare snapshots",
preview_page: "Preview page",
+ preview_structured_data: "Preview structured data",
publish: "Publish site",
rollback: "Roll back site",
list_menus: "List menus",
diff --git a/apps/studio/src/i18n/es.ts b/apps/studio/src/i18n/es.ts
index e2d14800..d6cef22c 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: 1296f701702e6c9a
+// hash: 8b6afc6dc8bd43f6
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -712,6 +712,16 @@ export const messages: Messages = {
field_values_unconvertible:
"Algunos valores no se pueden convertir. Permite vaciarlos o cambia antes esas entradas.",
field_operation_incomplete: "Ese cambio de campo no ha terminado o ya se revirtió.",
+ invalid_query:
+ "Este filtro u orden no encaja: usa los campos de listado de la colección y sus condiciones.",
+ structured_data_invalid:
+ "La configuración de datos estructurados no encaja con los campos de esta colección. Revisa las propiedades marcadas.",
+ invalid_expiry:
+ "La caducidad necesita un campo de fecha o de fecha y hora de esta colección.",
+ list_field_in_use:
+ "Hay secciones de lista de contenido que aún filtran u ordenan por este campo. Cambia primero esas páginas.",
+ gates_unsupported:
+ "El servidor en vivo de este sitio aún no admite esto. Despliega primero el sitio y vuelve a intentarlo.",
network: "No se puede conectar con el servidor. Comprueba tu conexión y vuelve a intentarlo.",
status: "La solicitud falló (HTTP {status}).",
},
@@ -1638,6 +1648,7 @@ export const messages: Messages = {
media: "añade o cambia archivos multimedia que no forman parte de un cambio de artículo",
"outside-window": "se ejecutaría después de que termine la autonomía del agente",
correction: "añade una corrección a un artículo publicado",
+ expires: "hace que una entrada publicada termine antes según la caducidad de su colección",
},
noHistory: "Aún no hay decisiones",
noHistoryDescription:
@@ -1773,6 +1784,7 @@ export const messages: Messages = {
list_snapshots: "Listar instantáneas",
diff_snapshots: "Comparar instantáneas",
preview_page: "Vista previa de página",
+ preview_structured_data: "Vista previa de datos estructurados",
publish: "Publicar sitio",
rollback: "Revertir sitio",
list_menus: "Listar menús",
diff --git a/apps/studio/src/i18n/fa.ts b/apps/studio/src/i18n/fa.ts
index f340ece4..a71bfb7e 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: 251281d95acc6db4
+// hash: 7186633ebe813150
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -693,6 +693,15 @@ export const messages: Messages = {
field_values_unconvertible:
"برخی مقدارها قابل تبدیل نیستند. اجازهٔ پاککردنشان را بدهید یا اول آن مطالب را تغییر دهید.",
field_operation_incomplete: "این تغییر فیلد هنوز تمام نشده یا قبلاً برگردانده شده است.",
+ invalid_query:
+ "این فیلتر یا ترتیب جور نیست: از فیلدهای فهرست مجموعه و شرطهای آنها استفاده کنید.",
+ structured_data_invalid:
+ "تنظیمات دادههای ساختاریافته با فیلدهای این مجموعه جور نیست. ویژگیهای علامتخورده را بررسی کنید.",
+ invalid_expiry: "انقضا به یک فیلد تاریخ یا تاریخ و ساعت از این مجموعه نیاز دارد.",
+ list_field_in_use:
+ "بخشهای فهرست محتوا هنوز بر اساس این فیلد فیلتر یا مرتب میکنند. ابتدا آن صفحهها را تغییر دهید.",
+ gates_unsupported:
+ "سرور زندهٔ این سایت هنوز از این پشتیبانی نمیکند. ابتدا سایت را مستقر کنید و سپس دوباره تلاش کنید.",
network: "دسترسی به سرور ممکن نیست. اتصال خود را بررسی کنید و دوباره تلاش کنید.",
status: "درخواست ناموفق بود (HTTP {status}).",
},
@@ -1613,6 +1622,7 @@ export const messages: Messages = {
media: "رسانهای را اضافه یا تغییر میدهد که بخشی از تغییر یک مقاله نیست",
"outside-window": "پس از پایان خودمختاری عامل اجرا میشد",
correction: "اصلاحیهای به یک مقالهٔ منتشرشده اضافه میکند",
+ expires: "باعث میشود یک ورودی منتشرشده طبق انقضای مجموعهاش زودتر پایان یابد",
},
noHistory: "هنوز تصمیمی گرفته نشده",
noHistoryDescription: "درخواستهای تأییدشده، ردشده، استفادهشده و منقضیشده اینجا فهرست میشوند.",
@@ -1748,6 +1758,7 @@ export const messages: Messages = {
list_snapshots: "فهرست اسنپشاتها",
diff_snapshots: "مقایسهٔ اسنپشاتها",
preview_page: "پیشنمایش صفحه",
+ preview_structured_data: "پیشنمایش دادههای ساختاریافته",
publish: "انتشار سایت",
rollback: "بازگردانی سایت",
list_menus: "فهرست منوها",
diff --git a/apps/studio/src/i18n/fr.ts b/apps/studio/src/i18n/fr.ts
index b7bb36bd..e5524d27 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: 7df6cf3bd8aaca32
+// hash: ebbb2c53717d8197
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -722,6 +722,16 @@ export const messages: Messages = {
field_values_unconvertible:
"Certaines valeurs ne peuvent pas être converties. Autorise leur effacement ou modifie d’abord ces entrées.",
field_operation_incomplete: "Ce changement de champ n’est pas terminé ou a déjà été annulé.",
+ invalid_query:
+ "Ce filtre ou ce tri ne convient pas : utilisez les champs de liste de la collection et leurs conditions.",
+ structured_data_invalid:
+ "Les paramètres de données structurées ne correspondent pas aux champs de cette collection. Vérifiez les propriétés signalées.",
+ invalid_expiry:
+ "L’expiration nécessite un champ de date ou de date et heure de cette collection.",
+ list_field_in_use:
+ "Des sections de liste de contenus filtrent ou trient encore selon ce champ. Modifiez d’abord ces pages.",
+ gates_unsupported:
+ "Le serveur en ligne de ce site ne prend pas encore cela en charge. Déployez d’abord le site, puis réessayez.",
network: "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez.",
status: "La requête a échoué (HTTP {status}).",
},
@@ -1665,6 +1675,7 @@ export const messages: Messages = {
"elle ajoute ou modifie des médias qui ne font pas partie d’une modification d’article",
"outside-window": "elle s’exécuterait après la fin de l’autonomie de l’agent",
correction: "elle ajoute une correction à un article publié",
+ expires: "elle fait expirer plus tôt une entrée publiée selon l’expiration de sa collection",
},
noHistory: "Pas encore de décisions",
noHistoryDescription:
@@ -1804,6 +1815,7 @@ export const messages: Messages = {
list_snapshots: "Lister les instantanés",
diff_snapshots: "Comparer des instantanés",
preview_page: "Prévisualiser une page",
+ preview_structured_data: "Prévisualiser les données structurées",
publish: "Publier le site",
rollback: "Restaurer le site",
list_menus: "Lister les menus",
diff --git a/apps/studio/src/i18n/generated/params.ts b/apps/studio/src/i18n/generated/params.ts
index 4a7456e9..d03143c9 100644
--- a/apps/studio/src/i18n/generated/params.ts
+++ b/apps/studio/src/i18n/generated/params.ts
@@ -361,6 +361,32 @@ export interface MessageParams {
"invites.reveal.warning": { role: string | number };
"languages.remove": { language: string | number };
"languages.useTag": { tag: string | number };
+ "listings.contentList.asc": { kind: "time" | "number" };
+ "listings.contentList.desc": { kind: "time" | "number" };
+ "listings.contentList.ops.gt": { kind: "time" | "number" };
+ "listings.contentList.ops.gte": { kind: "time" | "number" };
+ "listings.contentList.ops.lt": { kind: "time" | "number" };
+ "listings.contentList.ops.lte": { kind: "time" | "number" };
+ "listings.createPage.exists": { path: string | number };
+ "listings.expiry.past": { count: number };
+ "listings.filters.any": { label: string | number };
+ "listings.filters.is": { label: string | number; value: string | number };
+ "listings.filters.no": { label: string | number };
+ "listings.filters.past": { label: string | number };
+ "listings.filters.set": { label: string | number };
+ "listings.filters.sortBy": { label: string | number };
+ "listings.filters.unset": { label: string | number };
+ "listings.filters.upcoming": { label: string | number };
+ "listings.filters.yes": { label: string | number };
+ "listings.listFields.moveDown": { label: string | number };
+ "listings.listFields.moveUp": { label: string | number };
+ "listings.listFields.remove": { label: string | number };
+ "listings.structuredData.constant": { path: string | number };
+ "listings.structuredData.issues.required_value_missing": { path: string | number };
+ "listings.structuredData.sources.default": { source: string | number };
+ "listings.structuredData.sources.entry": { property: string | number };
+ "listings.structuredData.sources.field": { label: string | number };
+ "listings.structuredData.sources.site": { property: string | number };
"media.badges.agentHint": { name: string | number };
"media.danger.inUse": { count: string | number };
"media.detail.altHintLocale": { locale: string | number };
diff --git a/apps/studio/src/i18n/he.ts b/apps/studio/src/i18n/he.ts
index d77109db..b1458d30 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: a20b900a425b6521
+// hash: b7872f5218e325f5
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -686,6 +686,13 @@ export const messages: Messages = {
field_values_unconvertible:
"חלק מהערכים לא ניתנים להמרה. אפשרו לנקות אותם או שנו קודם את הרשומות האלה.",
field_operation_incomplete: "שינוי השדה הזה עוד לא הסתיים, או שכבר בוטל.",
+ invalid_query: "הסינון או המיון הזה לא מתאים: השתמשו בשדות הרשימה של האוסף ובתנאים שלהם.",
+ structured_data_invalid:
+ "הגדרות הנתונים המובנים לא מתאימות לשדות של האוסף הזה. בדקו את המאפיינים המסומנים.",
+ invalid_expiry: "תפוגה צריכה שדה תאריך או תאריך ושעה של האוסף הזה.",
+ list_field_in_use:
+ "קטעי רשימת תוכן עדיין מסננים או ממיינים לפי השדה הזה. שנו קודם את העמודים האלה.",
+ gates_unsupported: "השרת החי של האתר הזה עדיין לא תומך בזה. פרסו קודם את האתר, ואז נסו שוב.",
network: "אין גישה לשרת. יש לבדוק את החיבור ולנסות שוב.",
status: "הבקשה נכשלה (HTTP {status}).",
},
@@ -1581,6 +1588,7 @@ export const messages: Messages = {
media: "הוא מוסיף או משנה מדיה שאינה חלק משינוי במאמר",
"outside-window": "הוא ירוץ אחרי שהאוטונומיה של הסוכן מסתיימת",
correction: "הוא מוסיף תיקון למאמר שפורסם",
+ expires: "הוא גורם לרשומה שפורסמה להסתיים מוקדם יותר לפי התפוגה של האוסף שלה",
},
noHistory: "אין עדיין החלטות",
noHistoryDescription: "בקשות שאושרו, נדחו, נוצלו או פג תוקפן מופיעות כאן.",
@@ -1711,6 +1719,7 @@ export const messages: Messages = {
list_snapshots: "הצגת תמונות מצב",
diff_snapshots: "השוואת תמונות מצב",
preview_page: "תצוגה מקדימה של עמוד",
+ preview_structured_data: "תצוגה מקדימה של נתונים מובנים",
publish: "פרסום האתר",
rollback: "שחזור האתר",
list_menus: "הצגת תפריטים",
diff --git a/apps/studio/src/i18n/hi.ts b/apps/studio/src/i18n/hi.ts
index 07ec456a..ee9d45f3 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: 344bed5e1f4681c6
+// hash: 6e267f40b5a2ddb3
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -682,6 +682,13 @@ export const messages: Messages = {
field_values_unconvertible:
"कुछ मान बदले नहीं जा सकते। उन्हें मिटाने की अनुमति दें या पहले उन एंट्री को बदलें।",
field_operation_incomplete: "वह फ़ील्ड बदलाव अभी पूरा नहीं हुआ है, या पहले ही वापस लिया जा चुका है।",
+ invalid_query: "यह फ़िल्टर या क्रम फ़िट नहीं होता: संग्रह के लिस्टिंग फ़ील्ड और उनकी शर्तें इस्तेमाल करें।",
+ structured_data_invalid: "स्ट्रक्चर्ड डेटा सेटिंग इस संग्रह के फ़ील्ड से मेल नहीं खातीं। चिह्नित प्रॉपर्टी जाँचें।",
+ invalid_expiry: "समाप्ति के लिए इस संग्रह का तारीख़ या तारीख़ और समय फ़ील्ड चाहिए।",
+ list_field_in_use:
+ "सामग्री सूची सेक्शन अब भी इस फ़ील्ड से फ़िल्टर या क्रमबद्ध करते हैं। पहले उन पेजों को बदलें।",
+ gates_unsupported:
+ "इस साइट का लाइव सर्वर अभी इसे सपोर्ट नहीं करता। पहले साइट डिप्लॉय करें, फिर दोबारा कोशिश करें।",
network: "सर्वर तक नहीं पहुँच पा रहे। अपना कनेक्शन जाँचें और फिर से कोशिश करें।",
status: "अनुरोध विफल रहा (HTTP {status})।",
},
@@ -1575,6 +1582,7 @@ export const messages: Messages = {
media: "यह ऐसा मीडिया जोड़ता या बदलता है जो किसी लेख बदलाव का हिस्सा नहीं है",
"outside-window": "यह एजेंट की स्वायत्तता खत्म होने के बाद चलेगा",
correction: "यह किसी प्रकाशित लेख में एक सुधार जोड़ता है",
+ expires: "यह अपने संग्रह की समाप्ति के तहत एक प्रकाशित एंट्री को जल्दी ख़त्म करता है",
},
noHistory: "अभी कोई निर्णय नहीं",
noHistoryDescription: "स्वीकृत, अस्वीकृत, उपयोग किए गए और समाप्त अनुरोध यहाँ सूचीबद्ध हैं।",
@@ -1705,6 +1713,7 @@ export const messages: Messages = {
list_snapshots: "स्नैपशॉट की सूची",
diff_snapshots: "स्नैपशॉट की तुलना करें",
preview_page: "पेज का प्रीव्यू",
+ preview_structured_data: "स्ट्रक्चर्ड डेटा का प्रीव्यू",
publish: "साइट प्रकाशित करें",
rollback: "साइट रोलबैक करें",
list_menus: "मेनू की सूची",
diff --git a/apps/studio/src/i18n/id.ts b/apps/studio/src/i18n/id.ts
index 5fefac42..849c6847 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: bca1708c86583942
+// hash: b4856ded9f2c2666
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -704,6 +704,16 @@ export const messages: Messages = {
field_values_unconvertible:
"Beberapa nilai tidak bisa dikonversi. Izinkan untuk mengosongkannya atau ubah entri itu dulu.",
field_operation_incomplete: "Perubahan kolom itu belum selesai atau sudah dibatalkan.",
+ invalid_query:
+ "Filter atau urutan ini tidak cocok: gunakan kolom daftar koleksi dan kondisinya.",
+ structured_data_invalid:
+ "Pengaturan data terstruktur tidak cocok dengan kolom koleksi ini. Periksa properti yang ditandai.",
+ invalid_expiry:
+ "Kedaluwarsa memerlukan kolom tanggal atau tanggal dan waktu dari koleksi ini.",
+ list_field_in_use:
+ "Bagian daftar konten masih memfilter atau mengurutkan berdasarkan kolom ini. Ubah halaman-halaman itu terlebih dahulu.",
+ gates_unsupported:
+ "Server live situs ini belum mendukung ini. Deploy situs terlebih dahulu, lalu coba lagi.",
network: "Server tidak dapat dijangkau. Periksa koneksi Anda dan coba lagi.",
status: "Permintaan gagal (HTTP {status}).",
},
@@ -1633,6 +1643,7 @@ export const messages: Messages = {
media: "menambah atau mengubah media yang bukan bagian dari perubahan artikel",
"outside-window": "akan berjalan setelah otonomi agen berakhir",
correction: "menambahkan koreksi pada artikel yang diterbitkan",
+ expires: "membuat entri terbit berakhir lebih cepat menurut kedaluwarsa koleksinya",
},
noHistory: "Belum ada keputusan",
noHistoryDescription:
@@ -1771,6 +1782,7 @@ export const messages: Messages = {
list_snapshots: "Daftar snapshot",
diff_snapshots: "Bandingkan snapshot",
preview_page: "Pratinjau halaman",
+ preview_structured_data: "Pratinjau data terstruktur",
publish: "Terbitkan situs",
rollback: "Kembalikan situs",
list_menus: "Daftar menu",
diff --git a/apps/studio/src/i18n/it.ts b/apps/studio/src/i18n/it.ts
index b487744d..4fa8696b 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: 5f29f39341970b6b
+// hash: 1ea0c4faf23f67cc
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -705,6 +705,15 @@ export const messages: Messages = {
field_values_unconvertible:
"Alcuni valori non si possono convertire. Consenti di svuotarli o modifica prima quelle voci.",
field_operation_incomplete: "Questa modifica di campo non è finita o è già stata annullata.",
+ invalid_query:
+ "Questo filtro o ordinamento non è valido: usa i campi di elenco della raccolta e le loro condizioni.",
+ structured_data_invalid:
+ "Le impostazioni dei dati strutturati non corrispondono ai campi di questa raccolta. Controlla le proprietà segnalate.",
+ invalid_expiry: "La scadenza richiede un campo data o data e ora di questa raccolta.",
+ list_field_in_use:
+ "Alcune sezioni elenco di contenuti filtrano o ordinano ancora in base a questo campo. Modifica prima quelle pagine.",
+ gates_unsupported:
+ "Il server live di questo sito non lo supporta ancora. Distribuisci prima il sito, poi riprova.",
network: "Impossibile raggiungere il server. Controlla la connessione e riprova.",
status: "La richiesta non è riuscita (HTTP {status}).",
},
@@ -1633,6 +1642,7 @@ export const messages: Messages = {
media: "aggiunge o modifica media che non fanno parte di una modifica a un articolo",
"outside-window": "verrebbe eseguito dopo la fine dell’autonomia dell’agente",
correction: "aggiunge una correzione a un articolo pubblicato",
+ expires: "fa terminare prima una voce pubblicata secondo la scadenza della sua raccolta",
},
noHistory: "Ancora nessuna decisione",
noHistoryDescription: "Qui sono elencate le richieste approvate, rifiutate, usate e scadute.",
@@ -1769,6 +1779,7 @@ export const messages: Messages = {
list_snapshots: "Elenca snapshot",
diff_snapshots: "Confronta snapshot",
preview_page: "Anteprima pagina",
+ preview_structured_data: "Anteprima dei dati strutturati",
publish: "Pubblica sito",
rollback: "Ripristina sito",
list_menus: "Elenca menu",
diff --git a/apps/studio/src/i18n/ja.ts b/apps/studio/src/i18n/ja.ts
index c83b007b..2c14ff14 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: 0dd62e368277a5bb
+// hash: b3f228407b0fa9ba
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -703,6 +703,15 @@ export const messages: Messages = {
"変換できない値があります。消去を許可するか、先にそのエントリーを変更してください。",
field_operation_incomplete:
"そのフィールド変更はまだ終わっていないか、すでに元に戻されています。",
+ invalid_query:
+ "この絞り込みまたは並べ替えは使えません。コレクションのリスト用フィールドとその条件を使ってください。",
+ structured_data_invalid:
+ "構造化データの設定がこのコレクションのフィールドと合いません。マークされたプロパティを確認してください。",
+ invalid_expiry: "有効期限には、このコレクションの日付または日時のフィールドが必要です。",
+ list_field_in_use:
+ "このフィールドでまだ絞り込みや並べ替えをしているコンテンツリストセクションがあります。先にそれらのページを変更してください。",
+ gates_unsupported:
+ "このサイトのライブサーバーはまだこれに対応していません。先にサイトをデプロイしてから、もう一度お試しください。",
network: "サーバーに接続できません。接続を確認して再試行してください。",
status: "リクエストに失敗しました(HTTP {status})。",
},
@@ -1624,6 +1633,7 @@ export const messages: Messages = {
media: "記事の変更に含まれないメディアを追加または変更する",
"outside-window": "エージェントの自律期間の終了後に実行される",
correction: "公開済みの記事に訂正を追加する",
+ expires: "コレクションの有効期限により、公開済みエントリーの終了が早まる",
},
noHistory: "まだ判断はありません",
noHistoryDescription: "承認、拒否、使用済み、期限切れのリクエストがここに表示されます。",
@@ -1756,6 +1766,7 @@ export const messages: Messages = {
list_snapshots: "スナップショットを一覧",
diff_snapshots: "スナップショットを比較",
preview_page: "ページをプレビュー",
+ preview_structured_data: "構造化データをプレビュー",
publish: "サイトを公開",
rollback: "サイトをロールバック",
list_menus: "メニューを一覧",
diff --git a/apps/studio/src/i18n/ko.ts b/apps/studio/src/i18n/ko.ts
index 5e545599..6c4430cd 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: 5aef6e38556d777e
+// hash: f8aaccaf189e196d
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -685,6 +685,14 @@ export const messages: Messages = {
field_values_unconvertible:
"변환할 수 없는 값이 있습니다. 지우기를 허용하거나 먼저 해당 항목을 바꾸세요.",
field_operation_incomplete: "그 필드 변경은 아직 끝나지 않았거나 이미 되돌려졌습니다.",
+ invalid_query: "이 필터나 정렬은 맞지 않아요. 컬렉션의 목록 필드와 그 조건을 사용하세요.",
+ structured_data_invalid:
+ "구조화된 데이터 설정이 이 컬렉션의 필드와 맞지 않아요. 표시된 속성을 확인하세요.",
+ invalid_expiry: "만료에는 이 컬렉션의 날짜 또는 날짜와 시간 필드가 필요해요.",
+ list_field_in_use:
+ "이 필드로 아직 필터링하거나 정렬하는 콘텐츠 목록 섹션이 있어요. 먼저 그 페이지들을 변경하세요.",
+ gates_unsupported:
+ "이 사이트의 라이브 서버는 아직 이 기능을 지원하지 않아요. 먼저 사이트를 배포한 다음 다시 시도하세요.",
network: "서버에 연결할 수 없어요. 연결을 확인하고 다시 시도하세요.",
status: "요청이 실패했어요(HTTP {status}).",
},
@@ -1586,6 +1594,7 @@ export const messages: Messages = {
media: "글 변경에 속하지 않는 미디어를 추가하거나 변경해요",
"outside-window": "에이전트의 자율성이 끝난 뒤에 실행돼요",
correction: "게시된 글에 정정을 추가해요",
+ expires: "컬렉션의 만료 설정에 따라 게시된 항목이 더 일찍 종료돼요",
},
noHistory: "아직 결정이 없어요",
noHistoryDescription: "승인, 거부, 사용, 만료된 요청이 여기에 나열돼요.",
@@ -1717,6 +1726,7 @@ export const messages: Messages = {
list_snapshots: "스냅샷 목록",
diff_snapshots: "스냅샷 비교",
preview_page: "페이지 미리보기",
+ preview_structured_data: "구조화된 데이터 미리보기",
publish: "사이트 게시",
rollback: "사이트 롤백",
list_menus: "메뉴 목록",
diff --git a/apps/studio/src/i18n/lazy/listings.ar.ts b/apps/studio/src/i18n/lazy/listings.ar.ts
new file mode 100644
index 00000000..9efaed88
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.ar.ts
@@ -0,0 +1,175 @@
+// Generated by `pnpm i18n:import` (ADR-0034): ar `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: 0091155c70e9111a
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "حقول القوائم",
+ description:
+ "حتى 4 حقول يمكن للقوائم التصفية والترتيب حسبها: أعمدة في قائمة هذه المجموعة، وشروط في أقسام قوائم المحتوى، واستعلامات الوكلاء.",
+ add: "أضف حقل قائمة",
+ choose: "اختر حقلًا",
+ empty: "لا توجد حقول قوائم بعد",
+ emptyDescription: "اختر حقولًا لعرضها كأعمدة ولتصفية القوائم حسبها.",
+ noFields: "أضف حقولًا إلى هذه المجموعة أولًا؛ فحقول القوائم تُختار منها.",
+ full: "هذه 4 حقول، وهو أقصى ما يمكن لمجموعة أن تدرجه.",
+ moveUp: "انقل {label} لأعلى",
+ moveDown: "انقل {label} لأسفل",
+ remove: "أزل {label}",
+ sortable: "قابل للترتيب",
+ save: "احفظ حقول القوائم",
+ saved: "تم حفظ حقول القوائم",
+ inUse: "لا تزال أقسام قوائم المحتوى تصفّي أو ترتّب حسب حقل أزلته. غيّر هذه الصفحات أولًا:",
+ },
+ expiry: {
+ title: "الانتهاء",
+ description:
+ "يمكن أن تنتهي الإدخالات في تاريخ خاص بها، مثل اليوم الذي تنتهي فيه فعالية. لا يُحذف شيء ولا يُلغى نشره: يتحقق الموقع من التاريخ في كل مرة يعرض فيها إدخالًا.",
+ enabled: "أنهِ الإدخالات في تاريخ",
+ enabledHint: "أوقفه لإبقاء كل إدخال حتى يلغي أحدهم نشره.",
+ field: "حقل التاريخ",
+ fieldHint:
+ "ينتهي التاريخ عند منتصف الليل التالي له بتوقيت الموقع؛ أما التاريخ والوقت فينتهي في تلك اللحظة.",
+ noDateFields:
+ "لا تحتوي هذه المجموعة على حقل تاريخ أو تاريخ ووقت. أضف واحدًا لاستخدام الانتهاء.",
+ action: "عندما ينتهي إدخال",
+ unlist: "الخروج من القوائم",
+ unlistDescription:
+ "تبقى صفحته على عنوانها، لكنه يخرج من القوائم وأقسام قوائم المحتوى والخلاصات وخريطة الموقع والبحث. مناسب للفعاليات والوظائف.",
+ hide: "إخفاء الصفحة",
+ hideDescription:
+ "تجيب صفحته بـ«غير موجود»، ويخرج أيضًا من كل قائمة وخلاصة ومن البحث. مناسب للعروض التي يجب ألا تبقى متاحة.",
+ grace: "مهلة السماح بالدقائق",
+ graceHint: "انتظر هذه المدة بعد التاريخ قبل أن ينتهي الإدخال. حتى 10,080 (أسبوع واحد).",
+ past: "{count, plural, =0 {لم يتجاوزه أي إدخال منشور بعد.} zero {لم يتجاوزه أي إدخال منشور بعد.} one {تجاوزه إدخال منشور واحد بالفعل.} two {تجاوزه إدخالان منشوران بالفعل.} few {تجاوزته # إدخالات منشورة بالفعل.} many {تجاوزه # إدخالًا منشورًا بالفعل.} other {تجاوزه # إدخال منشور بالفعل.}}",
+ save: "احفظ الانتهاء",
+ saved: "تم حفظ الانتهاء",
+ gatesUnsupported:
+ "لا يطبّق الخادم المباشر لهذا الموقع الانتهاء بعد. وزّع الموقع أولًا، ثم احفظ الانتهاء مرة أخرى.",
+ },
+ structuredData: {
+ title: "البيانات المنظمة",
+ description:
+ "صِف كل إدخال لمحركات البحث كفعالية أو وظيفة أو منتج أو وصفة، انطلاقًا من حقوله. من دون نوع، تُوصف الإدخالات كتدوينات.",
+ type: "النوع",
+ none: "لا شيء (تدوينة)",
+ types: {
+ Event: "فعالية",
+ JobPosting: "إعلان وظيفة",
+ Product: "منتج",
+ Recipe: "وصفة",
+ },
+ property: "الخاصية",
+ source: "القيمة من",
+ required: "مطلوبة",
+ oneOf: "واحدة من هذه",
+ sources: {
+ default: "الافتراضي: {source}",
+ unmapped: "غير مستخدمة",
+ constant: "قيمة ثابتة",
+ field: "الحقل: {label}",
+ entry: "الإدخال: {property}",
+ site: "الموقع: {property}",
+ },
+ entryProps: {
+ title: "العنوان",
+ summary: "الملخص",
+ body: "النص",
+ coverImage: "صورة الغلاف",
+ url: "الرابط",
+ publishedAt: "تاريخ النشر",
+ updatedAt: "تاريخ التحديث",
+ },
+ siteProps: {
+ name: "اسم الموقع",
+ url: "عنوان الموقع",
+ logo: "شعار الموقع",
+ },
+ constant: "القيمة الثابتة لـ{path}",
+ currencyHint: "رمز عملة من ثلاثة أحرف مثل EUR.",
+ issues: {
+ unknown_path: "لا يملك هذا النوع خاصية كهذه.",
+ unknown_field: "هذا الحقل لم يعد موجودًا.",
+ source_mismatch: "هذه القيمة لا تناسب الخاصية.",
+ vocabulary:
+ "بعض خيارات هذا الحقل لا تعني شيئًا لمحركات البحث هنا؛ والإدخالات التي تستخدمها تُسقط الخاصية.",
+ currency: "استخدم رمز عملة من ثلاثة أحرف مثل EUR.",
+ required_missing: "مطلوب: اختر مصدر قيمتها.",
+ constant_required: "تأخذ الخصائص المطلوبة قيمتها من كل إدخال، لا من قيمة ثابتة.",
+ required_value_missing:
+ "لا يملك هذا الإدخال قيمة لخاصية مطلوبة ({path})، لذا تحتفظ صفحته بوصف التدوينة.",
+ too_large: "وصف هذا الإدخال كبير جدًا، لذا تحتفظ صفحته بوصف التدوينة.",
+ no_preset:
+ "لا يوجد لهذه المجموعة نوع بيانات منظمة بعد، لذا تصف صفحاتها الإدخالات كتدوينات.",
+ },
+ suggestRequired:
+ "نصيحة: علّم الحقول المرتبطة بخصائص مطلوبة بـ«مطلوب للنشر»، حتى لا يُنشر أي إدخال من دونها.",
+ themeReminder:
+ "تتوقع محركات البحث أن تكون هذه القيم ظاهرة في الصفحة، لذا تحقق من أن سمتك تعرض الحقول المرتبطة.",
+ preview: "معاينة",
+ previewEntry: "الإدخال",
+ previewChoose: "اختر إدخالًا",
+ previewDescription: "ما تُخرجه صفحة هذا الإدخال بالإعدادات المحفوظة.",
+ previewEmpty: "لا توجد إدخالات في هذه المجموعة بعد.",
+ noDocument: "لا تُخرج صفحة هذا الإدخال بيانات منظمة من هذا النوع.",
+ richResults: "اختبر في Rich Results Test من Google",
+ save: "احفظ البيانات المنظمة",
+ saved: "تم حفظ البيانات المنظمة",
+ },
+ createPage: {
+ action: "أنشئ صفحة القائمة",
+ open: "افتح صفحة القائمة",
+ created: "تم إنشاء صفحة القائمة",
+ exists: "توجد صفحة بالفعل في {path}.",
+ },
+ filters: {
+ label: "صفِّ حسب الحقول",
+ any: "{label}: أي قيمة",
+ upcoming: "{label}: قادمة",
+ past: "{label}: سابقة",
+ set: "{label}: له قيمة",
+ unset: "{label}: فارغ",
+ yes: "{label}: نعم",
+ no: "{label}: لا",
+ is: "{label}: {value}",
+ sortBy: "رتّب حسب {label}",
+ clear: "امسح عوامل التصفية",
+ empty: "لا توجد إدخالات تطابق عوامل التصفية هذه",
+ emptyDescription: "غيّر عوامل التصفية أو امسحها لرؤية المزيد.",
+ },
+ contentList: {
+ where: "فقط الإدخالات التي فيها",
+ add: "أضف شرطًا",
+ field: "الحقل",
+ op: "الشرط",
+ value: "القيمة",
+ remove: "أزل الشرط",
+ ops: {
+ eq: "يساوي",
+ ne: "لا يساوي",
+ lt: "{kind, select, time {قبل} number {أقل من} other {أقل من}}",
+ lte: "{kind, select, time {في أو قبل} number {على الأكثر} other {على الأكثر}}",
+ gt: "{kind, select, time {بعد} number {أكثر من} other {أكثر من}}",
+ gte: "{kind, select, time {في أو بعد} number {على الأقل} other {على الأقل}}",
+ in: "واحد من",
+ set: "له قيمة",
+ unset: "فارغ",
+ },
+ now: "الآن",
+ aDate: "تاريخ",
+ dateHint: "تاريخ مثل 2026-11-05.",
+ instantHint: "لحظة مع فارق توقيتها، مثل 2026-11-05T19:30:00+03:00.",
+ yes: "نعم",
+ no: "لا",
+ orderBy: "رتّب حسب حقل",
+ orderNone: "ترتيب القائمة أعلاه",
+ direction: "الاتجاه",
+ asc: "{kind, select, time {الأقدم أولًا} number {الأصغر أولًا} other {الأصغر أولًا}}",
+ desc: "{kind, select, time {الأحدث أولًا} number {الأكبر أولًا} other {الأكبر أولًا}}",
+ noListFields: "لا تحتوي هذه المجموعة على حقول قوائم للتصفية أو الترتيب حسبها.",
+ settingsLink: "اختر حقول القوائم",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.de.ts b/apps/studio/src/i18n/lazy/listings.de.ts
new file mode 100644
index 00000000..3807fd44
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.de.ts
@@ -0,0 +1,181 @@
+// Generated by `pnpm i18n:import` (ADR-0034): de `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: d026945593c61937
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "Listenfelder",
+ description:
+ "Bis zu 4 Felder, nach denen Listen filtern und sortieren können: Spalten in der Liste dieser Sammlung, Bedingungen in Inhaltslisten-Abschnitten und Abfragen von Agents.",
+ add: "Listenfeld hinzufügen",
+ choose: "Feld wählen",
+ empty: "Noch keine Listenfelder",
+ emptyDescription: "Wähl Felder, um sie als Spalten zu zeigen und Listen danach zu filtern.",
+ noFields: "Füg dieser Sammlung zuerst Felder hinzu; Listenfelder werden aus ihnen gewählt.",
+ full: "Das sind 4 Felder, mehr kann eine Sammlung nicht auflisten.",
+ moveUp: "{label} nach oben verschieben",
+ moveDown: "{label} nach unten verschieben",
+ remove: "{label} entfernen",
+ sortable: "Sortierbar",
+ save: "Listenfelder speichern",
+ saved: "Listenfelder gespeichert",
+ inUse:
+ "Inhaltslisten-Abschnitte filtern oder sortieren noch nach einem Feld, das du entfernt hast. Ändere zuerst diese Seiten:",
+ },
+ expiry: {
+ title: "Ablauf",
+ description:
+ "Einträge können an einem eigenen Datum enden, etwa an dem Tag, an dem eine Veranstaltung vorbei ist. Nichts wird gelöscht, keine Veröffentlichung zurückgenommen: Die Website prüft das Datum jedes Mal, wenn sie einen Eintrag zeigt.",
+ enabled: "Einträge an einem Datum enden lassen",
+ enabledHint:
+ "Ausschalten, um jeden Eintrag zu behalten, bis jemand seine Veröffentlichung zurücknimmt.",
+ field: "Datumsfeld",
+ fieldHint:
+ "Ein Datum endet um Mitternacht danach, in der Zeitzone der Website; Datum und Uhrzeit enden genau zu diesem Zeitpunkt.",
+ noDateFields:
+ "Diese Sammlung hat kein Feld für Datum oder Datum und Uhrzeit. Füg eines hinzu, um den Ablauf zu nutzen.",
+ action: "Wenn ein Eintrag abgelaufen ist",
+ unlist: "Aus Listen nehmen",
+ unlistDescription:
+ "Seine Seite bleibt unter ihrer Adresse, verschwindet aber aus Listen, Inhaltslisten-Abschnitten, Feeds, der Sitemap und der Suche. Gut für Veranstaltungen und Stellen.",
+ hide: "Seite ausblenden",
+ hideDescription:
+ "Seine Seite antwortet mit „nicht gefunden“ und verschwindet auch aus allen Listen, Feeds und der Suche. Gut für Angebote, die nicht erreichbar bleiben dürfen.",
+ grace: "Kulanzzeit in Minuten",
+ graceHint:
+ "So lange nach dem Datum warten, bevor der Eintrag endet. Bis zu 10.080 (eine Woche).",
+ past: "{count, plural, =0 {Noch keine veröffentlichten Einträge sind darüber hinaus.} one {# veröffentlichter Eintrag ist schon darüber hinaus.} other {# veröffentlichte Einträge sind schon darüber hinaus.}}",
+ save: "Ablauf speichern",
+ saved: "Ablauf gespeichert",
+ gatesUnsupported:
+ "Der Live-Server dieser Website wendet den Ablauf noch nicht an. Deploye zuerst die Website und speichere den Ablauf dann erneut.",
+ },
+ structuredData: {
+ title: "Strukturierte Daten",
+ description:
+ "Beschreib jeden Eintrag für Suchmaschinen aus seinen Feldern als Veranstaltung, Stelle, Produkt oder Rezept. Ohne Typ werden Einträge als Blogbeiträge beschrieben.",
+ type: "Typ",
+ none: "Keiner (Blogbeitrag)",
+ types: {
+ Event: "Veranstaltung",
+ JobPosting: "Stellenanzeige",
+ Product: "Produkt",
+ Recipe: "Rezept",
+ },
+ property: "Eigenschaft",
+ source: "Wert aus",
+ required: "Erforderlich",
+ oneOf: "Eines davon",
+ sources: {
+ default: "Standard: {source}",
+ unmapped: "Nicht verwendet",
+ constant: "Ein fester Wert",
+ field: "Feld: {label}",
+ entry: "Eintrag: {property}",
+ site: "Website: {property}",
+ },
+ entryProps: {
+ title: "Titel",
+ summary: "Zusammenfassung",
+ body: "Text",
+ coverImage: "Titelbild",
+ url: "Adresse",
+ publishedAt: "Veröffentlichungsdatum",
+ updatedAt: "Aktualisierungsdatum",
+ },
+ siteProps: {
+ name: "Name der Website",
+ url: "Adresse der Website",
+ logo: "Logo der Website",
+ },
+ constant: "Fester Wert von {path}",
+ currencyHint: "Ein dreistelliger Währungscode wie EUR.",
+ issues: {
+ unknown_path: "Dieser Typ hat keine solche Eigenschaft.",
+ unknown_field: "Dieses Feld gibt es nicht mehr.",
+ source_mismatch: "Dieser Wert passt nicht zur Eigenschaft.",
+ vocabulary:
+ "Manche Optionen dieses Felds bedeuten Suchmaschinen hier nichts; Einträge, die sie nutzen, lassen die Eigenschaft weg.",
+ currency: "Verwende einen dreistelligen Währungscode wie EUR.",
+ required_missing: "Nötig: Wähl, woher der Wert kommt.",
+ constant_required:
+ "Erforderliche Eigenschaften nehmen ihren Wert aus jedem Eintrag, nicht aus einem festen Wert.",
+ required_value_missing:
+ "Dieser Eintrag hat keinen Wert für eine erforderliche Eigenschaft ({path}), daher behält seine Seite die Beschreibung als Blogbeitrag.",
+ too_large:
+ "Die Beschreibung dieses Eintrags ist zu groß, daher behält seine Seite die Beschreibung als Blogbeitrag.",
+ no_preset:
+ "Diese Sammlung hat noch keinen Typ für strukturierte Daten, daher beschreiben ihre Seiten Einträge als Blogbeiträge.",
+ },
+ suggestRequired:
+ "Tipp: Markier die Felder, die erforderlichen Eigenschaften zugeordnet sind, als „Zum Veröffentlichen nötig“, damit kein Eintrag ohne sie erscheint.",
+ themeReminder:
+ "Suchmaschinen erwarten, dass diese Werte auf der Seite sichtbar sind. Prüf also, ob dein Theme die zugeordneten Felder zeigt.",
+ preview: "Vorschau",
+ previewEntry: "Eintrag",
+ previewChoose: "Eintrag wählen",
+ previewDescription:
+ "Was die Seite dieses Eintrags mit den gespeicherten Einstellungen ausgibt.",
+ previewEmpty: "Noch keine Einträge in dieser Sammlung.",
+ noDocument: "Die Seite dieses Eintrags gibt keine strukturierten Daten dieses Typs aus.",
+ richResults: "Im Rich Results Test von Google prüfen",
+ save: "Strukturierte Daten speichern",
+ saved: "Strukturierte Daten gespeichert",
+ },
+ createPage: {
+ action: "Listenseite erstellen",
+ open: "Listenseite öffnen",
+ created: "Listenseite erstellt",
+ exists: "Unter {path} gibt es schon eine Seite.",
+ },
+ filters: {
+ label: "Nach Feldern filtern",
+ any: "{label}: beliebig",
+ upcoming: "{label}: bevorstehend",
+ past: "{label}: vergangen",
+ set: "{label}: hat einen Wert",
+ unset: "{label}: leer",
+ yes: "{label}: ja",
+ no: "{label}: nein",
+ is: "{label}: {value}",
+ sortBy: "Nach {label} sortieren",
+ clear: "Filter zurücksetzen",
+ empty: "Keine Einträge passen zu diesen Filtern",
+ emptyDescription: "Ändere oder setz die Filter zurück, um mehr zu sehen.",
+ },
+ contentList: {
+ where: "Nur Einträge, bei denen",
+ add: "Bedingung hinzufügen",
+ field: "Feld",
+ op: "Bedingung",
+ value: "Wert",
+ remove: "Bedingung entfernen",
+ ops: {
+ eq: "ist",
+ ne: "ist nicht",
+ lt: "{kind, select, time {liegt vor} number {ist kleiner als} other {ist kleiner als}}",
+ lte: "{kind, select, time {liegt am oder vor} number {ist höchstens} other {ist höchstens}}",
+ gt: "{kind, select, time {liegt nach} number {ist größer als} other {ist größer als}}",
+ gte: "{kind, select, time {liegt am oder nach} number {ist mindestens} other {ist mindestens}}",
+ in: "ist eines von",
+ set: "hat einen Wert",
+ unset: "ist leer",
+ },
+ now: "Jetzt",
+ aDate: "Ein Datum",
+ dateHint: "Ein Datum wie 2026-11-05.",
+ instantHint: "Ein Zeitpunkt mit Zeitversatz, wie 2026-11-05T19:30:00+03:00.",
+ yes: "Ja",
+ no: "Nein",
+ orderBy: "Nach Feld ordnen",
+ orderNone: "Die Listenreihenfolge oben",
+ direction: "Richtung",
+ asc: "{kind, select, time {Früheste zuerst} number {Kleinste zuerst} other {Kleinste zuerst}}",
+ desc: "{kind, select, time {Späteste zuerst} number {Größte zuerst} other {Größte zuerst}}",
+ noListFields: "Diese Sammlung hat keine Listenfelder zum Filtern oder Sortieren.",
+ settingsLink: "Listenfelder wählen",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.en.ts b/apps/studio/src/i18n/lazy/listings.en.ts
new file mode 100644
index 00000000..0beff10c
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.en.ts
@@ -0,0 +1,184 @@
+/**
+ * RFC-0042: collections stage 3 — listing fields, expiry and structured data in collection
+ * settings, the collection list's field columns and filters, "Create listing page", and the page
+ * editor's content list conditions.
+ */
+export const messages = {
+ listings: {
+ listFields: {
+ title: "Listing fields",
+ description:
+ "Up to 4 fields that lists can filter and sort by: columns in this collection's list, conditions in content list sections, and agents' queries.",
+ add: "Add a listing field",
+ choose: "Choose a field",
+ empty: "No listing fields yet",
+ emptyDescription: "Choose fields to show them as columns and to filter lists by them.",
+ noFields: "Add fields to this collection first; listing fields are chosen from them.",
+ full: "That's 4 fields, the most a collection can list.",
+ moveUp: "Move {label} up",
+ moveDown: "Move {label} down",
+ remove: "Remove {label}",
+ /** Badge on a number or date field: lists can be ordered by it. */
+ sortable: "Sortable",
+ save: "Save listing fields",
+ saved: "Listing fields saved",
+ /** Above the list of pages a 409 `list_field_in_use` names. */
+ inUse:
+ "Content list sections still filter or sort by a field you took out. Change these pages first:",
+ },
+ expiry: {
+ title: "Expiry",
+ description:
+ "Entries can end on a date of their own, such as the day an event is over. Nothing is deleted or unpublished: the site checks the date each time it shows an entry.",
+ enabled: "End entries on a date",
+ enabledHint: "Turn off to keep every entry until someone unpublishes it.",
+ field: "Date field",
+ fieldHint:
+ "A date ends at midnight after it, in the site's time zone; a date and time ends at that moment.",
+ noDateFields: "This collection has no date or date-and-time field. Add one to use expiry.",
+ action: "When an entry has ended",
+ unlist: "Leave lists",
+ unlistDescription:
+ "Its page stays at its address, but it leaves lists, content list sections, feeds, the sitemap and search. Good for events and jobs.",
+ hide: "Hide the page",
+ hideDescription:
+ "Its page answers “not found”, and it leaves every list, feed and search too. Good for offers that must not stay reachable.",
+ grace: "Grace period in minutes",
+ graceHint: "Wait this long after the date before the entry ends. Up to 10,080 (one week).",
+ past: "{count, plural, =0 {No published entries are past it yet.} one {# published entry is already past it.} other {# published entries are already past it.}}",
+ save: "Save expiry",
+ saved: "Expiry saved",
+ /** 409 `gates_unsupported`: the site Worker is older than this rule. */
+ gatesUnsupported:
+ "This site's live server doesn't apply expiry yet. Deploy the site first, then save the expiry again.",
+ },
+ structuredData: {
+ title: "Structured data",
+ description:
+ "Describe each entry to search engines as an event, job, product or recipe, from its fields. Without a type, entries are described as blog posts.",
+ type: "Type",
+ none: "None (blog post)",
+ types: {
+ Event: "Event",
+ JobPosting: "Job posting",
+ Product: "Product",
+ Recipe: "Recipe",
+ },
+ /** Column header: the schema.org property path. */
+ property: "Property",
+ source: "Value from",
+ required: "Required",
+ /** Badge: at least one property of this group (an address) needs a value from a field. */
+ oneOf: "One of these",
+ sources: {
+ default: "Default: {source}",
+ unmapped: "Not used",
+ constant: "A fixed value",
+ field: "Field: {label}",
+ entry: "Entry: {property}",
+ site: "Site: {property}",
+ },
+ entryProps: {
+ title: "Title",
+ summary: "Summary",
+ body: "Body",
+ coverImage: "Cover image",
+ url: "Address",
+ publishedAt: "Published date",
+ updatedAt: "Updated date",
+ },
+ siteProps: {
+ name: "Site name",
+ url: "Site address",
+ logo: "Site logo",
+ },
+ constant: "Fixed value of {path}",
+ currencyHint: "A three-letter currency code such as EUR.",
+ issues: {
+ unknown_path: "This type has no such property.",
+ unknown_field: "This field no longer exists.",
+ source_mismatch: "This value doesn't fit the property.",
+ vocabulary:
+ "Some options of this field mean nothing to search engines here; entries using them leave the property out.",
+ currency: "Use a three-letter currency code such as EUR.",
+ required_missing: "Needed: choose where its value comes from.",
+ constant_required:
+ "Required properties take their value from each entry, not a fixed value.",
+ required_value_missing:
+ "This entry has no value for a required property ({path}), so its page keeps the blog post description.",
+ too_large:
+ "This entry's description is too large, so its page keeps the blog post description.",
+ no_preset:
+ "This collection has no structured data type yet, so its pages describe entries as blog posts.",
+ },
+ suggestRequired:
+ "Tip: mark the fields mapped to required properties as “Needed to publish”, so no entry goes out without them.",
+ themeReminder:
+ "Search engines expect these values to be visible on the page, so check that your theme shows the mapped fields.",
+ preview: "Preview",
+ previewEntry: "Entry",
+ previewChoose: "Choose an entry",
+ previewDescription: "What this entry's page emits with the saved settings.",
+ previewEmpty: "No entries in this collection yet.",
+ noDocument: "This entry's page emits no structured data of this type.",
+ richResults: "Test in Google's Rich Results Test",
+ save: "Save structured data",
+ saved: "Structured data saved",
+ },
+ createPage: {
+ action: "Create listing page",
+ open: "Open listing page",
+ created: "Listing page created",
+ /** Tooltip on "Open listing page": a page already lives at the collection's address. */
+ exists: "A page already lives at {path}.",
+ },
+ filters: {
+ label: "Filter by fields",
+ any: "{label}: any",
+ upcoming: "{label}: upcoming",
+ past: "{label}: past",
+ set: "{label}: has a value",
+ unset: "{label}: empty",
+ yes: "{label}: yes",
+ no: "{label}: no",
+ is: "{label}: {value}",
+ sortBy: "Sort by {label}",
+ clear: "Clear filters",
+ empty: "No entries match these filters",
+ emptyDescription: "Change or clear the filters to see more.",
+ },
+ contentList: {
+ where: "Only entries where",
+ add: "Add condition",
+ field: "Field",
+ op: "Condition",
+ value: "Value",
+ remove: "Remove condition",
+ /** `kind` is `time` for date fields, `number` for numbers and the rest. */
+ ops: {
+ eq: "is",
+ ne: "is not",
+ lt: "{kind, select, time {is before} number {is less than} other {is less than}}",
+ lte: "{kind, select, time {is on or before} number {is at most} other {is at most}}",
+ gt: "{kind, select, time {is after} number {is more than} other {is more than}}",
+ gte: "{kind, select, time {is on or after} number {is at least} other {is at least}}",
+ in: "is one of",
+ set: "has a value",
+ unset: "is empty",
+ },
+ now: "Now",
+ aDate: "A date",
+ dateHint: "A date such as 2026-11-05.",
+ instantHint: "A moment with its offset, such as 2026-11-05T19:30:00+03:00.",
+ yes: "Yes",
+ no: "No",
+ orderBy: "Order by field",
+ orderNone: "The list order above",
+ direction: "Direction",
+ asc: "{kind, select, time {Earliest first} number {Smallest first} other {Smallest first}}",
+ desc: "{kind, select, time {Latest first} number {Largest first} other {Largest first}}",
+ noListFields: "This collection has no listing fields to filter or sort by.",
+ settingsLink: "Choose listing fields",
+ },
+ },
+} as const;
diff --git a/apps/studio/src/i18n/lazy/listings.es.ts b/apps/studio/src/i18n/lazy/listings.es.ts
new file mode 100644
index 00000000..1c0ebb25
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.es.ts
@@ -0,0 +1,181 @@
+// Generated by `pnpm i18n:import` (ADR-0034): es `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: 1a559d2468df917b
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "Campos de listado",
+ description:
+ "Hasta 4 campos por los que las listas pueden filtrar y ordenar: columnas en la lista de esta colección, condiciones en las secciones de lista de contenido y consultas de los agentes.",
+ add: "Añadir un campo de listado",
+ choose: "Elige un campo",
+ empty: "Aún no hay campos de listado",
+ emptyDescription:
+ "Elige campos para mostrarlos como columnas y filtrar las listas por ellos.",
+ noFields:
+ "Primero añade campos a esta colección; los campos de listado se eligen entre ellos.",
+ full: "Son 4 campos, el máximo que una colección puede listar.",
+ moveUp: "Subir {label}",
+ moveDown: "Bajar {label}",
+ remove: "Quitar {label}",
+ sortable: "Ordenable",
+ save: "Guardar campos de listado",
+ saved: "Campos de listado guardados",
+ inUse:
+ "Hay secciones de lista de contenido que aún filtran u ordenan por un campo que quitaste. Cambia primero estas páginas:",
+ },
+ expiry: {
+ title: "Caducidad",
+ description:
+ "Las entradas pueden terminar en una fecha propia, como el día en que acaba un evento. No se borra ni se despublica nada: el sitio comprueba la fecha cada vez que muestra una entrada.",
+ enabled: "Terminar las entradas en una fecha",
+ enabledHint: "Desactívalo para conservar cada entrada hasta que alguien la despublique.",
+ field: "Campo de fecha",
+ fieldHint:
+ "Una fecha termina a la medianoche siguiente, en la zona horaria del sitio; una fecha y hora termina en ese momento.",
+ noDateFields:
+ "Esta colección no tiene ningún campo de fecha ni de fecha y hora. Añade uno para usar la caducidad.",
+ action: "Cuando una entrada ha terminado",
+ unlist: "Salir de las listas",
+ unlistDescription:
+ "Su página sigue en su dirección, pero sale de las listas, las secciones de lista de contenido, los feeds, el sitemap y la búsqueda. Ideal para eventos y empleos.",
+ hide: "Ocultar la página",
+ hideDescription:
+ "Su página responde «no encontrada» y además sale de todas las listas, feeds y búsquedas. Ideal para ofertas que no deben seguir accesibles.",
+ grace: "Periodo de gracia en minutos",
+ graceHint:
+ "Espera este tiempo después de la fecha antes de que la entrada termine. Hasta 10 080 (una semana).",
+ past: "{count, plural, =0 {Aún no hay entradas publicadas que la hayan superado.} one {# entrada publicada ya la ha superado.} many {# de entradas publicadas ya la han superado.} other {# entradas publicadas ya la han superado.}}",
+ save: "Guardar caducidad",
+ saved: "Caducidad guardada",
+ gatesUnsupported:
+ "El servidor en vivo de este sitio aún no aplica la caducidad. Despliega primero el sitio y luego vuelve a guardar la caducidad.",
+ },
+ structuredData: {
+ title: "Datos estructurados",
+ description:
+ "Describe cada entrada a los motores de búsqueda como evento, empleo, producto o receta, a partir de sus campos. Sin tipo, las entradas se describen como entradas de blog.",
+ type: "Tipo",
+ none: "Ninguno (entrada de blog)",
+ types: {
+ Event: "Evento",
+ JobPosting: "Oferta de empleo",
+ Product: "Producto",
+ Recipe: "Receta",
+ },
+ property: "Propiedad",
+ source: "Valor de",
+ required: "Obligatoria",
+ oneOf: "Una de estas",
+ sources: {
+ default: "Predeterminado: {source}",
+ unmapped: "Sin usar",
+ constant: "Un valor fijo",
+ field: "Campo: {label}",
+ entry: "Entrada: {property}",
+ site: "Sitio: {property}",
+ },
+ entryProps: {
+ title: "Título",
+ summary: "Resumen",
+ body: "Cuerpo",
+ coverImage: "Imagen de portada",
+ url: "Dirección",
+ publishedAt: "Fecha de publicación",
+ updatedAt: "Fecha de actualización",
+ },
+ siteProps: {
+ name: "Nombre del sitio",
+ url: "Dirección del sitio",
+ logo: "Logotipo del sitio",
+ },
+ constant: "Valor fijo de {path}",
+ currencyHint: "Un código de moneda de tres letras, como EUR.",
+ issues: {
+ unknown_path: "Este tipo no tiene esa propiedad.",
+ unknown_field: "Este campo ya no existe.",
+ source_mismatch: "Este valor no encaja con la propiedad.",
+ vocabulary:
+ "Algunas opciones de este campo no significan nada para los motores de búsqueda aquí; las entradas que las usan omiten la propiedad.",
+ currency: "Usa un código de moneda de tres letras, como EUR.",
+ required_missing: "Necesaria: elige de dónde viene su valor.",
+ constant_required:
+ "Las propiedades obligatorias toman su valor de cada entrada, no de un valor fijo.",
+ required_value_missing:
+ "Esta entrada no tiene valor para una propiedad obligatoria ({path}), así que su página mantiene la descripción de entrada de blog.",
+ too_large:
+ "La descripción de esta entrada es demasiado grande, así que su página mantiene la descripción de entrada de blog.",
+ no_preset:
+ "Esta colección aún no tiene tipo de datos estructurados, así que sus páginas describen las entradas como entradas de blog.",
+ },
+ suggestRequired:
+ "Consejo: marca los campos asignados a propiedades obligatorias como «Necesario para publicar», para que ninguna entrada salga sin ellos.",
+ themeReminder:
+ "Los motores de búsqueda esperan que estos valores se vean en la página, así que comprueba que tu tema muestra los campos asignados.",
+ preview: "Vista previa",
+ previewEntry: "Entrada",
+ previewChoose: "Elige una entrada",
+ previewDescription: "Lo que emite la página de esta entrada con la configuración guardada.",
+ previewEmpty: "Aún no hay entradas en esta colección.",
+ noDocument: "La página de esta entrada no emite datos estructurados de este tipo.",
+ richResults: "Probar en el Rich Results Test de Google",
+ save: "Guardar datos estructurados",
+ saved: "Datos estructurados guardados",
+ },
+ createPage: {
+ action: "Crear página de listado",
+ open: "Abrir página de listado",
+ created: "Página de listado creada",
+ exists: "Ya hay una página en {path}.",
+ },
+ filters: {
+ label: "Filtrar por campos",
+ any: "{label}: cualquiera",
+ upcoming: "{label}: próximas",
+ past: "{label}: pasadas",
+ set: "{label}: tiene valor",
+ unset: "{label}: vacío",
+ yes: "{label}: sí",
+ no: "{label}: no",
+ is: "{label}: {value}",
+ sortBy: "Ordenar por {label}",
+ clear: "Borrar filtros",
+ empty: "Ninguna entrada coincide con estos filtros",
+ emptyDescription: "Cambia o borra los filtros para ver más.",
+ },
+ contentList: {
+ where: "Solo entradas donde",
+ add: "Añadir condición",
+ field: "Campo",
+ op: "Condición",
+ value: "Valor",
+ remove: "Quitar condición",
+ ops: {
+ eq: "es",
+ ne: "no es",
+ lt: "{kind, select, time {es anterior a} number {es menor que} other {es menor que}}",
+ lte: "{kind, select, time {es igual o anterior a} number {es como máximo} other {es como máximo}}",
+ gt: "{kind, select, time {es posterior a} number {es mayor que} other {es mayor que}}",
+ gte: "{kind, select, time {es igual o posterior a} number {es como mínimo} other {es como mínimo}}",
+ in: "es uno de",
+ set: "tiene valor",
+ unset: "está vacío",
+ },
+ now: "Ahora",
+ aDate: "Una fecha",
+ dateHint: "Una fecha como 2026-11-05.",
+ instantHint: "Un momento con su desfase horario, como 2026-11-05T19:30:00+03:00.",
+ yes: "Sí",
+ no: "No",
+ orderBy: "Ordenar por campo",
+ orderNone: "El orden de lista de arriba",
+ direction: "Dirección",
+ asc: "{kind, select, time {Más antiguas primero} number {Menores primero} other {Menores primero}}",
+ desc: "{kind, select, time {Más recientes primero} number {Mayores primero} other {Mayores primero}}",
+ noListFields: "Esta colección no tiene campos de listado por los que filtrar u ordenar.",
+ settingsLink: "Elegir campos de listado",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.fa.ts b/apps/studio/src/i18n/lazy/listings.fa.ts
new file mode 100644
index 00000000..d9af0de4
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.fa.ts
@@ -0,0 +1,177 @@
+// Generated by `pnpm i18n:import` (ADR-0034): fa `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: a4045f73a5d8d0c6
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "فیلدهای فهرست",
+ description:
+ "حداکثر 4 فیلد که فهرستها میتوانند بر اساس آنها فیلتر و مرتب کنند: ستونهای فهرست این مجموعه، شرطهای بخشهای فهرست محتوا و پرسوجوهای عاملها.",
+ add: "افزودن فیلد فهرست",
+ choose: "انتخاب فیلد",
+ empty: "هنوز فیلد فهرستی نیست",
+ emptyDescription:
+ "فیلدهایی را انتخاب کنید تا بهصورت ستون نشان داده شوند و فهرستها بر اساس آنها فیلتر شوند.",
+ noFields: "ابتدا به این مجموعه فیلد اضافه کنید؛ فیلدهای فهرست از میان آنها انتخاب میشوند.",
+ full: "این 4 فیلد است، بیشترین تعدادی که یک مجموعه میتواند فهرست کند.",
+ moveUp: "بردن {label} به بالا",
+ moveDown: "بردن {label} به پایین",
+ remove: "حذف {label}",
+ sortable: "قابل مرتبسازی",
+ save: "ذخیرهٔ فیلدهای فهرست",
+ saved: "فیلدهای فهرست ذخیره شدند",
+ inUse:
+ "بخشهای فهرست محتوا هنوز بر اساس فیلدی که برداشتید فیلتر یا مرتب میکنند. ابتدا این صفحهها را تغییر دهید:",
+ },
+ expiry: {
+ title: "انقضا",
+ description:
+ "ورودیها میتوانند در تاریخی از آنِ خود به پایان برسند، مثلاً روزی که یک رویداد تمام میشود. چیزی حذف یا از انتشار خارج نمیشود: سایت هر بار که ورودیای را نشان میدهد تاریخ را بررسی میکند.",
+ enabled: "پایان ورودیها در یک تاریخ",
+ enabledHint: "خاموش کنید تا هر ورودی بماند تا کسی آن را از انتشار خارج کند.",
+ field: "فیلد تاریخ",
+ fieldHint:
+ "یک تاریخ در نیمهشبِ پس از آن، به منطقهٔ زمانی سایت، پایان مییابد؛ تاریخ و ساعت در همان لحظه پایان مییابد.",
+ noDateFields:
+ "این مجموعه فیلد تاریخ یا تاریخ و ساعت ندارد. برای استفاده از انقضا یکی اضافه کنید.",
+ action: "وقتی ورودیای به پایان رسید",
+ unlist: "خروج از فهرستها",
+ unlistDescription:
+ "صفحهاش در نشانی خود میماند، اما از فهرستها، بخشهای فهرست محتوا، فیدها، sitemap و جستجو بیرون میرود. مناسب رویدادها و فرصتهای شغلی.",
+ hide: "پنهان کردن صفحه",
+ hideDescription:
+ "صفحهاش پاسخ «پیدا نشد» میدهد و از همهٔ فهرستها، فیدها و جستجو هم بیرون میرود. مناسب پیشنهادهایی که نباید در دسترس بمانند.",
+ grace: "مهلت به دقیقه",
+ graceHint: "این مدت پس از تاریخ صبر کنید تا ورودی پایان یابد. حداکثر 10,080 (یک هفته).",
+ past: "{count, plural, =0 {هنوز هیچ ورودی منتشرشدهای از آن نگذشته است.} one {# ورودی منتشرشده از آن گذشته است.} other {# ورودی منتشرشده از آن گذشتهاند.}}",
+ save: "ذخیرهٔ انقضا",
+ saved: "انقضا ذخیره شد",
+ gatesUnsupported:
+ "سرور زندهٔ این سایت هنوز انقضا را اعمال نمیکند. ابتدا سایت را مستقر کنید و سپس انقضا را دوباره ذخیره کنید.",
+ },
+ structuredData: {
+ title: "دادههای ساختاریافته",
+ description:
+ "هر ورودی را بر اساس فیلدهایش بهعنوان رویداد، فرصت شغلی، محصول یا دستور پخت برای موتورهای جستجو توصیف کنید. بدون نوع، ورودیها بهعنوان پست وبلاگ توصیف میشوند.",
+ type: "نوع",
+ none: "هیچ (پست وبلاگ)",
+ types: {
+ Event: "رویداد",
+ JobPosting: "آگهی شغلی",
+ Product: "محصول",
+ Recipe: "دستور پخت",
+ },
+ property: "ویژگی",
+ source: "مقدار از",
+ required: "الزامی",
+ oneOf: "یکی از اینها",
+ sources: {
+ default: "پیشفرض: {source}",
+ unmapped: "استفاده نمیشود",
+ constant: "یک مقدار ثابت",
+ field: "فیلد: {label}",
+ entry: "ورودی: {property}",
+ site: "سایت: {property}",
+ },
+ entryProps: {
+ title: "عنوان",
+ summary: "خلاصه",
+ body: "متن",
+ coverImage: "تصویر جلد",
+ url: "نشانی",
+ publishedAt: "تاریخ انتشار",
+ updatedAt: "تاریخ بهروزرسانی",
+ },
+ siteProps: {
+ name: "نام سایت",
+ url: "نشانی سایت",
+ logo: "لوگوی سایت",
+ },
+ constant: "مقدار ثابت {path}",
+ currencyHint: "یک کد سهحرفی ارز مانند EUR.",
+ issues: {
+ unknown_path: "این نوع چنین ویژگیای ندارد.",
+ unknown_field: "این فیلد دیگر وجود ندارد.",
+ source_mismatch: "این مقدار با ویژگی جور نیست.",
+ vocabulary:
+ "برخی گزینههای این فیلد در اینجا برای موتورهای جستجو معنایی ندارند؛ ورودیهایی که از آنها استفاده میکنند این ویژگی را کنار میگذارند.",
+ currency: "از یک کد سهحرفی ارز مانند EUR استفاده کنید.",
+ required_missing: "لازم: انتخاب کنید مقدارش از کجا بیاید.",
+ constant_required: "ویژگیهای الزامی مقدارشان را از هر ورودی میگیرند، نه از یک مقدار ثابت.",
+ required_value_missing:
+ "این ورودی برای یک ویژگی الزامی ({path}) مقداری ندارد، پس صفحهاش توصیف پست وبلاگ را نگه میدارد.",
+ too_large: "توصیف این ورودی بیش از حد بزرگ است، پس صفحهاش توصیف پست وبلاگ را نگه میدارد.",
+ no_preset:
+ "این مجموعه هنوز نوع دادههای ساختاریافته ندارد، پس صفحههایش ورودیها را بهعنوان پست وبلاگ توصیف میکنند.",
+ },
+ suggestRequired:
+ "نکته: فیلدهایی را که به ویژگیهای الزامی نگاشت شدهاند «لازم برای انتشار» علامت بزنید تا هیچ ورودیای بدون آنها منتشر نشود.",
+ themeReminder:
+ "موتورهای جستجو انتظار دارند این مقادیر در صفحه دیده شوند، پس بررسی کنید که قالبتان فیلدهای نگاشتشده را نشان میدهد.",
+ preview: "پیشنمایش",
+ previewEntry: "ورودی",
+ previewChoose: "انتخاب ورودی",
+ previewDescription: "آنچه صفحهٔ این ورودی با تنظیمات ذخیرهشده تولید میکند.",
+ previewEmpty: "هنوز ورودیای در این مجموعه نیست.",
+ noDocument: "صفحهٔ این ورودی هیچ دادهٔ ساختاریافتهای از این نوع تولید نمیکند.",
+ richResults: "آزمایش در Rich Results Test گوگل",
+ save: "ذخیرهٔ دادههای ساختاریافته",
+ saved: "دادههای ساختاریافته ذخیره شدند",
+ },
+ createPage: {
+ action: "ساخت صفحهٔ فهرست",
+ open: "باز کردن صفحهٔ فهرست",
+ created: "صفحهٔ فهرست ساخته شد",
+ exists: "صفحهای از قبل در {path} وجود دارد.",
+ },
+ filters: {
+ label: "فیلتر بر اساس فیلدها",
+ any: "{label}: هر مقدار",
+ upcoming: "{label}: پیش رو",
+ past: "{label}: گذشته",
+ set: "{label}: مقدار دارد",
+ unset: "{label}: خالی",
+ yes: "{label}: بله",
+ no: "{label}: خیر",
+ is: "{label}: {value}",
+ sortBy: "مرتبسازی بر اساس {label}",
+ clear: "پاک کردن فیلترها",
+ empty: "هیچ ورودیای با این فیلترها جور نیست",
+ emptyDescription: "برای دیدن موارد بیشتر، فیلترها را تغییر دهید یا پاک کنید.",
+ },
+ contentList: {
+ where: "فقط ورودیهایی که",
+ add: "افزودن شرط",
+ field: "فیلد",
+ op: "شرط",
+ value: "مقدار",
+ remove: "حذف شرط",
+ ops: {
+ eq: "برابر است با",
+ ne: "برابر نیست با",
+ lt: "{kind, select, time {پیش از} number {کمتر از} other {کمتر از}}",
+ lte: "{kind, select, time {در یا پیش از} number {حداکثر} other {حداکثر}}",
+ gt: "{kind, select, time {پس از} number {بیشتر از} other {بیشتر از}}",
+ gte: "{kind, select, time {در یا پس از} number {حداقل} other {حداقل}}",
+ in: "یکی از",
+ set: "مقدار دارد",
+ unset: "خالی است",
+ },
+ now: "اکنون",
+ aDate: "یک تاریخ",
+ dateHint: "تاریخی مانند 2026-11-05.",
+ instantHint: "لحظهای همراه با اختلاف ساعتش، مانند 2026-11-05T19:30:00+03:00.",
+ yes: "بله",
+ no: "خیر",
+ orderBy: "ترتیب بر اساس فیلد",
+ orderNone: "ترتیب فهرست در بالا",
+ direction: "جهت",
+ asc: "{kind, select, time {قدیمیترین نخست} number {کوچکترین نخست} other {کوچکترین نخست}}",
+ desc: "{kind, select, time {جدیدترین نخست} number {بزرگترین نخست} other {بزرگترین نخست}}",
+ noListFields: "این مجموعه فیلد فهرستی برای فیلتر یا مرتبسازی ندارد.",
+ settingsLink: "انتخاب فیلدهای فهرست",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.fr.ts b/apps/studio/src/i18n/lazy/listings.fr.ts
new file mode 100644
index 00000000..565b5b59
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.fr.ts
@@ -0,0 +1,181 @@
+// Generated by `pnpm i18n:import` (ADR-0034): fr `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: f3f0c89b2f0fba4f
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "Champs de liste",
+ description:
+ "Jusqu’à 4 champs selon lesquels les listes peuvent filtrer et trier : colonnes de la liste de cette collection, conditions des sections de liste de contenus et requêtes des agents.",
+ add: "Ajouter un champ de liste",
+ choose: "Choisir un champ",
+ empty: "Aucun champ de liste pour l’instant",
+ emptyDescription:
+ "Choisissez des champs pour les afficher en colonnes et filtrer les listes selon eux.",
+ noFields:
+ "Ajoutez d’abord des champs à cette collection ; les champs de liste sont choisis parmi eux.",
+ full: "Cela fait 4 champs, le maximum qu’une collection peut lister.",
+ moveUp: "Monter {label}",
+ moveDown: "Descendre {label}",
+ remove: "Retirer {label}",
+ sortable: "Triable",
+ save: "Enregistrer les champs de liste",
+ saved: "Champs de liste enregistrés",
+ inUse:
+ "Des sections de liste de contenus filtrent ou trient encore selon un champ que vous avez retiré. Modifiez d’abord ces pages :",
+ },
+ expiry: {
+ title: "Expiration",
+ description:
+ "Les entrées peuvent prendre fin à une date qui leur est propre, comme le jour où un événement est terminé. Rien n’est supprimé ni dépublié : le site vérifie la date chaque fois qu’il affiche une entrée.",
+ enabled: "Faire expirer les entrées à une date",
+ enabledHint: "Désactivez pour conserver chaque entrée jusqu’à ce que quelqu’un la dépublie.",
+ field: "Champ de date",
+ fieldHint:
+ "Une date prend fin à minuit le soir même, dans le fuseau horaire du site ; une date et heure prend fin à ce moment précis.",
+ noDateFields:
+ "Cette collection n’a aucun champ de date ou de date et heure. Ajoutez-en un pour utiliser l’expiration.",
+ action: "Quand une entrée a expiré",
+ unlist: "Retirer des listes",
+ unlistDescription:
+ "Sa page reste à son adresse, mais elle quitte les listes, les sections de liste de contenus, les flux, le sitemap et la recherche. Idéal pour les événements et les offres d’emploi.",
+ hide: "Masquer la page",
+ hideDescription:
+ "Sa page répond « introuvable », et elle quitte aussi toutes les listes, tous les flux et la recherche. Idéal pour les offres qui ne doivent plus rester accessibles.",
+ grace: "Délai de grâce en minutes",
+ graceHint:
+ "Attendre ce délai après la date avant que l’entrée n’expire. Jusqu’à 10 080 (une semaine).",
+ past: "{count, plural, =0 {Aucune entrée publiée ne l’a encore dépassée.} one {# entrée publiée l’a déjà dépassée.} many {# d’entrées publiées l’ont déjà dépassée.} other {# entrées publiées l’ont déjà dépassée.}}",
+ save: "Enregistrer l’expiration",
+ saved: "Expiration enregistrée",
+ gatesUnsupported:
+ "Le serveur en ligne de ce site n’applique pas encore l’expiration. Déployez d’abord le site, puis enregistrez de nouveau l’expiration.",
+ },
+ structuredData: {
+ title: "Données structurées",
+ description:
+ "Décrivez chaque entrée aux moteurs de recherche comme un événement, une offre d’emploi, un produit ou une recette, à partir de ses champs. Sans type, les entrées sont décrites comme des articles de blog.",
+ type: "Type",
+ none: "Aucun (article de blog)",
+ types: {
+ Event: "Événement",
+ JobPosting: "Offre d’emploi",
+ Product: "Produit",
+ Recipe: "Recette",
+ },
+ property: "Propriété",
+ source: "Valeur issue de",
+ required: "Obligatoire",
+ oneOf: "L’une d’elles",
+ sources: {
+ default: "Par défaut : {source}",
+ unmapped: "Non utilisée",
+ constant: "Une valeur fixe",
+ field: "Champ : {label}",
+ entry: "Entrée : {property}",
+ site: "Site : {property}",
+ },
+ entryProps: {
+ title: "Titre",
+ summary: "Résumé",
+ body: "Corps",
+ coverImage: "Image de couverture",
+ url: "Adresse",
+ publishedAt: "Date de publication",
+ updatedAt: "Date de mise à jour",
+ },
+ siteProps: {
+ name: "Nom du site",
+ url: "Adresse du site",
+ logo: "Logo du site",
+ },
+ constant: "Valeur fixe de {path}",
+ currencyHint: "Un code de devise à trois lettres, comme EUR.",
+ issues: {
+ unknown_path: "Ce type n’a pas de telle propriété.",
+ unknown_field: "Ce champ n’existe plus.",
+ source_mismatch: "Cette valeur ne convient pas à la propriété.",
+ vocabulary:
+ "Certaines options de ce champ ne signifient rien pour les moteurs de recherche ici ; les entrées qui les utilisent omettent la propriété.",
+ currency: "Utilisez un code de devise à trois lettres, comme EUR.",
+ required_missing: "Nécessaire : choisissez d’où vient sa valeur.",
+ constant_required:
+ "Les propriétés obligatoires prennent leur valeur dans chaque entrée, pas dans une valeur fixe.",
+ required_value_missing:
+ "Cette entrée n’a pas de valeur pour une propriété obligatoire ({path}), sa page garde donc la description d’article de blog.",
+ too_large:
+ "La description de cette entrée est trop volumineuse, sa page garde donc la description d’article de blog.",
+ no_preset:
+ "Cette collection n’a pas encore de type de données structurées, ses pages décrivent donc les entrées comme des articles de blog.",
+ },
+ suggestRequired:
+ "Astuce : marquez les champs associés aux propriétés obligatoires comme « Nécessaire pour publier », pour qu’aucune entrée ne paraisse sans eux.",
+ themeReminder:
+ "Les moteurs de recherche s’attendent à voir ces valeurs sur la page ; vérifiez donc que votre thème affiche les champs associés.",
+ preview: "Aperçu",
+ previewEntry: "Entrée",
+ previewChoose: "Choisir une entrée",
+ previewDescription: "Ce que la page de cette entrée émet avec les paramètres enregistrés.",
+ previewEmpty: "Aucune entrée dans cette collection pour l’instant.",
+ noDocument: "La page de cette entrée n’émet aucune donnée structurée de ce type.",
+ richResults: "Tester dans le Rich Results Test de Google",
+ save: "Enregistrer les données structurées",
+ saved: "Données structurées enregistrées",
+ },
+ createPage: {
+ action: "Créer la page de liste",
+ open: "Ouvrir la page de liste",
+ created: "Page de liste créée",
+ exists: "Une page existe déjà à {path}.",
+ },
+ filters: {
+ label: "Filtrer par champs",
+ any: "{label} : tous",
+ upcoming: "{label} : à venir",
+ past: "{label} : passés",
+ set: "{label} : a une valeur",
+ unset: "{label} : vide",
+ yes: "{label} : oui",
+ no: "{label} : non",
+ is: "{label} : {value}",
+ sortBy: "Trier par {label}",
+ clear: "Effacer les filtres",
+ empty: "Aucune entrée ne correspond à ces filtres",
+ emptyDescription: "Modifiez ou effacez les filtres pour en voir davantage.",
+ },
+ contentList: {
+ where: "Seulement les entrées où",
+ add: "Ajouter une condition",
+ field: "Champ",
+ op: "Condition",
+ value: "Valeur",
+ remove: "Retirer la condition",
+ ops: {
+ eq: "est",
+ ne: "n’est pas",
+ lt: "{kind, select, time {est avant} number {est inférieur à} other {est inférieur à}}",
+ lte: "{kind, select, time {est le ou avant} number {est au plus} other {est au plus}}",
+ gt: "{kind, select, time {est après} number {est supérieur à} other {est supérieur à}}",
+ gte: "{kind, select, time {est le ou après} number {est au moins} other {est au moins}}",
+ in: "est l’un de",
+ set: "a une valeur",
+ unset: "est vide",
+ },
+ now: "Maintenant",
+ aDate: "Une date",
+ dateHint: "Une date comme 2026-11-05.",
+ instantHint: "Un instant avec son décalage horaire, comme 2026-11-05T19:30:00+03:00.",
+ yes: "Oui",
+ no: "Non",
+ orderBy: "Trier par champ",
+ orderNone: "L’ordre de liste ci-dessus",
+ direction: "Sens",
+ asc: "{kind, select, time {Plus anciennes d’abord} number {Plus petites d’abord} other {Plus petites d’abord}}",
+ desc: "{kind, select, time {Plus récentes d’abord} number {Plus grandes d’abord} other {Plus grandes d’abord}}",
+ noListFields: "Cette collection n’a aucun champ de liste pour filtrer ou trier.",
+ settingsLink: "Choisir les champs de liste",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.he.ts b/apps/studio/src/i18n/lazy/listings.he.ts
new file mode 100644
index 00000000..80716eed
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.he.ts
@@ -0,0 +1,173 @@
+// Generated by `pnpm i18n:import` (ADR-0034): he `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: 67b79b29a4fbf2ae
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "שדות רשימה",
+ description:
+ "עד 4 שדות שרשימות יכולות לסנן ולמיין לפיהם: עמודות ברשימה של האוסף הזה, תנאים בקטעי רשימת תוכן ושאילתות של סוכנים.",
+ add: "הוספת שדה רשימה",
+ choose: "בחירת שדה",
+ empty: "עדיין אין שדות רשימה",
+ emptyDescription: "בחרו שדות כדי להציג אותם כעמודות ולסנן לפיהם רשימות.",
+ noFields: "קודם הוסיפו שדות לאוסף הזה; שדות רשימה נבחרים מתוכם.",
+ full: "אלה 4 שדות, המקסימום שאוסף יכול להציג ברשימה.",
+ moveUp: "הזזת {label} למעלה",
+ moveDown: "הזזת {label} למטה",
+ remove: "הסרת {label}",
+ sortable: "ניתן למיון",
+ save: "שמירת שדות רשימה",
+ saved: "שדות הרשימה נשמרו",
+ inUse: "קטעי רשימת תוכן עדיין מסננים או ממיינים לפי שדה שהסרתם. שנו קודם את העמודים האלה:",
+ },
+ expiry: {
+ title: "תפוגה",
+ description:
+ "רשומות יכולות להסתיים בתאריך משלהן, למשל ביום שבו אירוע נגמר. שום דבר לא נמחק ולא מבוטל פרסומו: האתר בודק את התאריך בכל פעם שהוא מציג רשומה.",
+ enabled: "סיום רשומות בתאריך",
+ enabledHint: "כבו כדי לשמור כל רשומה עד שמישהו יבטל את הפרסום שלה.",
+ field: "שדה תאריך",
+ fieldHint: "תאריך מסתיים בחצות שאחריו, לפי אזור הזמן של האתר; תאריך ושעה מסתיימים באותו רגע.",
+ noDateFields: "לאוסף הזה אין שדה תאריך או תאריך ושעה. הוסיפו אחד כדי להשתמש בתפוגה.",
+ action: "כשרשומה הסתיימה",
+ unlist: "יציאה מרשימות",
+ unlistDescription:
+ "העמוד שלה נשאר בכתובת שלו, אבל היא יוצאת מרשימות, מקטעי רשימת תוכן, מפידים, מה-sitemap ומהחיפוש. מתאים לאירועים ולמשרות.",
+ hide: "הסתרת העמוד",
+ hideDescription:
+ "העמוד שלה עונה „לא נמצא”, והיא יוצאת גם מכל רשימה, פיד וחיפוש. מתאים להצעות שאסור שיישארו נגישות.",
+ grace: "תקופת חסד בדקות",
+ graceHint: "להמתין זמן זה אחרי התאריך לפני שהרשומה מסתיימת. עד 10,080 (שבוע).",
+ past: "{count, plural, =0 {עדיין אין רשומות שפורסמו שעברו אותו.} one {רשומה אחת שפורסמה כבר עברה אותו.} two {שתי רשומות שפורסמו כבר עברו אותו.} other {# רשומות שפורסמו כבר עברו אותו.}}",
+ save: "שמירת תפוגה",
+ saved: "התפוגה נשמרה",
+ gatesUnsupported:
+ "השרת החי של האתר הזה עדיין לא מחיל תפוגה. פרסו קודם את האתר, ואז שמרו את התפוגה שוב.",
+ },
+ structuredData: {
+ title: "נתונים מובנים",
+ description:
+ "תארו כל רשומה למנועי חיפוש כאירוע, משרה, מוצר או מתכון, על סמך השדות שלה. בלי סוג, רשומות מתוארות כפוסטים בבלוג.",
+ type: "סוג",
+ none: "ללא (פוסט בבלוג)",
+ types: {
+ Event: "אירוע",
+ JobPosting: "מודעת דרושים",
+ Product: "מוצר",
+ Recipe: "מתכון",
+ },
+ property: "מאפיין",
+ source: "ערך מתוך",
+ required: "חובה",
+ oneOf: "אחד מאלה",
+ sources: {
+ default: "ברירת מחדל: {source}",
+ unmapped: "לא בשימוש",
+ constant: "ערך קבוע",
+ field: "שדה: {label}",
+ entry: "רשומה: {property}",
+ site: "אתר: {property}",
+ },
+ entryProps: {
+ title: "כותרת",
+ summary: "תקציר",
+ body: "גוף",
+ coverImage: "תמונת שער",
+ url: "כתובת",
+ publishedAt: "תאריך פרסום",
+ updatedAt: "תאריך עדכון",
+ },
+ siteProps: {
+ name: "שם האתר",
+ url: "כתובת האתר",
+ logo: "לוגו האתר",
+ },
+ constant: "ערך קבוע של {path}",
+ currencyHint: "קוד מטבע בן שלוש אותיות, כמו EUR.",
+ issues: {
+ unknown_path: "לסוג הזה אין מאפיין כזה.",
+ unknown_field: "השדה הזה כבר לא קיים.",
+ source_mismatch: "הערך הזה לא מתאים למאפיין.",
+ vocabulary:
+ "לחלק מהאפשרויות של השדה הזה אין משמעות למנועי חיפוש כאן; רשומות שמשתמשות בהן משמיטות את המאפיין.",
+ currency: "השתמשו בקוד מטבע בן שלוש אותיות, כמו EUR.",
+ required_missing: "נדרש: בחרו מאיפה מגיע הערך שלו.",
+ constant_required: "מאפייני חובה לוקחים את הערך שלהם מכל רשומה, לא מערך קבוע.",
+ required_value_missing:
+ "לרשומה הזו אין ערך למאפיין חובה ({path}), ולכן העמוד שלה שומר על התיאור כפוסט בבלוג.",
+ too_large: "התיאור של הרשומה הזו גדול מדי, ולכן העמוד שלה שומר על התיאור כפוסט בבלוג.",
+ no_preset:
+ "לאוסף הזה עדיין אין סוג נתונים מובנים, ולכן העמודים שלו מתארים רשומות כפוסטים בבלוג.",
+ },
+ suggestRequired:
+ "טיפ: סמנו את השדות שממופים למאפייני חובה כ„נדרש לפרסום”, כדי שאף רשומה לא תתפרסם בלעדיהם.",
+ themeReminder:
+ "מנועי חיפוש מצפים שהערכים האלה יהיו גלויים בעמוד, אז בדקו שערכת הנושא שלכם מציגה את השדות הממופים.",
+ preview: "תצוגה מקדימה",
+ previewEntry: "רשומה",
+ previewChoose: "בחירת רשומה",
+ previewDescription: "מה שהעמוד של הרשומה הזו מפיק עם ההגדרות השמורות.",
+ previewEmpty: "עדיין אין רשומות באוסף הזה.",
+ noDocument: "העמוד של הרשומה הזו לא מפיק נתונים מובנים מהסוג הזה.",
+ richResults: "בדיקה ב-Rich Results Test של Google",
+ save: "שמירת נתונים מובנים",
+ saved: "הנתונים המובנים נשמרו",
+ },
+ createPage: {
+ action: "יצירת עמוד רשימה",
+ open: "פתיחת עמוד הרשימה",
+ created: "עמוד הרשימה נוצר",
+ exists: "כבר יש עמוד בכתובת {path}.",
+ },
+ filters: {
+ label: "סינון לפי שדות",
+ any: "{label}: הכול",
+ upcoming: "{label}: קרובים",
+ past: "{label}: שעברו",
+ set: "{label}: יש ערך",
+ unset: "{label}: ריק",
+ yes: "{label}: כן",
+ no: "{label}: לא",
+ is: "{label}: {value}",
+ sortBy: "מיון לפי {label}",
+ clear: "ניקוי מסננים",
+ empty: "אין רשומות שתואמות למסננים האלה",
+ emptyDescription: "שנו או נקו את המסננים כדי לראות עוד.",
+ },
+ contentList: {
+ where: "רק רשומות שבהן",
+ add: "הוספת תנאי",
+ field: "שדה",
+ op: "תנאי",
+ value: "ערך",
+ remove: "הסרת תנאי",
+ ops: {
+ eq: "שווה ל",
+ ne: "שונה מ",
+ lt: "{kind, select, time {לפני} number {קטן מ} other {קטן מ}}",
+ lte: "{kind, select, time {ב- או לפני} number {לכל היותר} other {לכל היותר}}",
+ gt: "{kind, select, time {אחרי} number {גדול מ} other {גדול מ}}",
+ gte: "{kind, select, time {ב- או אחרי} number {לפחות} other {לפחות}}",
+ in: "אחד מ",
+ set: "יש ערך",
+ unset: "ריק",
+ },
+ now: "עכשיו",
+ aDate: "תאריך",
+ dateHint: "תאריך כמו 2026-11-05.",
+ instantHint: "רגע עם הפרש השעות שלו, כמו 2026-11-05T19:30:00+03:00.",
+ yes: "כן",
+ no: "לא",
+ orderBy: "סידור לפי שדה",
+ orderNone: "סדר הרשימה שלמעלה",
+ direction: "כיוון",
+ asc: "{kind, select, time {המוקדמים ראשונים} number {הקטנים ראשונים} other {הקטנים ראשונים}}",
+ desc: "{kind, select, time {המאוחרים ראשונים} number {הגדולים ראשונים} other {הגדולים ראשונים}}",
+ noListFields: "לאוסף הזה אין שדות רשימה לסינון או למיון.",
+ settingsLink: "בחירת שדות רשימה",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.hi.ts b/apps/studio/src/i18n/lazy/listings.hi.ts
new file mode 100644
index 00000000..0f122662
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.hi.ts
@@ -0,0 +1,176 @@
+// Generated by `pnpm i18n:import` (ADR-0034): hi `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: b7f3ca8520cd2e2e
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "लिस्टिंग फ़ील्ड",
+ description:
+ "ज़्यादा से ज़्यादा 4 फ़ील्ड, जिनसे सूचियाँ फ़िल्टर और क्रमबद्ध कर सकती हैं: इस संग्रह की सूची के कॉलम, सामग्री सूची सेक्शन की शर्तें और एजेंटों की क्वेरी।",
+ add: "लिस्टिंग फ़ील्ड जोड़ें",
+ choose: "फ़ील्ड चुनें",
+ empty: "अभी कोई लिस्टिंग फ़ील्ड नहीं",
+ emptyDescription: "फ़ील्ड चुनें ताकि वे कॉलम के रूप में दिखें और सूचियाँ उनसे फ़िल्टर हो सकें।",
+ noFields: "पहले इस संग्रह में फ़ील्ड जोड़ें; लिस्टिंग फ़ील्ड उन्हीं में से चुने जाते हैं।",
+ full: "4 फ़ील्ड हो गए, एक संग्रह इतने ही लिस्ट कर सकता है।",
+ moveUp: "{label} को ऊपर ले जाएँ",
+ moveDown: "{label} को नीचे ले जाएँ",
+ remove: "{label} हटाएँ",
+ sortable: "क्रमबद्ध करने योग्य",
+ save: "लिस्टिंग फ़ील्ड सहेजें",
+ saved: "लिस्टिंग फ़ील्ड सहेजे गए",
+ inUse:
+ "सामग्री सूची सेक्शन अब भी उस फ़ील्ड से फ़िल्टर या क्रमबद्ध करते हैं जिसे आपने हटाया। पहले इन पेजों को बदलें:",
+ },
+ expiry: {
+ title: "समाप्ति",
+ description:
+ "एंट्री अपनी तारीख़ पर ख़त्म हो सकती हैं, जैसे जिस दिन कोई इवेंट पूरा हो जाए। कुछ भी हटाया या अप्रकाशित नहीं होता: साइट हर बार एंट्री दिखाते समय तारीख़ जाँचती है।",
+ enabled: "एंट्री को किसी तारीख़ पर ख़त्म करें",
+ enabledHint: "बंद करें ताकि हर एंट्री तब तक बनी रहे जब तक कोई उसे अप्रकाशित न करे।",
+ field: "तारीख़ फ़ील्ड",
+ fieldHint:
+ "तारीख़ साइट के टाइम ज़ोन में उसके बाद की आधी रात को ख़त्म होती है; तारीख़ और समय ठीक उसी पल ख़त्म होता है।",
+ noDateFields:
+ "इस संग्रह में कोई तारीख़ या तारीख़ और समय फ़ील्ड नहीं है। समाप्ति इस्तेमाल करने के लिए एक जोड़ें।",
+ action: "जब कोई एंट्री ख़त्म हो जाए",
+ unlist: "सूचियों से हटाएँ",
+ unlistDescription:
+ "उसका पेज अपने पते पर रहता है, लेकिन वह सूचियों, सामग्री सूची सेक्शन, फ़ीड, साइटमैप और खोज से हट जाता है। इवेंट और नौकरियों के लिए अच्छा।",
+ hide: "पेज छिपाएँ",
+ hideDescription:
+ "उसका पेज “नहीं मिला” जवाब देता है, और वह हर सूची, फ़ीड और खोज से भी हट जाता है। उन ऑफ़र के लिए अच्छा जिन तक पहुँच बनी नहीं रहनी चाहिए।",
+ grace: "छूट अवधि (मिनट में)",
+ graceHint: "तारीख़ के बाद एंट्री ख़त्म होने से पहले इतनी देर रुकें। ज़्यादा से ज़्यादा 10,080 (एक हफ़्ता)।",
+ past: "{count, plural, =0 {अभी कोई प्रकाशित एंट्री इसके पार नहीं गई है।} one {# प्रकाशित एंट्री पहले ही इसके पार जा चुकी है।} other {# प्रकाशित एंट्री पहले ही इसके पार जा चुकी हैं।}}",
+ save: "समाप्ति सहेजें",
+ saved: "समाप्ति सहेजी गई",
+ gatesUnsupported:
+ "इस साइट का लाइव सर्वर अभी समाप्ति लागू नहीं करता। पहले साइट डिप्लॉय करें, फिर समाप्ति दोबारा सहेजें।",
+ },
+ structuredData: {
+ title: "स्ट्रक्चर्ड डेटा",
+ description:
+ "हर एंट्री को उसके फ़ील्ड से खोज इंजनों के सामने इवेंट, नौकरी, प्रोडक्ट या रेसिपी के रूप में बताएँ। टाइप के बिना एंट्री ब्लॉग पोस्ट के रूप में बताई जाती हैं।",
+ type: "टाइप",
+ none: "कोई नहीं (ब्लॉग पोस्ट)",
+ types: {
+ Event: "इवेंट",
+ JobPosting: "नौकरी का विज्ञापन",
+ Product: "प्रोडक्ट",
+ Recipe: "रेसिपी",
+ },
+ property: "प्रॉपर्टी",
+ source: "मान कहाँ से",
+ required: "आवश्यक",
+ oneOf: "इनमें से एक",
+ sources: {
+ default: "डिफ़ॉल्ट: {source}",
+ unmapped: "इस्तेमाल नहीं",
+ constant: "एक तय मान",
+ field: "फ़ील्ड: {label}",
+ entry: "एंट्री: {property}",
+ site: "साइट: {property}",
+ },
+ entryProps: {
+ title: "शीर्षक",
+ summary: "सारांश",
+ body: "मुख्य भाग",
+ coverImage: "कवर इमेज",
+ url: "पता",
+ publishedAt: "प्रकाशन तारीख़",
+ updatedAt: "अपडेट की तारीख़",
+ },
+ siteProps: {
+ name: "साइट का नाम",
+ url: "साइट का पता",
+ logo: "साइट का लोगो",
+ },
+ constant: "{path} का तय मान",
+ currencyHint: "तीन अक्षरों का मुद्रा कोड, जैसे EUR।",
+ issues: {
+ unknown_path: "इस टाइप में ऐसी कोई प्रॉपर्टी नहीं है।",
+ unknown_field: "यह फ़ील्ड अब मौजूद नहीं है।",
+ source_mismatch: "यह मान इस प्रॉपर्टी पर फ़िट नहीं होता।",
+ vocabulary:
+ "इस फ़ील्ड के कुछ विकल्प यहाँ खोज इंजनों के लिए कोई अर्थ नहीं रखते; उनका इस्तेमाल करने वाली एंट्री में यह प्रॉपर्टी छोड़ दी जाती है।",
+ currency: "तीन अक्षरों का मुद्रा कोड इस्तेमाल करें, जैसे EUR।",
+ required_missing: "ज़रूरी: चुनें कि इसका मान कहाँ से आए।",
+ constant_required: "आवश्यक प्रॉपर्टी अपना मान हर एंट्री से लेती हैं, किसी तय मान से नहीं।",
+ required_value_missing:
+ "इस एंट्री में एक आवश्यक प्रॉपर्टी ({path}) का मान नहीं है, इसलिए इसका पेज ब्लॉग पोस्ट वाला विवरण ही रखता है।",
+ too_large: "इस एंट्री का विवरण बहुत बड़ा है, इसलिए इसका पेज ब्लॉग पोस्ट वाला विवरण ही रखता है।",
+ no_preset:
+ "इस संग्रह का अभी कोई स्ट्रक्चर्ड डेटा टाइप नहीं है, इसलिए इसके पेज एंट्री को ब्लॉग पोस्ट के रूप में बताते हैं।",
+ },
+ suggestRequired:
+ "सुझाव: आवश्यक प्रॉपर्टी से जुड़े फ़ील्ड को “प्रकाशन के लिए ज़रूरी” चिह्नित करें, ताकि उनके बिना कोई एंट्री प्रकाशित न हो।",
+ themeReminder:
+ "खोज इंजन उम्मीद करते हैं कि ये मान पेज पर दिखें, इसलिए जाँच लें कि आपकी थीम जुड़े हुए फ़ील्ड दिखाती है।",
+ preview: "प्रीव्यू",
+ previewEntry: "एंट्री",
+ previewChoose: "एंट्री चुनें",
+ previewDescription: "सहेजी गई सेटिंग के साथ इस एंट्री का पेज क्या आउटपुट करता है।",
+ previewEmpty: "इस संग्रह में अभी कोई एंट्री नहीं।",
+ noDocument: "इस एंट्री का पेज इस टाइप का कोई स्ट्रक्चर्ड डेटा आउटपुट नहीं करता।",
+ richResults: "Google के Rich Results Test में जाँचें",
+ save: "स्ट्रक्चर्ड डेटा सहेजें",
+ saved: "स्ट्रक्चर्ड डेटा सहेजा गया",
+ },
+ createPage: {
+ action: "लिस्टिंग पेज बनाएँ",
+ open: "लिस्टिंग पेज खोलें",
+ created: "लिस्टिंग पेज बन गया",
+ exists: "{path} पर पहले से एक पेज है।",
+ },
+ filters: {
+ label: "फ़ील्ड से फ़िल्टर करें",
+ any: "{label}: कोई भी",
+ upcoming: "{label}: आने वाले",
+ past: "{label}: बीते हुए",
+ set: "{label}: मान है",
+ unset: "{label}: खाली",
+ yes: "{label}: हाँ",
+ no: "{label}: नहीं",
+ is: "{label}: {value}",
+ sortBy: "{label} से क्रमबद्ध करें",
+ clear: "फ़िल्टर हटाएँ",
+ empty: "इन फ़िल्टर से कोई एंट्री मेल नहीं खाती",
+ emptyDescription: "और देखने के लिए फ़िल्टर बदलें या हटाएँ।",
+ },
+ contentList: {
+ where: "सिर्फ़ वे एंट्री जहाँ",
+ add: "शर्त जोड़ें",
+ field: "फ़ील्ड",
+ op: "शर्त",
+ value: "मान",
+ remove: "शर्त हटाएँ",
+ ops: {
+ eq: "बराबर है",
+ ne: "बराबर नहीं है",
+ lt: "{kind, select, time {से पहले है} number {से कम है} other {से कम है}}",
+ lte: "{kind, select, time {को या उससे पहले है} number {अधिकतम है} other {अधिकतम है}}",
+ gt: "{kind, select, time {के बाद है} number {से ज़्यादा है} other {से ज़्यादा है}}",
+ gte: "{kind, select, time {को या उसके बाद है} number {कम से कम है} other {कम से कम है}}",
+ in: "इनमें से एक है",
+ set: "मान है",
+ unset: "खाली है",
+ },
+ now: "अभी",
+ aDate: "एक तारीख़",
+ dateHint: "एक तारीख़, जैसे 2026-11-05।",
+ instantHint: "ऑफ़सेट के साथ एक पल, जैसे 2026-11-05T19:30:00+03:00।",
+ yes: "हाँ",
+ no: "नहीं",
+ orderBy: "फ़ील्ड से क्रम",
+ orderNone: "ऊपर दिया गया सूची क्रम",
+ direction: "दिशा",
+ asc: "{kind, select, time {सबसे पुराने पहले} number {सबसे छोटे पहले} other {सबसे छोटे पहले}}",
+ desc: "{kind, select, time {सबसे नए पहले} number {सबसे बड़े पहले} other {सबसे बड़े पहले}}",
+ noListFields: "इस संग्रह में फ़िल्टर या क्रमबद्ध करने के लिए कोई लिस्टिंग फ़ील्ड नहीं है।",
+ settingsLink: "लिस्टिंग फ़ील्ड चुनें",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.id.ts b/apps/studio/src/i18n/lazy/listings.id.ts
new file mode 100644
index 00000000..e3f6e7e5
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.id.ts
@@ -0,0 +1,180 @@
+// Generated by `pnpm i18n:import` (ADR-0034): id `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: 19280fd279b140c4
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "Kolom daftar",
+ description:
+ "Hingga 4 kolom yang bisa dipakai daftar untuk memfilter dan mengurutkan: kolom tabel di daftar koleksi ini, kondisi di bagian daftar konten, dan kueri agen.",
+ add: "Tambah kolom daftar",
+ choose: "Pilih kolom",
+ empty: "Belum ada kolom daftar",
+ emptyDescription:
+ "Pilih kolom untuk menampilkannya sebagai kolom tabel dan memfilter daftar berdasarkan kolom itu.",
+ noFields: "Tambahkan kolom ke koleksi ini terlebih dahulu; kolom daftar dipilih dari situ.",
+ full: "Sudah 4 kolom, jumlah maksimum yang bisa didaftarkan koleksi.",
+ moveUp: "Pindahkan {label} ke atas",
+ moveDown: "Pindahkan {label} ke bawah",
+ remove: "Hapus {label}",
+ sortable: "Bisa diurutkan",
+ save: "Simpan kolom daftar",
+ saved: "Kolom daftar disimpan",
+ inUse:
+ "Bagian daftar konten masih memfilter atau mengurutkan berdasarkan kolom yang Anda hapus. Ubah halaman-halaman ini terlebih dahulu:",
+ },
+ expiry: {
+ title: "Kedaluwarsa",
+ description:
+ "Entri bisa berakhir pada tanggalnya sendiri, misalnya hari ketika acara selesai. Tidak ada yang dihapus atau dibatalkan terbitnya: situs memeriksa tanggal setiap kali menampilkan entri.",
+ enabled: "Akhiri entri pada suatu tanggal",
+ enabledHint: "Matikan untuk menyimpan setiap entri sampai ada yang membatalkan terbitnya.",
+ field: "Kolom tanggal",
+ fieldHint:
+ "Tanggal berakhir pada tengah malam setelahnya, menurut zona waktu situs; tanggal dan waktu berakhir pada saat itu.",
+ noDateFields:
+ "Koleksi ini tidak punya kolom tanggal atau tanggal dan waktu. Tambahkan satu untuk memakai kedaluwarsa.",
+ action: "Saat entri telah berakhir",
+ unlist: "Keluarkan dari daftar",
+ unlistDescription:
+ "Halamannya tetap di alamatnya, tetapi keluar dari daftar, bagian daftar konten, feed, sitemap, dan pencarian. Cocok untuk acara dan lowongan kerja.",
+ hide: "Sembunyikan halaman",
+ hideDescription:
+ "Halamannya menjawab “tidak ditemukan”, dan juga keluar dari setiap daftar, feed, dan pencarian. Cocok untuk penawaran yang tidak boleh tetap bisa diakses.",
+ grace: "Masa tenggang dalam menit",
+ graceHint:
+ "Tunggu selama ini setelah tanggal sebelum entri berakhir. Hingga 10.080 (satu minggu).",
+ past: "{count, plural, =0 {Belum ada entri terbit yang melewatinya.} other {# entri terbit sudah melewatinya.}}",
+ save: "Simpan kedaluwarsa",
+ saved: "Kedaluwarsa disimpan",
+ gatesUnsupported:
+ "Server live situs ini belum menerapkan kedaluwarsa. Deploy situs terlebih dahulu, lalu simpan kedaluwarsa lagi.",
+ },
+ structuredData: {
+ title: "Data terstruktur",
+ description:
+ "Jelaskan setiap entri ke mesin pencari sebagai acara, lowongan kerja, produk, atau resep, dari kolom-kolomnya. Tanpa tipe, entri dijelaskan sebagai postingan blog.",
+ type: "Tipe",
+ none: "Tidak ada (postingan blog)",
+ types: {
+ Event: "Acara",
+ JobPosting: "Lowongan kerja",
+ Product: "Produk",
+ Recipe: "Resep",
+ },
+ property: "Properti",
+ source: "Nilai dari",
+ required: "Wajib",
+ oneOf: "Salah satu dari ini",
+ sources: {
+ default: "Bawaan: {source}",
+ unmapped: "Tidak dipakai",
+ constant: "Nilai tetap",
+ field: "Kolom: {label}",
+ entry: "Entri: {property}",
+ site: "Situs: {property}",
+ },
+ entryProps: {
+ title: "Judul",
+ summary: "Ringkasan",
+ body: "Isi",
+ coverImage: "Gambar sampul",
+ url: "Alamat",
+ publishedAt: "Tanggal terbit",
+ updatedAt: "Tanggal diperbarui",
+ },
+ siteProps: {
+ name: "Nama situs",
+ url: "Alamat situs",
+ logo: "Logo situs",
+ },
+ constant: "Nilai tetap untuk {path}",
+ currencyHint: "Kode mata uang tiga huruf seperti EUR.",
+ issues: {
+ unknown_path: "Tipe ini tidak punya properti tersebut.",
+ unknown_field: "Kolom ini sudah tidak ada.",
+ source_mismatch: "Nilai ini tidak cocok untuk properti tersebut.",
+ vocabulary:
+ "Beberapa opsi kolom ini tidak bermakna bagi mesin pencari di sini; entri yang memakainya tidak menyertakan properti tersebut.",
+ currency: "Gunakan kode mata uang tiga huruf seperti EUR.",
+ required_missing: "Diperlukan: pilih dari mana nilainya berasal.",
+ constant_required:
+ "Properti wajib mengambil nilainya dari setiap entri, bukan dari nilai tetap.",
+ required_value_missing:
+ "Entri ini tidak punya nilai untuk properti wajib ({path}), jadi halamannya tetap memakai deskripsi postingan blog.",
+ too_large:
+ "Deskripsi entri ini terlalu besar, jadi halamannya tetap memakai deskripsi postingan blog.",
+ no_preset:
+ "Koleksi ini belum punya tipe data terstruktur, jadi halamannya menjelaskan entri sebagai postingan blog.",
+ },
+ suggestRequired:
+ "Tips: tandai kolom yang dipetakan ke properti wajib sebagai “Diperlukan untuk terbit”, agar tidak ada entri yang terbit tanpanya.",
+ themeReminder:
+ "Mesin pencari mengharapkan nilai-nilai ini terlihat di halaman, jadi pastikan tema Anda menampilkan kolom yang dipetakan.",
+ preview: "Pratinjau",
+ previewEntry: "Entri",
+ previewChoose: "Pilih entri",
+ previewDescription: "Apa yang dihasilkan halaman entri ini dengan pengaturan yang disimpan.",
+ previewEmpty: "Belum ada entri di koleksi ini.",
+ noDocument: "Halaman entri ini tidak menghasilkan data terstruktur dengan tipe ini.",
+ richResults: "Uji di Rich Results Test Google",
+ save: "Simpan data terstruktur",
+ saved: "Data terstruktur disimpan",
+ },
+ createPage: {
+ action: "Buat halaman daftar",
+ open: "Buka halaman daftar",
+ created: "Halaman daftar dibuat",
+ exists: "Sudah ada halaman di {path}.",
+ },
+ filters: {
+ label: "Filter menurut kolom",
+ any: "{label}: semua",
+ upcoming: "{label}: mendatang",
+ past: "{label}: lampau",
+ set: "{label}: punya nilai",
+ unset: "{label}: kosong",
+ yes: "{label}: ya",
+ no: "{label}: tidak",
+ is: "{label}: {value}",
+ sortBy: "Urutkan menurut {label}",
+ clear: "Hapus filter",
+ empty: "Tidak ada entri yang cocok dengan filter ini",
+ emptyDescription: "Ubah atau hapus filter untuk melihat lebih banyak.",
+ },
+ contentList: {
+ where: "Hanya entri yang",
+ add: "Tambah kondisi",
+ field: "Kolom",
+ op: "Kondisi",
+ value: "Nilai",
+ remove: "Hapus kondisi",
+ ops: {
+ eq: "adalah",
+ ne: "bukan",
+ lt: "{kind, select, time {sebelum} number {kurang dari} other {kurang dari}}",
+ lte: "{kind, select, time {pada atau sebelum} number {paling banyak} other {paling banyak}}",
+ gt: "{kind, select, time {setelah} number {lebih dari} other {lebih dari}}",
+ gte: "{kind, select, time {pada atau setelah} number {paling sedikit} other {paling sedikit}}",
+ in: "salah satu dari",
+ set: "punya nilai",
+ unset: "kosong",
+ },
+ now: "Sekarang",
+ aDate: "Sebuah tanggal",
+ dateHint: "Tanggal seperti 2026-11-05.",
+ instantHint: "Waktu dengan offset-nya, seperti 2026-11-05T19:30:00+03:00.",
+ yes: "Ya",
+ no: "Tidak",
+ orderBy: "Urutkan menurut kolom",
+ orderNone: "Urutan daftar di atas",
+ direction: "Arah",
+ asc: "{kind, select, time {Paling awal dulu} number {Terkecil dulu} other {Terkecil dulu}}",
+ desc: "{kind, select, time {Terbaru dulu} number {Terbesar dulu} other {Terbesar dulu}}",
+ noListFields: "Koleksi ini tidak punya kolom daftar untuk memfilter atau mengurutkan.",
+ settingsLink: "Pilih kolom daftar",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.it.ts b/apps/studio/src/i18n/lazy/listings.it.ts
new file mode 100644
index 00000000..7fcc4168
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.it.ts
@@ -0,0 +1,182 @@
+// Generated by `pnpm i18n:import` (ADR-0034): it `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: c04407e2a6ec168b
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "Campi di elenco",
+ description:
+ "Fino a 4 campi in base ai quali gli elenchi possono filtrare e ordinare: colonne nell’elenco di questa raccolta, condizioni nelle sezioni elenco di contenuti e query degli agenti.",
+ add: "Aggiungi un campo di elenco",
+ choose: "Scegli un campo",
+ empty: "Ancora nessun campo di elenco",
+ emptyDescription:
+ "Scegli dei campi per mostrarli come colonne e filtrare gli elenchi in base a essi.",
+ noFields:
+ "Aggiungi prima dei campi a questa raccolta; i campi di elenco si scelgono tra questi.",
+ full: "Sono 4 campi, il massimo che una raccolta può elencare.",
+ moveUp: "Sposta {label} in alto",
+ moveDown: "Sposta {label} in basso",
+ remove: "Rimuovi {label}",
+ sortable: "Ordinabile",
+ save: "Salva campi di elenco",
+ saved: "Campi di elenco salvati",
+ inUse:
+ "Alcune sezioni elenco di contenuti filtrano o ordinano ancora in base a un campo che hai tolto. Modifica prima queste pagine:",
+ },
+ expiry: {
+ title: "Scadenza",
+ description:
+ "Le voci possono terminare in una propria data, ad esempio il giorno in cui un evento è concluso. Niente viene eliminato o ritirato dalla pubblicazione: il sito controlla la data ogni volta che mostra una voce.",
+ enabled: "Fai terminare le voci in una data",
+ enabledHint:
+ "Disattiva per mantenere ogni voce finché qualcuno non ne annulla la pubblicazione.",
+ field: "Campo data",
+ fieldHint:
+ "Una data termina alla mezzanotte successiva, nel fuso orario del sito; una data e ora termina in quel momento.",
+ noDateFields:
+ "Questa raccolta non ha campi data o data e ora. Aggiungine uno per usare la scadenza.",
+ action: "Quando una voce è terminata",
+ unlist: "Esci dagli elenchi",
+ unlistDescription:
+ "La sua pagina resta al suo indirizzo, ma esce da elenchi, sezioni elenco di contenuti, feed, sitemap e ricerca. Adatto a eventi e offerte di lavoro.",
+ hide: "Nascondi la pagina",
+ hideDescription:
+ "La sua pagina risponde «non trovata» ed esce anche da ogni elenco, feed e ricerca. Adatto a offerte che non devono restare raggiungibili.",
+ grace: "Periodo di tolleranza in minuti",
+ graceHint:
+ "Attendi questo tempo dopo la data prima che la voce termini. Fino a 10.080 (una settimana).",
+ past: "{count, plural, =0 {Nessuna voce pubblicata l’ha ancora superata.} one {# voce pubblicata l’ha già superata.} many {# voci pubblicate l’hanno già superata.} other {# voci pubblicate l’hanno già superata.}}",
+ save: "Salva scadenza",
+ saved: "Scadenza salvata",
+ gatesUnsupported:
+ "Il server live di questo sito non applica ancora la scadenza. Distribuisci prima il sito, poi salva di nuovo la scadenza.",
+ },
+ structuredData: {
+ title: "Dati strutturati",
+ description:
+ "Descrivi ogni voce ai motori di ricerca come evento, offerta di lavoro, prodotto o ricetta, a partire dai suoi campi. Senza un tipo, le voci sono descritte come articoli del blog.",
+ type: "Tipo",
+ none: "Nessuno (articolo del blog)",
+ types: {
+ Event: "Evento",
+ JobPosting: "Offerta di lavoro",
+ Product: "Prodotto",
+ Recipe: "Ricetta",
+ },
+ property: "Proprietà",
+ source: "Valore da",
+ required: "Obbligatorio",
+ oneOf: "Uno di questi",
+ sources: {
+ default: "Predefinito: {source}",
+ unmapped: "Non usato",
+ constant: "Un valore fisso",
+ field: "Campo: {label}",
+ entry: "Voce: {property}",
+ site: "Sito: {property}",
+ },
+ entryProps: {
+ title: "Titolo",
+ summary: "Riassunto",
+ body: "Corpo",
+ coverImage: "Immagine di copertina",
+ url: "Indirizzo",
+ publishedAt: "Data di pubblicazione",
+ updatedAt: "Data di aggiornamento",
+ },
+ siteProps: {
+ name: "Nome del sito",
+ url: "Indirizzo del sito",
+ logo: "Logo del sito",
+ },
+ constant: "Valore fisso di {path}",
+ currencyHint: "Un codice valuta di tre lettere, ad esempio EUR.",
+ issues: {
+ unknown_path: "Questo tipo non ha questa proprietà.",
+ unknown_field: "Questo campo non esiste più.",
+ source_mismatch: "Questo valore non è adatto alla proprietà.",
+ vocabulary:
+ "Alcune opzioni di questo campo non significano nulla per i motori di ricerca; le voci che le usano omettono la proprietà.",
+ currency: "Usa un codice valuta di tre lettere, ad esempio EUR.",
+ required_missing: "Necessario: scegli da dove proviene il valore.",
+ constant_required:
+ "Le proprietà obbligatorie prendono il valore da ogni voce, non da un valore fisso.",
+ required_value_missing:
+ "Questa voce non ha un valore per una proprietà obbligatoria ({path}), quindi la sua pagina mantiene la descrizione da articolo del blog.",
+ too_large:
+ "La descrizione di questa voce è troppo grande, quindi la sua pagina mantiene la descrizione da articolo del blog.",
+ no_preset:
+ "Questa raccolta non ha ancora un tipo di dati strutturati, quindi le sue pagine descrivono le voci come articoli del blog.",
+ },
+ suggestRequired:
+ "Suggerimento: segna i campi associati a proprietà obbligatorie come «Necessario per pubblicare», così nessuna voce esce senza di essi.",
+ themeReminder:
+ "I motori di ricerca si aspettano che questi valori siano visibili nella pagina, quindi verifica che il tuo tema mostri i campi associati.",
+ preview: "Anteprima",
+ previewEntry: "Voce",
+ previewChoose: "Scegli una voce",
+ previewDescription: "Cosa emette la pagina di questa voce con le impostazioni salvate.",
+ previewEmpty: "Ancora nessuna voce in questa raccolta.",
+ noDocument: "La pagina di questa voce non emette dati strutturati di questo tipo.",
+ richResults: "Prova nel Rich Results Test di Google",
+ save: "Salva dati strutturati",
+ saved: "Dati strutturati salvati",
+ },
+ createPage: {
+ action: "Crea pagina elenco",
+ open: "Apri pagina elenco",
+ created: "Pagina elenco creata",
+ exists: "Esiste già una pagina in {path}.",
+ },
+ filters: {
+ label: "Filtra per campi",
+ any: "{label}: qualsiasi",
+ upcoming: "{label}: futuri",
+ past: "{label}: passati",
+ set: "{label}: ha un valore",
+ unset: "{label}: vuoto",
+ yes: "{label}: sì",
+ no: "{label}: no",
+ is: "{label}: {value}",
+ sortBy: "Ordina per {label}",
+ clear: "Cancella filtri",
+ empty: "Nessuna voce corrisponde a questi filtri",
+ emptyDescription: "Modifica o cancella i filtri per vederne di più.",
+ },
+ contentList: {
+ where: "Solo le voci in cui",
+ add: "Aggiungi condizione",
+ field: "Campo",
+ op: "Condizione",
+ value: "Valore",
+ remove: "Rimuovi condizione",
+ ops: {
+ eq: "è",
+ ne: "non è",
+ lt: "{kind, select, time {è prima del} number {è minore di} other {è minore di}}",
+ lte: "{kind, select, time {è entro il} number {è al massimo} other {è al massimo}}",
+ gt: "{kind, select, time {è dopo il} number {è maggiore di} other {è maggiore di}}",
+ gte: "{kind, select, time {è dal} number {è almeno} other {è almeno}}",
+ in: "è uno tra",
+ set: "ha un valore",
+ unset: "è vuoto",
+ },
+ now: "Adesso",
+ aDate: "Una data",
+ dateHint: "Una data, ad esempio 2026-11-05.",
+ instantHint: "Un momento con il suo offset, ad esempio 2026-11-05T19:30:00+03:00.",
+ yes: "Sì",
+ no: "No",
+ orderBy: "Ordina per campo",
+ orderNone: "L’ordine dell’elenco sopra",
+ direction: "Direzione",
+ asc: "{kind, select, time {Prima i meno recenti} number {Prima i più piccoli} other {Prima i più piccoli}}",
+ desc: "{kind, select, time {Prima i più recenti} number {Prima i più grandi} other {Prima i più grandi}}",
+ noListFields: "Questa raccolta non ha campi di elenco per filtrare o ordinare.",
+ settingsLink: "Scegli i campi di elenco",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.ja.ts b/apps/studio/src/i18n/lazy/listings.ja.ts
new file mode 100644
index 00000000..7a16971a
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.ja.ts
@@ -0,0 +1,179 @@
+// Generated by `pnpm i18n:import` (ADR-0034): ja `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: 268f44abed418576
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "リスト用フィールド",
+ description:
+ "リストの絞り込みと並べ替えに使えるフィールド(最大4つ)。このコレクションの一覧の列、コンテンツリストセクションの条件、エージェントのクエリで使われます。",
+ add: "リスト用フィールドを追加",
+ choose: "フィールドを選択",
+ empty: "リスト用フィールドはまだありません",
+ emptyDescription: "フィールドを選ぶと、列として表示し、リストを絞り込めるようになります。",
+ noFields:
+ "まずこのコレクションにフィールドを追加してください。リスト用フィールドはその中から選びます。",
+ full: "4つのフィールドが設定済みです。これがコレクションの上限です。",
+ moveUp: "{label} を上へ移動",
+ moveDown: "{label} を下へ移動",
+ remove: "{label} を削除",
+ sortable: "並べ替え可能",
+ save: "リスト用フィールドを保存",
+ saved: "リスト用フィールドを保存しました",
+ inUse:
+ "外したフィールドで、まだ絞り込みや並べ替えをしているコンテンツリストセクションがあります。先に次のページを変更してください:",
+ },
+ expiry: {
+ title: "有効期限",
+ description:
+ "エントリーは、イベントの終了日など、独自の日付で終了できます。削除や公開取り消しは行われません。サイトはエントリーを表示するたびに日付を確認します。",
+ enabled: "日付でエントリーを終了する",
+ enabledHint: "オフにすると、誰かが公開を取り消すまですべてのエントリーが残ります。",
+ field: "日付フィールド",
+ fieldHint:
+ "日付はサイトのタイムゾーンでその翌日の午前0時に終了し、日時はその時刻に終了します。",
+ noDateFields:
+ "このコレクションには日付または日時のフィールドがありません。有効期限を使うには追加してください。",
+ action: "エントリーが終了したとき",
+ unlist: "リストから外す",
+ unlistDescription:
+ "ページは同じアドレスに残りますが、リスト、コンテンツリストセクション、フィード、サイトマップ、検索から外れます。イベントや求人に適しています。",
+ hide: "ページを非表示にする",
+ hideDescription:
+ "ページは「見つかりません」を返し、すべてのリスト、フィード、検索からも外れます。アクセスできてはいけないオファーに適しています。",
+ grace: "猶予期間(分)",
+ graceHint: "日付を過ぎてからエントリーが終了するまでの待ち時間です。最大10,080(1週間)。",
+ past: "{count, plural, =0 {期限を過ぎた公開済みエントリーはまだありません。} other {公開済みエントリーのうち#件がすでに期限を過ぎています。}}",
+ save: "有効期限を保存",
+ saved: "有効期限を保存しました",
+ gatesUnsupported:
+ "このサイトのライブサーバーはまだ有効期限に対応していません。先にサイトをデプロイしてから、もう一度有効期限を保存してください。",
+ },
+ structuredData: {
+ title: "構造化データ",
+ description:
+ "各エントリーを、そのフィールドからイベント、求人、商品、レシピとして検索エンジンに伝えます。タイプがない場合、エントリーはブログ記事として伝えられます。",
+ type: "タイプ",
+ none: "なし(ブログ記事)",
+ types: {
+ Event: "イベント",
+ JobPosting: "求人",
+ Product: "商品",
+ Recipe: "レシピ",
+ },
+ property: "プロパティ",
+ source: "値の取得元",
+ required: "必須",
+ oneOf: "いずれか1つ",
+ sources: {
+ default: "デフォルト:{source}",
+ unmapped: "使用しない",
+ constant: "固定値",
+ field: "フィールド:{label}",
+ entry: "エントリー:{property}",
+ site: "サイト:{property}",
+ },
+ entryProps: {
+ title: "タイトル",
+ summary: "概要",
+ body: "本文",
+ coverImage: "カバー画像",
+ url: "アドレス",
+ publishedAt: "公開日",
+ updatedAt: "更新日",
+ },
+ siteProps: {
+ name: "サイト名",
+ url: "サイトのアドレス",
+ logo: "サイトのロゴ",
+ },
+ constant: "{path} の固定値",
+ currencyHint: "EUR のような3文字の通貨コード。",
+ issues: {
+ unknown_path: "このタイプにはそのプロパティがありません。",
+ unknown_field: "このフィールドはもう存在しません。",
+ source_mismatch: "この値はプロパティに合いません。",
+ vocabulary:
+ "このフィールドの一部の選択肢は、ここでは検索エンジンにとって意味を持ちません。それらを使うエントリーではプロパティが省略されます。",
+ currency: "EUR のような3文字の通貨コードを使ってください。",
+ required_missing: "必要:値の取得元を選んでください。",
+ constant_required: "必須プロパティの値は固定値ではなく、各エントリーから取得されます。",
+ required_value_missing:
+ "このエントリーには必須プロパティ({path})の値がないため、ページはブログ記事としての記述のままです。",
+ too_large:
+ "このエントリーの記述が大きすぎるため、ページはブログ記事としての記述のままです。",
+ no_preset:
+ "このコレクションにはまだ構造化データのタイプがないため、ページはエントリーをブログ記事として記述します。",
+ },
+ suggestRequired:
+ "ヒント:必須プロパティに対応付けたフィールドを「公開に必要」に設定すると、それらがないエントリーは公開されません。",
+ themeReminder:
+ "検索エンジンはこれらの値がページ上に表示されていることを前提にしています。テーマが対応付けたフィールドを表示しているか確認してください。",
+ preview: "プレビュー",
+ previewEntry: "エントリー",
+ previewChoose: "エントリーを選択",
+ previewDescription: "保存済みの設定で、このエントリーのページが出力する内容です。",
+ previewEmpty: "このコレクションにはまだエントリーがありません。",
+ noDocument: "このエントリーのページは、このタイプの構造化データを出力しません。",
+ richResults: "Google のリッチリザルト テスト(Rich Results Test)で確認",
+ save: "構造化データを保存",
+ saved: "構造化データを保存しました",
+ },
+ createPage: {
+ action: "一覧ページを作成",
+ open: "一覧ページを開く",
+ created: "一覧ページを作成しました",
+ exists: "{path} にはすでにページがあります。",
+ },
+ filters: {
+ label: "フィールドで絞り込み",
+ any: "{label}:すべて",
+ upcoming: "{label}:今後",
+ past: "{label}:過去",
+ set: "{label}:値あり",
+ unset: "{label}:空",
+ yes: "{label}:はい",
+ no: "{label}:いいえ",
+ is: "{label}:{value}",
+ sortBy: "{label} で並べ替え",
+ clear: "絞り込みをクリア",
+ empty: "この条件に一致するエントリーはありません",
+ emptyDescription: "絞り込みを変更またはクリアすると、さらに表示されます。",
+ },
+ contentList: {
+ where: "次の条件に合うエントリーのみ",
+ add: "条件を追加",
+ field: "フィールド",
+ op: "条件",
+ value: "値",
+ remove: "条件を削除",
+ ops: {
+ eq: "等しい",
+ ne: "等しくない",
+ lt: "{kind, select, time {より前} number {より小さい} other {より小さい}}",
+ lte: "{kind, select, time {以前} number {以下} other {以下}}",
+ gt: "{kind, select, time {より後} number {より大きい} other {より大きい}}",
+ gte: "{kind, select, time {以降} number {以上} other {以上}}",
+ in: "いずれかに一致",
+ set: "値あり",
+ unset: "空",
+ },
+ now: "現在",
+ aDate: "日付を指定",
+ dateHint: "2026-11-05 のような日付。",
+ instantHint: "2026-11-05T19:30:00+03:00 のような、オフセット付きの日時。",
+ yes: "はい",
+ no: "いいえ",
+ orderBy: "並べ替えるフィールド",
+ orderNone: "上のリストの順序",
+ direction: "順序",
+ asc: "{kind, select, time {古い順} number {小さい順} other {小さい順}}",
+ desc: "{kind, select, time {新しい順} number {大きい順} other {大きい順}}",
+ noListFields:
+ "このコレクションには、絞り込みや並べ替えに使えるリスト用フィールドがありません。",
+ settingsLink: "リスト用フィールドを選択",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.ko.ts b/apps/studio/src/i18n/lazy/listings.ko.ts
new file mode 100644
index 00000000..d08b985a
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.ko.ts
@@ -0,0 +1,176 @@
+// Generated by `pnpm i18n:import` (ADR-0034): ko `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: 7316df5d29a2b7fb
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "목록 필드",
+ description:
+ "목록에서 필터링하고 정렬할 수 있는 필드로, 최대 4개까지 지정해요. 이 컬렉션 목록의 열, 콘텐츠 목록 섹션의 조건, 에이전트의 쿼리에 쓰여요.",
+ add: "목록 필드 추가",
+ choose: "필드 선택",
+ empty: "아직 목록 필드가 없어요",
+ emptyDescription: "필드를 선택하면 열로 표시하고 목록을 필터링할 수 있어요.",
+ noFields: "먼저 이 컬렉션에 필드를 추가하세요. 목록 필드는 그중에서 선택해요.",
+ full: "필드 4개를 모두 지정했어요. 컬렉션에서 쓸 수 있는 최대 개수예요.",
+ moveUp: "{label} 위로 이동",
+ moveDown: "{label} 아래로 이동",
+ remove: "{label} 삭제",
+ sortable: "정렬 가능",
+ save: "목록 필드 저장",
+ saved: "목록 필드를 저장했습니다",
+ inUse:
+ "뺀 필드로 아직 필터링하거나 정렬하는 콘텐츠 목록 섹션이 있어요. 먼저 다음 페이지를 변경하세요:",
+ },
+ expiry: {
+ title: "만료",
+ description:
+ "항목은 이벤트가 끝나는 날처럼 자체 날짜에 종료될 수 있어요. 삭제되거나 게시 취소되는 것은 없어요. 사이트가 항목을 표시할 때마다 날짜를 확인해요.",
+ enabled: "날짜에 항목 종료",
+ enabledHint: "끄면 누군가 게시를 취소할 때까지 모든 항목이 유지돼요.",
+ field: "날짜 필드",
+ fieldHint:
+ "날짜는 사이트 시간대 기준으로 그다음 자정에 종료되고, 날짜와 시간은 그 시각에 종료돼요.",
+ noDateFields:
+ "이 컬렉션에는 날짜 또는 날짜와 시간 필드가 없어요. 만료를 사용하려면 하나 추가하세요.",
+ action: "항목이 종료되면",
+ unlist: "목록에서 제외",
+ unlistDescription:
+ "페이지는 원래 주소에 남지만 목록, 콘텐츠 목록 섹션, 피드, 사이트맵, 검색에서 빠져요. 이벤트와 채용 공고에 적합해요.",
+ hide: "페이지 숨기기",
+ hideDescription:
+ "페이지가 “찾을 수 없음”으로 응답하고, 모든 목록, 피드, 검색에서도 빠져요. 계속 접근할 수 있으면 안 되는 혜택에 적합해요.",
+ grace: "유예 기간(분)",
+ graceHint: "날짜가 지난 뒤 항목이 종료되기 전까지 이만큼 기다려요. 최대 10,080(1주).",
+ past: "{count, plural, =0 {아직 기한이 지난 게시 항목이 없어요.} other {게시된 항목 #개가 이미 기한이 지났어요.}}",
+ save: "만료 저장",
+ saved: "만료를 저장했습니다",
+ gatesUnsupported:
+ "이 사이트의 라이브 서버는 아직 만료를 적용하지 않아요. 먼저 사이트를 배포한 다음 만료를 다시 저장하세요.",
+ },
+ structuredData: {
+ title: "구조화된 데이터",
+ description:
+ "각 항목을 필드를 바탕으로 이벤트, 채용 공고, 제품, 레시피로 검색 엔진에 설명해요. 유형이 없으면 항목은 블로그 글로 설명돼요.",
+ type: "유형",
+ none: "없음(블로그 글)",
+ types: {
+ Event: "이벤트",
+ JobPosting: "채용 공고",
+ Product: "제품",
+ Recipe: "레시피",
+ },
+ property: "속성",
+ source: "값 출처",
+ required: "필수",
+ oneOf: "다음 중 하나",
+ sources: {
+ default: "기본값: {source}",
+ unmapped: "사용 안 함",
+ constant: "고정값",
+ field: "필드: {label}",
+ entry: "항목: {property}",
+ site: "사이트: {property}",
+ },
+ entryProps: {
+ title: "제목",
+ summary: "요약",
+ body: "본문",
+ coverImage: "커버 이미지",
+ url: "주소",
+ publishedAt: "게시일",
+ updatedAt: "수정일",
+ },
+ siteProps: {
+ name: "사이트 이름",
+ url: "사이트 주소",
+ logo: "사이트 로고",
+ },
+ constant: "{path}의 고정값",
+ currencyHint: "EUR 같은 세 글자 통화 코드.",
+ issues: {
+ unknown_path: "이 유형에는 그런 속성이 없어요.",
+ unknown_field: "이 필드는 더 이상 존재하지 않아요.",
+ source_mismatch: "이 값은 속성에 맞지 않아요.",
+ vocabulary:
+ "이 필드의 일부 옵션은 여기서 검색 엔진에 아무 의미가 없어요. 그 옵션을 쓰는 항목에서는 속성이 빠져요.",
+ currency: "EUR 같은 세 글자 통화 코드를 사용하세요.",
+ required_missing: "필요: 값을 어디서 가져올지 선택하세요.",
+ constant_required: "필수 속성은 고정값이 아니라 각 항목에서 값을 가져와요.",
+ required_value_missing:
+ "이 항목에는 필수 속성({path})의 값이 없어서 페이지가 블로그 글 설명을 유지해요.",
+ too_large: "이 항목의 설명이 너무 커서 페이지가 블로그 글 설명을 유지해요.",
+ no_preset:
+ "이 컬렉션에는 아직 구조화된 데이터 유형이 없어서 페이지가 항목을 블로그 글로 설명해요.",
+ },
+ suggestRequired:
+ "팁: 필수 속성에 연결한 필드를 “게시에 필요”로 표시하면 그 값 없이 게시되는 항목이 없어요.",
+ themeReminder:
+ "검색 엔진은 이 값들이 페이지에 보이기를 기대하므로, 테마가 연결된 필드를 표시하는지 확인하세요.",
+ preview: "미리보기",
+ previewEntry: "항목",
+ previewChoose: "항목 선택",
+ previewDescription: "저장된 설정으로 이 항목의 페이지가 출력하는 내용이에요.",
+ previewEmpty: "이 컬렉션에 아직 항목이 없어요.",
+ noDocument: "이 항목의 페이지는 이 유형의 구조화된 데이터를 출력하지 않아요.",
+ richResults: "Google 리치 결과 테스트(Rich Results Test)에서 확인",
+ save: "구조화된 데이터 저장",
+ saved: "구조화된 데이터를 저장했습니다",
+ },
+ createPage: {
+ action: "목록 페이지 만들기",
+ open: "목록 페이지 열기",
+ created: "목록 페이지를 만들었습니다",
+ exists: "{path}에 이미 페이지가 있어요.",
+ },
+ filters: {
+ label: "필드로 필터링",
+ any: "{label}: 전체",
+ upcoming: "{label}: 예정",
+ past: "{label}: 지남",
+ set: "{label}: 값 있음",
+ unset: "{label}: 비어 있음",
+ yes: "{label}: 예",
+ no: "{label}: 아니요",
+ is: "{label}: {value}",
+ sortBy: "{label} 기준 정렬",
+ clear: "필터 지우기",
+ empty: "이 필터와 일치하는 항목이 없어요",
+ emptyDescription: "필터를 바꾸거나 지우면 더 볼 수 있어요.",
+ },
+ contentList: {
+ where: "다음 조건의 항목만",
+ add: "조건 추가",
+ field: "필드",
+ op: "조건",
+ value: "값",
+ remove: "조건 삭제",
+ ops: {
+ eq: "같음",
+ ne: "같지 않음",
+ lt: "{kind, select, time {이전} number {미만} other {미만}}",
+ lte: "{kind, select, time {당일 또는 이전} number {이하} other {이하}}",
+ gt: "{kind, select, time {이후} number {초과} other {초과}}",
+ gte: "{kind, select, time {당일 또는 이후} number {이상} other {이상}}",
+ in: "다음 중 하나",
+ set: "값 있음",
+ unset: "비어 있음",
+ },
+ now: "지금",
+ aDate: "날짜 지정",
+ dateHint: "2026-11-05 같은 날짜.",
+ instantHint: "2026-11-05T19:30:00+03:00처럼 오프셋이 포함된 시각.",
+ yes: "예",
+ no: "아니요",
+ orderBy: "정렬 기준 필드",
+ orderNone: "위 목록의 순서",
+ direction: "방향",
+ asc: "{kind, select, time {오래된 순} number {작은 순} other {작은 순}}",
+ desc: "{kind, select, time {최신순} number {큰 순} other {큰 순}}",
+ noListFields: "이 컬렉션에는 필터링하거나 정렬할 목록 필드가 없어요.",
+ settingsLink: "목록 필드 선택",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.nl.ts b/apps/studio/src/i18n/lazy/listings.nl.ts
new file mode 100644
index 00000000..cdaf33ef
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.nl.ts
@@ -0,0 +1,178 @@
+// Generated by `pnpm i18n:import` (ADR-0034): nl `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: 9d897fe6ee541f88
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "Lijstvelden",
+ description:
+ "Maximaal 4 velden waarop lijsten kunnen filteren en sorteren: kolommen in de lijst van deze collectie, voorwaarden in contentlijstsecties en zoekopdrachten van agents.",
+ add: "Lijstveld toevoegen",
+ choose: "Kies een veld",
+ empty: "Nog geen lijstvelden",
+ emptyDescription: "Kies velden om ze als kolommen te tonen en lijsten erop te filteren.",
+ noFields: "Voeg eerst velden toe aan deze collectie; lijstvelden kies je daaruit.",
+ full: "Dat zijn 4 velden, het maximum dat een collectie kan tonen.",
+ moveUp: "{label} omhoog verplaatsen",
+ moveDown: "{label} omlaag verplaatsen",
+ remove: "{label} verwijderen",
+ sortable: "Sorteerbaar",
+ save: "Lijstvelden opslaan",
+ saved: "Lijstvelden opgeslagen",
+ inUse:
+ "Contentlijstsecties filteren of sorteren nog op een veld dat je hebt weggehaald. Pas eerst deze pagina's aan:",
+ },
+ expiry: {
+ title: "Verloopdatum",
+ description:
+ "Items kunnen op een eigen datum aflopen, zoals de dag waarop een evenement voorbij is. Er wordt niets verwijderd of gedepubliceerd: de site controleert de datum telkens wanneer een item wordt getoond.",
+ enabled: "Items op een datum laten aflopen",
+ enabledHint: "Zet dit uit om elk item te bewaren tot iemand de publicatie ervan intrekt.",
+ field: "Datumveld",
+ fieldHint:
+ "Een datum loopt af om middernacht erna, in de tijdzone van de site; een datum en tijd loopt op dat moment af.",
+ noDateFields:
+ "Deze collectie heeft geen datum- of datum-en-tijdveld. Voeg er een toe om de verloopdatum te gebruiken.",
+ action: "Als een item is afgelopen",
+ unlist: "Uit lijsten halen",
+ unlistDescription:
+ "De pagina blijft op haar adres, maar verdwijnt uit lijsten, contentlijstsecties, feeds, de sitemap en de zoekfunctie. Handig voor evenementen en vacatures.",
+ hide: "Pagina verbergen",
+ hideDescription:
+ "De pagina antwoordt met “niet gevonden” en verdwijnt ook uit elke lijst, feed en zoekfunctie. Handig voor aanbiedingen die niet bereikbaar mogen blijven.",
+ grace: "Respijtperiode in minuten",
+ graceHint: "Wacht zo lang na de datum voordat het item afloopt. Maximaal 10.080 (één week).",
+ past: "{count, plural, =0 {Nog geen gepubliceerde items zijn verlopen.} one {# gepubliceerd item is al verlopen.} other {# gepubliceerde items zijn al verlopen.}}",
+ save: "Verloopdatum opslaan",
+ saved: "Verloopdatum opgeslagen",
+ gatesUnsupported:
+ "De live server van deze site past de verloopdatum nog niet toe. Deploy eerst de site en sla de verloopdatum daarna opnieuw op.",
+ },
+ structuredData: {
+ title: "Gestructureerde gegevens",
+ description:
+ "Beschrijf elk item aan zoekmachines als evenement, vacature, product of recept, op basis van de velden. Zonder type worden items beschreven als blogposts.",
+ type: "Type",
+ none: "Geen (blogpost)",
+ types: {
+ Event: "Evenement",
+ JobPosting: "Vacature",
+ Product: "Product",
+ Recipe: "Recept",
+ },
+ property: "Eigenschap",
+ source: "Waarde uit",
+ required: "Verplicht",
+ oneOf: "Een van deze",
+ sources: {
+ default: "Standaard: {source}",
+ unmapped: "Niet gebruikt",
+ constant: "Een vaste waarde",
+ field: "Veld: {label}",
+ entry: "Item: {property}",
+ site: "Site: {property}",
+ },
+ entryProps: {
+ title: "Titel",
+ summary: "Samenvatting",
+ body: "Hoofdtekst",
+ coverImage: "Omslagafbeelding",
+ url: "Adres",
+ publishedAt: "Publicatiedatum",
+ updatedAt: "Datum bijgewerkt",
+ },
+ siteProps: {
+ name: "Sitenaam",
+ url: "Siteadres",
+ logo: "Sitelogo",
+ },
+ constant: "Vaste waarde van {path}",
+ currencyHint: "Een valutacode van drie letters, zoals EUR.",
+ issues: {
+ unknown_path: "Dit type heeft deze eigenschap niet.",
+ unknown_field: "Dit veld bestaat niet meer.",
+ source_mismatch: "Deze waarde past niet bij de eigenschap.",
+ vocabulary:
+ "Sommige opties van dit veld betekenen hier niets voor zoekmachines; items die ze gebruiken laten de eigenschap weg.",
+ currency: "Gebruik een valutacode van drie letters, zoals EUR.",
+ required_missing: "Nodig: kies waar de waarde vandaan komt.",
+ constant_required:
+ "Verplichte eigenschappen halen hun waarde uit elk item, niet uit een vaste waarde.",
+ required_value_missing:
+ "Dit item heeft geen waarde voor een verplichte eigenschap ({path}), dus de pagina houdt de blogpostbeschrijving.",
+ too_large:
+ "De beschrijving van dit item is te groot, dus de pagina houdt de blogpostbeschrijving.",
+ no_preset:
+ "Deze collectie heeft nog geen type gestructureerde gegevens, dus de pagina's beschrijven items als blogposts.",
+ },
+ suggestRequired:
+ "Tip: markeer de velden die aan verplichte eigenschappen gekoppeld zijn als “Nodig om te publiceren”, zodat geen item zonder die velden online gaat.",
+ themeReminder:
+ "Zoekmachines verwachten dat deze waarden op de pagina zichtbaar zijn, dus controleer of je thema de gekoppelde velden toont.",
+ preview: "Voorbeeld",
+ previewEntry: "Item",
+ previewChoose: "Kies een item",
+ previewDescription: "Wat de pagina van dit item uitvoert met de opgeslagen instellingen.",
+ previewEmpty: "Nog geen items in deze collectie.",
+ noDocument: "De pagina van dit item voert geen gestructureerde gegevens van dit type uit.",
+ richResults: "Testen in de Rich Results Test van Google",
+ save: "Gestructureerde gegevens opslaan",
+ saved: "Gestructureerde gegevens opgeslagen",
+ },
+ createPage: {
+ action: "Lijstpagina maken",
+ open: "Lijstpagina openen",
+ created: "Lijstpagina gemaakt",
+ exists: "Er staat al een pagina op {path}.",
+ },
+ filters: {
+ label: "Filteren op velden",
+ any: "{label}: alle",
+ upcoming: "{label}: komend",
+ past: "{label}: voorbij",
+ set: "{label}: heeft een waarde",
+ unset: "{label}: leeg",
+ yes: "{label}: ja",
+ no: "{label}: nee",
+ is: "{label}: {value}",
+ sortBy: "Sorteren op {label}",
+ clear: "Filters wissen",
+ empty: "Geen items komen overeen met deze filters",
+ emptyDescription: "Wijzig of wis de filters om meer te zien.",
+ },
+ contentList: {
+ where: "Alleen items waarbij",
+ add: "Voorwaarde toevoegen",
+ field: "Veld",
+ op: "Voorwaarde",
+ value: "Waarde",
+ remove: "Voorwaarde verwijderen",
+ ops: {
+ eq: "is",
+ ne: "is niet",
+ lt: "{kind, select, time {is vóór} number {is kleiner dan} other {is kleiner dan}}",
+ lte: "{kind, select, time {is op of vóór} number {is hoogstens} other {is hoogstens}}",
+ gt: "{kind, select, time {is na} number {is groter dan} other {is groter dan}}",
+ gte: "{kind, select, time {is op of na} number {is minstens} other {is minstens}}",
+ in: "is een van",
+ set: "heeft een waarde",
+ unset: "is leeg",
+ },
+ now: "Nu",
+ aDate: "Een datum",
+ dateHint: "Een datum zoals 2026-11-05.",
+ instantHint: "Een moment met de tijdverschuiving, zoals 2026-11-05T19:30:00+03:00.",
+ yes: "Ja",
+ no: "Nee",
+ orderBy: "Sorteren op veld",
+ orderNone: "De lijstvolgorde hierboven",
+ direction: "Richting",
+ asc: "{kind, select, time {Vroegste eerst} number {Kleinste eerst} other {Kleinste eerst}}",
+ desc: "{kind, select, time {Laatste eerst} number {Grootste eerst} other {Grootste eerst}}",
+ noListFields: "Deze collectie heeft geen lijstvelden om op te filteren of te sorteren.",
+ settingsLink: "Lijstvelden kiezen",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.pl.ts b/apps/studio/src/i18n/lazy/listings.pl.ts
new file mode 100644
index 00000000..09e020c9
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.pl.ts
@@ -0,0 +1,177 @@
+// Generated by `pnpm i18n:import` (ADR-0034): pl `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: 256b7cb6e36a6391
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "Pola list",
+ description:
+ "Do 4 pól, według których listy mogą filtrować i sortować: kolumny na liście tej kolekcji, warunki w sekcjach listy treści i zapytania agentów.",
+ add: "Dodaj pole listy",
+ choose: "Wybierz pole",
+ empty: "Brak pól list",
+ emptyDescription: "Wybierz pola, aby pokazać je jako kolumny i filtrować według nich listy.",
+ noFields: "Najpierw dodaj pola do tej kolekcji; pola list wybiera się spośród nich.",
+ full: "To już 4 pola, najwięcej, ile kolekcja może mieć na listach.",
+ moveUp: "Przesuń {label} w górę",
+ moveDown: "Przesuń {label} w dół",
+ remove: "Usuń {label}",
+ sortable: "Sortowalne",
+ save: "Zapisz pola list",
+ saved: "Pola list zapisane",
+ inUse:
+ "Sekcje listy treści nadal filtrują lub sortują według usuniętego pola. Najpierw zmień te strony:",
+ },
+ expiry: {
+ title: "Wygaśnięcie",
+ description:
+ "Wpisy mogą kończyć się we własnym terminie, np. w dniu zakończenia wydarzenia. Nic nie jest usuwane ani wycofywane z publikacji: witryna sprawdza datę za każdym razem, gdy pokazuje wpis.",
+ enabled: "Kończ wpisy w określonym dniu",
+ enabledHint: "Wyłącz, aby zachować każdy wpis, dopóki ktoś nie wycofa jego publikacji.",
+ field: "Pole daty",
+ fieldHint:
+ "Data kończy się o północy po niej, w strefie czasowej witryny; data i godzina kończy się dokładnie w tym momencie.",
+ noDateFields:
+ "Ta kolekcja nie ma pola daty ani daty i godziny. Dodaj je, aby używać wygaśnięcia.",
+ action: "Gdy wpis się zakończy",
+ unlist: "Zniknij z list",
+ unlistDescription:
+ "Jego strona zostaje pod swoim adresem, ale znika z list, sekcji listy treści, kanałów, mapy witryny i wyszukiwania. Dobre dla wydarzeń i ofert pracy.",
+ hide: "Ukryj stronę",
+ hideDescription:
+ "Jego strona odpowiada „nie znaleziono” i znika też ze wszystkich list, kanałów i wyszukiwania. Dobre dla ofert, które nie mogą pozostać dostępne.",
+ grace: "Okres karencji w minutach",
+ graceHint: "Wpis kończy się tyle minut po dacie. Maksymalnie 10 080 (jeden tydzień).",
+ past: "{count, plural, =0 {Żaden opublikowany wpis nie jest jeszcze po terminie.} one {# opublikowany wpis jest już po terminie.} few {# opublikowane wpisy są już po terminie.} many {# opublikowanych wpisów jest już po terminie.} other {# opublikowanego wpisu jest już po terminie.}}",
+ save: "Zapisz wygaśnięcie",
+ saved: "Wygaśnięcie zapisane",
+ gatesUnsupported:
+ "Serwer tej witryny na żywo nie stosuje jeszcze wygaśnięcia. Najpierw wdróż witrynę, a potem ponownie zapisz wygaśnięcie.",
+ },
+ structuredData: {
+ title: "Dane strukturalne",
+ description:
+ "Opisz każdy wpis wyszukiwarkom jako wydarzenie, ofertę pracy, produkt lub przepis na podstawie jego pól. Bez typu wpisy są opisywane jako posty na blogu.",
+ type: "Typ",
+ none: "Brak (post na blogu)",
+ types: {
+ Event: "Wydarzenie",
+ JobPosting: "Oferta pracy",
+ Product: "Produkt",
+ Recipe: "Przepis",
+ },
+ property: "Właściwość",
+ source: "Wartość z",
+ required: "Wymagane",
+ oneOf: "Jedno z tych",
+ sources: {
+ default: "Domyślnie: {source}",
+ unmapped: "Nieużywane",
+ constant: "Stała wartość",
+ field: "Pole: {label}",
+ entry: "Wpis: {property}",
+ site: "Witryna: {property}",
+ },
+ entryProps: {
+ title: "Tytuł",
+ summary: "Podsumowanie",
+ body: "Treść",
+ coverImage: "Obraz okładki",
+ url: "Adres",
+ publishedAt: "Data publikacji",
+ updatedAt: "Data aktualizacji",
+ },
+ siteProps: {
+ name: "Nazwa witryny",
+ url: "Adres witryny",
+ logo: "Logo witryny",
+ },
+ constant: "Stała wartość {path}",
+ currencyHint: "Trzyliterowy kod waluty, np. EUR.",
+ issues: {
+ unknown_path: "Ten typ nie ma takiej właściwości.",
+ unknown_field: "To pole już nie istnieje.",
+ source_mismatch: "Ta wartość nie pasuje do właściwości.",
+ vocabulary:
+ "Niektóre opcje tego pola nic tu nie znaczą dla wyszukiwarek; wpisy, które ich używają, pomijają tę właściwość.",
+ currency: "Użyj trzyliterowego kodu waluty, np. EUR.",
+ required_missing: "Wymagane: wybierz, skąd pochodzi wartość.",
+ constant_required:
+ "Wymagane właściwości biorą wartość z każdego wpisu, a nie ze stałej wartości.",
+ required_value_missing:
+ "Ten wpis nie ma wartości wymaganej właściwości ({path}), więc jego strona zachowuje opis posta na blogu.",
+ too_large: "Opis tego wpisu jest za duży, więc jego strona zachowuje opis posta na blogu.",
+ no_preset:
+ "Ta kolekcja nie ma jeszcze typu danych strukturalnych, więc jej strony opisują wpisy jako posty na blogu.",
+ },
+ suggestRequired:
+ "Wskazówka: oznacz pola przypisane do wymaganych właściwości jako „Wymagane do publikacji”, aby żaden wpis nie wyszedł bez nich.",
+ themeReminder:
+ "Wyszukiwarki oczekują, że te wartości będą widoczne na stronie, więc sprawdź, czy twój motyw pokazuje przypisane pola.",
+ preview: "Podgląd",
+ previewEntry: "Wpis",
+ previewChoose: "Wybierz wpis",
+ previewDescription: "Co strona tego wpisu emituje przy zapisanych ustawieniach.",
+ previewEmpty: "Brak wpisów w tej kolekcji.",
+ noDocument: "Strona tego wpisu nie emituje danych strukturalnych tego typu.",
+ richResults: "Sprawdź w Rich Results Test od Google",
+ save: "Zapisz dane strukturalne",
+ saved: "Dane strukturalne zapisane",
+ },
+ createPage: {
+ action: "Utwórz stronę listy",
+ open: "Otwórz stronę listy",
+ created: "Strona listy utworzona",
+ exists: "Pod adresem {path} jest już strona.",
+ },
+ filters: {
+ label: "Filtruj według pól",
+ any: "{label}: dowolne",
+ upcoming: "{label}: nadchodzące",
+ past: "{label}: minione",
+ set: "{label}: ma wartość",
+ unset: "{label}: puste",
+ yes: "{label}: tak",
+ no: "{label}: nie",
+ is: "{label}: {value}",
+ sortBy: "Sortuj według {label}",
+ clear: "Wyczyść filtry",
+ empty: "Żaden wpis nie pasuje do tych filtrów",
+ emptyDescription: "Zmień lub wyczyść filtry, aby zobaczyć więcej.",
+ },
+ contentList: {
+ where: "Tylko wpisy, w których",
+ add: "Dodaj warunek",
+ field: "Pole",
+ op: "Warunek",
+ value: "Wartość",
+ remove: "Usuń warunek",
+ ops: {
+ eq: "jest",
+ ne: "nie jest",
+ lt: "{kind, select, time {jest przed} number {jest mniejsze niż} other {jest mniejsze niż}}",
+ lte: "{kind, select, time {jest nie później niż} number {wynosi najwyżej} other {wynosi najwyżej}}",
+ gt: "{kind, select, time {jest po} number {jest większe niż} other {jest większe niż}}",
+ gte: "{kind, select, time {jest nie wcześniej niż} number {wynosi co najmniej} other {wynosi co najmniej}}",
+ in: "jest jednym z",
+ set: "ma wartość",
+ unset: "jest puste",
+ },
+ now: "Teraz",
+ aDate: "Konkretna data",
+ dateHint: "Data, np. 2026-11-05.",
+ instantHint: "Moment z przesunięciem strefy, np. 2026-11-05T19:30:00+03:00.",
+ yes: "Tak",
+ no: "Nie",
+ orderBy: "Sortuj według pola",
+ orderNone: "Kolejność listy powyżej",
+ direction: "Kierunek",
+ asc: "{kind, select, time {Od najwcześniejszych} number {Od najmniejszych} other {Od najmniejszych}}",
+ desc: "{kind, select, time {Od najpóźniejszych} number {Od największych} other {Od największych}}",
+ noListFields: "Ta kolekcja nie ma pól list, według których można filtrować lub sortować.",
+ settingsLink: "Wybierz pola list",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.pt-BR.ts b/apps/studio/src/i18n/lazy/listings.pt-BR.ts
new file mode 100644
index 00000000..7e293f83
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.pt-BR.ts
@@ -0,0 +1,179 @@
+// Generated by `pnpm i18n:import` (ADR-0034): pt-BR `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: fdc20280db78af19
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "Campos de listagem",
+ description:
+ "Até 4 campos pelos quais as listas podem filtrar e ordenar: colunas na lista desta coleção, condições nas seções de lista de conteúdo e consultas dos agentes.",
+ add: "Adicionar campo de listagem",
+ choose: "Escolher um campo",
+ empty: "Nenhum campo de listagem ainda",
+ emptyDescription: "Escolha campos para mostrá-los como colunas e filtrar listas por eles.",
+ noFields:
+ "Adicione campos a esta coleção primeiro; os campos de listagem são escolhidos entre eles.",
+ full: "São 4 campos, o máximo que uma coleção pode listar.",
+ moveUp: "Mover {label} para cima",
+ moveDown: "Mover {label} para baixo",
+ remove: "Remover {label}",
+ sortable: "Ordenável",
+ save: "Salvar campos de listagem",
+ saved: "Campos de listagem salvos",
+ inUse:
+ "Seções de lista de conteúdo ainda filtram ou ordenam por um campo que você tirou. Altere estas páginas primeiro:",
+ },
+ expiry: {
+ title: "Expiração",
+ description:
+ "As entradas podem terminar em uma data própria, como o dia em que um evento acaba. Nada é excluído nem despublicado: o site verifica a data toda vez que mostra uma entrada.",
+ enabled: "Encerrar entradas em uma data",
+ enabledHint: "Desative para manter cada entrada até que alguém a despublique.",
+ field: "Campo de data",
+ fieldHint:
+ "Uma data termina à meia-noite seguinte, no fuso horário do site; uma data e hora termina naquele momento.",
+ noDateFields:
+ "Esta coleção não tem campo de data nem de data e hora. Adicione um para usar a expiração.",
+ action: "Quando uma entrada termina",
+ unlist: "Sair das listas",
+ unlistDescription:
+ "A página continua no mesmo endereço, mas sai das listas, das seções de lista de conteúdo, dos feeds, do sitemap e da busca. Bom para eventos e vagas.",
+ hide: "Ocultar a página",
+ hideDescription:
+ "A página responde “não encontrado” e também sai de todas as listas, feeds e da busca. Bom para ofertas que não podem continuar acessíveis.",
+ grace: "Período de tolerância em minutos",
+ graceHint: "A entrada termina esse tempo depois da data. Até 10.080 (uma semana).",
+ past: "{count, plural, =0 {Nenhuma entrada publicada passou da data ainda.} one {# entrada publicada já passou da data.} many {# entradas publicadas já passaram da data.} other {# entradas publicadas já passaram da data.}}",
+ save: "Salvar expiração",
+ saved: "Expiração salva",
+ gatesUnsupported:
+ "O servidor no ar deste site ainda não aplica a expiração. Implante o site primeiro e depois salve a expiração de novo.",
+ },
+ structuredData: {
+ title: "Dados estruturados",
+ description:
+ "Descreva cada entrada para os mecanismos de busca como evento, vaga, produto ou receita, a partir dos campos dela. Sem um tipo, as entradas são descritas como posts de blog.",
+ type: "Tipo",
+ none: "Nenhum (post de blog)",
+ types: {
+ Event: "Evento",
+ JobPosting: "Vaga de emprego",
+ Product: "Produto",
+ Recipe: "Receita",
+ },
+ property: "Propriedade",
+ source: "Valor de",
+ required: "Obrigatório",
+ oneOf: "Um destes",
+ sources: {
+ default: "Padrão: {source}",
+ unmapped: "Não usado",
+ constant: "Um valor fixo",
+ field: "Campo: {label}",
+ entry: "Entrada: {property}",
+ site: "Site: {property}",
+ },
+ entryProps: {
+ title: "Título",
+ summary: "Resumo",
+ body: "Corpo",
+ coverImage: "Imagem de capa",
+ url: "Endereço",
+ publishedAt: "Data de publicação",
+ updatedAt: "Data de atualização",
+ },
+ siteProps: {
+ name: "Nome do site",
+ url: "Endereço do site",
+ logo: "Logo do site",
+ },
+ constant: "Valor fixo de {path}",
+ currencyHint: "Um código de moeda de três letras, como EUR.",
+ issues: {
+ unknown_path: "Este tipo não tem essa propriedade.",
+ unknown_field: "Este campo não existe mais.",
+ source_mismatch: "Este valor não se encaixa na propriedade.",
+ vocabulary:
+ "Algumas opções deste campo não significam nada para os mecanismos de busca aqui; as entradas que as usam deixam a propriedade de fora.",
+ currency: "Use um código de moeda de três letras, como EUR.",
+ required_missing: "Necessário: escolha de onde vem o valor.",
+ constant_required:
+ "Propriedades obrigatórias recebem o valor de cada entrada, não de um valor fixo.",
+ required_value_missing:
+ "Esta entrada não tem valor para uma propriedade obrigatória ({path}), então a página dela mantém a descrição de post de blog.",
+ too_large:
+ "A descrição desta entrada é grande demais, então a página dela mantém a descrição de post de blog.",
+ no_preset:
+ "Esta coleção ainda não tem um tipo de dados estruturados, então as páginas dela descrevem as entradas como posts de blog.",
+ },
+ suggestRequired:
+ "Dica: marque os campos mapeados para propriedades obrigatórias como “Necessário para publicar”, para que nenhuma entrada saia sem eles.",
+ themeReminder:
+ "Os mecanismos de busca esperam que esses valores estejam visíveis na página, então confira se o seu tema mostra os campos mapeados.",
+ preview: "Pré-visualização",
+ previewEntry: "Entrada",
+ previewChoose: "Escolher uma entrada",
+ previewDescription: "O que a página desta entrada emite com as configurações salvas.",
+ previewEmpty: "Nenhuma entrada nesta coleção ainda.",
+ noDocument: "A página desta entrada não emite dados estruturados deste tipo.",
+ richResults: "Testar no Rich Results Test do Google",
+ save: "Salvar dados estruturados",
+ saved: "Dados estruturados salvos",
+ },
+ createPage: {
+ action: "Criar página de listagem",
+ open: "Abrir página de listagem",
+ created: "Página de listagem criada",
+ exists: "Já existe uma página em {path}.",
+ },
+ filters: {
+ label: "Filtrar por campos",
+ any: "{label}: qualquer",
+ upcoming: "{label}: próximos",
+ past: "{label}: passados",
+ set: "{label}: tem valor",
+ unset: "{label}: vazio",
+ yes: "{label}: sim",
+ no: "{label}: não",
+ is: "{label}: {value}",
+ sortBy: "Ordenar por {label}",
+ clear: "Limpar filtros",
+ empty: "Nenhuma entrada corresponde a estes filtros",
+ emptyDescription: "Altere ou limpe os filtros para ver mais.",
+ },
+ contentList: {
+ where: "Só entradas em que",
+ add: "Adicionar condição",
+ field: "Campo",
+ op: "Condição",
+ value: "Valor",
+ remove: "Remover condição",
+ ops: {
+ eq: "é",
+ ne: "não é",
+ lt: "{kind, select, time {é antes de} number {é menor que} other {é menor que}}",
+ lte: "{kind, select, time {é em ou antes de} number {é no máximo} other {é no máximo}}",
+ gt: "{kind, select, time {é depois de} number {é maior que} other {é maior que}}",
+ gte: "{kind, select, time {é em ou depois de} number {é no mínimo} other {é no mínimo}}",
+ in: "é um de",
+ set: "tem valor",
+ unset: "está vazio",
+ },
+ now: "Agora",
+ aDate: "Uma data",
+ dateHint: "Uma data como 2026-11-05.",
+ instantHint: "Um momento com o deslocamento de fuso, como 2026-11-05T19:30:00+03:00.",
+ yes: "Sim",
+ no: "Não",
+ orderBy: "Ordenar por campo",
+ orderNone: "A ordem da lista acima",
+ direction: "Direção",
+ asc: "{kind, select, time {Mais antigos primeiro} number {Menores primeiro} other {Menores primeiro}}",
+ desc: "{kind, select, time {Mais recentes primeiro} number {Maiores primeiro} other {Maiores primeiro}}",
+ noListFields: "Esta coleção não tem campos de listagem para filtrar ou ordenar.",
+ settingsLink: "Escolher campos de listagem",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.ru.ts b/apps/studio/src/i18n/lazy/listings.ru.ts
new file mode 100644
index 00000000..a7b4d56a
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.ru.ts
@@ -0,0 +1,180 @@
+// Generated by `pnpm i18n:import` (ADR-0034): ru `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: 212b5b0876a6ad71
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "Поля списков",
+ description:
+ "До 4 полей, по которым списки могут фильтровать и сортировать: столбцы в списке этой коллекции, условия в разделах «Список контента» и запросы агентов.",
+ add: "Добавить поле списка",
+ choose: "Выберите поле",
+ empty: "Полей списков пока нет",
+ emptyDescription: "Выберите поля, чтобы показать их как столбцы и фильтровать по ним списки.",
+ noFields: "Сначала добавьте поля в эту коллекцию: поля списков выбираются из них.",
+ full: "Это 4 поля — максимум для коллекции.",
+ moveUp: "Переместить {label} вверх",
+ moveDown: "Переместить {label} вниз",
+ remove: "Убрать {label}",
+ sortable: "Сортируемое",
+ save: "Сохранить поля списков",
+ saved: "Поля списков сохранены",
+ inUse:
+ "Разделы «Список контента» всё ещё фильтруют или сортируют по убранному вами полю. Сначала измените эти страницы:",
+ },
+ expiry: {
+ title: "Истечение срока",
+ description:
+ "У записей может быть своя дата окончания, например день, когда заканчивается событие. Ничего не удаляется и не снимается с публикации: сайт проверяет дату каждый раз, когда показывает запись.",
+ enabled: "Завершать записи в определённую дату",
+ enabledHint:
+ "Выключите, чтобы хранить каждую запись, пока кто-нибудь не снимет её с публикации.",
+ field: "Поле даты",
+ fieldHint:
+ "Дата заканчивается в полночь после неё по часовому поясу сайта; дата и время — ровно в этот момент.",
+ noDateFields:
+ "В этой коллекции нет поля даты или даты и времени. Добавьте его, чтобы использовать истечение срока.",
+ action: "Когда срок записи истёк",
+ unlist: "Убрать из списков",
+ unlistDescription:
+ "Её страница остаётся по своему адресу, но исчезает из списков, разделов «Список контента», лент, карты сайта и поиска. Подходит для событий и вакансий.",
+ hide: "Скрыть страницу",
+ hideDescription:
+ "Её страница отвечает «не найдено» и тоже исчезает из всех списков, лент и поиска. Подходит для предложений, которые не должны оставаться доступными.",
+ grace: "Отсрочка в минутах",
+ graceHint:
+ "Сколько ждать после даты, прежде чем срок записи истечёт. До 10 080 (одна неделя).",
+ past: "{count, plural, =0 {Ни у одной опубликованной записи срок ещё не истёк.} one {У # опубликованной записи срок уже истёк.} few {У # опубликованных записей срок уже истёк.} many {У # опубликованных записей срок уже истёк.} other {У # опубликованной записи срок уже истёк.}}",
+ save: "Сохранить истечение срока",
+ saved: "Истечение срока сохранено",
+ gatesUnsupported:
+ "Рабочий сервер этого сайта пока не применяет истечение срока. Сначала разверните сайт, затем снова сохраните настройки истечения срока.",
+ },
+ structuredData: {
+ title: "Структурированные данные",
+ description:
+ "Описывайте каждую запись для поисковых систем как событие, вакансию, товар или рецепт на основе её полей. Без типа записи описываются как посты блога.",
+ type: "Тип",
+ none: "Нет (пост блога)",
+ types: {
+ Event: "Событие",
+ JobPosting: "Вакансия",
+ Product: "Товар",
+ Recipe: "Рецепт",
+ },
+ property: "Свойство",
+ source: "Значение из",
+ required: "Обязательно",
+ oneOf: "Одно из этих",
+ sources: {
+ default: "По умолчанию: {source}",
+ unmapped: "Не используется",
+ constant: "Фиксированное значение",
+ field: "Поле: {label}",
+ entry: "Запись: {property}",
+ site: "Сайт: {property}",
+ },
+ entryProps: {
+ title: "Заголовок",
+ summary: "Краткое описание",
+ body: "Текст",
+ coverImage: "Обложка",
+ url: "Адрес",
+ publishedAt: "Дата публикации",
+ updatedAt: "Дата обновления",
+ },
+ siteProps: {
+ name: "Название сайта",
+ url: "Адрес сайта",
+ logo: "Логотип сайта",
+ },
+ constant: "Фиксированное значение {path}",
+ currencyHint: "Трёхбуквенный код валюты, например EUR.",
+ issues: {
+ unknown_path: "У этого типа нет такого свойства.",
+ unknown_field: "Этого поля больше нет.",
+ source_mismatch: "Это значение не подходит для свойства.",
+ vocabulary:
+ "Некоторые варианты этого поля здесь ничего не значат для поисковых систем; в записях с ними свойство опускается.",
+ currency: "Используйте трёхбуквенный код валюты, например EUR.",
+ required_missing: "Нужно: выберите, откуда берётся значение.",
+ constant_required:
+ "Обязательные свойства берут значение из каждой записи, а не из фиксированного значения.",
+ required_value_missing:
+ "У этой записи нет значения обязательного свойства ({path}), поэтому её страница сохраняет описание поста блога.",
+ too_large:
+ "Описание этой записи слишком большое, поэтому её страница сохраняет описание поста блога.",
+ no_preset:
+ "У этой коллекции пока нет типа структурированных данных, поэтому её страницы описывают записи как посты блога.",
+ },
+ suggestRequired:
+ "Совет: отметьте поля, сопоставленные с обязательными свойствами, как «Нужно для публикации», чтобы ни одна запись не вышла без них.",
+ themeReminder:
+ "Поисковые системы ожидают, что эти значения видны на странице, поэтому проверьте, что ваша тема показывает сопоставленные поля.",
+ preview: "Предпросмотр",
+ previewEntry: "Запись",
+ previewChoose: "Выберите запись",
+ previewDescription: "Что выдаёт страница этой записи с сохранёнными настройками.",
+ previewEmpty: "В этой коллекции пока нет записей.",
+ noDocument: "Страница этой записи не выдаёт структурированных данных этого типа.",
+ richResults: "Проверить в Google Rich Results Test",
+ save: "Сохранить структурированные данные",
+ saved: "Структурированные данные сохранены",
+ },
+ createPage: {
+ action: "Создать страницу списка",
+ open: "Открыть страницу списка",
+ created: "Страница списка создана",
+ exists: "По адресу {path} уже есть страница.",
+ },
+ filters: {
+ label: "Фильтр по полям",
+ any: "{label}: любое",
+ upcoming: "{label}: предстоящие",
+ past: "{label}: прошедшие",
+ set: "{label}: есть значение",
+ unset: "{label}: пусто",
+ yes: "{label}: да",
+ no: "{label}: нет",
+ is: "{label}: {value}",
+ sortBy: "Сортировать по {label}",
+ clear: "Сбросить фильтры",
+ empty: "Нет записей, подходящих под эти фильтры",
+ emptyDescription: "Измените или сбросьте фильтры, чтобы увидеть больше.",
+ },
+ contentList: {
+ where: "Только записи, где",
+ add: "Добавить условие",
+ field: "Поле",
+ op: "Условие",
+ value: "Значение",
+ remove: "Удалить условие",
+ ops: {
+ eq: "равно",
+ ne: "не равно",
+ lt: "{kind, select, time {раньше} number {меньше} other {меньше}}",
+ lte: "{kind, select, time {не позже} number {не больше} other {не больше}}",
+ gt: "{kind, select, time {позже} number {больше} other {больше}}",
+ gte: "{kind, select, time {не раньше} number {не меньше} other {не меньше}}",
+ in: "одно из",
+ set: "заполнено",
+ unset: "пусто",
+ },
+ now: "Сейчас",
+ aDate: "Дата",
+ dateHint: "Дата, например 2026-11-05.",
+ instantHint: "Момент со смещением, например 2026-11-05T19:30:00+03:00.",
+ yes: "Да",
+ no: "Нет",
+ orderBy: "Сортировать по полю",
+ orderNone: "Порядок списка выше",
+ direction: "Направление",
+ asc: "{kind, select, time {Сначала ранние} number {По возрастанию} other {По возрастанию}}",
+ desc: "{kind, select, time {Сначала поздние} number {По убыванию} other {По убыванию}}",
+ noListFields: "В этой коллекции нет полей списков для фильтрации или сортировки.",
+ settingsLink: "Выбрать поля списков",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.tr.ts b/apps/studio/src/i18n/lazy/listings.tr.ts
new file mode 100644
index 00000000..1e079744
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.tr.ts
@@ -0,0 +1,178 @@
+// Generated by `pnpm i18n:import` (ADR-0034): tr `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: 38525c43d447d964
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "Liste alanları",
+ description:
+ "Listelerin filtreleyip sıralayabileceği en fazla 4 alan: bu koleksiyonun listesindeki sütunlar, içerik listesi bölümlerindeki koşullar ve ajanların sorguları.",
+ add: "Liste alanı ekle",
+ choose: "Bir alan seç",
+ empty: "Henüz liste alanı yok",
+ emptyDescription:
+ "Alanları sütun olarak göstermek ve listeleri onlara göre filtrelemek için seç.",
+ noFields: "Önce bu koleksiyona alan ekle; liste alanları bunlar arasından seçilir.",
+ full: "4 alan oldu; bir koleksiyonun listeleyebileceği en fazla alan bu.",
+ moveUp: "{label} alanını yukarı taşı",
+ moveDown: "{label} alanını aşağı taşı",
+ remove: "{label} alanını kaldır",
+ sortable: "Sıralanabilir",
+ save: "Liste alanlarını kaydet",
+ saved: "Liste alanları kaydedildi",
+ inUse:
+ "İçerik listesi bölümleri, çıkardığın bir alana göre hâlâ filtreliyor ya da sıralıyor. Önce bu sayfaları değiştir:",
+ },
+ expiry: {
+ title: "Sona erme",
+ description:
+ "Kayıtlar kendi tarihlerinde sona erebilir, örneğin bir etkinliğin bittiği gün. Hiçbir şey silinmez ya da yayından kaldırılmaz: site bir kaydı her gösterdiğinde tarihi kontrol eder.",
+ enabled: "Kayıtları bir tarihte sonlandır",
+ enabledHint: "Biri yayından kaldırana kadar her kaydı tutmak için kapat.",
+ field: "Tarih alanı",
+ fieldHint:
+ "Bir tarih, sitenin saat diliminde ardından gelen gece yarısında sona erer; tarih ve saat ise tam o anda sona erer.",
+ noDateFields:
+ "Bu koleksiyonda tarih ya da tarih ve saat alanı yok. Sona ermeyi kullanmak için bir tane ekle.",
+ action: "Bir kaydın süresi dolduğunda",
+ unlist: "Listelerden çıkar",
+ unlistDescription:
+ "Sayfası adresinde kalır ama listelerden, içerik listesi bölümlerinden, akışlardan, site haritasından ve aramadan çıkar. Etkinlikler ve iş ilanları için uygun.",
+ hide: "Sayfayı gizle",
+ hideDescription:
+ "Sayfası “bulunamadı” yanıtı verir; ayrıca tüm listelerden, akışlardan ve aramadan çıkar. Erişilebilir kalmaması gereken teklifler için uygun.",
+ grace: "Ek süre (dakika)",
+ graceHint: "Kayıt, tarihten bu kadar dakika sonra sona erer. En fazla 10.080 (bir hafta).",
+ past: "{count, plural, =0 {Henüz süresi geçmiş yayımlanmış kayıt yok.} one {# yayımlanmış kaydın süresi şimdiden geçti.} other {# yayımlanmış kaydın süresi şimdiden geçti.}}",
+ save: "Sona ermeyi kaydet",
+ saved: "Sona erme kaydedildi",
+ gatesUnsupported:
+ "Bu sitenin canlı sunucusu sona ermeyi henüz uygulamıyor. Önce siteyi dağıt, sonra sona ermeyi yeniden kaydet.",
+ },
+ structuredData: {
+ title: "Yapılandırılmış veri",
+ description:
+ "Her kaydı, alanlarından yola çıkarak arama motorlarına etkinlik, iş ilanı, ürün ya da tarif olarak tanıt. Tür seçilmezse kayıtlar blog yazısı olarak tanıtılır.",
+ type: "Tür",
+ none: "Yok (blog yazısı)",
+ types: {
+ Event: "Etkinlik",
+ JobPosting: "İş ilanı",
+ Product: "Ürün",
+ Recipe: "Tarif",
+ },
+ property: "Özellik",
+ source: "Değerin kaynağı",
+ required: "Zorunlu",
+ oneOf: "Bunlardan biri",
+ sources: {
+ default: "Varsayılan: {source}",
+ unmapped: "Kullanılmıyor",
+ constant: "Sabit bir değer",
+ field: "Alan: {label}",
+ entry: "Kayıt: {property}",
+ site: "Site: {property}",
+ },
+ entryProps: {
+ title: "Başlık",
+ summary: "Özet",
+ body: "Gövde",
+ coverImage: "Kapak görseli",
+ url: "Adres",
+ publishedAt: "Yayın tarihi",
+ updatedAt: "Güncellenme tarihi",
+ },
+ siteProps: {
+ name: "Site adı",
+ url: "Site adresi",
+ logo: "Site logosu",
+ },
+ constant: "{path} için sabit değer",
+ currencyHint: "EUR gibi üç harfli bir para birimi kodu.",
+ issues: {
+ unknown_path: "Bu türde böyle bir özellik yok.",
+ unknown_field: "Bu alan artık yok.",
+ source_mismatch: "Bu değer özelliğe uymuyor.",
+ vocabulary:
+ "Bu alanın bazı seçenekleri burada arama motorları için bir şey ifade etmiyor; bunları kullanan kayıtlarda özellik yer almaz.",
+ currency: "EUR gibi üç harfli bir para birimi kodu kullan.",
+ required_missing: "Gerekli: değerin nereden geleceğini seç.",
+ constant_required:
+ "Zorunlu özellikler değerini sabit bir değerden değil, her kayıttan alır.",
+ required_value_missing:
+ "Bu kaydın zorunlu bir özellik ({path}) için değeri yok, bu yüzden sayfası blog yazısı tanımını korur.",
+ too_large: "Bu kaydın tanımı çok büyük, bu yüzden sayfası blog yazısı tanımını korur.",
+ no_preset:
+ "Bu koleksiyonun henüz bir yapılandırılmış veri türü yok, bu yüzden sayfaları kayıtları blog yazısı olarak tanıtır.",
+ },
+ suggestRequired:
+ "İpucu: zorunlu özelliklere eşlenen alanları “Yayın için gerekli” olarak işaretle, böylece hiçbir kayıt onlarsız yayına çıkmaz.",
+ themeReminder:
+ "Arama motorları bu değerlerin sayfada görünmesini bekler, bu yüzden temanın eşlenen alanları gösterdiğinden emin ol.",
+ preview: "Önizleme",
+ previewEntry: "Kayıt",
+ previewChoose: "Bir kayıt seç",
+ previewDescription: "Bu kaydın sayfasının kayıtlı ayarlarla ürettiği çıktı.",
+ previewEmpty: "Bu koleksiyonda henüz kayıt yok.",
+ noDocument: "Bu kaydın sayfası bu türde yapılandırılmış veri üretmiyor.",
+ richResults: "Google'ın Rich Results Test aracında dene",
+ save: "Yapılandırılmış veriyi kaydet",
+ saved: "Yapılandırılmış veri kaydedildi",
+ },
+ createPage: {
+ action: "Liste sayfası oluştur",
+ open: "Liste sayfasını aç",
+ created: "Liste sayfası oluşturuldu",
+ exists: "{path} adresinde zaten bir sayfa var.",
+ },
+ filters: {
+ label: "Alanlara göre filtrele",
+ any: "{label}: herhangi",
+ upcoming: "{label}: yaklaşan",
+ past: "{label}: geçmiş",
+ set: "{label}: değeri var",
+ unset: "{label}: boş",
+ yes: "{label}: evet",
+ no: "{label}: hayır",
+ is: "{label}: {value}",
+ sortBy: "{label} alanına göre sırala",
+ clear: "Filtreleri temizle",
+ empty: "Bu filtrelere uyan kayıt yok",
+ emptyDescription: "Daha fazlasını görmek için filtreleri değiştir ya da temizle.",
+ },
+ contentList: {
+ where: "Yalnızca şu koşula uyan kayıtlar",
+ add: "Koşul ekle",
+ field: "Alan",
+ op: "Koşul",
+ value: "Değer",
+ remove: "Koşulu kaldır",
+ ops: {
+ eq: "eşittir",
+ ne: "eşit değildir",
+ lt: "{kind, select, time {öncesinde} number {küçüktür} other {küçüktür}}",
+ lte: "{kind, select, time {aynı gün ya da öncesinde} number {en fazla} other {en fazla}}",
+ gt: "{kind, select, time {sonrasında} number {büyüktür} other {büyüktür}}",
+ gte: "{kind, select, time {aynı gün ya da sonrasında} number {en az} other {en az}}",
+ in: "şunlardan biri",
+ set: "değeri var",
+ unset: "boş",
+ },
+ now: "Şimdi",
+ aDate: "Bir tarih",
+ dateHint: "2026-11-05 gibi bir tarih.",
+ instantHint: "2026-11-05T19:30:00+03:00 gibi, saat farkıyla birlikte bir an.",
+ yes: "Evet",
+ no: "Hayır",
+ orderBy: "Alana göre sırala",
+ orderNone: "Yukarıdaki liste sırası",
+ direction: "Yön",
+ asc: "{kind, select, time {Önce en erken} number {Önce en küçük} other {Önce en küçük}}",
+ desc: "{kind, select, time {Önce en geç} number {Önce en büyük} other {Önce en büyük}}",
+ noListFields: "Bu koleksiyonda filtrelemek ya da sıralamak için liste alanı yok.",
+ settingsLink: "Liste alanlarını seç",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.uk.ts b/apps/studio/src/i18n/lazy/listings.uk.ts
new file mode 100644
index 00000000..b217632c
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.uk.ts
@@ -0,0 +1,178 @@
+// Generated by `pnpm i18n:import` (ADR-0034): uk `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: 0964e008fbfdafb0
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "Поля списків",
+ description:
+ "До 4 полів, за якими списки можуть фільтрувати й сортувати: стовпці у списку цієї колекції, умови в розділах «Список вмісту» та запити агентів.",
+ add: "Додати поле списку",
+ choose: "Виберіть поле",
+ empty: "Полів списків поки немає",
+ emptyDescription: "Виберіть поля, щоб показати їх як стовпці та фільтрувати за ними списки.",
+ noFields: "Спершу додайте поля до цієї колекції: поля списків вибирають з них.",
+ full: "Це 4 поля — максимум для колекції.",
+ moveUp: "Перемістити {label} вгору",
+ moveDown: "Перемістити {label} вниз",
+ remove: "Прибрати {label}",
+ sortable: "Можна сортувати",
+ save: "Зберегти поля списків",
+ saved: "Поля списків збережено",
+ inUse:
+ "Розділи «Список вмісту» досі фільтрують або сортують за полем, яке ви прибрали. Спершу змініть ці сторінки:",
+ },
+ expiry: {
+ title: "Закінчення терміну",
+ description:
+ "Записи можуть завершуватися у власну дату, наприклад у день, коли подія закінчується. Нічого не видаляється й не знімається з публікації: сайт перевіряє дату щоразу, коли показує запис.",
+ enabled: "Завершувати записи в певну дату",
+ enabledHint: "Вимкніть, щоб зберігати кожен запис, доки хтось не зніме його з публікації.",
+ field: "Поле дати",
+ fieldHint:
+ "Дата закінчується опівночі після неї за часовим поясом сайту; дата й час — саме в цю мить.",
+ noDateFields:
+ "У цій колекції немає поля дати чи дати й часу. Додайте його, щоб використовувати закінчення терміну.",
+ action: "Коли термін запису минув",
+ unlist: "Прибрати зі списків",
+ unlistDescription:
+ "Його сторінка лишається за своєю адресою, але зникає зі списків, розділів «Список вмісту», стрічок, мапи сайту та пошуку. Підходить для подій і вакансій.",
+ hide: "Сховати сторінку",
+ hideDescription:
+ "Його сторінка відповідає «не знайдено» й також зникає з усіх списків, стрічок і пошуку. Підходить для пропозицій, які не мають лишатися доступними.",
+ grace: "Відстрочка у хвилинах",
+ graceHint:
+ "Скільки чекати після дати, перш ніж термін запису мине. До 10 080 (один тиждень).",
+ past: "{count, plural, =0 {У жодного опублікованого запису термін ще не минув.} one {У # опублікованого запису термін уже минув.} few {У # опублікованих записів термін уже минув.} many {У # опублікованих записів термін уже минув.} other {У # опублікованого запису термін уже минув.}}",
+ save: "Зберегти закінчення терміну",
+ saved: "Закінчення терміну збережено",
+ gatesUnsupported:
+ "Робочий сервер цього сайту ще не застосовує закінчення терміну. Спершу розгорніть сайт, потім знову збережіть налаштування закінчення терміну.",
+ },
+ structuredData: {
+ title: "Структуровані дані",
+ description:
+ "Описуйте кожен запис для пошукових систем як подію, вакансію, товар чи рецепт на основі його полів. Без типу записи описуються як дописи блогу.",
+ type: "Тип",
+ none: "Немає (допис блогу)",
+ types: {
+ Event: "Подія",
+ JobPosting: "Вакансія",
+ Product: "Товар",
+ Recipe: "Рецепт",
+ },
+ property: "Властивість",
+ source: "Значення з",
+ required: "Обов’язково",
+ oneOf: "Одне з цих",
+ sources: {
+ default: "За замовчуванням: {source}",
+ unmapped: "Не використовується",
+ constant: "Фіксоване значення",
+ field: "Поле: {label}",
+ entry: "Запис: {property}",
+ site: "Сайт: {property}",
+ },
+ entryProps: {
+ title: "Заголовок",
+ summary: "Короткий опис",
+ body: "Текст",
+ coverImage: "Обкладинка",
+ url: "Адреса",
+ publishedAt: "Дата публікації",
+ updatedAt: "Дата оновлення",
+ },
+ siteProps: {
+ name: "Назва сайту",
+ url: "Адреса сайту",
+ logo: "Логотип сайту",
+ },
+ constant: "Фіксоване значення {path}",
+ currencyHint: "Трилітерний код валюти, наприклад EUR.",
+ issues: {
+ unknown_path: "Цей тип не має такої властивості.",
+ unknown_field: "Цього поля більше немає.",
+ source_mismatch: "Це значення не підходить для властивості.",
+ vocabulary:
+ "Деякі варіанти цього поля тут нічого не означають для пошукових систем; у записах із ними властивість пропускається.",
+ currency: "Використайте трилітерний код валюти, наприклад EUR.",
+ required_missing: "Потрібно: виберіть, звідки береться значення.",
+ constant_required:
+ "Обов’язкові властивості беруть значення з кожного запису, а не з фіксованого значення.",
+ required_value_missing:
+ "Цей запис не має значення обов’язкової властивості ({path}), тож його сторінка зберігає опис допису блогу.",
+ too_large: "Опис цього запису завеликий, тож його сторінка зберігає опис допису блогу.",
+ no_preset:
+ "Ця колекція ще не має типу структурованих даних, тож її сторінки описують записи як дописи блогу.",
+ },
+ suggestRequired:
+ "Порада: позначте поля, зіставлені з обов’язковими властивостями, як «Потрібно для публікації», щоб жоден запис не вийшов без них.",
+ themeReminder:
+ "Пошукові системи очікують, що ці значення видно на сторінці, тож перевірте, чи ваша тема показує зіставлені поля.",
+ preview: "Попередній перегляд",
+ previewEntry: "Запис",
+ previewChoose: "Виберіть запис",
+ previewDescription: "Що видає сторінка цього запису зі збереженими налаштуваннями.",
+ previewEmpty: "У цій колекції ще немає записів.",
+ noDocument: "Сторінка цього запису не видає структурованих даних цього типу.",
+ richResults: "Перевірити в Google Rich Results Test",
+ save: "Зберегти структуровані дані",
+ saved: "Структуровані дані збережено",
+ },
+ createPage: {
+ action: "Створити сторінку списку",
+ open: "Відкрити сторінку списку",
+ created: "Сторінку списку створено",
+ exists: "За адресою {path} уже є сторінка.",
+ },
+ filters: {
+ label: "Фільтр за полями",
+ any: "{label}: будь-яке",
+ upcoming: "{label}: майбутні",
+ past: "{label}: минулі",
+ set: "{label}: має значення",
+ unset: "{label}: порожнє",
+ yes: "{label}: так",
+ no: "{label}: ні",
+ is: "{label}: {value}",
+ sortBy: "Сортувати за {label}",
+ clear: "Скинути фільтри",
+ empty: "Немає записів, що відповідають цим фільтрам",
+ emptyDescription: "Змініть або скиньте фільтри, щоб побачити більше.",
+ },
+ contentList: {
+ where: "Лише записи, де",
+ add: "Додати умову",
+ field: "Поле",
+ op: "Умова",
+ value: "Значення",
+ remove: "Видалити умову",
+ ops: {
+ eq: "дорівнює",
+ ne: "не дорівнює",
+ lt: "{kind, select, time {раніше} number {менше} other {менше}}",
+ lte: "{kind, select, time {не пізніше} number {не більше} other {не більше}}",
+ gt: "{kind, select, time {пізніше} number {більше} other {більше}}",
+ gte: "{kind, select, time {не раніше} number {не менше} other {не менше}}",
+ in: "одне з",
+ set: "заповнено",
+ unset: "порожнє",
+ },
+ now: "Зараз",
+ aDate: "Дата",
+ dateHint: "Дата, наприклад 2026-11-05.",
+ instantHint: "Момент зі зміщенням, наприклад 2026-11-05T19:30:00+03:00.",
+ yes: "Так",
+ no: "Ні",
+ orderBy: "Сортувати за полем",
+ orderNone: "Порядок списку вище",
+ direction: "Напрямок",
+ asc: "{kind, select, time {Спершу ранні} number {За зростанням} other {За зростанням}}",
+ desc: "{kind, select, time {Спершу пізні} number {За спаданням} other {За спаданням}}",
+ noListFields: "У цій колекції немає полів списків для фільтрування чи сортування.",
+ settingsLink: "Вибрати поля списків",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/lazy/listings.zh-Hans.ts b/apps/studio/src/i18n/lazy/listings.zh-Hans.ts
new file mode 100644
index 00000000..215331b5
--- /dev/null
+++ b/apps/studio/src/i18n/lazy/listings.zh-Hans.ts
@@ -0,0 +1,168 @@
+// Generated by `pnpm i18n:import` (ADR-0034): zh-Hans `listings.*` messages.
+// Do not edit: change the translation file and import it; only English is edited here.
+// hash: 73abbf89e76c9c11
+import type { NamespaceMessages } from "../namespaces.ts";
+
+export const messages: NamespaceMessages<"listings"> = {
+ listings: {
+ listFields: {
+ title: "列表字段",
+ description:
+ "列表可用于筛选和排序的字段,最多 4 个:此集合列表中的列、内容列表区块中的条件,以及智能体的查询。",
+ add: "添加列表字段",
+ choose: "选择一个字段",
+ empty: "还没有列表字段",
+ emptyDescription: "选择字段,将其显示为列,并按其筛选列表。",
+ noFields: "请先为此集合添加字段;列表字段从中选择。",
+ full: "已有 4 个字段,这是一个集合可列出的上限。",
+ moveUp: "上移 {label}",
+ moveDown: "下移 {label}",
+ remove: "移除 {label}",
+ sortable: "可排序",
+ save: "保存列表字段",
+ saved: "列表字段已保存",
+ inUse: "仍有内容列表区块按你移除的字段筛选或排序。请先修改这些页面:",
+ },
+ expiry: {
+ title: "到期",
+ description:
+ "条目可以在自己的日期结束,例如活动结束的那天。不会删除或取消发布任何内容:站点每次显示条目时都会检查日期。",
+ enabled: "在指定日期结束条目",
+ enabledHint: "关闭后,每个条目都会一直保留,直到有人取消发布。",
+ field: "日期字段",
+ fieldHint: "日期在其后的午夜结束(按站点时区);日期和时间在该时刻结束。",
+ noDateFields: "此集合没有日期或日期和时间字段。请添加一个以使用到期功能。",
+ action: "条目结束后",
+ unlist: "移出列表",
+ unlistDescription:
+ "页面仍保留在原地址,但会移出列表、内容列表区块、订阅源、站点地图和搜索。适合活动和职位。",
+ hide: "隐藏页面",
+ hideDescription:
+ "页面返回“未找到”,同时也会移出所有列表、订阅源和搜索。适合不能继续被访问的优惠。",
+ grace: "宽限期(分钟)",
+ graceHint: "日期过后再等待这么久,条目才会结束。最多 10,080(一周)。",
+ past: "{count, plural, =0 {还没有已发布的条目过期。} other {已有 # 个已发布的条目过期。}}",
+ save: "保存到期设置",
+ saved: "到期设置已保存",
+ gatesUnsupported: "此站点的线上服务器尚不执行到期。请先部署站点,然后再次保存到期设置。",
+ },
+ structuredData: {
+ title: "结构化数据",
+ description:
+ "根据字段,将每个条目作为活动、职位、产品或食谱描述给搜索引擎。未选择类型时,条目会被描述为博客文章。",
+ type: "类型",
+ none: "无(博客文章)",
+ types: {
+ Event: "活动",
+ JobPosting: "职位",
+ Product: "产品",
+ Recipe: "食谱",
+ },
+ property: "属性",
+ source: "值来源",
+ required: "必填",
+ oneOf: "其中之一",
+ sources: {
+ default: "默认:{source}",
+ unmapped: "不使用",
+ constant: "固定值",
+ field: "字段:{label}",
+ entry: "条目:{property}",
+ site: "站点:{property}",
+ },
+ entryProps: {
+ title: "标题",
+ summary: "摘要",
+ body: "正文",
+ coverImage: "封面图片",
+ url: "地址",
+ publishedAt: "发布日期",
+ updatedAt: "更新日期",
+ },
+ siteProps: {
+ name: "站点名称",
+ url: "站点地址",
+ logo: "站点徽标",
+ },
+ constant: "{path} 的固定值",
+ currencyHint: "三个字母的货币代码,例如 EUR。",
+ issues: {
+ unknown_path: "此类型没有该属性。",
+ unknown_field: "此字段已不存在。",
+ source_mismatch: "此值不适合该属性。",
+ vocabulary: "此字段的某些选项在这里对搜索引擎没有意义;使用它们的条目会省略该属性。",
+ currency: "请使用三个字母的货币代码,例如 EUR。",
+ required_missing: "必需:请选择值的来源。",
+ constant_required: "必填属性从每个条目取值,而不是使用固定值。",
+ required_value_missing: "此条目缺少必填属性({path})的值,因此其页面保留博客文章的描述。",
+ too_large: "此条目的描述过大,因此其页面保留博客文章的描述。",
+ no_preset: "此集合还没有结构化数据类型,因此其页面将条目描述为博客文章。",
+ },
+ suggestRequired:
+ "提示:将映射到必填属性的字段标记为“发布所需”,这样就不会有条目缺少它们而发布。",
+ themeReminder: "搜索引擎希望这些值在页面上可见,因此请检查你的主题是否显示了已映射的字段。",
+ preview: "预览",
+ previewEntry: "条目",
+ previewChoose: "选择一个条目",
+ previewDescription: "使用已保存的设置时,此条目页面输出的内容。",
+ previewEmpty: "此集合中还没有条目。",
+ noDocument: "此条目的页面不输出此类型的结构化数据。",
+ richResults: "在 Google 的 Rich Results Test 中测试",
+ save: "保存结构化数据",
+ saved: "结构化数据已保存",
+ },
+ createPage: {
+ action: "创建列表页面",
+ open: "打开列表页面",
+ created: "列表页面已创建",
+ exists: "{path} 已有页面。",
+ },
+ filters: {
+ label: "按字段筛选",
+ any: "{label}:任意",
+ upcoming: "{label}:即将到来",
+ past: "{label}:已过去",
+ set: "{label}:有值",
+ unset: "{label}:为空",
+ yes: "{label}:是",
+ no: "{label}:否",
+ is: "{label}:{value}",
+ sortBy: "按 {label} 排序",
+ clear: "清除筛选",
+ empty: "没有符合这些筛选条件的条目",
+ emptyDescription: "更改或清除筛选条件以查看更多。",
+ },
+ contentList: {
+ where: "仅限满足以下条件的条目",
+ add: "添加条件",
+ field: "字段",
+ op: "条件",
+ value: "值",
+ remove: "移除条件",
+ ops: {
+ eq: "等于",
+ ne: "不等于",
+ lt: "{kind, select, time {早于} number {小于} other {小于}}",
+ lte: "{kind, select, time {不晚于} number {不大于} other {不大于}}",
+ gt: "{kind, select, time {晚于} number {大于} other {大于}}",
+ gte: "{kind, select, time {不早于} number {不小于} other {不小于}}",
+ in: "属于其中之一",
+ set: "有值",
+ unset: "为空",
+ },
+ now: "现在",
+ aDate: "指定日期",
+ dateHint: "日期,例如 2026-11-05。",
+ instantHint: "带时区偏移的时刻,例如 2026-11-05T19:30:00+03:00。",
+ yes: "是",
+ no: "否",
+ orderBy: "按字段排序",
+ orderNone: "上方的列表顺序",
+ direction: "方向",
+ asc: "{kind, select, time {最早的在前} number {从小到大} other {从小到大}}",
+ desc: "{kind, select, time {最晚的在前} number {从大到小} other {从大到小}}",
+ noListFields: "此集合没有可用于筛选或排序的列表字段。",
+ settingsLink: "选择列表字段",
+ },
+ },
+};
diff --git a/apps/studio/src/i18n/namespaces.ts b/apps/studio/src/i18n/namespaces.ts
index 71e99685..899b0f98 100644
--- a/apps/studio/src/i18n/namespaces.ts
+++ b/apps/studio/src/i18n/namespaces.ts
@@ -22,6 +22,7 @@ import type { messages as collections } from "./lazy/collections.en.ts";
import type { messages as comments } from "./lazy/comments.en.ts";
import type { messages as commentsSettings } from "./lazy/commentsSettings.en.ts";
import type { messages as fields } from "./lazy/fields.en.ts";
+import type { messages as listings } from "./lazy/listings.en.ts";
import type { messages as media } from "./lazy/media.en.ts";
import type { messages as menus } from "./lazy/menus.en.ts";
import type { messages as readers } from "./lazy/readers.en.ts";
@@ -43,6 +44,7 @@ export interface NamespaceCatalogs {
comments: DeepString<(typeof comments)["comments"]>;
commentsSettings: DeepString<(typeof commentsSettings)["commentsSettings"]>;
fields: DeepString<(typeof fields)["fields"]>;
+ listings: DeepString<(typeof listings)["listings"]>;
media: DeepString<(typeof media)["media"]>;
menus: DeepString<(typeof menus)["menus"]>;
readers: DeepString<(typeof readers)["readers"]>;
diff --git a/apps/studio/src/i18n/nl.ts b/apps/studio/src/i18n/nl.ts
index f21f942d..c1771a39 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: 742d1ea3ccc68717
+// hash: cbc76b64b136c8e1
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -710,6 +710,16 @@ export const messages: Messages = {
field_values_unconvertible:
"Sommige waarden kunnen niet worden omgezet. Sta wissen toe of wijzig eerst die items.",
field_operation_incomplete: "Die veldwijziging is nog niet klaar of is al teruggedraaid.",
+ invalid_query:
+ "Dit filter of deze sortering past niet: gebruik de lijstvelden van de collectie en hun voorwaarden.",
+ structured_data_invalid:
+ "De instellingen voor gestructureerde gegevens passen niet bij de velden van deze collectie. Controleer de gemarkeerde eigenschappen.",
+ invalid_expiry:
+ "De verloopdatum heeft een datum- of datum-en-tijdveld van deze collectie nodig.",
+ list_field_in_use:
+ "Contentlijstsecties filteren of sorteren nog op dit veld. Pas eerst die pagina's aan.",
+ gates_unsupported:
+ "De live server van deze site ondersteunt dit nog niet. Deploy eerst de site en probeer het daarna opnieuw.",
network: "Kan de server niet bereiken. Controleer je verbinding en probeer het opnieuw.",
status: "Het verzoek is mislukt (HTTP {status}).",
},
@@ -1635,6 +1645,8 @@ export const messages: Messages = {
media: "hij media toevoegt of wijzigt die geen deel uitmaken van een artikelwijziging",
"outside-window": "hij zou draaien nadat de autonomie van de agent is afgelopen",
correction: "hij een correctie toevoegt aan een gepubliceerd artikel",
+ expires:
+ "hij een gepubliceerd item eerder laat aflopen volgens de verloopdatum van de collectie",
},
noHistory: "Nog geen besluiten",
noHistoryDescription: "Goedgekeurde, geweigerde, gebruikte en verlopen verzoeken staan hier.",
@@ -1770,6 +1782,7 @@ export const messages: Messages = {
list_snapshots: "Snapshots weergeven",
diff_snapshots: "Snapshots vergelijken",
preview_page: "Paginavoorbeeld tonen",
+ preview_structured_data: "Voorbeeld van gestructureerde gegevens",
publish: "Site publiceren",
rollback: "Site terugdraaien",
list_menus: "Menu’s weergeven",
diff --git a/apps/studio/src/i18n/pl.ts b/apps/studio/src/i18n/pl.ts
index 95ca3903..d8ce5750 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: f467e98e8c6bd040
+// hash: d6de4290043bec9f
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -698,6 +698,14 @@ export const messages: Messages = {
"Niektórych wartości nie da się przekonwertować. Zezwól na ich wyczyszczenie albo najpierw zmień te wpisy.",
field_operation_incomplete:
"Ta zmiana pola jeszcze się nie zakończyła albo została już cofnięta.",
+ invalid_query: "Ten filtr lub sortowanie nie pasuje: użyj pól list kolekcji i ich warunków.",
+ structured_data_invalid:
+ "Ustawienia danych strukturalnych nie pasują do pól tej kolekcji. Sprawdź oznaczone właściwości.",
+ invalid_expiry: "Wygaśnięcie wymaga pola daty lub daty i godziny z tej kolekcji.",
+ list_field_in_use:
+ "Sekcje listy treści nadal filtrują lub sortują według tego pola. Najpierw zmień te strony.",
+ gates_unsupported:
+ "Serwer tej witryny na żywo jeszcze tego nie obsługuje. Najpierw wdróż witrynę, a potem spróbuj ponownie.",
network: "Brak połączenia z serwerem. Sprawdź połączenie i spróbuj ponownie.",
status: "Żądanie nie powiodło się (HTTP {status}).",
},
@@ -1618,6 +1626,8 @@ export const messages: Messages = {
media: "dodaje lub zmienia multimedia niezwiązane ze zmianą artykułu",
"outside-window": "zostałaby wykonana po zakończeniu autonomii agenta",
correction: "dodaje sprostowanie do opublikowanego artykułu",
+ expires:
+ "sprawia, że opublikowany wpis kończy się wcześniej zgodnie z wygaśnięciem jego kolekcji",
},
noHistory: "Jeszcze brak decyzji",
noHistoryDescription: "Tutaj są zatwierdzone, odrzucone, wykorzystane i wygasłe prośby.",
@@ -1753,6 +1763,7 @@ export const messages: Messages = {
list_snapshots: "Lista snapshotów",
diff_snapshots: "Porównanie snapshotów",
preview_page: "Podgląd strony",
+ preview_structured_data: "Podgląd danych strukturalnych",
publish: "Publikacja witryny",
rollback: "Wycofanie witryny",
list_menus: "Lista menu",
diff --git a/apps/studio/src/i18n/pt-BR.ts b/apps/studio/src/i18n/pt-BR.ts
index 125d2eca..d04f553d 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: 2ba5074052bb0db1
+// hash: c7a43832fa22b9af
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -704,6 +704,15 @@ export const messages: Messages = {
field_values_unconvertible:
"Alguns valores não podem ser convertidos. Permita apagá-los ou altere essas entradas antes.",
field_operation_incomplete: "Essa alteração de campo não terminou ou já foi revertida.",
+ invalid_query:
+ "Este filtro ou ordenação não se encaixa: use os campos de listagem da coleção e as condições deles.",
+ structured_data_invalid:
+ "As configurações de dados estruturados não se encaixam nos campos desta coleção. Confira as propriedades marcadas.",
+ invalid_expiry: "A expiração precisa de um campo de data ou de data e hora desta coleção.",
+ list_field_in_use:
+ "Seções de lista de conteúdo ainda filtram ou ordenam por este campo. Altere essas páginas primeiro.",
+ gates_unsupported:
+ "O servidor no ar deste site ainda não oferece suporte a isso. Implante o site primeiro e tente de novo.",
network: "Não foi possível alcançar o servidor. Verifique sua conexão e tente de novo.",
status: "A requisição falhou (HTTP {status}).",
},
@@ -1622,6 +1631,7 @@ export const messages: Messages = {
media: "adiciona ou altera mídia que não faz parte de uma alteração de artigo",
"outside-window": "rodaria depois que a autonomia do agente terminar",
correction: "adiciona uma correção a um artigo publicado",
+ expires: "faz uma entrada publicada terminar mais cedo pela expiração da coleção dela",
},
noHistory: "Ainda sem decisões",
noHistoryDescription: "Pedidos aprovados, negados, usados e expirados são listados aqui.",
@@ -1756,6 +1766,7 @@ export const messages: Messages = {
list_snapshots: "Listar snapshots",
diff_snapshots: "Comparar snapshots",
preview_page: "Pré-visualizar página",
+ preview_structured_data: "Pré-visualizar dados estruturados",
publish: "Publicar site",
rollback: "Reverter site",
list_menus: "Listar menus",
diff --git a/apps/studio/src/i18n/ru.ts b/apps/studio/src/i18n/ru.ts
index 3467e3e0..d71c24f4 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: b58d37cdfa84e657
+// hash: 48287cae510527d5
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -709,6 +709,15 @@ export const messages: Messages = {
field_values_unconvertible:
"Некоторые значения нельзя преобразовать. Разрешите их очистить или сначала измените эти записи.",
field_operation_incomplete: "Это изменение поля ещё не завершено или уже отменено.",
+ invalid_query:
+ "Этот фильтр или сортировка не подходит: используйте поля списков коллекции и их условия.",
+ structured_data_invalid:
+ "Настройки структурированных данных не соответствуют полям этой коллекции. Проверьте отмеченные свойства.",
+ invalid_expiry: "Для истечения срока нужно поле даты или даты и времени из этой коллекции.",
+ list_field_in_use:
+ "Разделы «Список контента» всё ещё фильтруют или сортируют по этому полю. Сначала измените эти страницы.",
+ gates_unsupported:
+ "Рабочий сервер этого сайта пока это не поддерживает. Сначала разверните сайт, затем повторите попытку.",
network: "Не удаётся связаться с сервером. Проверьте подключение и повторите попытку.",
status: "Запрос не выполнен (HTTP {status}).",
},
@@ -1643,6 +1652,8 @@ export const messages: Messages = {
media: "оно добавляет или меняет медиафайлы вне изменений статей",
"outside-window": "оно выполнилось бы после окончания самостоятельности агента",
correction: "оно добавляет исправление к опубликованной статье",
+ expires:
+ "оно приближает окончание опубликованной записи по правилам истечения срока её коллекции",
},
noHistory: "Решений пока нет",
noHistoryDescription:
@@ -1779,6 +1790,7 @@ export const messages: Messages = {
list_snapshots: "Список снимков",
diff_snapshots: "Сравнить снимки",
preview_page: "Предпросмотр страницы",
+ preview_structured_data: "Предпросмотр структурированных данных",
publish: "Опубликовать сайт",
rollback: "Откатить сайт",
list_menus: "Список меню",
diff --git a/apps/studio/src/i18n/tr.ts b/apps/studio/src/i18n/tr.ts
index a1447ff6..0856d3b5 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: 476c6d4d7b9475a1
+// hash: a8aab0fe1a674b26
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -693,6 +693,16 @@ export const messages: Messages = {
field_values_unconvertible:
"Bazı değerler dönüştürülemiyor. Silinmelerine izin ver ya da önce o kayıtları değiştir.",
field_operation_incomplete: "Bu alan değişikliği henüz bitmedi ya da zaten geri alındı.",
+ invalid_query:
+ "Bu filtre ya da sıralama uymuyor: koleksiyonun liste alanlarını ve onların koşullarını kullan.",
+ structured_data_invalid:
+ "Yapılandırılmış veri ayarları bu koleksiyonun alanlarına uymuyor. İşaretli özelliklere bak.",
+ invalid_expiry:
+ "Sona erme için bu koleksiyonun bir tarih ya da tarih ve saat alanı gerekiyor.",
+ list_field_in_use:
+ "İçerik listesi bölümleri bu alana göre hâlâ filtreliyor ya da sıralıyor. Önce o sayfaları değiştir.",
+ gates_unsupported:
+ "Bu sitenin canlı sunucusu bunu henüz desteklemiyor. Önce siteyi dağıt, sonra tekrar dene.",
network: "Sunucuya ulaşılamıyor. Bağlantını kontrol edip tekrar dene.",
status: "İstek başarısız oldu (HTTP {status}).",
},
@@ -1617,6 +1627,8 @@ export const messages: Messages = {
media: "bir makale değişikliğine ait olmayan medya ekliyor ya da değiştiriyor",
"outside-window": "ajanın özerkliği bittikten sonra çalışacak",
correction: "yayımlanmış bir makaleye düzeltme ekliyor",
+ expires:
+ "yayımlanmış bir kaydın, koleksiyonunun sona erme ayarına göre daha erken bitmesine yol açıyor",
},
noHistory: "Henüz karar yok",
noHistoryDescription:
@@ -1752,6 +1764,7 @@ export const messages: Messages = {
list_snapshots: "Snapshot'ları listele",
diff_snapshots: "Snapshot'ları karşılaştır",
preview_page: "Sayfayı önizle",
+ preview_structured_data: "Yapılandırılmış veriyi önizle",
publish: "Siteyi yayınla",
rollback: "Siteyi geri al",
list_menus: "Menüleri listele",
diff --git a/apps/studio/src/i18n/uk.ts b/apps/studio/src/i18n/uk.ts
index 8e338fbd..881d4e2c 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: e4a02a8d499de96e
+// hash: d7ed2ae0e3ae3018
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -702,6 +702,15 @@ export const messages: Messages = {
field_values_unconvertible:
"Деякі значення не можна перетворити. Дозвольте їх очистити або спершу змініть ці записи.",
field_operation_incomplete: "Ця зміна поля ще не завершена або вже скасована.",
+ invalid_query:
+ "Цей фільтр чи сортування не підходить: використовуйте поля списків колекції та їхні умови.",
+ structured_data_invalid:
+ "Налаштування структурованих даних не відповідають полям цієї колекції. Перевірте позначені властивості.",
+ invalid_expiry: "Для закінчення терміну потрібне поле дати чи дати й часу з цієї колекції.",
+ list_field_in_use:
+ "Розділи «Список вмісту» досі фільтрують або сортують за цим полем. Спершу змініть ці сторінки.",
+ gates_unsupported:
+ "Робочий сервер цього сайту ще цього не підтримує. Спершу розгорніть сайт, потім спробуйте ще раз.",
network: "Не вдається зв’язатися із сервером. Перевірте з’єднання й спробуйте знову.",
status: "Запит не вдався (HTTP {status}).",
},
@@ -1626,6 +1635,8 @@ export const messages: Messages = {
media: "воно додає або змінює медіа поза зміною статті",
"outside-window": "воно виконалося б після закінчення автономії агента",
correction: "воно додає виправлення до опублікованої статті",
+ expires:
+ "воно наближає завершення опублікованого запису за правилами закінчення терміну його колекції",
},
noHistory: "Рішень ще немає",
noHistoryDescription: "Тут наведено схвалені, відхилені, використані та протерміновані запити.",
@@ -1761,6 +1772,7 @@ export const messages: Messages = {
list_snapshots: "Список знімків",
diff_snapshots: "Порівняти знімки",
preview_page: "Попередній перегляд сторінки",
+ preview_structured_data: "Попередній перегляд структурованих даних",
publish: "Опублікувати сайт",
rollback: "Відкотити сайт",
list_menus: "Список меню",
diff --git a/apps/studio/src/i18n/zh-Hans.ts b/apps/studio/src/i18n/zh-Hans.ts
index df74b2ad..a55e7d12 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: 2a9cef10f9581d62
+// hash: f0d17008d26c0f14
import type { Messages } from "./en.ts";
export const messages: Messages = {
@@ -668,6 +668,11 @@ export const messages: Messages = {
operation_in_progress: "此集合中仍有另一项字段更改在进行。",
field_values_unconvertible: "有些值无法转换。请允许清除它们,或先修改这些条目。",
field_operation_incomplete: "该字段更改尚未完成,或已被撤销。",
+ invalid_query: "此筛选或排序不适用:请使用集合的列表字段及其条件。",
+ structured_data_invalid: "结构化数据设置与此集合的字段不匹配。请检查标出的属性。",
+ invalid_expiry: "到期需要此集合的一个日期或日期和时间字段。",
+ list_field_in_use: "仍有内容列表区块按此字段筛选或排序。请先修改那些页面。",
+ gates_unsupported: "此站点的线上服务器尚不支持此功能。请先部署站点,然后再试一次。",
network: "无法连接服务器。请检查网络连接后重试。",
status: "请求失败(HTTP {status})。",
},
@@ -1532,6 +1537,7 @@ export const messages: Messages = {
media: "它添加或更改了不属于文章更改的媒体",
"outside-window": "它将在智能体的自主时段结束后运行",
correction: "它为已发布的文章添加了更正",
+ expires: "它使已发布的条目按其集合的到期设置更早结束",
},
noHistory: "还没有决定",
noHistoryDescription: "已批准、已拒绝、已使用和已过期的请求会列在这里。",
@@ -1658,6 +1664,7 @@ export const messages: Messages = {
list_snapshots: "列出快照",
diff_snapshots: "比较快照",
preview_page: "预览页面",
+ preview_structured_data: "预览结构化数据",
publish: "发布站点",
rollback: "回滚站点",
list_menus: "列出菜单",
diff --git a/apps/studio/src/lib/activity.ts b/apps/studio/src/lib/activity.ts
index d80b8cb4..c7824a85 100644
--- a/apps/studio/src/lib/activity.ts
+++ b/apps/studio/src/lib/activity.ts
@@ -37,6 +37,7 @@ export const KNOWN_TOOLS = [
"list_snapshots",
"diff_snapshots",
"preview_page",
+ "preview_structured_data",
"publish",
"rollback",
"request_elevation",
diff --git a/apps/studio/src/lib/api-errors.ts b/apps/studio/src/lib/api-errors.ts
index bf123301..748ccb12 100644
--- a/apps/studio/src/lib/api-errors.ts
+++ b/apps/studio/src/lib/api-errors.ts
@@ -4,6 +4,9 @@ import { ApiRequestError } from "./api/http.ts";
type ApiErrorKey = keyof (typeof en)["errors"]["api"];
+/** True when the catalog words this API error code (`errors.api.`). */
+export const isWordedCode = (code: string | undefined) => code !== undefined && isKnown(code);
+
/** A 409: someone changed the document since it was read (CAS on `rev`). */
export const isConflict = (e: unknown) => e instanceof ApiRequestError && e.status === 409;
diff --git a/apps/studio/src/lib/api/agents.ts b/apps/studio/src/lib/api/agents.ts
index 14a96790..d1ff6241 100644
--- a/apps/studio/src/lib/api/agents.ts
+++ b/apps/studio/src/lib/api/agents.ts
@@ -61,7 +61,9 @@ export type AutoCheckCode =
| "too-many"
| "media"
| "outside-window"
- | "correction";
+ | "correction"
+ /** RFC-0042 §3.5: a live entry gains a derived gate (its collection's expiry) or an earlier one. */
+ | "expires";
export interface AutoCheck {
rule: "low-risk-publish";
lowRisk: boolean;
diff --git a/apps/studio/src/lib/api/listings.ts b/apps/studio/src/lib/api/listings.ts
new file mode 100644
index 00000000..8fdd30f2
--- /dev/null
+++ b/apps/studio/src/lib/api/listings.ts
@@ -0,0 +1,61 @@
+/**
+ * RFC-0042 (collections stage 3): the entry list by list fields (`where`, `orderBy`, `limit`,
+ * `cursor`) and the structured-data preview. Imported directly by the lazy listing screens and
+ * cards, not through the `api` barrel, so the shell never carries it (`LAZY_ONLY`).
+ */
+import type {
+ ContentEntry,
+ FieldCondition,
+ FieldOrder,
+ PublishStatus,
+} from "@cloudhal/core-contracts";
+import type { StructuredDataIssue } from "@cloudhal/core-ts/structured-data";
+import { query, request } from "./http.ts";
+
+export interface EntryFieldQuery {
+ collection: string;
+ /** RFC-0002 term filter (category / tag facets), ANDed with `where`. */
+ filter?: string | undefined;
+ where?: readonly FieldCondition[] | undefined;
+ orderBy?: FieldOrder | undefined;
+ status?: readonly PublishStatus[] | undefined;
+ /** 1–100 entries per page. */
+ limit: number;
+ /** The previous page's `nextCursor`. */
+ cursor?: string | undefined;
+}
+
+/** One page of `GET …/entries` with `limit`. */
+export interface EntryPage {
+ entries: ContentEntry[];
+ total: number;
+ nextCursor?: string;
+}
+
+/** `GET …/entries/:id/structured-data` (MCP's `preview_structured_data`). */
+export interface StructuredDataPreview {
+ preset: string | null;
+ origin: string;
+ document: object | null;
+ issues: StructuredDataIssue[];
+}
+
+export const listingsApi = {
+ /** A page of the collection's entries filtered and ordered by its list fields (422 `invalid_query`). */
+ entries: (siteId: string, q: EntryFieldQuery) =>
+ request(
+ `/api/sites/${siteId}/entries${query({
+ collection: q.collection,
+ filter: q.filter,
+ where: q.where?.length ? JSON.stringify(q.where) : undefined,
+ orderBy: q.orderBy ? JSON.stringify(q.orderBy) : undefined,
+ status: q.status?.join(","),
+ limit: q.limit,
+ cursor: q.cursor,
+ })}`,
+ ),
+ structuredData: (siteId: string, entryId: string) =>
+ request(
+ `/api/sites/${siteId}/entries/${encodeURIComponent(entryId)}/structured-data`,
+ ),
+};
diff --git a/apps/studio/src/lib/listing-query.ts b/apps/studio/src/lib/listing-query.ts
new file mode 100644
index 00000000..07fb66a5
--- /dev/null
+++ b/apps/studio/src/lib/listing-query.ts
@@ -0,0 +1,68 @@
+/**
+ * RFC-0042 §4 "Studio": the API query of a collection list's field state in the URL
+ * (`listing-search.ts`), and the URL with one filter changed. Loads with the list screen.
+ */
+import type { FieldCondition, FieldOrder } from "@cloudhal/core-contracts";
+import { KEY, listingSearch, SORT } from "./listing-search.ts";
+
+/** The condition a token stands for. */
+export function conditionOf(field: string, token: string): FieldCondition | undefined {
+ switch (token) {
+ case "upcoming":
+ return { field, op: "gte", value: "now" };
+ case "past":
+ return { field, op: "lt", value: "now" };
+ case "set":
+ case "unset":
+ return { field, op: token };
+ case "yes":
+ return { field, op: "eq", value: "true" };
+ case "no":
+ return { field, op: "eq", value: "false" };
+ default:
+ return token.startsWith("is:") && token.length > 3
+ ? { field, op: "eq", value: token.slice(3) }
+ : undefined;
+ }
+}
+
+export interface ListingQuery {
+ where: FieldCondition[];
+ orderBy: FieldOrder | undefined;
+ /** True when the list asks the API by field (filters or a field sort). */
+ active: boolean;
+ /** The filter token per field. */
+ filters: Record;
+}
+
+/** The API query of the URL's field state. */
+export function listingQuery(search: Record): ListingQuery {
+ const clean = listingSearch(search);
+ const filters: Record = {};
+ const where: FieldCondition[] = [];
+ for (const [key, token] of Object.entries(clean)) {
+ const field = KEY.exec(key)?.[1];
+ const condition = field ? conditionOf(field, token) : undefined;
+ if (field && condition) {
+ filters[field] = token;
+ where.push(condition);
+ }
+ }
+ const m = clean.sort ? SORT.exec(clean.sort) : null;
+ const orderBy: FieldOrder | undefined =
+ m?.[1] && m[2] ? { field: m[1], direction: m[2] as FieldOrder["direction"] } : undefined;
+ return { where, orderBy, active: where.length > 0 || orderBy !== undefined, filters };
+}
+
+/** The URL search with one filter set (or cleared with `undefined`), the rest kept. */
+export function withFilter(
+ search: Record,
+ field: string,
+ token: string | undefined,
+): Record {
+ const next: Record = {};
+ for (const [k, v] of Object.entries(search))
+ if (typeof v === "string" && k !== `f_${field}`) next[k] = v;
+ if (token) next[`f_${field}`] = token;
+ return next;
+}
diff --git a/apps/studio/src/lib/listing-search.ts b/apps/studio/src/lib/listing-search.ts
new file mode 100644
index 00000000..73473cf1
--- /dev/null
+++ b/apps/studio/src/lib/listing-search.ts
@@ -0,0 +1,33 @@
+/**
+ * RFC-0042 §4 "Studio": a collection list's field filters and field sort in the URL — one
+ * `f_=` per filtered list field and `sort=:asc|desc` — and the API query they
+ * stand for. Tokens are type-free (`upcoming` → `gte now`, `is:` → `eq `), so the router's
+ * search check and the list's query (`listing-query.ts`) need no field definitions. Only the
+ * router's check lives here, in the shell.
+ */
+
+/** The URL keys of the list's field state. */
+export type ListingSearch = Record<`f_${string}`, string> & { sort?: string };
+
+/** What a filter token means; `is:` compares with one value. */
+export type FilterToken = "upcoming" | "past" | "set" | "unset" | "yes" | "no" | `is:${string}`;
+
+export const KEY = /^f_([a-z][A-Za-z0-9]{0,39})$/;
+const TOKEN = /^(upcoming|past|set|unset|yes|no|is:[^\s].{0,99})$/;
+export const SORT = /^([a-z][A-Za-z0-9]{0,39}):(asc|desc)$/;
+/** The contract's limit on conditions (`ContentListSection.where`, `FieldCondition` lists). */
+const MAX_CONDITIONS = 4;
+
+/** The router's check: only well-formed filter and sort params survive. */
+export function listingSearch(search: Record): ListingSearch {
+ const out: Record = {};
+ let n = 0;
+ for (const [key, value] of Object.entries(search)) {
+ if (n < MAX_CONDITIONS && KEY.test(key) && typeof value === "string" && TOKEN.test(value)) {
+ out[key] = value;
+ n++;
+ }
+ }
+ if (typeof search.sort === "string" && SORT.test(search.sort)) out.sort = search.sort;
+ return out as ListingSearch;
+}
diff --git a/apps/studio/src/lib/listings.ts b/apps/studio/src/lib/listings.ts
new file mode 100644
index 00000000..e15acbd2
--- /dev/null
+++ b/apps/studio/src/lib/listings.ts
@@ -0,0 +1,232 @@
+/**
+ * RFC-0042 §7 (Studio): the collection settings' stage 3 model — the settings with one of
+ * `listFields`, `expiry` or `structuredData` replaced, the fields each may use, the preset table as
+ * rows with only the sources that fit each path, and the listing page "Create listing page" makes.
+ * Lazy with the collection screens (`LAZY_ONLY`).
+ */
+import type {
+ CollectionSettings,
+ ContentListSection,
+ EntryValueProperty,
+ FieldDefinition,
+ FieldOrder,
+ Site,
+ SiteValueProperty,
+ StructuredDataPreset,
+ StructuredDataSource,
+} from "@cloudhal/core-contracts";
+import { canOrderBy, isBeingRemoved } from "@cloudhal/core-ts/fields";
+import {
+ KIND_SOURCES,
+ type PathSpec,
+ PRESETS,
+ type PresetType,
+ VOCABULARIES,
+} from "@cloudhal/core-ts/structured-data";
+import { ApiRequestError } from "./api.ts";
+
+type StageKey = "listFields" | "expiry" | "structuredData";
+
+/** `settings` with one stage 3 setting replaced; `undefined` (or an empty list) removes it. */
+export function withSetting(
+ settings: CollectionSettings,
+ key: K,
+ value: CollectionSettings[K] | undefined,
+): CollectionSettings {
+ const { [key]: _old, ...rest } = settings;
+ const empty = value === undefined || (Array.isArray(value) && (value as unknown[]).length === 0);
+ return empty ? rest : { ...rest, [key]: value };
+}
+
+/** The collection's fields that are not being removed by an operation. */
+export const liveFields = (settings: CollectionSettings): FieldDefinition[] =>
+ (settings.fields ?? []).filter((d) => !isBeingRemoved(d));
+
+/** Fields an expiry can read: dates and dates with times. */
+export const dateFields = (defs: readonly FieldDefinition[]) =>
+ defs.filter((d) => d.type === "date" || d.type === "datetime");
+
+/** The list fields' definitions, in column order (unknown keys left out). */
+export function listFieldDefs(settings: CollectionSettings | undefined): FieldDefinition[] {
+ const defs = new Map(liveFields(settings ?? {}).map((d) => [d.key, d]));
+ return (settings?.listFields ?? []).flatMap((k) => defs.get(k) ?? []);
+}
+
+/** The `kind` argument of the condition words: dates read "before / after", the rest "less / more". */
+export const timeKind = (def: Pick | undefined): "time" | "number" =>
+ def?.type === "date" || def?.type === "datetime" ? "time" : "number";
+
+/** Pages a 409 `list_field_in_use` names (`details.pages`). */
+export function pagesInUse(error: unknown): { id: string; path: string }[] {
+ if (!(error instanceof ApiRequestError) || error.body.error !== "list_field_in_use") return [];
+ const pages = (error.body.details as { pages?: unknown } | undefined)?.pages;
+ return Array.isArray(pages)
+ ? pages.filter((p): p is { id: string; path: string } => typeof p?.id === "string")
+ : [];
+}
+
+/** The `details.issues` of a failed request (`path`, `code`, `message`). */
+export function issuesOf(error: unknown): { path: string; code: string; message: string }[] {
+ if (!(error instanceof ApiRequestError)) return [];
+ const issues = (error.body.details as { issues?: unknown } | undefined)?.issues;
+ return Array.isArray(issues)
+ ? issues.filter((i): i is { path: string; code: string; message: string } => {
+ return typeof i?.path === "string" && typeof i?.code === "string";
+ })
+ : [];
+}
+
+// ---- Structured data ----
+
+/** One way to fill a path, as a `Select` value: `default`, `none`, `field:k`, `entry:p`, `site:p`, `constant`. */
+export type SourceChoice = string;
+
+export interface SourceOption {
+ value: SourceChoice;
+ kind: "default" | "none" | "field" | "entry" | "site" | "constant";
+ /** Field key or entry/site property. */
+ key?: string;
+}
+
+/** The preset's rows: required paths first, then "one of" groups, then the rest (table order). */
+export function presetRows(type: PresetType): { path: string; spec: PathSpec }[] {
+ const rows = Object.entries(PRESETS[type].paths).map(([path, spec]) => ({ path, spec }));
+ const rank = (s: PathSpec) => (s.required ? 0 : s.group ? 1 : 2);
+ return rows.sort((a, b) => rank(a.spec) - rank(b.spec));
+}
+
+/** Whether a field fits a path (the save-time check's rules, so Studio offers only these). */
+export function fieldFits(spec: PathSpec, def: FieldDefinition): boolean {
+ const fits = [...KIND_SOURCES[spec.kind].fields, ...(spec.alsoFields ?? [])];
+ if (!fits.includes(def.type)) return false;
+ if ((spec.kind === "lines" || spec.kind === "steps") && def.type === "text")
+ return def.multiline === true;
+ if (def.type === "select" && def.multiple && !spec.many) return false;
+ return true;
+}
+
+/** Whether a fixed value may fill a path (never a required one: those come from each entry). */
+export const constantFits = (spec: PathSpec) =>
+ KIND_SOURCES[spec.kind].constant && spec.required !== true;
+
+/** Every source that fits `spec`: its default (or "not used"), fitting fields, entry and site properties, a constant. */
+export function sourceOptions(spec: PathSpec, defs: readonly FieldDefinition[]): SourceOption[] {
+ const sources = KIND_SOURCES[spec.kind];
+ return [
+ spec.default ? { value: "default", kind: "default" } : { value: "none", kind: "none" },
+ ...defs
+ .filter((d) => fieldFits(spec, d))
+ .map((d) => ({ value: `field:${d.key}`, kind: "field" as const, key: d.key })),
+ ...sources.entry.map((p) => ({ value: `entry:${p}`, kind: "entry" as const, key: p })),
+ ...sources.site.map((p) => ({ value: `site:${p}`, kind: "site" as const, key: p })),
+ ...(constantFits(spec) ? [{ value: "constant", kind: "constant" as const }] : []),
+ ] as SourceOption[];
+}
+
+/** The `Select` value of a mapped source (or of an unmapped path). */
+export function choiceOf(source: StructuredDataSource | undefined, spec: PathSpec): SourceChoice {
+ if (!source) return spec.default ? "default" : "none";
+ switch (source.kind) {
+ case "field":
+ return `field:${source.field}`;
+ case "entry":
+ case "site":
+ return `${source.kind}:${source.property}`;
+ case "constant":
+ return "constant";
+ }
+}
+
+/** The source a choice stands for; `undefined` leaves the path unmapped (its default applies). */
+export function sourceOf(choice: SourceChoice, constant: string): StructuredDataSource | undefined {
+ const [kind, key = ""] = choice.split(":");
+ if (kind === "field") return { kind: "field", field: key };
+ if (kind === "entry") return { kind: "entry", property: key as EntryValueProperty };
+ if (kind === "site") return { kind: "site", property: key as SiteValueProperty };
+ if (kind === "constant" && constant.trim()) return { kind: "constant", value: constant.trim() };
+ return undefined;
+}
+
+/** A path's choice and its constant, as the card edits them. */
+export type MappingDraft = Record;
+
+export function mappingDraft(preset: StructuredDataPreset | undefined): MappingDraft {
+ if (!preset) return {};
+ const table = PRESETS[preset.type].paths;
+ return Object.fromEntries(
+ Object.entries(preset.properties).flatMap(([path, source]) => {
+ const spec = table[path];
+ if (!spec || !source) return [];
+ const constant = source.kind === "constant" ? source.value : "";
+ return [[path, { choice: choiceOf(source, spec), constant }]];
+ }),
+ );
+}
+
+/** The preset the draft describes; `undefined` when nothing is mapped and no type is chosen. */
+export function presetOf(
+ type: PresetType | undefined,
+ draft: MappingDraft,
+): StructuredDataPreset | undefined {
+ if (!type) return undefined;
+ const table = PRESETS[type].paths;
+ const properties: Record = {};
+ for (const [path, { choice, constant }] of Object.entries(draft)) {
+ if (!table[path]) continue;
+ const source = sourceOf(choice, constant);
+ if (source) properties[path] = source;
+ }
+ return { type, properties } as StructuredDataPreset;
+}
+
+/** The vocabulary's option keys of an enum path (a constant is chosen from these). */
+export const vocabularyKeys = (spec: PathSpec): string[] =>
+ spec.vocabulary ? VOCABULARIES[spec.vocabulary].map((e) => e.key) : [];
+
+/** The fields mapped to required paths that are not yet "Needed to publish" (open question 6). */
+export function suggestRequired(
+ preset: StructuredDataPreset | undefined,
+ defs: readonly FieldDefinition[],
+): string[] {
+ if (!preset) return [];
+ const table = PRESETS[preset.type].paths;
+ const keys = Object.entries(preset.properties)
+ .filter(([path, s]) => table[path]?.required && s?.kind === "field")
+ .map(([, s]) => (s as { field: string }).field);
+ return defs
+ .filter((d) => keys.includes(d.key) && !("requiredToPublish" in d && d.requiredToPublish))
+ .map((d) => d.key);
+}
+
+// ---- Listing page ----
+
+/**
+ * RFC-0042 §5: the `contentList` section of a new listing page. With a date list field and an
+ * expiry, it lists what is still to come, soonest first (the expiry's field when it is listed).
+ */
+export function listingSection(
+ id: string,
+ collection: string,
+ settings: CollectionSettings,
+): ContentListSection {
+ const section: ContentListSection = {
+ id,
+ kind: "contentList",
+ collection,
+ limit: 10,
+ sort: settings.sort ?? "newest",
+ };
+ if (!settings.expiry) return section;
+ const dates = dateFields(listFieldDefs(settings));
+ const def = dates.find((d) => d.key === settings.expiry?.field) ?? dates[0];
+ if (!def) return section;
+ const orderBy: FieldOrder = { field: def.key, direction: "asc" };
+ return { ...section, where: [{ field: def.key, op: "gte", value: "now" }], orderBy };
+}
+
+/** The title of a listing page: the plural label in the site's language, else the key. */
+export const listingTitle = (site: Site, collection: string) =>
+ site.collections?.[collection]?.labels?.[site.defaultLocale]?.plural ?? collection;
+
+/** Whether a list may be ordered by this field. */
+export const sortable = (def: FieldDefinition) => canOrderBy(def.type);
diff --git a/apps/studio/src/router.tsx b/apps/studio/src/router.tsx
index 7eb0074e..fb02546d 100644
--- a/apps/studio/src/router.tsx
+++ b/apps/studio/src/router.tsx
@@ -13,6 +13,7 @@ import { type AnalyticsSearch, validateAnalyticsSearch } from "./lib/analytics-s
import { api } from "./lib/api.ts";
import { authClient } from "./lib/auth.ts";
import { isQueueTab, type QueueTab } from "./lib/comments-shell.ts";
+import { listingSearch } from "./lib/listing-search.ts";
import { cachedSession } from "./lib/session-cache.ts";
import { AccountShell } from "./routes/AccountShell.tsx";
import { SiteShell } from "./routes/SiteShell.tsx";
@@ -48,7 +49,7 @@ const ArticleEditorPage = lazyRouteComponent(
"ArticleEditorPage",
);
const ArticlesPage = lazyRouteComponent(
- () => withMessages(import("./routes/ArticlesPage.tsx"), ["readers", "menus"]),
+ () => withMessages(import("./routes/ArticlesPage.tsx"), ["readers", "menus", "listings"]),
"ArticlesPage",
);
const CollectionsPage = lazyRouteComponent(
@@ -61,6 +62,7 @@ const CollectionSettingsPage = lazyRouteComponent(
"collections",
"commentsSettings",
"fields",
+ "listings",
]),
"CollectionSettingsPage",
);
@@ -94,6 +96,7 @@ const PageEditorPage = lazyRouteComponent(
"bodyEditor",
"media",
"menus",
+ "listings",
]),
"PageEditorPage",
);
@@ -321,7 +324,11 @@ const collectionsRoute = createRoute({
const collectionRoute = createRoute({
getParentRoute: () => siteRoute,
path: "/collections/$collection",
- validateSearch: facetSearch,
+ // RFC-0042 §4: list-field filters (`f_`) and the field sort (`sort`) live in the URL too.
+ validateSearch: (search: Record) => ({
+ ...facetSearch(search),
+ ...listingSearch(search),
+ }),
component: ArticlesPage,
});
const collectionSettingsRoute = createRoute({
diff --git a/apps/studio/src/routes/ArticlesPage.tsx b/apps/studio/src/routes/ArticlesPage.tsx
index 47c0278f..57fb1aae 100644
--- a/apps/studio/src/routes/ArticlesPage.tsx
+++ b/apps/studio/src/routes/ArticlesPage.tsx
@@ -1,4 +1,4 @@
-import type { ContentEntry, Taxonomy } from "@cloudhal/core-contracts";
+import type { ContentEntry, FieldOrder, Site, Taxonomy } from "@cloudhal/core-contracts";
import {
Badge,
Button,
@@ -28,7 +28,7 @@ import {
} from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
-import { useState } from "react";
+import { lazy, type ReactNode, Suspense, useState } from "react";
import { CollectionActions } from "../components/CollectionActions.tsx";
import { SiteCrumbs } from "../components/Crumbs.tsx";
import { deleteResourceProps } from "../components/deleteLabels.ts";
@@ -56,6 +56,7 @@ import {
type TranslationList,
useTranslationList,
} from "../components/TranslationList.tsx";
+import { type ServerPaging, useCollectionEntries } from "../components/useCollectionEntries.ts";
import {
scopeTaxonomies,
useCollectionHeader,
@@ -67,11 +68,18 @@ import { usePaging } from "../components/usePaging.ts";
import { listOf, useI18n, useT } from "../i18n/index.tsx";
import { api } from "../lib/api.ts";
import { entryHref, entryRoute } from "../lib/entry-href.ts";
+import { listingQuery, withFilter } from "../lib/listing-query.ts";
import { imageRefPreviewUrl } from "../lib/media.ts";
import { entryAccess } from "../lib/roles.ts";
import { primaryHostname } from "../lib/site-settings.ts";
import { facetFilter, needsTagsMigration } from "../lib/taxonomy.ts";
+// RFC-0042 §4: a collection with listing fields gets their columns, filters and sort (lazy).
+const columnsModule = () => import("../components/listings/ListFieldColumns.tsx");
+const ListFieldFilters = lazy(() => columnsModule().then((m) => ({ default: m.ListFieldFilters })));
+const ListFieldHeads = lazy(() => columnsModule().then((m) => ({ default: m.ListFieldHeads })));
+const ListFieldCells = lazy(() => columnsModule().then((m) => ({ default: m.ListFieldCells })));
+
const AUTHOR_ICON: Record = {
human: User,
agent: Robot,
@@ -102,7 +110,8 @@ export function ArticlesPage() {
const { siteId } = useParams({ from: "/sites/$siteId" });
const collection = useCollectionParam();
const header = useCollectionHeader(siteId, collection);
- const facets = useSearch({ strict: false }) as { category?: string; tag?: string };
+ const facets = useSearch({ strict: false }) as Record & Facets;
+ const listing = listingQuery(facets);
const qc = useQueryClient();
const navigate = useNavigate();
const site = useQuery({ queryKey: ["site", siteId], queryFn: () => api.sites.get(siteId) });
@@ -116,11 +125,9 @@ export function ArticlesPage() {
: undefined,
tag: tagTax && facets.tag ? { key: tagTax.key, slug: facets.tag } : undefined,
});
- const entries = useQuery({
- queryKey: filter ? ["entries", siteId, collection, filter] : ["entries", siteId, collection],
- queryFn: () => api.entries.list(siteId, { collection, filter }),
- enabled: !filter || data.taxonomies.isSuccess,
- });
+ const enabled = !filter || data.taxonomies.isSuccess;
+ const entries = useCollectionEntries({ siteId, collection, filter, listing, enabled });
+ const listFields = site.data?.collections?.[collection]?.listFields?.length ?? 0;
const all = useQuery({
queryKey: ["entries", siteId, collection],
queryFn: () => api.entries.list(siteId, collection),
@@ -135,23 +142,27 @@ export function ArticlesPage() {
siteId,
toDelete ? { kind: "contentEntry", id: toDelete.id } : undefined,
);
- const sorted = [...(entries.data ?? [])].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
- const list = useTranslationList(siteId, site.data, sorted);
+ const list = useTranslationList(siteId, site.data, entries.data ?? []);
const paging = usePaging(
list.items,
(e, q, fold) => fold(e.title).includes(q) || e.slug.includes(q),
);
- const setFacets = (next: { category?: string | undefined; tag?: string | undefined }) =>
+ const setSearch = (search: Record) =>
navigate({
to: "/sites/$siteId/collections/$collection",
params: { siteId, collection },
- search: {
- ...(next.category ? { category: next.category } : {}),
- ...(next.tag ? { tag: next.tag } : {}),
- },
+ search,
replace: true,
});
- const filtered = Boolean(facets.category || facets.tag);
+ const setFacets = (next: Facets) => {
+ const { category: _c, tag: _t, ...rest } = facets;
+ setSearch({
+ ...(next.category || next.tag ? rest : {}),
+ ...(next.category ? { category: next.category } : {}),
+ ...(next.tag ? { tag: next.tag } : {}),
+ });
+ };
+ const filtered = Boolean(facets.category || facets.tag || listing.where.length);
const create = useNewEntry(siteId, collection, list.newLocale);
const remove = useMutation({
mutationFn: (entry: ContentEntry) => api.entries.remove(entry),
@@ -173,7 +184,8 @@ export function ArticlesPage() {
);
const migrated = (data.taxonomies.data?.length ?? 0) > 0;
const facetsShown = migrated && Boolean(catTax || tagTax);
- const columns = 6 + (catTax ? 1 : 0) + (viewsColumn ? 1 : 0) + (list.groupOf ? 1 : 0);
+ const columns =
+ 6 + listFields + (catTax ? 1 : 0) + (viewsColumn ? 1 : 0) + (list.groupOf ? 1 : 0);
return (
}
@@ -191,7 +203,7 @@ export function ArticlesPage() {
onSearch={paging.setSearch}
placeholder={header.search}
actions={
- facetsShown || list.multilingual ? (
+ facetsShown || list.multilingual || listFields > 0 ? (
{filtered && (
setFacets({})} data-testid="clear-facets">
@@ -204,48 +216,45 @@ export function ArticlesPage() {
)}
{facetsShown && }
+ {site.data && listFields > 0 && (
+
+ setSearch(withFilter(facets, field, token))}
+ />
+
+ )}
) : undefined
}
/>
{entries.data?.length === 0 ? (
- filtered ? (
- }
- title={t("articles.facets.empty")}
- description={t("articles.facets.emptyDescription")}
- contents={
- setFacets({})}>
- {t("articles.facets.clear")}
-
- }
- />
- ) : (
- }
- title={header.empty}
- description={t("articles.emptyDescription")}
- contents={newButton}
- />
- )
+ setFacets({})}
+ Icon={header.Icon}
+ title={header.empty}
+ newButton={newButton}
+ />
) : (
-
-
- {t("articles.columns.title")}
- {catTax && {data.label(catTax, "singular")} }
- {tagTax ? data.label(tagTax) : t("editor.tags")}
- {t("articles.columns.author")}
- {list.groupOf && {t("translationList.column")} }
- {t("articles.columns.status")}
- {t("articles.columns.updated")}
- {viewsColumn && }
-
- {t("site.rowMenu")}
-
-
-
+ 0 ? site.data : undefined}
+ collection={collection}
+ data={data}
+ translations={Boolean(list.groupOf)}
+ views={Boolean(viewsColumn)}
+ orderBy={listing.orderBy}
+ onSort={(o) =>
+ setSearch({
+ ...withoutSort(facets),
+ ...(o ? { sort: `${o.field}:${o.direction}` } : {}),
+ })
+ }
+ />
{entries.isPending ? (
) : (
@@ -258,6 +267,7 @@ export function ArticlesPage() {
host={host}
data={data}
list={list}
+ fields={site.data && listFields > 0 ? site.data : undefined}
views={viewsColumn}
deleteDisabledReason={
entryAccess(e, { editAny, editOwnPublished }, self) === "edit"
@@ -272,14 +282,7 @@ export function ArticlesPage() {
)}
- {paging.total > paging.perPage && (
-
- )}
+
!o && setToDelete(undefined)}
@@ -295,6 +298,114 @@ export function ArticlesPage() {
);
}
+/** Nothing to list: no entries yet, or none matching the filters. */
+function ArticlesEmpty({
+ filtered,
+ onClear,
+ Icon,
+ title,
+ newButton,
+}: {
+ filtered: boolean;
+ onClear: () => void;
+ Icon: Icon;
+ title: string;
+ newButton: ReactNode;
+}) {
+ const t = useT();
+ return filtered ? (
+ }
+ title={t("articles.facets.empty")}
+ description={t("articles.facets.emptyDescription")}
+ contents={
+
+ {t("articles.facets.clear")}
+
+ }
+ />
+ ) : (
+ }
+ title={title}
+ description={t("articles.emptyDescription")}
+ contents={newButton}
+ />
+ );
+}
+
+/** The list's header row; `site` when the collection has listing fields (their columns, lazy). */
+function ArticlesHead({
+ site,
+ collection,
+ data,
+ translations,
+ views,
+ orderBy,
+ onSort,
+}: {
+ site: Site | undefined;
+ collection: string;
+ data: TaxonomyData;
+ translations: boolean;
+ views: boolean;
+ orderBy: FieldOrder | undefined;
+ onSort: (orderBy: FieldOrder | undefined) => void;
+}) {
+ const t = useT();
+ const { category: catTax, tag: tagTax } = data.defaults;
+ return (
+
+
+ {t("articles.columns.title")}
+ {site && (
+
+
+
+ )}
+ {catTax && {data.label(catTax, "singular")} }
+ {tagTax ? data.label(tagTax) : t("editor.tags")}
+ {t("articles.columns.author")}
+ {translations && {t("translationList.column")} }
+ {t("articles.columns.status")}
+ {t("articles.columns.updated")}
+ {views && }
+
+ {t("site.rowMenu")}
+
+
+
+ );
+}
+
+/** Server pages (previous / next) while the list asks by field, else the client-side pages. */
+function ListPagination({
+ server,
+ paging,
+}: {
+ server: ServerPaging | undefined;
+ paging: { page: number; perPage: number; total: number; setPage: (page: number) => void };
+}) {
+ if (server)
+ return server.total > server.perPage ? (
+
+ ) : null;
+ return paging.total > paging.perPage ? (
+
+ ) : null;
+}
+
/** One article; on a multilingual site's grouped list, with its group's Languages cell. */
function ArticleRow({
entry: e,
@@ -302,6 +413,7 @@ function ArticleRow({
host,
data,
list,
+ fields,
views,
deleteDisabledReason,
onDelete,
@@ -311,6 +423,8 @@ function ArticleRow({
host: string | undefined;
data: TaxonomyData;
list: TranslationList;
+ /** The site, when the collection has listing fields to show (RFC-0042). */
+ fields: Site | undefined;
views: ListViewsColumn;
deleteDisabledReason: string | undefined;
onDelete: () => void;
@@ -350,6 +464,11 @@ function ArticleRow({
+ {fields && (
+
+
+
+ )}
{catTax && (
{cats.names.length === 0 ? (
@@ -437,6 +556,12 @@ function ArticleRow({
type Facets = { category?: string | undefined; tag?: string | undefined };
+/** The URL search without the field sort. */
+function withoutSort(search: Record): Record {
+ const { sort: _s, ...rest } = search;
+ return rest;
+}
+
/** The category and tag filters, which query the API's filter language (DESIGN.md §2p). */
function ArticleFacets({
data,
diff --git a/apps/studio/src/routes/CollectionSettingsPage.tsx b/apps/studio/src/routes/CollectionSettingsPage.tsx
index c84642fc..10d0f79b 100644
--- a/apps/studio/src/routes/CollectionSettingsPage.tsx
+++ b/apps/studio/src/routes/CollectionSettingsPage.tsx
@@ -21,6 +21,7 @@ import {
import { SiteCrumbs } from "../components/Crumbs.tsx";
import { ErrorBanner } from "../components/ErrorBanner.tsx";
import { ReadOnly } from "../components/Gating.tsx";
+import { LISTING_SECTIONS } from "../components/listings/sections.ts";
import { SettingsLayout, SettingsSection } from "../components/Settings.tsx";
import { useTaxonomyData } from "../components/Taxonomy.tsx";
import { SwitchField } from "../components/TaxonomySettingsDialog.tsx";
@@ -36,6 +37,13 @@ const FieldsSection = lazy(() =>
import("../components/fields/FieldsSection.tsx").then((m) => ({ default: m.FieldsSection })),
);
+// RFC-0042 §7: listing fields, expiry, structured data and "Create listing page", one lazy chunk.
+const listingsModule = () => import("../components/listings/ListingSections.tsx");
+const ListingSections = lazy(() => listingsModule().then((m) => ({ default: m.ListingSections })));
+const CreateListingPage = lazy(() =>
+ listingsModule().then((m) => ({ default: m.CreateListingPage })),
+);
+
type Sort = NonNullable;
type LabelForm = Record;
@@ -92,7 +100,7 @@ export function withForm(
* stays in Site settings › Collections.
*/
export function CollectionSettingsPage() {
- const { t, locale } = useI18n("collections", "fields");
+ const { t, locale } = useI18n("collections", "fields", "listings");
const { siteId, collection } = useParams({
from: "/sites/$siteId/collections/$collection/settings",
});
@@ -115,6 +123,13 @@ export function CollectionSettingsPage() {
}
title={t("collections.settings.title", { name })}
description={t("collections.settings.description")}
+ actions={
+ site.data && (
+
+
+
+ )
+ }
>
)}
+ {site.data && (
+ }>
+
+
+ )}
@@ -307,8 +335,14 @@ function SettingsForm({
/** The Fields section while its code loads. */
function FieldsSkeleton() {
const { t } = useI18n("fields");
+ return ;
+}
+
+/** A settings section while its code loads. */
+function SectionSkeleton({ id, title }: { id: string; title?: string }) {
+ const { t } = useI18n("listings");
return (
-
+
{t("common.loading")}
diff --git a/apps/studio/test/listings-studio.test.tsx b/apps/studio/test/listings-studio.test.tsx
new file mode 100644
index 00000000..b2e47696
--- /dev/null
+++ b/apps/studio/test/listings-studio.test.tsx
@@ -0,0 +1,737 @@
+/**
+ * RFC-0042 step 5 (collections stage 3 in Studio): the listing fields, expiry and structured-data
+ * cards (save, errors, compatible sources only, JSON-LD preview, read-only), the collection list's
+ * field columns, filters and sort (the query they build), "Create listing page", and the page
+ * editor's content list conditions.
+ */
+import type {
+ ContentEntry,
+ ContentListSection,
+ FieldDefinition,
+ Site,
+} from "@cloudhal/core-contracts";
+import { PRESETS } from "@cloudhal/core-ts/structured-data";
+import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { useState } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ContentListQuery } from "../src/components/listings/ContentListQuery.tsx";
+import { CreateListingPage } from "../src/components/listings/CreateListingPage.tsx";
+import { ExpirySection } from "../src/components/listings/ExpirySection.tsx";
+import { ListFieldsSection } from "../src/components/listings/ListFieldsSection.tsx";
+import { StructuredDataSection } from "../src/components/listings/StructuredDataSection.tsx";
+import { sectionQueryIssues } from "../src/components/page-editor/sections.ts";
+import { en } from "../src/i18n/en.ts";
+import { isKnownTool } from "../src/lib/activity.ts";
+import { ApiRequestError } from "../src/lib/api/http.ts";
+import { listingQuery, withFilter } from "../src/lib/listing-query.ts";
+import { listingSearch } from "../src/lib/listing-search.ts";
+import {
+ listingSection,
+ presetOf,
+ sourceOptions,
+ suggestRequired,
+ withSetting,
+} from "../src/lib/listings.ts";
+import { ArticlesPage } from "../src/routes/ArticlesPage.tsx";
+import { asRole, bodyOf, on, serve, wrap } from "./roles-fixtures.tsx";
+
+const nav = vi.hoisted(() => ({ search: {} as Record
, navigate: vi.fn() }));
+vi.mock("@tanstack/react-router", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useParams: () => ({ siteId: "site_1", collection: "events" }),
+ useNavigate: () => nav.navigate,
+ useSearch: () => nav.search,
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ nav.navigate.mockReset();
+ nav.search = {};
+});
+
+const L = (label: string) => ({ en: { label } });
+const DEFS: [FieldDefinition, ...FieldDefinition[]] = [
+ { key: "startsAt", type: "datetime", labels: L("Starts"), localized: false },
+ { key: "day", type: "date", labels: L("Day"), localized: false },
+ { key: "venue", type: "text", labels: L("Venue"), localized: true },
+ { key: "seats", type: "number", labels: L("Seats"), localized: false },
+ {
+ key: "status",
+ type: "select",
+ labels: L("Status"),
+ localized: false,
+ options: [
+ { value: "scheduled", labels: { en: "Scheduled" } },
+ { value: "cancelled", labels: { en: "Cancelled" } },
+ ],
+ },
+];
+
+function siteWith(settings: Record = {}): Site {
+ return {
+ schemaVersion: 1,
+ id: "site_1",
+ projectId: "proj_1",
+ name: "Site",
+ slug: "site",
+ defaultLocale: "en",
+ locales: ["en"],
+ domains: [{ hostname: "example.com", primary: true }],
+ pageIds: [],
+ theme: { name: "blog" },
+ timeZone: "Europe/Istanbul",
+ collections: {
+ events: {
+ labels: { en: { singular: "Event", plural: "Events" } },
+ fields: DEFS,
+ ...settings,
+ },
+ },
+ rev: 7,
+ createdAt: "2026-10-18T10:00:00Z",
+ updatedAt: "2026-10-18T10:00:00Z",
+ } as unknown as Site;
+}
+
+const entry = (id: string, fields: Record, extra = {}): ContentEntry =>
+ ({
+ schemaVersion: 1,
+ id,
+ siteId: "site_1",
+ collection: "events",
+ slug: id,
+ locale: "en",
+ status: "published",
+ title: id,
+ body: { format: "markdown", value: "" },
+ fields,
+ tags: [],
+ author: { kind: "human", id: "u_owner" },
+ rev: 1,
+ createdAt: "2026-10-18T10:00:00Z",
+ updatedAt: "2026-10-18T10:00:00Z",
+ ...extra,
+ }) as unknown as ContentEntry;
+
+const PUT = "/api/sites/site_1/collections/events";
+const saved = (init?: RequestInit) => {
+ const body = JSON.parse(String(init?.body)) as { settings: unknown };
+ return {
+ status: 200,
+ body: { site: { ...siteWith(), rev: 8, collections: { events: body.settings } } },
+ };
+};
+
+/** Opens a Kumo `Select` (by its accessible name) and picks an option. */
+async function choose(combobox: HTMLElement, option: string | RegExp) {
+ fireEvent.click(combobox);
+ const item = await screen.findByRole("option", { name: option });
+ fireEvent.pointerDown(item, { pointerType: "mouse" });
+ fireEvent.click(item);
+}
+const optionNames = async (combobox: HTMLElement) => {
+ fireEvent.click(combobox);
+ await screen.findAllByRole("option");
+ const names = screen.getAllByRole("option").map((o) => o.textContent ?? "");
+ fireEvent.keyDown(combobox, { key: "Escape" });
+ return names;
+};
+
+describe("listings (lib)", () => {
+ it("turns URL filter tokens into conditions and keeps only well-formed params", () => {
+ expect(
+ listingSearch({ f_startsAt: "upcoming", f_Bad: "set", f_day: "nope", sort: "day:asc", x: 1 }),
+ ).toEqual({
+ f_startsAt: "upcoming",
+ sort: "day:asc",
+ });
+ const q = listingQuery({
+ f_startsAt: "upcoming",
+ f_status: "is:cancelled",
+ f_seats: "unset",
+ sort: "seats:desc",
+ category: "news",
+ });
+ expect(q.where).toEqual([
+ { field: "startsAt", op: "gte", value: "now" },
+ { field: "status", op: "eq", value: "cancelled" },
+ { field: "seats", op: "unset" },
+ ]);
+ expect(q.orderBy).toEqual({ field: "seats", direction: "desc" });
+ expect(q.active).toBe(true);
+ expect(listingQuery({ category: "news" }).active).toBe(false);
+ expect(withFilter({ category: "news", f_day: "past" }, "day", undefined)).toEqual({
+ category: "news",
+ });
+ expect(withFilter({}, "day", "past")).toEqual({ f_day: "past" });
+ });
+
+ it("offers only the sources that fit a structured-data path", () => {
+ const values = (path: string) =>
+ sourceOptions(PRESETS.Event.paths[path] as never, DEFS).map((o) => o.value);
+ // A required date: date fields and the entry's dates, no text field, no fixed value.
+ expect(values("startDate")).toEqual([
+ "none",
+ "field:startsAt",
+ "field:day",
+ "entry:publishedAt",
+ "entry:updatedAt",
+ ]);
+ // An optional enum: the select (single) and a fixed value.
+ expect(values("eventStatus")).toEqual(["none", "field:status", "constant"]);
+ // A text path with a default: the default first, text fields, entry and site text, a constant.
+ expect(values("organizer.name")).toEqual([
+ "none",
+ "field:venue",
+ "entry:title",
+ "entry:summary",
+ "site:name",
+ "constant",
+ ]);
+ expect(values("name")[0]).toBe("default");
+ expect(values("offers.price")).toEqual(["none", "field:seats"]);
+ });
+
+ it("builds presets, settings and listing sections", () => {
+ const preset = presetOf("Event", {
+ startDate: { choice: "field:startsAt", constant: "" },
+ name: { choice: "default", constant: "" },
+ eventStatus: { choice: "constant", constant: "scheduled" },
+ "offers.priceCurrency": { choice: "constant", constant: " " },
+ });
+ expect(preset).toEqual({
+ type: "Event",
+ properties: {
+ startDate: { kind: "field", field: "startsAt" },
+ eventStatus: { kind: "constant", value: "scheduled" },
+ },
+ });
+ expect(suggestRequired(preset, DEFS)).toEqual(["startsAt"]);
+ expect(withSetting({ icon: "x", listFields: ["day"] }, "listFields", [] as never)).toEqual({
+ icon: "x",
+ });
+ const settings = {
+ listFields: ["seats", "day"],
+ expiry: { field: "startsAt", action: "unlist" },
+ } as never;
+ expect(
+ listingSection("sec_1", "events", { ...(settings as object), fields: DEFS } as never),
+ ).toEqual({
+ id: "sec_1",
+ kind: "contentList",
+ collection: "events",
+ limit: 10,
+ sort: "newest",
+ where: [{ field: "day", op: "gte", value: "now" }],
+ orderBy: { field: "day", direction: "asc" },
+ });
+ expect(listingSection("sec_1", "events", { fields: DEFS })).not.toHaveProperty("where");
+ });
+
+ it("words the new error codes, the expiry auto-check and the preview tool", () => {
+ for (const code of [
+ "invalid_query",
+ "structured_data_invalid",
+ "invalid_expiry",
+ "list_field_in_use",
+ "gates_unsupported",
+ ] as const)
+ expect(en.errors.api[code]).toBeTruthy();
+ expect(en.approvals.autoCheck.expires).toMatch(/expiry/);
+ expect(en.tools.preview_structured_data).toBe("Preview structured data");
+ expect(isKnownTool("preview_structured_data")).toBe(true);
+ const error = new ApiRequestError(422, {
+ error: "invalid_query",
+ message: "x",
+ details: {
+ issues: [
+ { path: "sections/1/where/0/value", code: "invalid_query", message: "not a number" },
+ ],
+ },
+ });
+ expect(sectionQueryIssues(error, 1)).toEqual([
+ { path: "where/0/value", message: "not a number" },
+ ]);
+ expect(sectionQueryIssues(error, 0)).toEqual([]);
+ });
+});
+
+describe("Collection settings › stage 3 cards", () => {
+ it("Listing fields: adds and orders fields, saves them alone", async () => {
+ const fetch = serve(on("PUT", PUT, saved));
+ const site = siteWith({ listFields: ["seats"], icon: "calendar" });
+ render(
+ wrap(
+ ,
+ ),
+ );
+ await choose(await screen.findByRole("combobox", { name: "Add a listing field" }), "Starts");
+ fireEvent.click(screen.getByRole("button", { name: "Move Starts up" }));
+ expect(screen.getAllByText("Sortable")).toHaveLength(2);
+ fireEvent.click(screen.getByTestId("save-list-fields"));
+ await waitFor(() =>
+ expect(bodyOf(fetch, "PUT", PUT)).toMatchObject({
+ rev: 7,
+ settings: { icon: "calendar", listFields: ["startsAt", "seats"] },
+ }),
+ );
+ });
+
+ it("Listing fields: a field still used by sections names the pages", async () => {
+ serve(
+ on("PUT", PUT, {
+ status: 409,
+ body: {
+ error: "list_field_in_use",
+ message: "in use",
+ details: { pages: [{ id: "page_up", path: "/upcoming", locale: "en" }] },
+ },
+ }),
+ );
+ const site = siteWith({ listFields: ["seats", "day"] });
+ render(
+ wrap(
+ ,
+ ),
+ );
+ fireEvent.click(await screen.findByRole("button", { name: "Remove Day" }));
+ fireEvent.click(screen.getByTestId("save-list-fields"));
+ const box = await screen.findByTestId("list-fields-in-use");
+ expect(within(box).getByRole("link", { name: "/upcoming" })).toHaveAttribute(
+ "href",
+ "/sites/site_1/pages/page_up",
+ );
+ expect(screen.getByText(en.errors.api.list_field_in_use)).toBeInTheDocument();
+ });
+
+ it("Expiry: date fields only, the action in words, grace, and how many are past it", async () => {
+ const fetch = serve(
+ on("PUT", PUT, saved),
+ on("GET", "/api/sites/site_1/entries", {
+ status: 200,
+ body: [
+ entry("past", { startsAt: { at: "2020-01-01T10:00:00Z", timeZone: "UTC" } }),
+ entry("soon", { startsAt: { at: "2999-01-01T10:00:00Z", timeZone: "UTC" } }),
+ entry("none", {}),
+ ],
+ }),
+ );
+ const site = siteWith();
+ render(
+ wrap(
+ ,
+ ),
+ );
+ fireEvent.click(await screen.findByRole("switch", { name: "End entries on a date" }));
+ const field = await screen.findByRole("combobox", { name: /Date field/ });
+ expect(await optionNames(field)).toEqual(["Starts", "Day"]);
+ expect(screen.getByText(/leaves lists, content list sections, feeds/)).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("radio", { name: /Hide the page/ }));
+ fireEvent.change(screen.getByTestId("expiry-grace"), { target: { value: "60" } });
+ expect(await screen.findByText("1 published entry is already past it.")).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId("save-expiry"));
+ await waitFor(() =>
+ expect(bodyOf(fetch, "PUT", PUT)?.settings).toMatchObject({
+ expiry: { field: "startsAt", action: "hide", afterMinutes: 60 },
+ }),
+ );
+ });
+
+ it("Expiry: an older site Worker says to deploy first", async () => {
+ serve(on("PUT", PUT, { status: 409, body: { error: "gates_unsupported", message: "deploy" } }));
+ const site = siteWith({ expiry: { field: "day", action: "unlist" } });
+ render(
+ wrap(
+ ,
+ ),
+ );
+ fireEvent.change(await screen.findByTestId("expiry-grace"), { target: { value: "30" } });
+ fireEvent.click(screen.getByTestId("save-expiry"));
+ expect(await screen.findByTestId("expiry-gates-unsupported")).toHaveTextContent(
+ /Deploy the site first/,
+ );
+ });
+
+ it("Structured data: maps paths with fitting sources only, saves, and shows refused issues", async () => {
+ const fetch = serve(
+ on("PUT", PUT, {
+ status: 422,
+ body: {
+ error: "structured_data_invalid",
+ message: "x",
+ details: {
+ issues: [
+ {
+ path: "collections/events/structuredData/properties/location.name",
+ code: "required_missing",
+ message: "x",
+ },
+ ],
+ },
+ },
+ }),
+ );
+ const site = siteWith();
+ render(
+ wrap(
+ ,
+ ),
+ );
+ await choose(await screen.findByRole("combobox", { name: "Type" }), "Event");
+ await screen.findByTestId("preset-rows");
+ const start = within(
+ document.querySelector('[data-path="startDate"]') as HTMLElement,
+ ).getByRole("combobox");
+ const names = await optionNames(start);
+ expect(names).toEqual([
+ "Not used",
+ "Field: Starts",
+ "Field: Day",
+ "Entry: Published date",
+ "Entry: Updated date",
+ ]);
+ expect(names).not.toContain("Field: Venue");
+ // Live check, before saving: the required start date is not mapped yet.
+ const row = document.querySelector('[data-path="startDate"]') as HTMLElement;
+ expect(within(row).getByText(/Needed: choose where its value comes from/)).toBeInTheDocument();
+ await choose(start, "Field: Starts");
+ await waitFor(() => expect(within(row).queryByText(/Needed:/)).toBeNull());
+ fireEvent.click(screen.getByTestId("save-structured-data"));
+ await waitFor(() =>
+ expect(bodyOf(fetch, "PUT", PUT)?.settings.structuredData).toEqual({
+ type: "Event",
+ properties: { startDate: { kind: "field", field: "startsAt" } },
+ }),
+ );
+ const location = document.querySelector('[data-path="location.name"]') as HTMLElement;
+ expect(await within(location).findAllByText(/Needed: choose/)).not.toHaveLength(0);
+ });
+
+ it("Structured data: previews a chosen entry's JSON-LD and why another emits none", async () => {
+ serve(
+ on("GET", "/api/sites/site_1/entries", {
+ status: 200,
+ body: [entry("launch", {}), entry("draft", {})],
+ }),
+ on("GET", "/api/sites/site_1/entries/launch/structured-data", {
+ status: 200,
+ body: {
+ preset: "Event",
+ origin: "https://example.com",
+ document: { "@type": "Event", name: "launch" },
+ issues: [],
+ },
+ }),
+ on("GET", "/api/sites/site_1/entries/draft/structured-data", {
+ status: 200,
+ body: {
+ preset: "Event",
+ origin: "https://example.com",
+ document: null,
+ issues: [
+ {
+ path: "collections/events/structuredData/properties/startDate",
+ code: "required_value_missing",
+ message: "x",
+ },
+ ],
+ },
+ }),
+ );
+ const site = siteWith({
+ structuredData: {
+ type: "Event",
+ properties: { startDate: { kind: "field", field: "startsAt" } },
+ },
+ });
+ render(
+ wrap(
+ ,
+ ),
+ );
+ const preview = await screen.findByTestId("structured-data-preview");
+ await waitFor(() =>
+ expect(within(preview).getByRole("combobox", { name: "Entry" })).not.toHaveAttribute(
+ "data-disabled",
+ ),
+ );
+ await new Promise((r) => setTimeout(r, 50));
+ await choose(within(preview).getByRole("combobox", { name: "Entry" }), /^launch/);
+ await waitFor(() => expect(preview.textContent).toContain("@type"));
+ expect(within(preview).getByRole("link", { name: /Rich Results Test/ })).toHaveAttribute(
+ "href",
+ `https://search.google.com/test/rich-results?url=${encodeURIComponent("https://example.com/events/launch")}`,
+ );
+ await choose(within(preview).getByRole("combobox", { name: "Entry" }), /^draft/);
+ expect(
+ await within(preview).findByText(/no value for a required property \(startDate\)/),
+ ).toBeInTheDocument();
+ expect(within(preview).getByText(/emits no structured data/)).toBeInTheDocument();
+ });
+
+ it("is read-only without site:settings", async () => {
+ serve();
+ const site = siteWith({ listFields: ["seats"], expiry: { field: "day", action: "unlist" } });
+ const props = {
+ site,
+ collection: "events",
+ settings: site.collections?.events ?? {},
+ readOnlyReason: "Only Administrators change site settings.",
+ };
+ render(
+ wrap(
+ <>
+
+
+
+ >,
+ ),
+ );
+ expect(await screen.findByTestId("save-list-fields")).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Remove Seats" })).toBeDisabled();
+ expect(screen.getByRole("combobox", { name: "Add a listing field" })).toHaveAttribute(
+ "data-disabled",
+ );
+ expect(screen.getByTestId("save-expiry")).toBeDisabled();
+ expect(screen.getByRole("switch", { name: "End entries on a date" })).toBeDisabled();
+ expect(screen.getByTestId("save-structured-data")).toBeDisabled();
+ });
+});
+
+describe("Collection list › listing fields", () => {
+ const routes = (site: Site) => [
+ ...asRole("owner"),
+ on("GET", "/api/sites/site_1", { status: 200, body: site }),
+ ];
+
+ it("shows formatted columns and filters, and a filter goes to the URL", async () => {
+ const site = siteWith({ listFields: ["startsAt", "seats", "status"] });
+ serve(
+ ...routes(site),
+ on("GET", "/api/sites/site_1/entries", {
+ status: 200,
+ body: [
+ entry("launch", {
+ startsAt: { at: "2026-11-05T16:30:00Z", timeZone: "Europe/Istanbul" },
+ seats: 1200,
+ status: "cancelled",
+ }),
+ ],
+ }),
+ );
+ render(wrap( ));
+ const row = await waitFor(() => {
+ const r = document.querySelector('tr[data-entry-id="launch"]');
+ expect(r).not.toBeNull();
+ return r as HTMLElement;
+ });
+ await waitFor(() =>
+ expect(row.querySelector('[data-list-cell="seats"]')).toHaveTextContent("1,200"),
+ );
+ expect(row.querySelector('[data-list-cell="status"]')).toHaveTextContent("Cancelled");
+ expect(row.querySelector('[data-list-cell="startsAt"]')?.textContent).toMatch(
+ /November 5, 2026/,
+ );
+ await choose(screen.getByRole("combobox", { name: "Starts" }), "Starts: upcoming");
+ expect(nav.navigate).toHaveBeenLastCalledWith(
+ expect.objectContaining({ search: { f_startsAt: "upcoming" } }),
+ );
+ fireEvent.click(screen.getByTestId("list-sort-seats"));
+ expect(nav.navigate).toHaveBeenLastCalledWith(
+ expect.objectContaining({ search: { sort: "seats:asc" } }),
+ );
+ });
+
+ it("asks the API by field while the URL filters or sorts, one page at a time", async () => {
+ nav.search = { f_startsAt: "upcoming", f_status: "is:scheduled", sort: "seats:desc" };
+ const site = siteWith({ listFields: ["startsAt", "seats", "status"] });
+ const fetch = serve(
+ ...routes(site),
+ on("GET", "/api/sites/site_1/entries", (init) => ({
+ status: 200,
+ body: { entries: [entry("a", {}), entry("b", {})], total: 30, nextCursor: "next1", init },
+ })),
+ );
+ render(wrap( ));
+ await waitFor(() => expect(document.querySelectorAll("tr[data-entry-id]")).toHaveLength(2));
+ const urls = fetch.mock.calls.map(([u]) => decodeURIComponent(String(u)));
+ const asked = urls.find((u) => u.includes("where="));
+ expect(asked).toContain(
+ 'where=[{"field":"startsAt","op":"gte","value":"now"},{"field":"status","op":"eq","value":"scheduled"}]',
+ );
+ expect(asked).toContain('orderBy={"field":"seats","direction":"desc"}');
+ expect(asked).toContain("limit=25");
+ fireEvent.click(await screen.findByRole("button", { name: /next/i }));
+ await waitFor(() =>
+ expect(fetch.mock.calls.some(([u]) => String(u).includes("cursor=next1"))).toBe(true),
+ );
+ });
+});
+
+describe("Create listing page", () => {
+ it("creates a draft page at the collection's address with an upcoming content list", async () => {
+ const site = siteWith({
+ listFields: ["startsAt"],
+ expiry: { field: "startsAt", action: "unlist" },
+ });
+ const fetch = serve(
+ ...asRole("owner"),
+ on("GET", "/api/sites/site_1/pages", { status: 200, body: [] }),
+ on("POST", "/api/sites/site_1/pages", {
+ status: 201,
+ body: { page: { id: "page_events", path: "/events" }, site },
+ }),
+ );
+ render(wrap( ));
+ await waitFor(() => expect(screen.getByTestId("create-listing-page")).toBeEnabled());
+ fireEvent.click(screen.getByTestId("create-listing-page"));
+ await waitFor(() =>
+ expect(nav.navigate).toHaveBeenCalledWith(
+ expect.objectContaining({ params: { siteId: "site_1", pageId: "page_events" } }),
+ ),
+ );
+ expect(bodyOf(fetch, "POST", "/api/sites/site_1/pages")).toMatchObject({
+ path: "/events",
+ title: "Events",
+ locale: "en",
+ status: "draft",
+ sections: [
+ {
+ kind: "contentList",
+ collection: "events",
+ where: [{ field: "startsAt", op: "gte", value: "now" }],
+ orderBy: { field: "startsAt", direction: "asc" },
+ },
+ ],
+ });
+ });
+
+ it("opens the page that already lives at that address", async () => {
+ serve(
+ ...asRole("owner"),
+ on("GET", "/api/sites/site_1/pages", {
+ status: 200,
+ body: [{ id: "page_old", path: "/events", locale: "en" }],
+ }),
+ );
+ render(wrap( ));
+ expect(await screen.findByTestId("open-listing-page")).toHaveAttribute(
+ "href",
+ "/sites/site_1/pages/page_old",
+ );
+ });
+});
+
+describe("Page editor › content list conditions", () => {
+ function Harness({
+ initial,
+ issues = [],
+ }: {
+ initial: ContentListSection;
+ issues?: { path: string; message: string }[];
+ }) {
+ const [section, setSection] = useState(initial);
+ return (
+ <>
+
+ {JSON.stringify(section)}
+ >
+ );
+ }
+ const base: ContentListSection = {
+ id: "sec_1",
+ kind: "contentList",
+ collection: "events",
+ limit: 6,
+ sort: "newest",
+ };
+ const current = () =>
+ JSON.parse(screen.getByTestId("section").textContent ?? "{}") as ContentListSection;
+
+ it("adds conditions with the field type's operators and orders by a date", async () => {
+ serve(
+ on("GET", "/api/sites/site_1", {
+ status: 200,
+ body: siteWith({ listFields: ["startsAt", "venue"] }),
+ }),
+ );
+ render(wrap( ));
+ fireEvent.click(await screen.findByTestId("add-condition"));
+ expect(current().where).toEqual([{ field: "startsAt", op: "eq", value: "now" }]);
+ const op = within(
+ document.querySelector('[data-condition="startsAt"]') as HTMLElement,
+ ).getByRole("combobox", { name: "Condition" });
+ expect(await optionNames(op)).toEqual([
+ "is",
+ "is not",
+ "is before",
+ "is on or before",
+ "is after",
+ "is on or after",
+ "has a value",
+ "is empty",
+ ]);
+ await choose(op, "is on or after");
+ expect(current().where).toEqual([{ field: "startsAt", op: "gte", value: "now" }]);
+ await choose(screen.getByRole("combobox", { name: "Order by field" }), "Starts");
+ expect(current().orderBy).toEqual({ field: "startsAt", direction: "asc" });
+ await choose(screen.getByRole("combobox", { name: "Direction" }), "Latest first");
+ expect(current().orderBy).toEqual({ field: "startsAt", direction: "desc" });
+ fireEvent.click(screen.getByRole("button", { name: "Remove condition" }));
+ expect(current()).not.toHaveProperty("where");
+ });
+
+ it("shows the save's invalid_query issues on their row, and a hint without listing fields", async () => {
+ serve(
+ on("GET", "/api/sites/site_1", { status: 200, body: siteWith({ listFields: ["seats"] }) }),
+ );
+ const section = {
+ ...base,
+ where: [{ field: "seats", op: "gt", value: "ten" }],
+ } as ContentListSection;
+ render(
+ wrap(
+ ,
+ ),
+ );
+ expect(await screen.findByText("not a number")).toBeInTheDocument();
+ cleanup();
+ serve(on("GET", "/api/sites/site_1", { status: 200, body: siteWith() }));
+ render(wrap( ));
+ expect(await screen.findByRole("link", { name: "Choose listing fields" })).toHaveAttribute(
+ "href",
+ "/sites/site_1/collections/events/settings#listing-fields",
+ );
+ });
+});
diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md
index 74a6f8e2..72e120ba 100644
--- a/docs/design/DESIGN.md
+++ b/docs/design/DESIGN.md
@@ -482,7 +482,11 @@ Words in the lazy `fields` namespace (loaded with the entry editor and collectio
- **Collection settings › Fields** (`FieldsSection`, `SettingsSection #fields` after "Listing", in the table of contents; a skeleton section while its chunk loads): rows separated by hairlines — the label (bold), the key in mono, a secondary type `Badge`, info "Per language", outline "Needed to publish", beta "Not saved" for fields added in the draft, warning "Being changed" while an operation's marker is on it; ghost square up/down buttons and a ⋯ `DropdownMenu` (Edit…; for saved fields Rename…, Change type… (only types the conversion table allows), Languages…, Remove… (danger); for unsaved ones Remove). Header action: secondary `Plus` "Add field" (disabled at 30). Footer: ghost "Discard" while dirty, primary "Save fields" — one `PUT /collections/:key` with the whole list (free edits only). Operations wait until the draft is saved or discarded (a muted line says so). Empty: `SectionEmpty` (`Textbox`). Without `site:settings`, the page's `ReadOnly` fieldset and `readOnlyReason` disable every control.
- **Add / edit field** (`FieldDialog`, `LayerDialog lg`): type `Select` (new fields only), label and help per site language (`Input` pairs, two columns from `sm`; the default language's label is required), the key (mono, derived from the default label until edited — `fieldKeyFrom` — and checked against the contract's pattern, the reserved keys and the collection's other keys; disabled once saved), `SwitchField`s "A value per language" (pre-set on for text, rich text and links) and "Needed to publish" (not for yes/no), then the type's own: maximum length, "Several lines" and "Include in site search" (text), search (rich text), minimum / maximum and "Whole numbers only" (number), options as `InsetPanel`s with a label per language and a mono value (derived from the default label until edited), "Add option" and "Allow several" (select). Errors show after the first Apply. A saved field keeps key, type, localization and `multiple` disabled with the reason: those change through operations.
- **Operation dialogs** (`FieldOperationDialog`, `LayerDialog lg`, one for rename, change type, languages and remove): the inputs (new key; new type and, for a select, its options), then primary "Check what changes" runs the dry run (languages and removal run it on open). The report (`FieldOperationReport`): an outline `InsetPanel` `dl` of entries checked and changed and the non-zero counts (moved, converted, cleared, removed, can't be converted in danger text), per language on multilingual sites, and up to five recessed sample panels (kind `Badge`, entry id · locale in mono, before → after with a `DirectionalIcon` arrow). Then an alert `Banner` about themes reading `entry.fields.` (rename, type), the `Checkbox` "Clear n values that can't be converted" that must be ticked when the dry run found any, and the `Camera` snapshot line ("…the live site changes only when you publish"). The primary becomes the operation's verb ("Rename field", "Remove field"); running shows a `Loader` with the entries updated so far while Studio continues every `done: false`; finished, a `Banner` "Done · n entries changed." with `Banner.Action` "Revert", then "Reverted". The dialog can't be dismissed while it runs. A field carrying a running operation's marker puts an alert `Banner` in the section ("A field change is still running · n entries left", polled) with "Continue", which reopens the dialog on that run (or its revert).
-- **Approvals and Activity**: `rename_field`, `change_field_type`, `set_field_localized`, `remove_field`, `list_collections`, `create_collection` and `update_collection` have labels in `tools.*`; `change_field_type` and `remove_field` are `CONFIRM_TOOLS` (the server's dry-run prompt shows, and approving asks first). Field error codes read through `apiErrorText` (`errors.api.*`).
+- **Collection settings › Listing fields, Expiry, Structured data** (RFC-0042 §7, ADR-0044; `components/listings/`, words in the lazy `listings` namespace, `LAZY_ONLY` with `lib/listings.ts`, `lib/listing-query.ts`, `lib/api/listings.ts` and `@cloudhal/core-ts/structured-data`): three `SettingsSection`s after Fields, in the table of contents (`#listing-fields`, `#expiry`, `#structured-data`), one lazy chunk with a skeleton section while it loads. Each saves only its own setting (`PUT /collections/:key` with the current settings and that key replaced): footer ghost "Discard" while dirty, primary "Save …" (gated, `title` = reason); without `site:settings` the page's `ReadOnly` fieldset and `readOnlyReason` disable every control. **Listing fields**: up to four chosen fields as hairline rows (label bold, key mono, secondary type `Badge`, outline "Sortable" for number/date/datetime; ghost square up / down / remove), then a `Select` "Add a listing field" (disabled at four, with the reason as its description); empty → `SectionEmpty` (`Columns`); a 409 `list_field_in_use` shows the error `Banner` and links to the pages whose content lists still use the field. **Expiry**: `SwitchField` "End entries on a date", the date field `Select` (date and date-and-time fields only; none → a muted line), the action as `Radio.Group appearance="card"` ("Leave lists" / "Hide the page", each with RFC-0042 §3.3 in words), the grace in minutes (`Input type=number`, 0–10,080), and a live secondary line counting published entries already past it (counted in Studio with `fieldGateInstant`). A 409 `gates_unsupported` becomes an alert `Banner` (`CloudArrowUp`) that says to deploy the site first. **Structured data**: a type `Select` (None = blog post, Event, Job posting, Product, Recipe), then hairline rows of the type's properties — required first (warning `Badge` "Required", outline "One of these" for address groups) — each path in mono with a `Select` of only the sources that fit (its default, fields of a fitting type, entry and site properties, "A fixed value" where allowed; a fixed value on an enum path is a vocabulary `Select`, a currency gets the ISO hint). `checkPreset` runs while editing and the refused save's `details.issues` join it, worded per code under their row (`Text variant="error"`). Muted lines suggest "Needed to publish" for fields behind required properties and remind that the theme should show mapped fields. With a saved preset, an `InsetPanel` previews a chosen entry (`GET …/entries/:id/structured-data`): issues, the JSON-LD in `CodeHighlighted`, and for a published entry on a domain a secondary `LinkButton` to Google's Rich Results Test.
+- **Create listing page** (RFC-0042 §5): a secondary header action on collection settings (`pages.write` only) that creates a draft page at the collection's prefix titled with its plural, holding one `contentList` (upcoming first, soonest first, when the collection has a date listing field and an expiry), then opens it in the page editor. When a page already lives there it becomes "Open listing page" (`LinkButton`, the address in its `title`); a refused write is an error toast.
+- **Collection list › listing fields** (RFC-0042 §4): a collection with `listFields` shows them as columns after the title (values through `formatFieldValue` in the Studio language, "—" when empty), a `Toolbar` of `Select`s after the facets — one per field with type-fitting choices (dates: upcoming / past / has a value / empty; yes / no; each option of a choice; has a value / empty) — and ghost sort buttons in number and date headers (ascending → descending → off, `aria-sort`). Filters and sort live in the URL (`f_=`, `sort=:asc|desc`, `lib/listing-search.ts`); while any is set, the list asks `GET …/entries` with `where`/`orderBy`/`limit`/`cursor` one page at a time (`Pagination controls="simple"`), "Clear filters" clears facets and field filters together. The columns, filters and sort are a lazy chunk.
+- **Page editor › Content list** (RFC-0042 §4): under collection / limit / sort, a lazy "Only entries where" block over the listed collection's listing fields — rows of field, condition (only that type's operators, worded "is before / after" for dates), value (now or a date for dates, the options for a choice, a `Checkbox.Group` for "is one of", yes / no) and a ghost square remove; secondary "Add condition" (up to four); "Order by field" (number and date fields) with its direction. A save refused with `invalid_query` shows each issue under its row. A collection without listing fields shows a muted line with a link to its settings.
+- **Approvals and Activity**: `rename_field`, `change_field_type`, `set_field_localized`, `remove_field`, `list_collections`, `create_collection`, `update_collection` and `preview_structured_data` have labels in `tools.*`; `change_field_type` and `remove_field` are `CONFIRM_TOOLS` (the server's dry-run prompt shows, and approving asks first). Field and RFC-0042 error codes (`invalid_query`, `structured_data_invalid`, `invalid_expiry`, `list_field_in_use`, `gates_unsupported`) read through `apiErrorText` (`errors.api.*`); a 409 whose code has its own words shows them, not "someone else changed this". An `auto`-mode publish held back by a new or earlier collection expiry gives the reason `approvals.autoCheck.expires`.
## 3. Sidebar specification
```tsx
diff --git a/docs/engineering/code-structure.md b/docs/engineering/code-structure.md
index 1f52fb65..c2dfbc9f 100644
--- a/docs/engineering/code-structure.md
+++ b/docs/engineering/code-structure.md
@@ -70,7 +70,7 @@ of cases. Test files still have a file cap; split suites by behaviour (`store.pu
Generated and vendored code is never measured: `packages/core-contracts/src/generated`,
`crates/cloudhal-schema/src/generated`, `packages/core-ts/src/generated`,
-`packages/ui/src/blocks/kumo`, `dist`, `node_modules`, `*.d.ts`, tool configs.
+`apps/studio/src/i18n/generated` (message parameter types), `packages/ui/src/blocks/kumo`, `dist`, `node_modules`, `*.d.ts`, tool configs.
## 3. The ratchet
diff --git a/packages/api/src/playground.ts b/packages/api/src/playground.ts
index f81a8ef7..948f4488 100644
--- a/packages/api/src/playground.ts
+++ b/packages/api/src/playground.ts
@@ -130,6 +130,7 @@ export const PLAYGROUND_ALLOW: ReadonlySet = new Set([
`POST ${S}/entries`,
`GET ${S}/entries/:entryId/preview`,
`POST ${S}/entries/:entryId/preview`,
+ `GET ${S}/entries/:entryId/structured-data`,
`POST ${S}/entries/preview-fragment`,
`GET ${S}/entries/:entryId`,
`PUT ${S}/entries/:entryId`,
diff --git a/packages/api/src/routes/entries.ts b/packages/api/src/routes/entries.ts
index 533a3c73..b3a0ad86 100644
--- a/packages/api/src/routes/entries.ts
+++ b/packages/api/src/routes/entries.ts
@@ -14,6 +14,7 @@ import { API_ACTOR, writerOf } from "../governance.ts";
import { editsLiveCorrections, entryLimit, personOf } from "../own.ts";
import { param } from "../params.ts";
import { type DraftPreview, previewResponse } from "../preview.ts";
+import { previewStructuredData } from "../structured-data.ts";
const STATUSES = new Set(["draft", "review", "published", "archived"]);
@@ -120,6 +121,15 @@ export const entries: Hono = new Hono()
html: renderEntryPreview(site, draft, { blocks: store.blocks }).html,
} satisfies DraftPreview);
})
+ // RFC-0042 §7: the JSON-LD the entry's page emits under its collection's preset (read-only).
+ .get("/:entryId/structured-data", async (c) => {
+ const store = c.get("store");
+ const site = await store.getSite(param(c, "siteId"));
+ if (!site) throw notFound("site not found");
+ const entry = await store.getContentEntry(param(c, "entryId"));
+ if (!entry || entry.siteId !== site.id) throw notFound("entry not found");
+ return c.json(await previewStructuredData(store, site, entry));
+ })
.get("/:entryId", async (c) => {
const entry = await c.get("store").getContentEntry(param(c, "entryId"));
if (!entry || entry.siteId !== param(c, "siteId")) throw notFound("entry not found");
diff --git a/packages/api/src/scopes.ts b/packages/api/src/scopes.ts
index 955179a3..2c8645e1 100644
--- a/packages/api/src/scopes.ts
+++ b/packages/api/src/scopes.ts
@@ -155,6 +155,7 @@ export const ROUTE_SCOPES: readonly (readonly [Method, string, RouteRequirement]
["GET", `${S}/entries`, "content:read"],
["POST", `${S}/entries`, "content:draft:own"],
["GET", `${S}/entries/:entryId/preview`, "content:read"],
+ ["GET", `${S}/entries/:entryId/structured-data`, "content:read"],
["POST", `${S}/entries/:entryId/preview`, "content:read"],
// the body editor's inline preview of one ```surface (writes nothing)
["POST", `${S}/entries/preview-fragment`, "content:read"],
diff --git a/packages/api/src/structured-data.ts b/packages/api/src/structured-data.ts
new file mode 100644
index 00000000..6e756f5e
--- /dev/null
+++ b/packages/api/src/structured-data.ts
@@ -0,0 +1,91 @@
+/**
+ * RFC-0042 §7: `GET /api/sites/:siteId/entries/:entryId/structured-data`, Studio's read-only twin of
+ * MCP's `preview_structured_data`. It answers the JSON-LD document an entry's page emits under its
+ * collection's preset (the renderer's own `entryStructuredData`, so exactly what the site emits),
+ * why it emits none (no preset, a required value missing, over the cap) and the preset's warnings.
+ * Drafts work too; nothing is written.
+ */
+import { entryStructuredData } from "@cloudhal/astro-renderer/structured-data";
+import type { Asset, ContentEntry, Site } from "@cloudhal/core-contracts";
+import { primaryOrigin, type SiteGraphStore } from "@cloudhal/core-ts";
+import { checkStructuredData, type StructuredDataIssue } from "@cloudhal/core-ts/structured-data";
+
+export interface StructuredDataPreview {
+ /** The collection's preset type; null without one. */
+ preset: string | null;
+ /** The origin every URL in the document uses (the primary domain, else a local placeholder). */
+ origin: string;
+ document: object | null;
+ issues: StructuredDataIssue[];
+}
+
+/** Without a domain, URLs are shown on the local preview server's origin. */
+const LOCAL_ORIGIN = "http://localhost:4321";
+
+/** Every `assetId` a JSON value names (image fields, cover images, the site's logos). */
+function assetIds(value: unknown, out = new Set()): Set {
+ if (Array.isArray(value)) for (const v of value) assetIds(v, out);
+ else if (value && typeof value === "object")
+ for (const [k, v] of Object.entries(value)) {
+ if (k === "assetId" && typeof v === "string") out.add(v);
+ else assetIds(v, out);
+ }
+ return out;
+}
+
+async function assetsFor(store: SiteGraphStore, site: Site, nodes: readonly unknown[]) {
+ const ids = new Set();
+ for (const n of nodes) assetIds(n, ids);
+ const found = await Promise.all([...ids].map((i) => store.getAsset(i)));
+ return found.filter((a): a is Asset => a !== undefined && a.siteId === site.id);
+}
+
+/** The preset's save-time warnings for one collection (select options outside a vocabulary). */
+function presetWarnings(site: Site, key: string): StructuredDataIssue[] {
+ const only = site.collections?.[key];
+ if (!only?.structuredData) return [];
+ return checkStructuredData({ ...site, collections: { [key]: only } }).filter((i) => i.warning);
+}
+
+/** The preview of `entry` (already checked to belong to `site`). */
+export async function previewStructuredData(
+ store: SiteGraphStore,
+ site: Site,
+ entry: ContentEntry,
+): Promise {
+ const origin = primaryOrigin(site) ?? LOCAL_ORIGIN;
+ const preset = site.collections?.[entry.collection]?.structuredData;
+ if (!preset)
+ return {
+ preset: null,
+ origin,
+ document: null,
+ issues: [
+ {
+ path: `collections/${entry.collection}/structuredData`,
+ // The MCP tool's code; not a save-time issue code.
+ code: "no_preset" as StructuredDataIssue["code"],
+ message: `the collection ${entry.collection} has no structured-data preset, so its pages emit BlogPosting`,
+ },
+ ],
+ };
+ const group = entry.translation?.group;
+ const original = group && group !== entry.id ? await store.getContentEntry(group) : undefined;
+ const sameSite = original?.siteId === site.id ? original : undefined;
+ const assets = await assetsFor(store, site, [entry, sameSite, site.brand]);
+ const result = await entryStructuredData(
+ {
+ site: async () => site,
+ entryById: async (key: string) => (key === sameSite?.id ? sameSite : undefined),
+ assets: async () => assets,
+ },
+ entry,
+ { origin },
+ );
+ return {
+ preset: preset.type,
+ origin,
+ document: result.doc ?? null,
+ issues: [...result.issues, ...presetWarnings(site, entry.collection)],
+ };
+}
diff --git a/packages/api/src/testing-listings.ts b/packages/api/src/testing-listings.ts
index 3819b737..3674b327 100644
--- a/packages/api/src/testing-listings.ts
+++ b/packages/api/src/testing-listings.ts
@@ -240,5 +240,32 @@ export function describeListingsApi(label: string, factory: ApiStoreFactory): vo
["collection invalid_query"],
]);
});
+
+ it("GET /entries/:id/structured-data previews the preset document and its issues", async () => {
+ await seeded();
+ const city = { startsAt: at("2026-11-05T16:30:00Z"), city: "Istanbul" };
+ expect((await entry("d", city)).status).toBe(201);
+ const all = (await list({})).body as unknown as Json[];
+ const id = (slug: string) => all.find((e) => e.slug === slug)?.id as string;
+ const preview = (entryId: string) =>
+ call(`/api/sites/${siteId}/entries/${entryId}/structured-data`);
+ const d = await preview(id("d"));
+ expect(d.status, JSON.stringify(d.body)).toBe(200);
+ expect(d.body).toMatchObject({
+ preset: "Event",
+ origin: expect.stringMatching(/^https?:\/\//),
+ document: { "@type": "Event", name: "d", startDate: "2026-11-05T19:30:00+03:00" },
+ });
+ // No start date: no document, and the reason.
+ const c = await preview(id("c"));
+ expect(c.body.document).toBeNull();
+ expect((c.body.issues as { code: string }[]).map((i) => i.code)).toContain(
+ "required_value_missing",
+ );
+ expect((await preview("entry_missing")).status).toBe(404);
+ expect((await put({ ...EVENTS, structuredData: undefined })).status).toBe(200);
+ const none = await preview(id("d"));
+ expect([none.body.preset, none.body.document]).toEqual([null, null]);
+ });
});
}
diff --git a/scripts/code-size/measure.ts b/scripts/code-size/measure.ts
index d8b0672d..bb652b36 100644
--- a/scripts/code-size/measure.ts
+++ b/scripts/code-size/measure.ts
@@ -25,6 +25,8 @@ const IGNORED = [
/^packages\/core-contracts\/src\/generated\//,
/^crates\/cloudhal-schema\/src\/generated\//,
/^packages\/core-ts\/src\/generated\//,
+ // Studio's message parameter types, written by `pnpm gen` from the catalogs (RFC-0032 §3).
+ /^apps\/studio\/src\/i18n\/generated\//,
/^packages\/ui\/src\/blocks\/kumo\//,
/\.d\.ts$/,
/(^|\/)(vite|vitest|astro)\.config\.[cm]?[jt]s$/,
diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json
index 6323f6b8..b93bef79 100644
--- a/scripts/size-baseline.json
+++ b/scripts/size-baseline.json
@@ -184,7 +184,7 @@
"lines": 446
},
"apps/studio/src/routes/ArticlesPage.tsx::ArticlesPage": {
- "lines": 197
+ "lines": 192
},
"apps/studio/src/routes/AssetPage.tsx::AssetPage": {
"lines": 739