@cardinali-studio/adminkit 1.0.0 → 2.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,21 @@
1
1
  import type * as z from "zod";
2
2
  export type Shape = Record<string, z.ZodType>;
3
+ /** Contratto di UNA tipologia di pagina. Il tipo sceglie la forma prima che un file venga
4
+ * letto, validato o riscritto: non esiste più uno schema che contenga i campi di tutte le
5
+ * pagine del sito. */
6
+ export interface PageTypeConfig {
7
+ label: string;
8
+ shape: Shape;
9
+ object: z.ZodType;
10
+ payload: z.ZodType;
11
+ hasBody: boolean;
12
+ }
13
+ /** Lingue dei contenuti, assenti nei siti monolingui. I codici sono quelli dei sibling Hugo
14
+ * (`index.md`, `index.en.md`): la lingua predefinita non porta alcun suffisso. */
15
+ export interface LanguagesConfig {
16
+ default: string;
17
+ available: readonly string[];
18
+ }
3
19
  export interface SiteAdminConfig {
4
20
  /** Il nome mostrato in cima al rail, e il ripiego del titolo nell'anteprima SEO. */
5
21
  name: string;
@@ -16,14 +32,14 @@ export interface SiteAdminConfig {
16
32
  name: string;
17
33
  email: string;
18
34
  };
19
- /** Forma del front matter di una pagina della collezione. */
20
- page: Shape;
35
+ /** Forme delle pagine della collezione, indicizzate dal loro `type` nel front matter. */
36
+ pages: Record<string, PageTypeConfig>;
37
+ /** Dichiararla abilita il contenuto bilingue; assente significa sito monolingue. */
38
+ languages?: LanguagesConfig;
21
39
  /** Forma del singleton home: i suoi `group` diventano le voci della seconda colonna. */
22
40
  home: Shape;
23
41
  /** Peso di ordinamento di una pagina data la sua posizione nell'elenco. */
24
42
  positionWeight: (item: Record<string, unknown>, index: number, all: Record<string, unknown>[]) => number;
25
- /** Quali tipi di pagina hanno un corpo markdown oltre al front matter. */
26
- hasBody: (type: unknown) => boolean;
27
43
  /**
28
44
  * Le funzioni aggiuntive accese su questo sito. Ciò che non è dichiarato è SPENTO: un
29
45
  * interruttore dimenticato deve dare una funzione assente, non una accesa per sbaglio.
@@ -35,3 +51,4 @@ export interface SiteAdminConfig {
35
51
  /** Chiamata da `mountAdmin` prima del primo render. */
36
52
  export declare function setSiteConfig(config: SiteAdminConfig): void;
37
53
  export declare function site(): SiteAdminConfig;
54
+ export declare function pageType(type: unknown): PageTypeConfig | null;
package/dist/app/site.js CHANGED
@@ -16,3 +16,6 @@ export function site() {
16
16
  }
17
17
  return current;
18
18
  }
19
+ export function pageType(type) {
20
+ return site().pages[String(type ?? "")] ?? null;
21
+ }
@@ -17,5 +17,7 @@ export interface SchemaFormProps {
17
17
  }) => Promise<AssetFile[] | null>;
18
18
  /** Campi da non mostrare, anche se dichiarati (es. quelli già gestiti dall'elenco). */
19
19
  omit?: readonly string[];
20
+ /** Vero nella lingua secondaria: i campi senza il marchio schema restano visibili ma bloccati. */
21
+ translationReadOnly?: boolean;
20
22
  }
21
- export declare function SchemaForm({ shape, value, onChange, pickMedia, pickMediaFiles, omit }: SchemaFormProps): import("react").JSX.Element;
23
+ export declare function SchemaForm({ shape, value, onChange, pickMedia, pickMediaFiles, omit, translationReadOnly }: SchemaFormProps): import("react").JSX.Element;
@@ -9,6 +9,7 @@ import { useMemo } from "react";
9
9
  import { useForm, Controller } from "react-hook-form";
10
10
  import * as z from "zod";
11
11
  import { ui } from "../registries.js";
12
+ import { isTranslatable } from "../i18n.js";
12
13
  import { fieldFor } from "./registry.js";
13
14
  import "./fields/index.js"; // registra gli otto widget
