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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fields-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudhal/core-ts": patch
"@cloudhal/mcp-server": patch
---

Typed fields follow-ups (RFC-0041). One asset check for field values, `fieldAssetIssues` in `@cloudhal/core-ts/media` (with `fieldAssetRefs` and `newFieldAssetRefs`), now used by both the store's entry write and MCP's `create_entry`/`update_entry` precheck; same codes and messages. Machine translation now follows §5: `get_translation_source` returns only the localized text and rich-text field values to translate (`fields`) and names the shared ones (`sharedFields`); `save_translation` takes translated `fields`, refuses shared ones in precheck with `field_not_localized` (paths in `details.issues`), and no longer copies the source's field values into a new translation in the same site. New helpers in `@cloudhal/core-ts/fields`: `localizedFieldValues`, `translatableFieldValues`, `sharedFieldKeys`, `sharedFieldIssues`. Studio's "write by hand" draft copies only localized field values.
19 changes: 16 additions & 3 deletions apps/studio/src/components/TranslationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,16 @@ const editorHref = (siteId: string, n: Localized) =>
"sections" in n ? `/sites/${siteId}/pages/${n.id}` : entryHref(siteId, n.id, n.collection);

/** A new draft in `locale` from `source`: its content to translate, linked to its group. */
async function writeByHand(siteId: string, source: Localized, locale: string): Promise<Localized> {
async function writeByHand(input: {
siteId: string;
site: Site;
source: Localized;
locale: string;
}): Promise<Localized> {
const { siteId, site, source, locale } = input;
const group = await api.translations.group(siteId, source.id);
const hash = group.items.find((m) => m.id === source.id)?.hash ?? "";
const draft = handTranslationDraft(source, locale, hash);
const draft = handTranslationDraft(source, locale, hash, site);
if (draft.kind === "page") return (await api.pages.create(siteId, draft.input)).page;
return api.entries.create(siteId, draft.input);
}
Expand All @@ -149,7 +155,14 @@ export function LanguagesCell<N extends Localized>({
const toast = useToast();
const source = row.cells.find((c) => c.node?.id === row.group)?.node ?? row.node;
const create = useMutation({
mutationFn: (target: string) => writeByHand(siteId, source, target),
mutationFn: async (target: string) => {
// RFC-0041 §5: the collection's definitions say which field values a translation holds.
const site = await qc.ensureQueryData({
queryKey: ["site", siteId],
queryFn: () => api.sites.get(siteId),
});
return writeByHand({ siteId, site, source, locale: target });
},
onSuccess: async (node) => {
await qc.invalidateQueries({ queryKey: ["translations", siteId] });
await qc.invalidateQueries({ queryKey: ["sections" in node ? "pages" : "entries", siteId] });
Expand Down
13 changes: 10 additions & 3 deletions apps/studio/src/lib/translation-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* one row per translation group (`translation.group`; a node without one is its own group), shown
* by its default-language version (or the original), with one language state per site locale.
*/
import type { ContentEntry, Page } from "@cloudhal/core-contracts";
import type { ContentEntry, Page, Site } from "@cloudhal/core-contracts";
import { fieldDefinitions, localizedFieldValues } from "@cloudhal/core-ts/fields";
import type { NewEntry, NewPage } from "./api/content.ts";

type Localized = Page | ContentEntry;
Expand Down Expand Up @@ -94,12 +95,15 @@ export function handTranslation(
/**
* The new version a person writes by hand for a missing language: a draft in `locale` holding a
* copy of `source`'s content to translate (title, path or slug, sections or body), linked to the
* group. Its schedule, publishing record and metadata are its own, so they are not copied.
* group. Its schedule, publishing record and metadata are its own, so they are not copied, and
* of an entry's typed fields only the localized values are (RFC-0041 §5: shared ones are read from
* the original, `site`'s definitions).
*/
export function handTranslationDraft(
source: Localized,
locale: string,
hash: string,
site?: Pick<Site, "collections">,
): { kind: "page"; input: NewPage } | { kind: "entry"; input: NewEntry } {
const translation = handTranslation(source, hash);
if ("sections" in source) {
Expand Down Expand Up @@ -129,5 +133,8 @@ export function handTranslationDraft(
publishedAt,
...entry
} = source;
return { kind: "entry", input: { ...entry, locale, status: "draft", translation } };
const fields = site
? localizedFieldValues(fieldDefinitions(site, entry.collection), entry.fields)
: entry.fields;
return { kind: "entry", input: { ...entry, fields, locale, status: "draft", translation } };
}
33 changes: 33 additions & 0 deletions apps/studio/test/translation-list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,39 @@ describe("handTranslationDraft", () => {
);
expect(draft.input.translation?.group).toBe("entry_1");
});

it("copies only an entry's localized field values (RFC-0041 §5)", () => {
const entry = {
schemaVersion: 1,
id: "entry_2",
siteId: "site_1",
collection: "events",
slug: "launch",
locale: "en",
status: "draft",
title: "Launch",
body: { format: "markdown", value: "Hi" },
fields: { speaker: "Ada", day: "2026-11-05" },
tags: [],
author: { kind: "human", id: "u_1" },
rev: 1,
createdAt: AT,
updatedAt: AT,
} as ContentEntry;
const site = {
collections: {
events: {
labels: { en: { singular: "Event", plural: "Events" } },
fields: [
{ key: "speaker", type: "text", labels: { en: { label: "Speaker" } }, localized: true },
{ key: "day", type: "date", labels: { en: { label: "Day" } }, localized: false },
],
},
},
} as Pick<Site, "collections">;
const draft = handTranslationDraft(entry, "es", HASH, site);
expect(draft.kind === "entry" && draft.input.fields).toEqual({ speaker: "Ada" });
});
});

describe("working language", () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/core-ts/src/fields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ export {
type ResolvedFields,
resolveFields,
} from "./resolve.ts";
export {
localizedFieldValues,
sharedFieldIssues,
sharedFieldKeys,
translatableFieldValues,
} from "./translation.ts";
export {
FIELD_LIMITS,
FIELD_TYPES,
Expand Down
66 changes: 66 additions & 0 deletions packages/core-ts/src/fields/translation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* RFC-0041 §5: typed fields in a translation of the same site. Localized values are each language
* version's own (absent ones fall back to the original's); non-localized values live on the
* group's original only and are never copied into a translation. Machine translation gets only
* the localized `text` and `richText` values to translate. Worker-safe.
*/
import type { FieldDefinition } from "@cloudhal/core-contracts";
import type { FieldIssue } from "./types.ts";

type Values = Readonly<Record<string, unknown>>;

/** Field types whose values are words to translate. */
const TRANSLATABLE = new Set<FieldDefinition["type"]>(["text", "richText"]);

/**
* The values a new translation in the same site starts from: the localized ones. In a collection
* without definitions `fields` is any JSON and is kept whole.
*/
export function localizedFieldValues(
defs: readonly FieldDefinition[],
values: Values | undefined,
): Record<string, unknown> {
if (defs.length === 0) return { ...(values ?? {}) };
const out: Record<string, unknown> = {};
for (const def of defs)
if (def.localized && values?.[def.key] !== undefined) out[def.key] = values[def.key];
return out;
}

/** The localized `text` and `richText` values to translate. */
export function translatableFieldValues(
defs: readonly FieldDefinition[],
values: Values | undefined,
): Record<string, string> {
const out: Record<string, string> = {};
for (const def of defs) {
const value = values?.[def.key];
if (def.localized && TRANSLATABLE.has(def.type) && typeof value === "string" && value !== "")
out[def.key] = value;
}
return out;
}

/** Keys of the non-localized fields: a translation reads them from the original. */
export function sharedFieldKeys(defs: readonly FieldDefinition[]): string[] {
return defs.filter((d) => !d.localized).map((d) => d.key);
}

/**
* `field_not_localized` for each given value of a non-localized field (a translation's values
* are translated text; shared ones are set on the original, `originalId`).
*/
export function sharedFieldIssues(
defs: readonly FieldDefinition[],
values: Values | undefined,
originalId: string,
): FieldIssue[] {
const shared = new Set(sharedFieldKeys(defs));
return Object.keys(values ?? {})
.filter((key) => shared.has(key))
.map((key) => ({
path: `fields/${key}`,
code: "field_not_localized" as const,
message: `${key} is not localized: set it on ${originalId}`,
}));
}
71 changes: 69 additions & 2 deletions packages/core-ts/src/media/field-refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
* and a `richText` field's `asset:` links and images count like a body's (RFC-0004 §4.4). Each is
* recorded in `asset_refs` under `fields.<key>`, so a used asset cannot be deleted. Only fields a
* collection defines count: in a collection without definitions `fields` stays "any JSON".
* Worker-safe.
* `fieldAssetIssues` is the one asset check of field values, run by the store's entry write and by
* MCP's precheck; it reads assets through a lookup, so it stays storage-free. Worker-safe.
*/
import type { FieldDefinition } from "@cloudhal/core-contracts";
import type { Asset, FieldDefinition } from "@cloudhal/core-contracts";
import { ASSET_ID_PATTERN } from "../fields/props.ts";
import type { FieldIssue } from "../fields/types.ts";
import { type AssetRefUse, markdownAssetIds } from "./refs.ts";

const ASSET_ID = new RegExp(ASSET_ID_PATTERN);
Expand Down Expand Up @@ -42,3 +44,68 @@ export function siteFieldDefinitions(site: {
if (settings?.fields?.length) out[key] = settings.fields;
return out;
}

/** The assets of one site among `ids` (absent ids are missing or belong to another site). */
export type SiteAssetLookup = (ids: readonly string[]) => Promise<ReadonlyMap<string, Asset>>;

/** What `fieldAssetIssues` checks: the references a write would hold and those the entry holds. */
export interface FieldAssetCheck {
/** The entry's field definitions (its new collection's). */
readonly defs: readonly FieldDefinition[];
/** `fieldAssetRefs` of the values being written. */
readonly refs: readonly AssetRefUse[];
/** `fieldAssetRefs` of the stored entry (empty for a new one). */
readonly kept: readonly AssetRefUse[];
/** Assets of the entry's site, by id. */
readonly lookup: SiteAssetLookup;
/** Paths (`fields/<key>`) whose value already has an issue: their references are not checked. */
readonly skip?: ReadonlySet<string>;
}

/** References a write adds: not held by the stored entry under the same field. */
export function newFieldAssetRefs(
refs: readonly AssetRefUse[],
kept: readonly AssetRefUse[],
): AssetRefUse[] {
const held = new Set(kept.map((r) => `${r.assetId}\n${r.field}`));
return refs.filter((r) => !held.has(`${r.assetId}\n${r.field}`));
}

/**
* RFC-0041 §2: every reference a write adds names a file of the site; an image field's is an
* image; a retired file is not newly used unless the entry already used it. Issues are
* `invalid_value` at `fields/<key>`.
*/
export async function fieldAssetIssues(check: FieldAssetCheck): Promise<FieldIssue[]> {
const { defs, kept, skip } = check;
const refs = newFieldAssetRefs(check.refs, kept).filter(
(r) => !skip?.has(r.field.replace(".", "/")),
);
if (refs.length === 0) return [];
const assets = await check.lookup(refs.map((r) => r.assetId));
const keptIds = new Set(kept.map((r) => r.assetId));
const types = new Map(defs.map((d) => [d.key, d.type]));
const out: FieldIssue[] = [];
for (const ref of refs) {
const key = ref.field.slice("fields.".length);
const problem = assetProblem(assets.get(ref.assetId), types.get(key), keptIds.has(ref.assetId));
if (problem)
out.push({
path: `fields/${key}`,
code: "invalid_value",
message: `${ref.assetId} ${problem}`,
});
}
return out;
}

function assetProblem(
asset: Asset | undefined,
type: FieldDefinition["type"] | undefined,
kept: boolean,
): string | undefined {
if (!asset) return "is not a file of this site";
if (type === "image" && asset.kind !== "image") return `is a ${asset.kind} file, not an image`;
if (asset.status === "retired" && !kept) return "was retired and cannot be newly used";
return undefined;
}
4 changes: 3 additions & 1 deletion packages/core-ts/src/media/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
* `@cloudhal/core-ts/media` — the Worker-safe media pipeline of RFC-0004: the type table, sniffing,
* image headers and metadata stripping, the SVG validator, the upload inspection every path runs,
* the SSRF-safe fetcher (Workers transport; the Node one is `@cloudhal/core-ts/media-node`), and
* reference extraction. Pure TypeScript, no native image library.
* reference extraction (typed field values' too, with their asset check). Pure TypeScript, no
* native image library.
*/
export * from "./fetcher.ts";
export * from "./field-refs.ts";
export * from "./image.ts";
export * from "./ingest.ts";
export * from "./placeholders.ts";
Expand Down
64 changes: 7 additions & 57 deletions packages/core-ts/src/store/entry-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,18 @@
*
* Sites without any field definition run only the size check and add no statements.
*/
import type { Asset, ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts";
import type { ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts";
import type { SqlStatement } from "@cloudhal/storage";
import {
checkFieldValues,
checkRequiredToPublish,
convertContext,
type FieldIssue,
fieldDefinitions,
normalizeThroughMarkers,
refuseFieldIssues,
} from "../fields/index.ts";
import { sameJson } from "../fields/types.ts";
import { fieldAssetRefs } from "../media/field-refs.ts";
import { fieldAssetIssues, fieldAssetRefs } from "../media/field-refs.ts";
import type { AssetRefUse } from "../media/refs.ts";
import type { Cond } from "../node-sql.ts";
import type { StoreContext } from "./context.ts";
Expand Down Expand Up @@ -105,62 +104,14 @@ export async function planEntryFields(
const kept = stored
? fieldAssetRefs(fieldDefinitions(site, stored.collection), stored.fields)
: [];
const invalid = new Set(issues.map((i) => i.path.split("/").slice(0, 2).join("/")));
const fresh = newRefs(refs, kept).filter((r) => !invalid.has(r.field.replace(".", "/")));
issues.push(...(await assetIssues(ctx, { site, defs, refs: fresh, kept })));
const skip = new Set(issues.map((i) => i.path.split("/").slice(0, 2).join("/")));
const lookup = (ids: readonly string[]) => ctx.assets.getAssets(site.id, ids);
issues.push(...(await fieldAssetIssues({ defs, refs, kept, lookup, skip })));
issues.push(...checkRequiredToPublish(site, draft, original));
refuseFieldIssues(issues);
return refsPlan(ctx, { site, draft, refs, kept, defs });
}

/** References a write adds: not held by the stored entry under the same field. */
function newRefs(refs: readonly AssetRefUse[], kept: readonly AssetRefUse[]): AssetRefUse[] {
const held = new Set(kept.map((r) => `${r.assetId}\n${r.field}`));
return refs.filter((r) => !held.has(`${r.assetId}\n${r.field}`));
}

interface AssetCheck {
site: Site;
defs: readonly FieldDefinition[];
refs: readonly AssetRefUse[];
kept: readonly AssetRefUse[];
}

/** RFC-0041 §2: new references name files of the site; an image field's is an image, not retired. */
async function assetIssues(ctx: StoreContext, check: AssetCheck): Promise<FieldIssue[]> {
const { site, defs, refs, kept } = check;
if (refs.length === 0) return [];
const assets = await ctx.assets.getAssets(
site.id,
refs.map((r) => r.assetId),
);
const keptIds = new Set(kept.map((r) => r.assetId));
const types = new Map(defs.map((d) => [d.key, d.type]));
const out: FieldIssue[] = [];
for (const ref of refs) {
const key = ref.field.slice("fields.".length);
const problem = assetProblem(assets.get(ref.assetId), types.get(key), keptIds.has(ref.assetId));
if (problem)
out.push({
path: `fields/${key}`,
code: "invalid_value",
message: `${ref.assetId} ${problem}`,
});
}
return out;
}

function assetProblem(
asset: Asset | undefined,
type: FieldDefinition["type"] | undefined,
kept: boolean,
): string | undefined {
if (!asset) return "is not a file of this site";
if (type === "image" && asset.kind !== "image") return `is a ${asset.kind} file, not an image`;
if (asset.status === "retired" && !kept) return "was retired and cannot be newly used";
return undefined;
}

/**
* The `fields.*` rows of `asset_refs` for an entry, rewritten: anchored on its new hash, so they
* run only when the entry's write did (rule 4). Field operations use it for every entry they move.
Expand Down Expand Up @@ -213,9 +164,8 @@ function refsPlan(ctx: StoreContext, input: RefsInput): EntryFieldsPlan {
sql: "(SELECT count(*) FROM nodes WHERE kind = 'asset' AND site_id = ? AND id IN (SELECT value FROM json_each(?)) AND (json_extract(body, '$.status') = 'active' OR id IN (SELECT value FROM json_each(?)))) = ?",
params: [site.id, JSON.stringify(ids), JSON.stringify(keptIds), ids.length],
};
const lookup = (wanted: readonly string[]) => ctx.assets.getAssets(site.id, wanted);
const diagnose = async () =>
refuseFieldIssues(
await assetIssues(ctx, { site, defs: input.defs ?? [], refs: newRefs(refs, kept), kept }),
);
refuseFieldIssues(await fieldAssetIssues({ defs: input.defs ?? [], refs, kept, lookup }));
return { guard, statements, diagnose };
}
Loading
Loading