14
15
  /* Resolver per React Hook Form scritto a mano invece di usare @hookform/resolvers: quel
@@ -29,7 +30,7 @@ export function zodResolver(schema) {
29
30
  return { values: {}, errors: errors };
30
31
  };
31
32
  }
32
- export function SchemaForm({ shape, value, onChange, pickMedia, pickMediaFiles, omit = [] }) {
33
+ export function SchemaForm({ shape, value, onChange, pickMedia, pickMediaFiles, omit = [], translationReadOnly = false }) {
33
34
  const object = useMemo(() => z.object(shape), [shape]);
34
35
  const { control, formState } = useForm({
35
36
  values: value,
@@ -56,12 +57,17 @@ export function SchemaForm({ shape, value, onChange, pickMedia, pickMediaFiles,
56
57
  return (_jsx("div", { className: "ak-form", children: groups.map((g) => (_jsxs("fieldset", { className: "ak-group", children: [g.name && _jsx("legend", { className: "ak-legend", children: g.name }), g.fields.map((name) => {
57
58
  const meta = ui.get(shape[name]);
58
59
  const Field = fieldFor(meta.widget);
59
- return (_jsx(Controller, { name: name, control: control, render: ({ field }) => (_jsx(Field, { name: name,
60
- /* `schema` serve a `objectList`, l'unico widget che deve scendere nei figli.
61
- Passarlo a tutti costa nulla e evita un canale a parte per un caso solo. */
62
- meta: { ...meta, schema: shape[name] }, value: field.value, error: formState.errors?.[name]?.message, pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, onChange: (v) => {
63
- field.onChange(v);
64
- onChange({ ...value, [name]: v });
65
- } })) }, name));
60
+ const nested = meta.widget === "objectList";
61
+ const inherited = translationReadOnly && !isTranslatable(shape[name]);
62
+ return (_jsx(Controller, { name: name, control: control, render: ({ field }) => {
63
+ const control = (_jsx(Field, { name: name,
64
+ /* `schema` serve a `objectList`, l'unico widget che deve scendere nei figli.
65
+ Passarlo a tutti costa nulla e evita un canale a parte per un caso solo. */
66
+ meta: { ...meta, schema: shape[name] }, value: field.value, error: formState.errors?.[name]?.message, pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, translationReadOnly: translationReadOnly, onChange: (v) => {
67
+ field.onChange(v);
68
+ onChange({ ...value, [name]: v });
69
+ } }));
70
+ return nested ? control : _jsx("fieldset", { disabled: inherited, children: control });
71
+ } }, name));
66
72
  })] }, g.name || "_"))) }));
67
73
  }
@@ -9,6 +9,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  */
10
10
  import * as z from "zod";
11
11
  import { ui } from "../../registries.js";
12
+ import { isTranslatable } from "../../i18n.js";
12
13
  import { registerField, fieldFor } from "../registry.js";
13
14
  /** Sotto-schema di un campo Zod, se è un oggetto: serve a scendere di un livello. */
14
15
  function shapeOf(schema) {
@@ -54,14 +55,15 @@ function blankFor(schema) {
54
55
  }
55
56
  /** Widget che rendono da soli un campo-array: qui la ricorsione non deve scendere. */
56
57
  const SELF_RENDERING_LISTS = new Set(["r2Gallery", "r2Curated", "paragraphs"]);
57
- function Node({ schema, value, onChange, pickMedia, pickMediaFiles, label, }) {
58
+ function Node({ schema, value, onChange, pickMedia, pickMediaFiles, label, translationReadOnly, inheritedEditable, }) {
58
59
  const meta = ui.get(schema);
59
60
  const shape = shapeOf(schema);
60
61
  const element = elementOf(schema);
62
+ const editable = !translationReadOnly || inheritedEditable || isTranslatable(schema);
61
63
  // Oggetto: un riquadro con dentro i suoi campi.
62
64
  if (shape) {
63
65
  const obj = (value ?? {});
64
- return (_jsx("div", { className: "ol-obj", children: Object.entries(shape).map(([k, sub]) => (_jsx(Node, { schema: sub, label: ui.get(sub)?.label ?? k, value: obj[k], pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, onChange: (v) => onChange({ ...obj, [k]: v }) }, k))) }));
66
+ return (_jsx("div", { className: "ol-obj", children: Object.entries(shape).map(([k, sub]) => (_jsx(Node, { schema: sub, label: ui.get(sub)?.label ?? k, value: obj[k], pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, translationReadOnly: translationReadOnly, inheritedEditable: editable, onChange: (v) => onChange({ ...obj, [k]: v }) }, k))) }));
65
67
  }
66
68
  /* Lista: elementi riordinabili e rimovibili — A MENO che il campo dichiari un widget che sa
67
69
  * già rendere una lista per conto suo. La ricorsione si ferma lì e disegna il widget.
@@ -82,18 +84,19 @@ function Node({ schema, value, onChange, pickMedia, pickMediaFiles, label, }) {
82
84
  next.splice(to, 0, x);
83
85
  onChange(next);
84
86
  };
85
- return (_jsxs("fieldset", { className: "ol-list", children: [_jsx("legend", { className: "ak-legend", children: label ?? meta?.label }), list.map((item, i) => (_jsxs("div", { className: "ol-item", children: [_jsxs("div", { className: "ol-item-bar", children: [_jsx("span", { className: "ak-hint", children: i + 1 }), _jsx("span", { className: "ak-spacer" }), _jsx("button", { type: "button", className: "ak-icon", title: "Su", onClick: () => move(i, i - 1), children: "\u2191" }), _jsx("button", { type: "button", className: "ak-icon", title: "Gi\u00F9", onClick: () => move(i, i + 1), children: "\u2193" }), _jsx("button", { type: "button", className: "ak-icon", title: "Rimuovi", onClick: () => onChange(list.filter((_, j) => j !== i)), children: "\u2715" })] }), _jsx(Node, { schema: element, value: item, pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, onChange: (v) => onChange(list.map((x, j) => (j === i ? v : x))) })] }, i))), _jsx("button", { type: "button", className: "ak-btn", onClick: () => onChange([...list, blankFor(element)]), children: "+ Aggiungi" })] }));
87
+ return (_jsxs("fieldset", { className: "ol-list", children: [_jsx("legend", { className: "ak-legend", children: label ?? meta?.label }), list.map((item, i) => (_jsxs("div", { className: "ol-item", children: [_jsxs("div", { className: "ol-item-bar", children: [_jsx("span", { className: "ak-hint", children: i + 1 }), _jsx("span", { className: "ak-spacer" }), _jsx("button", { type: "button", disabled: !editable, className: "ak-icon", title: "Su", onClick: () => move(i, i - 1), children: "\u2191" }), _jsx("button", { type: "button", disabled: !editable, className: "ak-icon", title: "Gi\u00F9", onClick: () => move(i, i + 1), children: "\u2193" }), _jsx("button", { type: "button", disabled: !editable, className: "ak-icon", title: "Rimuovi", onClick: () => onChange(list.filter((_, j) => j !== i)), children: "\u2715" })] }), _jsx(Node, { schema: element, value: item, pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, translationReadOnly: translationReadOnly, inheritedEditable: editable, onChange: (v) => onChange(list.map((x, j) => (j === i ? v : x))) })] }, i))), _jsx("button", { type: "button", disabled: !editable, className: "ak-btn", onClick: () => onChange([...list, blankFor(element)]), children: "+ Aggiungi" })] }));
86
88
  }
87
89
  // Foglia: la disegna il widget dichiarato, come qualsiasi altro campo.
88
90
  const w = meta?.widget ?? "text";
89
91
  const Field = fieldFor(w);
90
- return (_jsx(Field, { name: label ?? "", meta: { ...(meta ?? { label: label ?? "", widget: w }), label: label ?? meta?.label ?? "" }, value: value, onChange: onChange, pickMedia: pickMedia, pickMediaFiles: pickMediaFiles }));
92
+ const control = (_jsx(Field, { name: label ?? "", meta: { ...(meta ?? { label: label ?? "", widget: w }), label: label ?? meta?.label ?? "" }, value: value, onChange: onChange, pickMedia: pickMedia, pickMediaFiles: pickMediaFiles }));
93
+ return _jsx("fieldset", { disabled: !editable, children: control });
91
94
  }
92
95
  /* Il widget registrato riceve lo SCHEMA del campo attraverso `meta.schema`, che SchemaForm gli
93
96
  * passa: senza, non potrebbe sapere che forma abbiano i figli. */
94
- registerField("objectList", ({ meta, value, onChange, pickMedia, pickMediaFiles }) => {
97
+ registerField("objectList", ({ meta, value, onChange, pickMedia, pickMediaFiles, translationReadOnly }) => {
95
98
  const schema = meta.schema;
96
99
  if (!schema)
97
100
  return _jsx("p", { className: "ak-error", children: "objectList senza schema: va passato da SchemaForm." });
98
- return (_jsxs("fieldset", { className: "ak-group ol", children: [_jsx("legend", { className: "ak-legend", children: meta.label }), meta.hint && _jsx("p", { className: "ak-hint", children: meta.hint }), _jsx(Node, { schema: schema, value: value, onChange: onChange, pickMedia: pickMedia, pickMediaFiles: pickMediaFiles })] }));
101
+ return (_jsxs("fieldset", { className: "ak-group ol", children: [_jsx("legend", { className: "ak-legend", children: meta.label }), meta.hint && _jsx("p", { className: "ak-hint", children: meta.hint }), _jsx(Node, { schema: schema, value: value, onChange: onChange, pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, translationReadOnly: translationReadOnly })] }));
99
102
  });
@@ -6,6 +6,8 @@ export interface FieldProps {
6
6
  value: unknown;
7
7
  onChange: (v: unknown) => void;
8
8
  error?: string;
9
+ /** La lingua secondaria delega ai widget ricorsivi i soli campi marcati traducibili. */
10
+ translationReadOnly?: boolean;
9
11
  /** Aperto dai widget che scelgono media e vogliono solo il percorso (r2Image, r2Gallery). */
10
12
  pickMedia?: (opts: {
11
13
  multiple: boolean;
@@ -5,7 +5,7 @@
5
5
  * quando possibile, riscritti durante delete/move dei media.
6
6
  */
7
7
  import { deserializeItem, checkFidelity } from "../../index.js";
8
- import { site } from "../site.js";
8
+ import { site, pageType } from "../site.js";
9
9
  import {
10
10
  getRepoSnapshot, getText, commitUpserts, GitHubConflictError,
11
11
  } from "./github.js";
@@ -36,6 +36,12 @@ function jsonSource(path, text, label) {
36
36
  return { kind: "settings", path, text, label };
37
37
  }
38
38
 
39
+ function typeOf(md) {
40
+ const m = (md || "").match(/^---\n([\s\S]*?)\n---/);
41
+ const t = m && m[1].match(/^type:\s*(.+)$/m);
42
+ return t ? t[1].trim().replace(/^["']|["']$/g, "") : "";
43
+ }
44
+
39
45
  /** Legge tutte le sorgenti dallo stesso commit, senza trasformare errori in file vuoti. */
40
46
  export async function readMediaSources(env) {
41
47
  const snapshot = await getRepoSnapshot(env);
@@ -61,7 +67,9 @@ export async function readMediaSources(env) {
61
67
  }
62
68
  const slug = path.split("/")[1] || "";
63
69
  const extra = { slug };
64
- return frontmatterSource(path, text, pageLabel(text, path, site().page, extra), site().page, site().pageObject, extra);
70
+ const definition = pageType(typeOf(text));
71
+ if (!definition) throw new Error(`${path}: tipo pagina non gestito`);
72
+ return frontmatterSource(path, text, pageLabel(text, path, definition.shape, extra), definition.shape, definition.object, extra);
65
73
  });
66
74
  return { base: { commitSha: snapshot.commitSha, treeSha: snapshot.treeSha }, sources };
67
75
  }
@@ -0,0 +1,18 @@
1
+ /* Convenzioni Hugo per le traduzioni di una pagina: `index.md` è la lingua predefinita,
2
+ * `index.en.md` una sibling. Tutto ciò che non è dichiarato in site.config resta invisibile
3
+ * qui: non si deve mai dedurre una lingua da un file trovato per caso. */
4
+ import { languages } from "../site.js";
5
+
6
+ export function languageConfig() {
7
+ return languages();
8
+ }
9
+
10
+ export function pagePath(slug, language, defaultLanguage) {
11
+ const suffix = language === defaultLanguage ? "" : `.${language}`;
12
+ return `content/${slug}/index${suffix}.md`;
13
+ }
14
+
15
+ export function translatedLanguages() {
16
+ const config = languageConfig();
17
+ return config ? config.available.filter((language) => language !== config.default) : [];
18
+ }
@@ -3,24 +3,49 @@
3
3
  */
4
4
  import { json } from "../../_shared/assets.js";
5
5
  import { getText } from "../../_shared/github.js";
6
- import { splitFrontMatter, deserializeItem } from "../../../index.js";
7
- import { site } from "../../site.js";
6
+ import { splitFrontMatter, deserializeItem, translationModel } from "../../../index.js";
7
+ import { pageType } from "../../site.js";
8
+ import { languageConfig, pagePath } from "../../_shared/translations.js";
9
+
10
+ function typeOf(md) {
11
+ const m = (md || "").match(/^---\n([\s\S]*?)\n---/);
12
+ const t = m && m[1].match(/^type:\s*(.+)$/m);
13
+ return t ? t[1].trim().replace(/^["']|["']$/g, "") : "";
14
+ }
8
15
 
9
16
  export async function onRequestGet({ request, env }) {
10
17
  if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return json({ ok: false, error: "backend GitHub non configurato" }, 500);
11
- const slug = new URL(request.url).searchParams.get("slug") || "";
18
+ const url = new URL(request.url);
19
+ const slug = url.searchParams.get("slug") || "";
12
20
  if (!/^[a-z0-9-]+$/.test(slug)) return json({ ok: false, error: "slug non valido" }, 400);
13
21
 
14
- const text = await getText(env, `content/${slug}/index.md`);
15
- if (text == null) return json({ ok: false, error: "pagina non trovata" }, 404);
22
+ const languages = languageConfig();
23
+ const requested = url.searchParams.get("lang") || languages?.default || "";
24
+ if (languages && !languages.available.includes(requested)) return json({ ok: false, error: "lingua non dichiarata" }, 400);
25
+ if (!languages && url.searchParams.has("lang")) return json({ ok: false, error: "questo sito è monolingue" }, 400);
16
26
 
17
- const { body: rawBody } = splitFrontMatter(text);
18
- const body = (rawBody ?? text).replace(/^\n+/, "").replace(/\s+$/, "");
27
+ const defaultLanguage = languages?.default || "";
28
+ const defaultText = await getText(env, pagePath(slug, defaultLanguage, defaultLanguage));
29
+ if (defaultText == null) return json({ ok: false, error: "pagina non trovata" }, 404);
30
+ const isDefault = !languages || requested === defaultLanguage;
31
+ const text = isDefault ? defaultText : await getText(env, pagePath(slug, requested, defaultLanguage));
32
+
33
+ const { body: defaultRawBody } = splitFrontMatter(defaultText);
34
+ const defaultBody = (defaultRawBody ?? defaultText).replace(/^\n+/, "").replace(/\s+$/, "");
19
35
 
20
36
  // Il front matter lo legge lo schema, non uno scanner di righe scritto qui: prima questo
21
37
  // file aveva la sua interpretazione, diversa da quella del serializzatore, e due letture
22
38
  // dello stesso formato sono due occasioni di divergere.
23
- const model = deserializeItem(text, site().page, { slug });
39
+ const definition = pageType(typeOf(defaultText));
40
+ if (!definition) return json({ ok: false, error: "tipo pagina non gestito" }, 404);
41
+ const defaultModel = deserializeItem(defaultText, definition.shape, { slug });
42
+ const model = isDefault ? defaultModel : translationModel(
43
+ definition.shape,
44
+ defaultModel,
45
+ text ? deserializeItem(text, definition.shape, { slug }) : {},
46
+ );
47
+ const { body: rawBody } = splitFrontMatter(text);
48
+ const body = ((rawBody ?? (text ?? defaultBody))).replace(/^\n+/, "").replace(/\s+$/, "");
24
49
 
25
- return json({ ok: true, slug, body, page: model });
50
+ return json({ ok: true, slug, body, page: model, language: requested || undefined, exists: isDefault || !!text });
26
51
  }
@@ -16,9 +16,10 @@
16
16
  * Sta dietro Cloudflare Access E dietro la verifica del JWT in functions/admin/_middleware.js.
17
17
  * Secret/vars: GITHUB_TOKEN (secret), GITHUB_REPO, GITHUB_BRANCH.
18
18
  */
19
- import { serializeItem, deserializeItem, checkFidelity } from "../../index.js";
20
- import { site } from "../site.js";
19
+ import { serializeItem, deserializeItem, checkFidelity, translationModel, inheritedFieldPaths, withTranslationHeader } from "../../index.js";
20
+ import { site, pageType } from "../site.js";
21
21
  import { getRepoSnapshot, getText, DRAFT } from "../_shared/github.js";
22
+ import { languageConfig, pagePath, translatedLanguages } from "../_shared/translations.js";
22
23
 
23
24
  const GH = "https://api.github.com";
24
25
 
@@ -83,6 +84,8 @@ export async function onRequestGet({ env }) {
83
84
  const snapshot = await getRepoSnapshot(env);
84
85
  const blobs = snapshot.tree.filter((t) => t.type === "blob" && site().isManagedPath(t.path));
85
86
 
87
+ const language = languageConfig();
88
+ const shaByPath = new Map(snapshot.tree.filter((t) => t.type === "blob").map((t) => [t.path, t.sha]));
86
89
  const letti = await Promise.all(blobs.map(async (t) => {
87
90
  const text = await getText(env, t.path, snapshot.commitSha);
88
91
  if (text == null) return null;
@@ -90,7 +93,18 @@ export async function onRequestGet({ env }) {
90
93
  // due criteri erano scritti in due posti e coincidevano solo per caso.
91
94
  if (site().excludedTypes.includes(typeOf(text))) return null;
92
95
  const slug = t.path.split("/")[1];
93
- return { slug, url: `/${slug}/`, sha: t.sha, page: deserializeItem(text, site().page, { slug }) };
96
+ const definition = pageType(typeOf(text));
97
+ if (!definition) return null;
98
+ const translations = language
99
+ ? Object.fromEntries(translatedLanguages().map((code) => {
100
+ const sha = shaByPath.get(pagePath(slug, code, language.default));
101
+ return [code, { exists: !!sha, ...(sha ? { sha } : {}) }];
102
+ }))
103
+ : undefined;
104
+ return {
105
+ slug, url: `/${slug}/`, sha: t.sha, page: deserializeItem(text, definition.shape, { slug }),
106
+ ...(translations ? { translations } : {}),
107
+ };
94
108
  }));
95
109
 
96
110
  const pages = letti.filter(Boolean)
@@ -140,7 +154,17 @@ export async function onRequestPost({ request, env, data }) {
140
154
  * risultato del parse la romperebbe in silenzio. Si valida l'oggetto e si serializza quello. */
141
155
  const problems = [];
142
156
  for (const p of pages) {
143
- const r = site().pagePayload.safeParse(p);
157
+ const definition = pageType(p?.type);
158
+ if (!definition) {
159
+ /* Le pagine lasciate intatte viaggiano intenzionalmente come solo riferimento
160
+ * (`slug`, `_orig`, `_sha`, `inMenu`): il loro tipo si ricava dal file esistente più
161
+ * avanti, quando lo stesso giro carica il contenuto da preservare. Una pagina NUOVA,
162
+ * invece, non ha nessun file da cui scoprirlo e deve dichiararlo qui. */
163
+ if (p?.type === undefined && p?._orig) continue;
164
+ problems.push({ slug: typeof p?.slug === "string" ? p.slug : "?", detail: "tipo pagina non gestito" });
165
+ continue;
166
+ }
167
+ const r = definition.payload.safeParse(p);
144
168
  if (r.success) continue;
145
169
  const detail = r.error.issues.slice(0, 5)
146
170
  .map((i) => `${i.path.join(".") || "?"} ${i.message}`).join("; ");
@@ -184,6 +208,8 @@ async function commitPages(args, attempt = 0) {
184
208
  const managed = tree.filter((t) => site().isManagedPath(t.path));
185
209
  const existingSlugs = new Set(managed.map((t) => t.path.split("/")[1]));
186
210
  const shaOf = new Map(managed.map((t) => [t.path.split("/")[1], t.sha]));
211
+ const shaByPath = new Map(tree.filter((t) => t.type === "blob").map((t) => [t.path, t.sha]));
212
+ const language = languageConfig();
187
213
 
188
214
  /* CONTROLLO DI VERSIONE PER FILE. Il client dice da quale versione è partito (`_sha`, lo sha
189
215
  * del blob che gli ha dato l'elenco); se quel file nel frattempo è cambiato, la sua copia è
@@ -204,6 +230,17 @@ async function commitPages(args, attempt = 0) {
204
230
  const attuale = shaOf.get(sourceSlug);
205
231
  if (attuale && attuale !== p._sha) stale.push(sourceSlug);
206
232
  }
233
+ if (language) {
234
+ for (const p of pages) {
235
+ const sourceSlug = existingSlugs.has(p.slug) ? p.slug : (p._orig || p.slug);
236
+ for (const code of translatedLanguages()) {
237
+ const update = p?._translations?.[code];
238
+ if (!update?._sha) continue;
239
+ const current = shaByPath.get(pagePath(sourceSlug, code, language.default));
240
+ if (current && current !== update._sha) stale.push(`${sourceSlug}.${code}`);
241
+ }
242
+ }
243
+ }
207
244
  if (stale.length) {
208
245
  // Tutto o niente come gli altri rifiuti: il commit è atomico, e salvarne metà lascerebbe a
209
246
  // terra l'altra metà senza che nessuno sappia quale.
@@ -226,6 +263,7 @@ async function commitPages(args, attempt = 0) {
226
263
  const entries = [];
227
264
  const desiredSlugs = new Set();
228
265
  const rejected = [];
266
+ const invalidTypes = [];
229
267
 
230
268
  for (const p of pages) {
231
269
  desiredSlugs.add(p.slug);
@@ -235,12 +273,14 @@ async function commitPages(args, attempt = 0) {
235
273
  const oldMd = sourceSlug
236
274
  ? await readFile(env, repo, branch, `content/${sourceSlug}/index.md`)
237
275
  : null;
276
+ const definition = pageType(p.type || typeOf(oldMd));
277
+ if (!definition) { invalidTypes.push(p.slug); continue; }
238
278
 
239
279
  // GUARDIA DI FEDELTÀ: se non sappiamo riprodurre il file che stiamo per sovrascrivere,
240
280
  // non lo sovrascriviamo. Intercetta i costrutti che il lettore del front matter non regge
241
281
  // e trasforma una corruzione silenziosa in un rifiuto visibile.
242
282
  if (oldMd) {
243
- const f = checkFidelity(oldMd, site().page, { slug: sourceSlug });
283
+ const f = checkFidelity(oldMd, definition.shape, { slug: sourceSlug });
244
284
  if (!f.ok) { rejected.push(sourceSlug); continue; }
245
285
  }
246
286
 
@@ -249,15 +289,64 @@ async function commitPages(args, attempt = 0) {
249
289
  mode: "100644",
250
290
  type: "blob",
251
291
  content: serializeItem(p, {
252
- shape: site().page,
292
+ shape: definition.shape,
253
293
  oldMd,
254
294
  // Solo per i tipi che un corpo ce l'hanno: su una gallery il segnaposto sarebbe
255
295
  // testo scritto nel repo che non compare da nessuna parte (vedi hasBody).
256
- placeholderBody: site().hasBody(p.type)
296
+ placeholderBody: definition.hasBody
257
297
  ? (m) => `Placeholder content for **${m.title || p.slug}**.`
258
298
  : undefined,
259
299
  }),
260
300
  });
301
+
302
+ /* Una lingua secondaria arriva esplicitamente dal client solo se è stata aperta o
303
+ * modificata. Non creare file vuoti per ogni pagina: la prima apertura di EN li mostra
304
+ * già come ereditati, ma il file nasce al primo salvataggio di quella lingua. */
305
+ if (language) {
306
+ for (const code of translatedLanguages()) {
307
+ const update = p?._translations?.[code];
308
+ if (!update) continue;
309
+ const oldTranslation = sourceSlug
310
+ ? await readFile(env, repo, branch, pagePath(sourceSlug, code, language.default))
311
+ : null;
312
+ if (oldTranslation) {
313
+ const fidelity = checkFidelity(oldTranslation, definition.shape, { slug: sourceSlug });
314
+ if (!fidelity.ok) { rejected.push(`${p.slug}.${code}`); continue; }
315
+ }
316
+ const candidate = update.page && typeof update.page === "object" ? update.page : update;
317
+ const translated = translationModel(definition.shape, p, candidate);
318
+ if (definition.hasBody) {
319
+ if (typeof update.body === "string") translated.body = update.body;
320
+ else if (!oldTranslation && typeof p.body === "string") translated.body = p.body;
321
+ }
322
+ const valid = definition.payload.safeParse(translated);
323
+ if (!valid.success) { invalidTypes.push(`${p.slug}.${code}`); continue; }
324
+ const content = withTranslationHeader(serializeItem(translated, {
325
+ shape: definition.shape,
326
+ oldMd: oldTranslation,
327
+ placeholderBody: definition.hasBody
328
+ ? (m) => `Placeholder content for **${m.title || p.slug}**.`
329
+ : undefined,
330
+ }), {
331
+ language: code,
332
+ defaultLanguage: language.default,
333
+ inherited: inheritedFieldPaths(definition.shape),
334
+ });
335
+ entries.push({ path: pagePath(p.slug, code, language.default), mode: "100644", type: "blob", content });
336
+ }
337
+ }
338
+ }
339
+
340
+ if (invalidTypes.length) {
341
+ return {
342
+ status: 400,
343
+ body: {
344
+ ok: false,
345
+ code: "invalid",
346
+ error: `Tipo pagina non gestito per: ${invalidTypes.join(", ")}. Non ho salvato nulla.`,
347
+ slugs: invalidTypes,
348
+ },
349
+ };
261
350
  }
262
351
 
263
352
  if (rejected.length) {
@@ -286,6 +375,12 @@ async function commitPages(args, attempt = 0) {
286
375
  const md = await readFile(env, repo, branch, `content/${slug}/index.md`);
287
376
  if (md && site().excludedTypes.includes(typeOf(md))) { skipped.push(slug); continue; }
288
377
  entries.push({ path: `content/${slug}/index.md`, mode: "100644", type: "blob", sha: null });
378
+ if (language) {
379
+ for (const code of translatedLanguages()) {
380
+ const path = pagePath(slug, code, language.default);
381
+ if (shaByPath.has(path)) entries.push({ path, mode: "100644", type: "blob", sha: null });
382
+ }
383
+ }
289
384
  }
290
385
 
291
386
  // 5) tree, commit, ref
@@ -342,7 +437,20 @@ async function commitPages(args, attempt = 0) {
342
437
  const t = await gh(env, `/repos/${repo}/git/trees/${newTreeSha}?recursive=1`);
343
438
  if (t.ok) {
344
439
  for (const e of (await t.json()).tree || []) {
345
- if (e.type === "blob" && site().isManagedPath(e.path)) shas[e.path.split("/")[1]] = e.sha;
440
+ if (e.type !== "blob") continue;
441
+ if (site().isManagedPath(e.path)) {
442
+ shas[e.path.split("/")[1]] = e.sha;
443
+ continue;
444
+ }
445
+ /* Le sibling non passano da isManagedPath (che seleziona il solo index.md), ma hanno
446
+ lo stesso controllo di versione. Rimandare anche il loro SHA evita un falso stale al
447
+ salvataggio successivo della stessa traduzione. */
448
+ if (language) {
449
+ for (const code of translatedLanguages()) {
450
+ const match = e.path.match(/^content\/([^/]+)\/index\.([a-z0-9-]+)\.md$/);
451
+ if (match?.[2] === code) shas[`${match[1]}.${code}`] = e.sha;
452
+ }
453
+ }
346
454
  }
347
455
  }
348
456
  } catch { /* il salvataggio è andato: senza gli sha il client ricarica, non perde niente */ }
@@ -36,3 +36,18 @@ export function site() {
36
36
  export function moduleEnabled(name) {
37
37
  return current?.modules?.[name] === true;
38
38
  }
39
+
40
+ /** La stessa scelta di tipo che fa la shell. Tenerla qui evita che endpoint diversi
41
+ * deserializzino, validino e serializzino una pagina con forme differenti. */
42
+ export function pageType(type) {
43
+ return current?.pages?.[String(type ?? "")] ?? null;
44
+ }
45
+
46
+ /** `null` lascia il sito rigorosamente monolingue. Gli endpoint non inferiscono lingue dai file:
47
+ * una coppia `index.en.md` non dichiarata non deve comparire né essere riscritta per caso. */
48
+ export function languages() {
49
+ const config = current?.languages;
50
+ if (!config || typeof config.default !== "string" || !Array.isArray(config.available)) return null;
51
+ const available = [...new Set(config.available.filter((language) => typeof language === "string" && language))];
52
+ return available.includes(config.default) ? { default: config.default, available } : null;
53
+ }
package/dist/i18n.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import * as z from "zod";
2
+ export type TranslationShape = Record<string, z.ZodType>;
3
+ export interface TranslationMeta {
4
+ translatable: boolean;
5
+ }
6
+ /** Registro separato da `ui`: la scelta influenza il Worker e il codec, non solo il form. */
7
+ export declare const i18n: z.core.$ZodRegistry<TranslationMeta, z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>;
8
+ /** Marca un campo (anche un array o un oggetto intero) come modificabile in una traduzione. */
9
+ export declare function translatable<T extends z.ZodType>(field: T): T;
10
+ export declare function isTranslatable(schema: z.ZodType): boolean;
11
+ /** Unisce una traduzione al modello predefinito senza lasciarle modificare dati ereditati.
12
+ *
13
+ * Gli array non traducibili mantengono la loro struttura predefinita; i loro elementi possono
14
+ * però contenere campi foglia traducibili. Se un'intera lista deve essere traducibile (titolo
15
+ * spezzato in righe, paragrafi, ecc.), va marcata la lista stessa, non ogni sua stringa. */
16
+ export declare function mergeTranslation(schema: z.ZodType, defaultValue: unknown, translationValue: unknown): unknown;
17
+ /** Modello completo della traduzione: utile sia al primo salvataggio sia a ogni risalvataggio. */
18
+ export declare function translationModel(shape: TranslationShape, defaultModel: Record<string, unknown>, translationModel?: Record<string, unknown>): Record<string, unknown>;
19
+ /** Percorsi dei valori che saranno sempre ricopiati dalla lingua predefinita. */
20
+ export declare function inheritedFieldPaths(shape: TranslationShape): string[];
21
+ /** Inserisce (o aggiorna) l'avviso che impedisce modifiche manuali ingannevoli ai dati ereditati. */
22
+ export declare function withTranslationHeader(markdown: string, options: {
23
+ language: string;
24
+ defaultLanguage: string;
25
+ inherited: readonly string[];
26
+ }): string;