@cardinali-studio/adminkit 2.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.
package/dist/api.d.ts CHANGED
@@ -44,6 +44,11 @@ export interface RepoPage {
44
44
  /** Lo `sha` del blob su GitHub: identifica la VERSIONE di questo file, non il suo contenuto. */
45
45
  sha: string;
46
46
  page: Record<string, unknown>;
47
+ /** Le sibling dichiarate dal sito, senza caricare il loro contenuto nell'elenco. */
48
+ translations?: Record<string, {
49
+ exists: boolean;
50
+ sha?: string;
51
+ }>;
47
52
  }
48
53
  export declare const api: {
49
54
  pages: () => Promise<{
@@ -51,11 +56,13 @@ export declare const api: {
51
56
  commit: string;
52
57
  pages: RepoPage[];
53
58
  }>;
54
- pageBody: (slug: string) => Promise<{
59
+ pageBody: (slug: string, language?: string) => Promise<{
55
60
  ok: true;
56
61
  slug: string;
57
62
  body: string;
58
63
  page: Record<string, unknown>;
64
+ language?: string;
65
+ exists?: boolean;
59
66
  }>;
60
67
  savePages: (pages: unknown[]) => Promise<{
61
68
  ok: true;
package/dist/api.js CHANGED
@@ -39,7 +39,7 @@ export const api = {
39
39
  pages: () => call("/admin/api/pages"),
40
40
  /* Il corpo markdown, che nell'elenco non c'è: sono testi lunghi e servono solo alla pagina
41
41
  * che si sta aprendo. `page` è il modello letto dallo stesso file. */
42
- pageBody: (slug) => call(`/admin/api/pages/get?slug=${encodeURIComponent(slug)}`),
42
+ pageBody: (slug, language) => call(`/admin/api/pages/get?slug=${encodeURIComponent(slug)}${language ? `&lang=${encodeURIComponent(language)}` : ""}`),
43
43
  /* `shas` è la versione di ogni file DOPO il commit: senza, il salvataggio successivo
44
44
  * partirebbe da una versione che non esiste più e verrebbe rifiutato. */
45
45
  savePages: (pages) => post("/admin/api/pages", { pages }),
package/dist/app/App.js CHANGED
@@ -108,6 +108,11 @@ export function App() {
108
108
  ? list.find((p) => p.slug === route[1])
109
109
  : undefined;
110
110
  const seoEditing = !!editing && route[2] === "seo";
111
+ const languages = site().languages;
112
+ const [pageLanguage, setPageLanguage] = useState(() => languages?.default ?? "");
113
+ /* Ogni pagina si apre nella sua lingua predefinita. La scelta resta locale alla pagina aperta:
114
+ non deve cambiare Home, Media o la pagina successiva nell'elenco. */
115
+ useEffect(() => { setPageLanguage(languages?.default ?? ""); }, [editing?.slug, languages?.default]);
111
116
  /* Le destinazioni interne del dialogo «Collegamento», dalle pagine già caricate: la barra di
112
117
  formattazione compare dentro widget annidati anche di tre livelli, e le riceve da un
113
118
  contesto invece che da una catena di prop (vedi adminkit form/prose/pages.ts). Le pagine
@@ -211,7 +216,7 @@ export function App() {
211
216
  });
212
217
  return (_jsx(ProsePages.Provider, { value: pageLinks, children: _jsxs("div", { className: "app" + (hasList(section) && !offModule ? "" : " is-two"), children: [_jsxs("nav", { className: "rail", children: [_jsxs("div", { className: "rail-brand", children: [_jsx("span", { className: "rail-brand-name", children: site().name }), _jsx("span", { className: "rail-brand-role", children: "pannello" })] }), _jsx("div", { className: "rail-nav", children: navGroups.map((g) => (_jsxs("div", { className: "rail-group", children: [g.title && _jsx("span", { className: "rail-group-title", children: g.title }), g.items.map((n) => (_jsxs("button", { className: "rail-item" + (section === n.id ? " is-on" : ""), onClick: () => go(`/${n.id}`), children: [_jsx(n.icon, {}), _jsx("span", { children: n.label }), "badge" in n && n.badge ? _jsx("span", { className: "rail-badge", children: n.badge }) : null] }, n.id)))] }, g.title ?? "top"))) }), _jsxs("div", { className: "rail-foot", children: [_jsxs("a", { className: "rail-item", href: "/", target: "_blank", rel: "noreferrer", children: [_jsx(Icon.out, {}), _jsx("span", { children: "Vedi il sito" })] }), _jsxs("button", { className: "rail-item", onClick: toggleTheme, children: [theme === "dark" ? _jsx(Icon.sun, {}) : _jsx(Icon.moon, {}), _jsx("span", { children: theme === "dark" ? "Tema chiaro" : "Tema scuro" })] }), _jsxs("button", { className: "rail-item" + (section === "settings" ? " is-on" : ""), onClick: () => go("/settings/site"), children: [_jsx(Icon.settings, {}), _jsx("span", { children: "Impostazioni" })] })] })] }), hasList(section) && !offModule && (_jsxs("div", { className: "col", children: [section === "pages" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: list && _jsx(NewPageButton, { pages: list, onCreate: (p) => pages.update([...list, p]) }) }), _jsx("div", { className: "col-body", children: pages.error ? _jsx("p", { className: "ak-error", children: pages.error })
213
218
  : !list ? _jsx("p", { className: "ak-hint", children: "Carico\u2026" })
214
- : _jsx(PagesList, { pages: list, current: editing?.slug, onEdit: (slug) => go(`/pages/${slug}`), onChange: pages.update, onRenamed: (from, to) => {
219
+ : _jsx(PagesList, { pages: list, current: editing?.slug, onEdit: (slug) => go(`/pages/${slug}`), languages: languages, onChange: pages.update, onRenamed: (from, to) => {
215
220
  if (editing?.slug === from)
216
221
  go(`/pages/${to}${seoEditing ? "/seo" : ""}`);
217
222
  } }) })] })), section === "media" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Cartelle" }) }), _jsx("div", { className: "col-body", children: _jsx(MediaFolders, { lib: media, trash: { active: view === "trash", open: () => go("/trash"), leave: () => go("/media") } }) })] })), section === "requests" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: pickedReq.size === 0 ? (_jsx("span", { className: "col-title", children: requests.rows ? `${requests.rows.length} richieste` : "Richieste" })) : (_jsxs("span", { className: "rq-bulk", children: [_jsxs("span", { className: "col-title", children: [pickedReq.size, " scelte"] }), _jsx("button", { className: "mg-act", onClick: () => { void requests.mark([...pickedReq], true); setPickedReq(new Set()); }, children: "Gestite" }), _jsx("button", { className: "mg-act is-danger", onClick: () => { void requests.remove([...pickedReq]); setPickedReq(new Set()); }, children: "Elimina" }), _jsx("button", { className: "mg-act", onClick: () => setPickedReq(new Set()), children: "\u00D7" })] })) }), _jsx("div", { className: "col-body", children: requests.error ? _jsx("p", { className: "ak-error", children: requests.error })
@@ -234,7 +239,7 @@ export function App() {
234
239
  : deliveries.rows.length === 0 ? _jsx("p", { className: "ak-hint", children: "Nessun transfer, per ora." })
235
240
  : _jsx(DeliveriesList, { rows: deliveries.rows, current: activeDeliveryId || quickSelectedId, onOpen: (id) => { setQuickSelectedId(undefined); go(`/deliveries/${id}`); } }) }), _jsxs("div", { className: "dl-col-foot", children: [deliveries.rows && _jsx(DeliveriesTotal, { rows: deliveries.rows }), _jsxs("button", { className: "col-item" + (deliverySettingsOpen ? " is-on" : ""), onClick: () => { setQuickSelectedId(undefined); go("/deliveries/settings"); }, children: [_jsx(Icon.settings, {}), _jsx("span", { children: "Impostazioni" })] })] })] })), section === "bookings" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: bookings.rows ? `${bookings.rows.length} appuntamenti` : "Prenotazioni" }) }), _jsx("div", { className: "col-body", children: bookings.error ? _jsx("p", { className: "ak-error", children: bookings.error })
236
241
  : !bookings.rows ? _jsx("p", { className: "ak-hint", children: "Carico\u2026" })
237
- : _jsx(BookingsList, { rows: bookings.rows, current: bookingSettingsOpen ? "settings" : openBooking?.id, onOpen: (id) => go(`/bookings/${id}`), onAgenda: () => go("/bookings"), onSettings: () => go("/bookings/settings") }) })] })), section === "home" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Sezioni" }) }), _jsx("div", { className: "col-body", children: _jsx("ul", { className: "col-list", children: homeGroups.map((g) => (_jsx("li", { children: _jsx("button", { className: "col-item" + (g === homeGroup ? " is-on" : ""), onClick: () => go(`/home/${groupSlug(g)}`), children: g || "Generale" }) }, g))) }) })] })), section === "settings" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Impostazioni" }) }), _jsx("div", { className: "col-body", children: _jsxs("ul", { className: "col-list settings-list", children: [_jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "site" ? " is-on" : ""), onClick: () => go("/settings/site"), children: [_jsx("strong", { children: "Sito" }), _jsx("span", { children: "Favicon, identit\u00E0 e accesso generale." })] }) }), _jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "seo" ? " is-on" : ""), onClick: () => go("/settings/seo"), children: [_jsx("strong", { children: "SEO" }), _jsx("span", { children: "Motori di ricerca e crawler AI." })] }) })] }) })] }))] })), _jsxs("main", { className: "pane", children: [_jsxs("header", { className: "pane-head", children: [editing ? (_jsx(PageHeader, { page: editing, pages: list ?? [], onChange: replace, seoActive: seoEditing, onSeoToggle: () => go(`/pages/${editing.slug}${seoEditing ? "" : "/seo"}`) })) : view === "media" ? (_jsxs("nav", { className: "pane-crumbs", "aria-label": "Percorso", children: [_jsx("button", { className: "pane-crumb" + (mediaCrumbs.length ? "" : " is-current"), onClick: () => media.go(""), children: "Senza cartella" }), mediaCrumbs.map((c, i) => (_jsxs("span", { className: "pane-crumb-part", children: [_jsx("span", { className: "pane-crumb-sep", "aria-hidden": "true", children: "\u203A" }), _jsx("button", { className: "pane-crumb" + (i === mediaCrumbs.length - 1 ? " is-current" : ""), onClick: () => media.go(c.prefix), children: c.label })] }, c.prefix)))] })) : (_jsx("h1", { className: "pane-title", children: heading })), view === "media" && _jsx("span", { className: "pane-count", children: media.shown.length }), _jsx("span", { className: "pane-spacer" }), view === "media" && (_jsx("button", { className: "ak-btn ak-btn-primary", onClick: () => media.setUploadOpen(true), children: "Carica immagini" })), saver?.notice && (_jsx("button", { className: "pane-notice", onClick: () => saver.setNotice(null), children: saver.notice })), section === "bookings" && bookings.notice && (_jsx("button", { className: "pane-notice", onClick: bookings.clearNotice, children: bookings.notice })), publish.notice && (_jsx("button", { className: "pane-notice", onClick: publish.clearNotice, children: publish.notice })), !publish.configured && publish.error && (_jsx("span", { className: "pane-notice", title: publish.error, children: publish.error })), saver && (_jsxs(_Fragment, { children: [saver.dirty && _jsx("span", { className: "pane-dot", title: "modifiche non salvate" }), _jsx("button", { className: "ak-btn", disabled: !saver.dirty || saver.saving,
242
+ : _jsx(BookingsList, { rows: bookings.rows, current: bookingSettingsOpen ? "settings" : openBooking?.id, onOpen: (id) => go(`/bookings/${id}`), onAgenda: () => go("/bookings"), onSettings: () => go("/bookings/settings") }) })] })), section === "home" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Sezioni" }) }), _jsx("div", { className: "col-body", children: _jsx("ul", { className: "col-list", children: homeGroups.map((g) => (_jsx("li", { children: _jsx("button", { className: "col-item" + (g === homeGroup ? " is-on" : ""), onClick: () => go(`/home/${groupSlug(g)}`), children: g || "Generale" }) }, g))) }) })] })), section === "settings" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Impostazioni" }) }), _jsx("div", { className: "col-body", children: _jsxs("ul", { className: "col-list settings-list", children: [_jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "site" ? " is-on" : ""), onClick: () => go("/settings/site"), children: [_jsx("strong", { children: "Sito" }), _jsx("span", { children: "Favicon, identit\u00E0 e accesso generale." })] }) }), _jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "seo" ? " is-on" : ""), onClick: () => go("/settings/seo"), children: [_jsx("strong", { children: "SEO" }), _jsx("span", { children: "Motori di ricerca e crawler AI." })] }) })] }) })] }))] })), _jsxs("main", { className: "pane", children: [_jsxs("header", { className: "pane-head", children: [editing ? (_jsx(PageHeader, { page: editing, pages: list ?? [], onChange: replace, seoActive: seoEditing, onSeoToggle: () => go(`/pages/${editing.slug}${seoEditing ? "" : "/seo"}`), languages: languages, activeLanguage: pageLanguage, onLanguageChange: setPageLanguage })) : view === "media" ? (_jsxs("nav", { className: "pane-crumbs", "aria-label": "Percorso", children: [_jsx("button", { className: "pane-crumb" + (mediaCrumbs.length ? "" : " is-current"), onClick: () => media.go(""), children: "Senza cartella" }), mediaCrumbs.map((c, i) => (_jsxs("span", { className: "pane-crumb-part", children: [_jsx("span", { className: "pane-crumb-sep", "aria-hidden": "true", children: "\u203A" }), _jsx("button", { className: "pane-crumb" + (i === mediaCrumbs.length - 1 ? " is-current" : ""), onClick: () => media.go(c.prefix), children: c.label })] }, c.prefix)))] })) : (_jsx("h1", { className: "pane-title", children: heading })), view === "media" && _jsx("span", { className: "pane-count", children: media.shown.length }), _jsx("span", { className: "pane-spacer" }), view === "media" && (_jsx("button", { className: "ak-btn ak-btn-primary", onClick: () => media.setUploadOpen(true), children: "Carica immagini" })), saver?.notice && (_jsx("button", { className: "pane-notice", onClick: () => saver.setNotice(null), children: saver.notice })), section === "bookings" && bookings.notice && (_jsx("button", { className: "pane-notice", onClick: bookings.clearNotice, children: bookings.notice })), publish.notice && (_jsx("button", { className: "pane-notice", onClick: publish.clearNotice, children: publish.notice })), !publish.configured && publish.error && (_jsx("span", { className: "pane-notice", title: publish.error, children: publish.error })), saver && (_jsxs(_Fragment, { children: [saver.dirty && _jsx("span", { className: "pane-dot", title: "modifiche non salvate" }), _jsx("button", { className: "ak-btn", disabled: !saver.dirty || saver.saving,
238
243
  /* Dopo un salvataggio il conteggio delle modifiche in attesa è cambiato: senza
239
244
  rileggerlo, «Pubblica» comparirebbe solo al ricaricamento della pagina — cioè
240
245
  il pannello direbbe che non c'è niente da pubblicare subito dopo aver salvato
@@ -248,7 +253,7 @@ export function App() {
248
253
  _jsx(Overview, { deliveries: deliveries.rows, expiring: deliveries.expiring, requests: requests.rows, pending: requests.pending, features: settings.data?.features ?? null, bookings: bookings.rows, bookingsPending: bookings.pending, bookingsError: bookings.error, go: go })), section === "bookings" && (bookings.error ? (_jsxs("div", { className: "pane-empty", children: [_jsx("p", { className: "ak-error", children: bookings.error }), _jsx("button", { className: "ak-btn", onClick: () => void bookings.load(), children: "Riprova" })] })) : !bookings.rows || !bookings.settings ? _jsx("p", { className: "pane-empty ak-hint", children: "Carico\u2026" })
249
254
  : bookingSettingsOpen ? _jsx(BookingSettingsHub, { store: bookings })
250
255
  : openBooking ? _jsx(BookingDetail, { row: openBooking, store: bookings })
251
- : _jsx(BookingAgenda, { store: bookings, onOpen: (id) => go(`/bookings/${id}`) })), section === "pages" && (pages.error ? (_jsxs("div", { className: "pane-empty", children: [_jsx("p", { className: "ak-error", children: pages.error }), _jsx("button", { className: "ak-btn", onClick: () => void pages.load(), children: "Riprova" })] })) : editing ? (seoEditing ? (_jsx(PageSeoEditor, { page: editing, onChange: replace, onRefresh: pages.refresh, pristine: pages.isPristine(editing.slug), pickMediaFiles: pickMediaFiles, siteSettings: settings.data })) : (_jsx(PageEditor, { page: editing, onChange: replace, onRefresh: pages.refresh, pristine: pages.isPristine(editing.slug), pickMedia: pickMedia, pickMediaFiles: pickMediaFiles }))) : (_jsx("p", { className: "pane-empty ak-hint", children: list ? "Scegli una pagina dall'elenco, o creane una nuova." : "Carico…" }))), section === "home" && (
256
+ : _jsx(BookingAgenda, { store: bookings, onOpen: (id) => go(`/bookings/${id}`) })), section === "pages" && (pages.error ? (_jsxs("div", { className: "pane-empty", children: [_jsx("p", { className: "ak-error", children: pages.error }), _jsx("button", { className: "ak-btn", onClick: () => void pages.load(), children: "Riprova" })] })) : editing ? (seoEditing ? (_jsx(PageSeoEditor, { page: editing, onChange: replace, onRefresh: pages.refresh, pristine: pages.isPristine(editing.slug), pickMediaFiles: pickMediaFiles, siteSettings: settings.data, readOnly: !!languages && pageLanguage !== languages.default })) : (_jsx(PageEditor, { page: editing, onChange: replace, onRefresh: pages.refresh, pristine: pages.isPristine(editing.slug), pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, activeLanguage: pageLanguage, defaultLanguage: languages?.default }))) : (_jsx("p", { className: "pane-empty ak-hint", children: list ? "Scegli una pagina dall'elenco, o creane una nuova." : "Carico…" }))), section === "home" && (
252
257
  /* Con un errore di caricamento c'era solo la riga rossa: nessun modo di riprovare
253
258
  se non ricaricando il pannello a mano. Le altre sezioni il bottone ce l'hanno. */
254
259
  home.error ? (_jsxs("div", { className: "pane-empty", children: [_jsx("p", { className: "ak-error", children: home.error }), _jsx("button", { className: "ak-btn", onClick: () => void home.load(), children: "Riprova" })] }))
package/dist/app/app.css CHANGED
@@ -305,6 +305,13 @@ body { margin: 0; background: var(--ak-bg); color: var(--ak-ink); font-family: v
305
305
  .pg-icon { color: var(--ak-ink-quiet); }
306
306
  .pg-off { color: var(--ak-danger); opacity: .7; }
307
307
  .pg-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
308
+ .pg-language {
309
+ flex: none; min-width: 22px; padding: 3px 4px; border: 1px solid var(--ak-line); border-radius: 3px;
310
+ color: var(--ak-ink-quiet); font: 700 8px/1 var(--ak-font); letter-spacing: .05em; text-align: center;
311
+ }
312
+ .pg-language.is-saved { border-color: color-mix(in srgb, #168666 40%, var(--ak-line)); color: #168666; }
313
+ .pg-language.is-draft { border-color: color-mix(in srgb, var(--ak-accent) 44%, var(--ak-line)); color: var(--ak-accent); }
314
+ .pg-language.is-inherited { opacity: .5; }
308
315
 
309
316
  /* ---- Azioni sulla riga: popover ancorato ------------------------------------------------ */
310
317
  .pa { position: relative; flex: none; }
@@ -348,6 +355,7 @@ body { margin: 0; background: var(--ak-bg); color: var(--ak-ink); font-family: v
348
355
  font: 600 17px/1.2 var(--ak-font); white-space: nowrap; text-align: left;
349
356
  }
350
357
  .pe-title:hover { color: var(--ak-accent); }
358
+ .pe-title:disabled { cursor: default; color: var(--ak-ink); }
351
359
  .pe-title:focus-visible,
352
360
  .pe-seo-button:focus-visible,
353
361
  .pe-status:focus-visible,
@@ -366,6 +374,25 @@ body { margin: 0; background: var(--ak-bg); color: var(--ak-ink); font-family: v
366
374
  color: var(--ak-accent);
367
375
  }
368
376
  .pe-seo-button .ico { width: 14px; height: 14px; }
377
+ .pe-seo-button:disabled,
378
+ .pe-status:disabled,
379
+ .pe-menu-toggle:has(input:disabled) { opacity: .46; cursor: not-allowed; }
380
+ .pe-language {
381
+ flex: none; display: inline-flex; overflow: hidden; border: 1px solid var(--ak-line); border-radius: 4px;
382
+ background: var(--ak-bg);
383
+ }
384
+ .pe-language button {
385
+ min-width: 33px; min-height: 27px; border: 0; border-left: 1px solid var(--ak-line); padding: 0 8px;
386
+ background: transparent; color: var(--ak-ink-quiet); cursor: pointer; font: 700 10px/1 var(--ak-font); letter-spacing: .06em;
387
+ }
388
+ .pe-language button:first-child { border-left: 0; }
389
+ .pe-language button:hover { color: var(--ak-ink); }
390
+ .pe-language button.is-active { background: var(--ak-panel); color: var(--ak-accent); box-shadow: inset 0 -2px var(--ak-accent); }
391
+ .pe-translation-note {
392
+ margin: 0; padding: 11px 13px; border: 1px solid color-mix(in srgb, var(--ak-accent) 36%, var(--ak-line));
393
+ border-radius: 6px; background: color-mix(in srgb, var(--ak-accent) 6%, var(--ak-panel)); color: var(--ak-ink-quiet);
394
+ font: 400 13px/1.45 var(--ak-font);
395
+ }
369
396
  .pe-visibility { position: relative; flex: none; }
370
397
  .pe-status {
371
398
  display: inline-flex; align-items: center; gap: 7px; border: 0; padding: 5px 2px;
@@ -4,6 +4,17 @@ export type PageModel = Record<string, unknown> & {
4
4
  /** Versione del file da cui questa pagina è stata letta. Torna al server nel salvataggio: se
5
5
  * nel frattempo il file è cambiato, il server rifiuta invece di sovrascrivere. */
6
6
  _sha?: string;
7
+ /** Presenza/versione delle sibling dichiarate; il loro contenuto è caricato solo aprendole. */
8
+ translations?: Record<string, {
9
+ exists: boolean;
10
+ sha?: string;
11
+ }>;
12
+ /** Modelli delle lingue aperte nell'editor, da scrivere nello stesso commit della pagina base. */
13
+ _translations?: Record<string, {
14
+ page: Record<string, unknown>;
15
+ body?: string;
16
+ _sha?: string;
17
+ }>;
7
18
  };
8
19
  export declare function usePages(): {
9
20
  pages: PageModel[] | null;
@@ -31,7 +31,7 @@ export function usePages() {
31
31
  * andava riconciliato con `modelFromRaw` — un passaggio in meno, e una grafia in meno da
32
32
  * far combaciare. L'ordine per peso lo dà già il server. */
33
33
  const list = (d.pages ?? []).map((p) => ({
34
- ...p.page, slug: p.slug, url: p.url, _orig: p.slug, _sha: p.sha,
34
+ ...p.page, slug: p.slug, url: p.url, _orig: p.slug, _sha: p.sha, translations: p.translations,
35
35
  }));
36
36
  base.current = new Map(list.map((p) => [p.slug, p]));
37
37
  setPages(list);
@@ -91,6 +91,22 @@ export function usePages() {
91
91
  const s = r.shas?.[p.slug];
92
92
  if (s)
93
93
  p._sha = s;
94
+ /* Le sibling hanno lo stesso ciclo di vita del file base: se non si aggiorna il loro
95
+ SHA qui, la modifica EN appena salvata parte già da una versione che non esiste più
96
+ e il Worker la rifiuta come conflitto al click successivo. */
97
+ for (const code of site().languages?.available ?? []) {
98
+ if (code === site().languages?.default)
99
+ continue;
100
+ const translationSha = r.shas?.[`${p.slug}.${code}`];
101
+ if (!translationSha)
102
+ continue;
103
+ p.translations = {
104
+ ...(p.translations ?? {}),
105
+ [code]: { exists: true, sha: translationSha },
106
+ };
107
+ if (p._translations?.[code])
108
+ p._translations[code]._sha = translationSha;
109
+ }
94
110
  });
95
111
  // Da qui in poi il file contiene ciò che hanno queste pagine: tornano tutte "non toccate".
96
112
  base.current = new Map(pages.map((p) => [p.slug, p]));
@@ -1,13 +1,20 @@
1
1
  import type { AssetFile } from "../../index.js";
2
2
  import type { PageModel } from "../lib/pages";
3
- export declare function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, }: {
3
+ export declare function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, languages, activeLanguage, onLanguageChange, }: {
4
4
  page: PageModel;
5
5
  pages: PageModel[];
6
6
  onChange: (next: PageModel) => void;
7
7
  seoActive: boolean;
8
8
  onSeoToggle: () => void;
9
+ /** Assente nei siti monolingui: il controllo non viene nemmeno disegnato. */
10
+ languages?: {
11
+ default: string;
12
+ available: readonly string[];
13
+ };
14
+ activeLanguage?: string;
15
+ onLanguageChange?: (language: string) => void;
9
16
  }): import("react").JSX.Element;
10
- export declare function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pickMediaFiles, }: {
17
+ export declare function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pickMediaFiles, activeLanguage, defaultLanguage, }: {
11
18
  page: PageModel;
12
19
  onChange: (next: PageModel) => void;
13
20
  /** Sostituisce la pagina con quella letta dal repo, senza contare come modifica dell'utente. */
@@ -20,4 +27,7 @@ export declare function PageEditor({ page, onChange, onRefresh, pristine, pickMe
20
27
  pickMediaFiles?: (o: {
21
28
  multiple: boolean;
22
29
  }) => Promise<AssetFile[] | null>;
30
+ /** La lingua è scelta nella testata della pagina, non è un'impostazione globale del pannello. */
31
+ activeLanguage?: string;
32
+ defaultLanguage?: string;
23
33
  }): import("react").JSX.Element;
@@ -22,7 +22,7 @@ import { GallerySectionsEditor } from "./GallerySectionsEditor";
22
22
  import { PortfolioWorksEditor } from "./PortfolioWorksEditor";
23
23
  import { AboutEditor } from "./AboutEditor";
24
24
  import { WorkshopEditionsEditor } from "./WorkshopEditionsEditor";
25
- export function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, }) {
25
+ export function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, languages, activeLanguage, onLanguageChange, }) {
26
26
  const [titleOpen, setTitleOpen] = useState(false);
27
27
  const [visibilityOpen, setVisibilityOpen] = useState(false);
28
28
  const [title, setTitle] = useState(String(page.title ?? ""));
@@ -69,12 +69,13 @@ export function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, }) {
69
69
  setTitleOpen(false);
70
70
  };
71
71
  const online = page.published !== false;
72
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "pe-head-main", children: [_jsx("button", { type: "button", className: "pe-title", title: "Modifica nome e indirizzo", onClick: () => {
72
+ const translationActive = !!languages && activeLanguage !== languages.default;
73
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "pe-head-main", children: [_jsx("button", { type: "button", className: "pe-title", title: "Modifica nome e indirizzo", disabled: translationActive, onClick: () => {
73
74
  setTitle(String(page.title ?? ""));
74
75
  setSlug(page.slug);
75
76
  setVisibilityOpen(false);
76
77
  setTitleOpen(true);
77
- }, children: String(page.title ?? page.slug) }), _jsxs("button", { type: "button", className: "pe-seo-button" + (seoActive ? " is-active" : ""), "aria-pressed": seoActive, onClick: onSeoToggle, children: [_jsx(Icon.search, {}), "SEO"] }), _jsxs("div", { className: "pe-visibility", ref: visibilityRef, children: [_jsxs("button", { type: "button", className: "pe-status" + (online ? " is-online" : " is-offline"), "aria-haspopup": "menu", "aria-expanded": visibilityOpen, onClick: () => setVisibilityOpen((open) => !open), children: [_jsx("span", { className: "pe-status-dot" }), online ? "Online" : "Offline", _jsx("span", { className: "pe-chevron", "aria-hidden": "true", children: "\u2304" })] }), visibilityOpen && (_jsxs("div", { className: "pe-status-menu", role: "menu", children: [_jsx("span", { className: "pe-status-label", children: "Visibilit\u00E0 della pagina" }), _jsxs("button", { type: "button", className: online ? "is-selected" : "", onClick: () => {
78
+ }, children: String(page.title ?? page.slug) }), languages && (_jsx("div", { className: "pe-language", "aria-label": "Lingua del contenuto", children: languages.available.map((language) => (_jsx("button", { type: "button", className: activeLanguage === language ? "is-active" : "", "aria-pressed": activeLanguage === language, onClick: () => onLanguageChange?.(language), children: language.toUpperCase() }, language))) })), _jsxs("button", { type: "button", className: "pe-seo-button" + (seoActive ? " is-active" : ""), "aria-pressed": seoActive, disabled: translationActive, onClick: onSeoToggle, children: [_jsx(Icon.search, {}), "SEO"] }), _jsxs("div", { className: "pe-visibility", ref: visibilityRef, children: [_jsxs("button", { type: "button", className: "pe-status" + (online ? " is-online" : " is-offline"), "aria-haspopup": "menu", "aria-expanded": visibilityOpen, disabled: translationActive, onClick: () => setVisibilityOpen((open) => !open), children: [_jsx("span", { className: "pe-status-dot" }), online ? "Online" : "Offline", _jsx("span", { className: "pe-chevron", "aria-hidden": "true", children: "\u2304" })] }), visibilityOpen && (_jsxs("div", { className: "pe-status-menu", role: "menu", children: [_jsx("span", { className: "pe-status-label", children: "Visibilit\u00E0 della pagina" }), _jsxs("button", { type: "button", className: online ? "is-selected" : "", onClick: () => {
78
79
  if (!online)
79
80
  onChange({ ...page, published: true });
80
81
  setVisibilityOpen(false);
@@ -82,7 +83,7 @@ export function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, }) {
82
83
  if (online)
83
84
  onChange({ ...page, published: false });
84
85
  setVisibilityOpen(false);
85
- }, children: [_jsx("span", { className: "pe-radio", children: _jsx("span", {}) }), _jsxs("span", { children: [_jsx("strong", { children: "Offline" }), _jsx("small", { children: "La pagina non \u00E8 visibile sul sito." })] })] })] }))] }), _jsxs("label", { className: "pe-menu-toggle", children: [_jsx("input", { type: "checkbox", checked: !!page.inMenu, onChange: (event) => onChange({ ...page, inMenu: event.target.checked }) }), _jsx("span", { children: "Aggiungi al menu" })] })] }), titleOpen && (_jsx("div", { className: "pe-modal", role: "presentation", onMouseDown: (event) => {
86
+ }, children: [_jsx("span", { className: "pe-radio", children: _jsx("span", {}) }), _jsxs("span", { children: [_jsx("strong", { children: "Offline" }), _jsx("small", { children: "La pagina non \u00E8 visibile sul sito." })] })] })] }))] }), _jsxs("label", { className: "pe-menu-toggle", children: [_jsx("input", { type: "checkbox", checked: !!page.inMenu, disabled: translationActive, onChange: (event) => onChange({ ...page, inMenu: event.target.checked }) }), _jsx("span", { children: "Aggiungi al menu" })] })] }), titleOpen && (_jsx("div", { className: "pe-modal", role: "presentation", onMouseDown: (event) => {
86
87
  if (event.target === event.currentTarget)
87
88
  setTitleOpen(false);
88
89
  }, children: _jsxs("div", { className: "pe-modal-card", role: "dialog", "aria-modal": "true", "aria-labelledby": "pe-title-dialog", children: [_jsxs("header", { children: [_jsx("h2", { id: "pe-title-dialog", children: "Modifica pagina" }), _jsx("button", { type: "button", onClick: () => setTitleOpen(false), "aria-label": "Chiudi", children: "\u00D7" })] }), _jsxs("div", { className: "pe-modal-fields", children: [_jsxs("label", { children: [_jsx("span", { children: "Nome pagina" }), _jsx("input", { autoFocus: true, value: title, onChange: (event) => setTitle(event.target.value), onKeyDown: (event) => {
@@ -161,16 +162,51 @@ function GalleryEditor({ value, onChange, pickMediaFiles, }) {
161
162
  ? { ...candidate, hidden: candidate.hidden ? undefined : true }
162
163
  : candidate)), onRemove: () => onChange(list.filter((_, itemIndex) => itemIndex !== index)) }, item.src))) }) }), _jsx(DragOverlay, { children: activeItem && (_jsx("div", { className: "pe-photo-overlay", children: _jsx("img", { src: activeItem.src, alt: "" }) })) })] }))] }));
163
164
  }
164
- export function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pickMediaFiles, }) {
165
+ export function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pickMediaFiles, activeLanguage, defaultLanguage, }) {
165
166
  const [bodyState, setBodyState] = useState("idle");
167
+ const translationActive = !!defaultLanguage && !!activeLanguage && activeLanguage !== defaultLanguage;
168
+ const translationKey = translationActive ? `${page.slug}:${activeLanguage}` : "";
169
+ /* Aprire una lingua non è una modifica: finché non si cambia davvero un campo, il modello
170
+ composto resta qui e il pulsante Salva non si accende. */
171
+ const [openedTranslations, setOpenedTranslations] = useState({});
172
+ const translation = translationActive
173
+ ? page._translations?.[activeLanguage] ?? openedTranslations[translationKey]
174
+ : undefined;
175
+ const [translationState, setTranslationState] = useState("idle");
166
176
  const definition = pageType(page.type);
167
177
  const showBody = definition?.hasBody === true;
168
178
  const isGallery = page.type === "gallery";
169
179
  const isPortfolio = page.type === "portfolio";
180
+ /* Il Worker restituisce già il modello composto: l'editor può mostrarlo come una pagina
181
+ ordinaria. Aprirlo resta una lettura; il primo cambio lo sposta in `_translations`,
182
+ lasciando immutata la pagina predefinita e rendendo esplicita la futura scrittura. */
183
+ useEffect(() => {
184
+ if (!translationActive || translation || !activeLanguage)
185
+ return;
186
+ let alive = true;
187
+ setTranslationState("loading");
188
+ api.pageBody(page.slug, activeLanguage)
189
+ .then((data) => {
190
+ if (!alive)
191
+ return;
192
+ setTranslationState("idle");
193
+ setOpenedTranslations((current) => ({
194
+ ...current,
195
+ [translationKey]: {
196
+ page: data.page,
197
+ ...(showBody ? { body: data.body } : {}),
198
+ _sha: page.translations?.[activeLanguage]?.sha,
199
+ },
200
+ }));
201
+ })
202
+ .catch(() => { if (alive)
203
+ setTranslationState("error"); });
204
+ return () => { alive = false; };
205
+ }, [translationActive, translation, activeLanguage, page.slug, page.translations, showBody, translationKey]);
170
206
  /* APRENDO UNA PAGINA SI RILEGGE DAL REPO, non dal manifest pubblicato. */
171
207
  const [loadedFor, setLoadedFor] = useState(null);
172
208
  useEffect(() => {
173
- if (loadedFor === page.slug || !pristine)
209
+ if (translationActive || loadedFor === page.slug || !pristine)
174
210
  return;
175
211
  let alive = true;
176
212
  if (showBody)
@@ -196,7 +232,36 @@ export function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pic
196
232
  }
197
233
  });
198
234
  return () => { alive = false; };
199
- }, [page.slug, showBody, pristine, loadedFor]);
235
+ }, [page.slug, showBody, pristine, loadedFor, translationActive]);
236
+ if (translationActive && translationState === "loading" && !translation) {
237
+ return _jsx("p", { className: "pane-empty ak-hint", children: "Carico la traduzione\u2026" });
238
+ }
239
+ if (translationActive && translationState === "error" && !translation) {
240
+ return _jsx("p", { className: "pe-load-error", children: "Non sono riuscito a caricare la traduzione. Riprova o torna alla lingua predefinita." });
241
+ }
242
+ const shownPage = translationActive && translation
243
+ ? { ...page, ...translation.page, ...(showBody ? { body: translation.body ?? "" } : {}) }
244
+ : page;
245
+ const changeShown = (next) => {
246
+ if (!translationActive || !activeLanguage) {
247
+ onChange(next);
248
+ return;
249
+ }
250
+ const { _translations: _ignoredTranslations, translations: _ignoredPresence, _orig: _ignoredOrig, _sha: _ignoredSha, url: _ignoredUrl, body, ...model } = next;
251
+ const update = {
252
+ page: model,
253
+ ...(showBody ? { body: typeof body === "string" ? body : "" } : {}),
254
+ _sha: translation?._sha ?? page.translations?.[activeLanguage]?.sha,
255
+ };
256
+ setOpenedTranslations((current) => ({ ...current, [translationKey]: update }));
257
+ onChange({
258
+ ...page,
259
+ _translations: {
260
+ ...(page._translations ?? {}),
261
+ [activeLanguage]: update,
262
+ },
263
+ });
264
+ };
200
265
  const commonOmissions = useMemo(() => [
201
266
  "title", "published", "inMenu",
202
267
  "seoTitle", "seoDescription", "ogTitle", "ogDescription", "ogImage",
@@ -206,29 +271,31 @@ export function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pic
206
271
  «Ritratto» e «Riconoscimenti» — campi di altri tipi di pagina, che compaiono perché il
207
272
  form disegna tutto ciò che lo schema dichiara e non sa quale tipo si stia modificando.
208
273
  Titolo, visibilità e SEO restano dove sono già: la barra in alto e la scheda SEO. */
209
- if (isPortfolio) {
274
+ if (isPortfolio && !translationActive) {
210
275
  return (_jsxs("div", { className: "ed ed-portfolio", children: [bodyState === "error" && (_jsx("p", { className: "pe-load-error", children: "Non sono riuscito ad aggiornare i dati della pagina dal sito." })), _jsx(PortfolioWorksEditor, { value: page.works, onChange: (works) => onChange({ ...page, works }), pickMediaFiles: pickMediaFiles })] }));
211
276
  }
212
277
  /* Stessa ragione delle due qui sopra: la pagina "chi sono" ha un'anatomia sua — biografia,
213
278
  ritratto, riconoscimenti — e il form generico ci metteva accanto «Lavori», «Edizioni» e
214
279
  «Sezioni», cioè i campi delle altre tipologie. Il corpo markdown è la biografia, quindi la
215
280
  textarea in fondo non serve più: sta dentro l'editor, in cima, dov'è il suo posto. */
216
- if (page.type === "about") {
281
+ if (page.type === "about" && !translationActive) {
217
282
  return (_jsx("div", { className: "ed ed-about", children: _jsx(AboutEditor, { page: page, onChange: onChange, bodyState: bodyState, pickMediaFiles: pickMediaFiles }) }));
218
283
  }
219
284
  /* La pagina workshop: l'introduzione e le edizioni, nell'ordine in cui escono sul sito —
220
285
  l'introduzione apre la colonna dei testi, sopra la prima locandina (WORKSHOP.md). Il corpo
221
286
  markdown è quell'introduzione, quindi non c'è nessuna textarea in fondo: sta in cima, dove
222
287
  la si legge. */
223
- if (page.type === "workshop") {
288
+ if (page.type === "workshop" && !translationActive) {
224
289
  return (_jsxs("div", { className: "ed ed-workshop", children: [_jsxs("section", { className: "wsh-intro", "aria-label": "Introduzione", children: [_jsxs("header", { className: "pw-head", children: [_jsxs("div", { children: [_jsx("h3", { children: "Introduzione" }), _jsx("p", { children: "Il testo che apre la pagina, sopra la prima edizione." })] }), _jsx("span", { className: "ed-count", children: bodyState === "loading" ? "Carico…" : proseStats(String(page.body ?? "")) })] }), bodyState === "error" && (_jsx("p", { className: "ak-error", children: "Non sono riuscito a caricare il testo dal sito. Ricarica prima di modificarlo." })), _jsx(ProseField, { className: "pw-field ed-prose", value: typeof page.body === "string" ? page.body : "", placeholder: "Racconta i workshop.\nVai a capo per iniziare un paragrafo nuovo.", onChange: (body) => onChange({ ...page, body }) })] }), _jsx(WorkshopEditionsEditor, { value: page.workshops, onChange: (workshops) => onChange({ ...page, workshops }), pickMediaFiles: pickMediaFiles })] }));
225
290
  }
226
- if (isGallery) {
291
+ if (isGallery && !translationActive) {
227
292
  /* Le foto si modificano DOVE stanno: dentro il loro blocco, che è anche dove vivono nei dati
228
293
  (`sections[].images`) e dove la griglia esce sul sito. Non c'è più un pannello staccato in
229
294
  fondo — c'era finché le foto stavano in un campo della pagina, e insieme a lui è sparito
230
295
  il caso in cui la stessa griglia si vedeva due volte. */
231
296
  return (_jsxs("div", { className: "ed ed-gallery", children: [bodyState === "error" && (_jsx("p", { className: "pe-load-error", children: "Non sono riuscito ad aggiornare i dati della pagina dal sito." })), _jsx(GallerySectionsEditor, { value: page.sections, onChange: (sections) => onChange({ ...page, sections }), renderGallery: (images, onImages) => (_jsx(GalleryEditor, { value: images, onChange: onImages, pickMediaFiles: pickMediaFiles })), pickMediaFiles: pickMediaFiles })] }));
232
297
  }
233
- return (_jsxs("div", { className: "ed", children: [_jsx(SchemaForm, { shape: definition?.shape ?? {}, value: page, omit: commonOmissions, onChange: (next) => onChange({ ...next, slug: page.slug }), pickMedia: pickMedia, pickMediaFiles: pickMediaFiles }), showBody && (_jsxs("fieldset", { className: "ak-group", children: [_jsx("legend", { className: "ak-legend", children: "testo della pagina" }), bodyState === "loading" && _jsx("p", { className: "ak-hint", children: "Carico\u2026" }), bodyState === "error" && _jsx("p", { className: "ak-error", children: "Non sono riuscito a caricare il testo." }), _jsx("textarea", { className: "ak-input ak-textarea", rows: 12, value: typeof page.body === "string" ? page.body : "", onChange: (event) => onChange({ ...page, body: event.target.value }) })] }))] }));
298
+ return (_jsxs("div", { className: "ed", children: [translationActive && (_jsx("p", { className: "pe-translation-note", children: page.translations?.[activeLanguage]?.exists || page._translations?.[activeLanguage]
299
+ ? `Stai modificando ${activeLanguage.toUpperCase()}. I campi non traducibili restano visibili e vengono copiati dalla lingua predefinita al salvataggio.`
300
+ : `${activeLanguage.toUpperCase()} è uguale all’italiano finché non modifichi e salvi una traduzione.` })), _jsx(SchemaForm, { shape: definition?.shape ?? {}, value: shownPage, omit: commonOmissions, onChange: (next) => changeShown({ ...next, slug: page.slug }), pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, translationReadOnly: translationActive }), showBody && (_jsxs("fieldset", { className: "ak-group", children: [_jsx("legend", { className: "ak-legend", children: "testo della pagina" }), bodyState === "loading" && _jsx("p", { className: "ak-hint", children: "Carico\u2026" }), bodyState === "error" && _jsx("p", { className: "ak-error", children: "Non sono riuscito a caricare il testo." }), _jsx("textarea", { className: "ak-input ak-textarea", rows: 12, value: typeof shownPage.body === "string" ? shownPage.body : "", onChange: (event) => changeShown({ ...shownPage, body: event.target.value }) })] }))] }));
234
301
  }
@@ -1,7 +1,7 @@
1
1
  import type { AssetFile } from "../../index.js";
2
2
  import type { PageModel } from "../lib/pages";
3
3
  import type { SiteSettings } from "../lib/settings";
4
- export declare function PageSeoEditor({ page, onChange, onRefresh, pristine, pickMediaFiles, siteSettings, }: {
4
+ export declare function PageSeoEditor({ page, onChange, onRefresh, pristine, pickMediaFiles, siteSettings, readOnly, }: {
5
5
  page: PageModel;
6
6
  onChange: (next: PageModel) => void;
7
7
  onRefresh: (next: PageModel) => void;
@@ -10,4 +10,7 @@ export declare function PageSeoEditor({ page, onChange, onRefresh, pristine, pic
10
10
  multiple: boolean;
11
11
  }) => Promise<AssetFile[] | null>;
12
12
  siteSettings?: SiteSettings | null;
13
+ /** La vista SEO non carica un modello tradotto: in una lingua secondaria non deve mai
14
+ scrivere per sbaglio i metadati della pagina predefinita. */
15
+ readOnly?: boolean;
13
16
  }): import("react").JSX.Element;
@@ -48,7 +48,7 @@ function FieldHelp({ children, }) {
48
48
  function Count({ value, recommended, max }) {
49
49
  return (_jsxs("span", { className: "seo-count" + (value.length > recommended ? " is-long" : ""), children: [value.length, "/", max, " caratteri"] }));
50
50
  }
51
- export function PageSeoEditor({ page, onChange, onRefresh, pristine, pickMediaFiles, siteSettings, }) {
51
+ export function PageSeoEditor({ page, onChange, onRefresh, pristine, pickMediaFiles, siteSettings, readOnly = false, }) {
52
52
  const [loadedFor, setLoadedFor] = useState(null);
53
53
  const [loadError, setLoadError] = useState(false);
54
54
  /* Arrivando direttamente a /seo la pagina normale non viene montata, quindi la lettura dal
@@ -98,5 +98,5 @@ export function PageSeoEditor({ page, onChange, onRefresh, pristine, pickMediaFi
98
98
  if (selected?.[0])
99
99
  onChange({ ...page, ogImage: selected[0].url });
100
100
  };
101
- return (_jsxs("section", { className: "seo-editor", children: [_jsxs("header", { className: "seo-intro", children: [_jsxs("div", { children: [_jsx("span", { className: "seo-kicker", children: "Impostazioni della pagina" }), _jsx("h2", { children: "Ottimizzazione SEO" }), _jsx("p", { children: "Controlla come questa pagina appare nei motori di ricerca e quando viene condivisa." })] }), _jsxs("span", { className: "seo-complete" + (complete ? " is-complete" : ""), children: [_jsx("span", { children: complete ? "✓" : "!" }), complete ? "SEO completa" : "Da completare"] })] }), loadError && (_jsx("p", { className: "pe-load-error", children: "Non sono riuscito ad aggiornare i dati SEO dal sito." })), _jsxs("section", { className: "seo-section", children: [_jsxs("div", { className: "seo-section-heading", children: [_jsx("span", { children: "Motori di ricerca" }), _jsxs("div", { children: [_jsx("h3", { children: "Anteprima del risultato" }), _jsx("p", { children: "Il contenuto si aggiorna mentre scrivi." })] })] }), _jsxs("div", { className: "seo-row seo-preview-row", children: [_jsx("span", { className: "seo-row-label", children: "Anteprima" }), _jsxs("div", { className: "seo-search-preview", "aria-label": "Anteprima del risultato di ricerca", children: [_jsx("span", { className: "seo-search-site", children: globalTitle }), _jsx("span", { className: "seo-search-url", children: pageUrl }), _jsx("strong", { children: searchTitle }), _jsx("p", { children: searchDescription })] }), _jsx(FieldHelp, { children: "\u00C8 una simulazione del risultato. Il motore di ricerca pu\u00F2 adattarne il testo." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Titolo SEO" }), _jsx("input", { value: seoTitle, maxLength: 120, placeholder: `${text(page.title)} — ${globalTitle}`, onChange: (event) => onChange({ ...page, seoTitle: event.target.value }) }), _jsx(Count, { value: seoTitle, recommended: 60, max: 120 })] }), _jsx(FieldHelp, { children: "Un titolo descrittivo e unico. Per una lettura completa nei risultati \u00E8 preferibile restare entro circa 60 caratteri." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Descrizione SEO" }), _jsx("textarea", { rows: 5, value: seoDescription, maxLength: 255, placeholder: "Descrivi il contenuto della pagina e il valore che offre.", onChange: (event) => onChange({ ...page, seoDescription: event.target.value }) }), _jsx(Count, { value: seoDescription, recommended: 160, max: 255 })] }), _jsx(FieldHelp, { children: "Riassumi la pagina con parole naturali e pertinenti. I primi 160 caratteri sono quelli che hanno pi\u00F9 probabilit\u00E0 di essere mostrati per intero." })] })] }), _jsxs("section", { className: "seo-section", children: [_jsxs("div", { className: "seo-section-heading", children: [_jsx("span", { children: "Open Graph" }), _jsxs("div", { children: [_jsx("h3", { children: "Anteprima della condivisione" }), _jsx("p", { children: "Usata da social, chat e applicazioni quando viene condiviso il link." })] })] }), _jsxs("div", { className: "seo-row seo-preview-row", children: [_jsx("span", { className: "seo-row-label", children: "Anteprima" }), _jsxs("article", { className: "seo-og-preview", "aria-label": "Anteprima Open Graph", children: [_jsx("div", { className: "seo-og-image", children: socialImage ? (_jsx("img", { src: socialImage, alt: "" })) : (_jsxs("span", { children: [_jsx(Icon.media, {}), " Nessuna immagine"] })) }), _jsxs("div", { className: "seo-og-copy", children: [_jsx("span", { children: site().publicBase.replace(/^https?:\/\//, "").toUpperCase() }), _jsx("strong", { children: socialTitle }), _jsx("p", { children: socialDescription })] })] }), _jsx(FieldHelp, { children: "L\u2019anteprima usa i campi Open Graph; quelli vuoti ereditano automaticamente i valori SEO." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Titolo Open Graph" }), _jsx("input", { value: ogTitle, maxLength: 120, placeholder: searchTitle, onChange: (event) => onChange({ ...page, ogTitle: event.target.value }) }), _jsx(Count, { value: ogTitle, recommended: 70, max: 120 })] }), _jsx(FieldHelp, { children: "Lascia vuoto per usare il titolo SEO. Puoi renderlo pi\u00F9 editoriale per la condivisione." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Descrizione Open Graph" }), _jsx("textarea", { rows: 4, value: ogDescription, maxLength: 300, placeholder: searchDescription, onChange: (event) => onChange({ ...page, ogDescription: event.target.value }) }), _jsx(Count, { value: ogDescription, recommended: 200, max: 300 })] }), _jsx(FieldHelp, { children: "Lascia vuoto per usare la descrizione SEO. Mantienila breve per evitare tagli nelle card." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("div", { className: "seo-field", children: [_jsx("span", { children: "Immagine Open Graph" }), _jsxs("div", { className: "seo-image-picker", children: [socialImage ? _jsx("img", { src: socialImage, alt: "" }) : _jsx("div", { children: _jsx(Icon.media, {}) }), _jsxs("div", { children: [_jsx("button", { type: "button", className: "ak-btn", onClick: () => void chooseImage(), children: explicitImage ? "Cambia immagine" : "Scegli immagine" }), explicitImage && (_jsx("button", { type: "button", className: "ak-btn ak-btn-quiet", onClick: () => onChange({ ...page, ogImage: "" }), children: "Usa automatica" })), _jsx("small", { children: explicitImage ? "Immagine personalizzata" : socialImage ? "Prima immagine pubblica della pagina" : "Nessun ripiego disponibile" })] })] })] }), _jsx(FieldHelp, { children: "Una foto orizzontale funziona meglio nelle condivisioni. Se non ne scegli una, viene usata la prima immagine pubblica della pagina." })] })] })] }));
101
+ return (_jsxs("section", { className: "seo-editor", children: [_jsxs("header", { className: "seo-intro", children: [_jsxs("div", { children: [_jsx("span", { className: "seo-kicker", children: "Impostazioni della pagina" }), _jsx("h2", { children: "Ottimizzazione SEO" }), _jsx("p", { children: "Controlla come questa pagina appare nei motori di ricerca e quando viene condivisa." })] }), _jsxs("span", { className: "seo-complete" + (complete ? " is-complete" : ""), children: [_jsx("span", { children: complete ? "✓" : "!" }), complete ? "SEO completa" : "Da completare"] })] }), loadError && (_jsx("p", { className: "pe-load-error", children: "Non sono riuscito ad aggiornare i dati SEO dal sito." })), readOnly && (_jsx("p", { className: "pe-translation-note", children: "I metadati SEO di questa lingua sono ereditati. Torna alla lingua predefinita per modificarli." })), _jsxs("section", { className: "seo-section", children: [_jsxs("div", { className: "seo-section-heading", children: [_jsx("span", { children: "Motori di ricerca" }), _jsxs("div", { children: [_jsx("h3", { children: "Anteprima del risultato" }), _jsx("p", { children: "Il contenuto si aggiorna mentre scrivi." })] })] }), _jsxs("div", { className: "seo-row seo-preview-row", children: [_jsx("span", { className: "seo-row-label", children: "Anteprima" }), _jsxs("div", { className: "seo-search-preview", "aria-label": "Anteprima del risultato di ricerca", children: [_jsx("span", { className: "seo-search-site", children: globalTitle }), _jsx("span", { className: "seo-search-url", children: pageUrl }), _jsx("strong", { children: searchTitle }), _jsx("p", { children: searchDescription })] }), _jsx(FieldHelp, { children: "\u00C8 una simulazione del risultato. Il motore di ricerca pu\u00F2 adattarne il testo." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Titolo SEO" }), _jsx("input", { value: seoTitle, maxLength: 120, disabled: readOnly, placeholder: `${text(page.title)} — ${globalTitle}`, onChange: (event) => onChange({ ...page, seoTitle: event.target.value }) }), _jsx(Count, { value: seoTitle, recommended: 60, max: 120 })] }), _jsx(FieldHelp, { children: "Un titolo descrittivo e unico. Per una lettura completa nei risultati \u00E8 preferibile restare entro circa 60 caratteri." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Descrizione SEO" }), _jsx("textarea", { rows: 5, value: seoDescription, maxLength: 255, disabled: readOnly, placeholder: "Descrivi il contenuto della pagina e il valore che offre.", onChange: (event) => onChange({ ...page, seoDescription: event.target.value }) }), _jsx(Count, { value: seoDescription, recommended: 160, max: 255 })] }), _jsx(FieldHelp, { children: "Riassumi la pagina con parole naturali e pertinenti. I primi 160 caratteri sono quelli che hanno pi\u00F9 probabilit\u00E0 di essere mostrati per intero." })] })] }), _jsxs("section", { className: "seo-section", children: [_jsxs("div", { className: "seo-section-heading", children: [_jsx("span", { children: "Open Graph" }), _jsxs("div", { children: [_jsx("h3", { children: "Anteprima della condivisione" }), _jsx("p", { children: "Usata da social, chat e applicazioni quando viene condiviso il link." })] })] }), _jsxs("div", { className: "seo-row seo-preview-row", children: [_jsx("span", { className: "seo-row-label", children: "Anteprima" }), _jsxs("article", { className: "seo-og-preview", "aria-label": "Anteprima Open Graph", children: [_jsx("div", { className: "seo-og-image", children: socialImage ? (_jsx("img", { src: socialImage, alt: "" })) : (_jsxs("span", { children: [_jsx(Icon.media, {}), " Nessuna immagine"] })) }), _jsxs("div", { className: "seo-og-copy", children: [_jsx("span", { children: site().publicBase.replace(/^https?:\/\//, "").toUpperCase() }), _jsx("strong", { children: socialTitle }), _jsx("p", { children: socialDescription })] })] }), _jsx(FieldHelp, { children: "L\u2019anteprima usa i campi Open Graph; quelli vuoti ereditano automaticamente i valori SEO." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Titolo Open Graph" }), _jsx("input", { value: ogTitle, maxLength: 120, disabled: readOnly, placeholder: searchTitle, onChange: (event) => onChange({ ...page, ogTitle: event.target.value }) }), _jsx(Count, { value: ogTitle, recommended: 70, max: 120 })] }), _jsx(FieldHelp, { children: "Lascia vuoto per usare il titolo SEO. Puoi renderlo pi\u00F9 editoriale per la condivisione." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Descrizione Open Graph" }), _jsx("textarea", { rows: 4, value: ogDescription, maxLength: 300, disabled: readOnly, placeholder: searchDescription, onChange: (event) => onChange({ ...page, ogDescription: event.target.value }) }), _jsx(Count, { value: ogDescription, recommended: 200, max: 300 })] }), _jsx(FieldHelp, { children: "Lascia vuoto per usare la descrizione SEO. Mantienila breve per evitare tagli nelle card." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("div", { className: "seo-field", children: [_jsx("span", { children: "Immagine Open Graph" }), _jsxs("div", { className: "seo-image-picker", children: [socialImage ? _jsx("img", { src: socialImage, alt: "" }) : _jsx("div", { children: _jsx(Icon.media, {}) }), _jsxs("div", { children: [_jsx("button", { type: "button", className: "ak-btn", disabled: readOnly, onClick: () => void chooseImage(), children: explicitImage ? "Cambia immagine" : "Scegli immagine" }), explicitImage && (_jsx("button", { type: "button", className: "ak-btn ak-btn-quiet", disabled: readOnly, onClick: () => onChange({ ...page, ogImage: "" }), children: "Usa automatica" })), _jsx("small", { children: explicitImage ? "Immagine personalizzata" : socialImage ? "Prima immagine pubblica della pagina" : "Nessun ripiego disponibile" })] })] })] }), _jsx(FieldHelp, { children: "Una foto orizzontale funziona meglio nelle condivisioni. Se non ne scegli una, viene usata la prima immagine pubblica della pagina." })] })] })] }));
102
102
  }
@@ -1,5 +1,5 @@
1
1
  import type { PageModel } from "../lib/pages";
2
- export declare function PagesList({ pages, current, onEdit, onChange, onRenamed, }: {
2
+ export declare function PagesList({ pages, current, onEdit, onChange, onRenamed, languages, }: {
3
3
  pages: PageModel[];
4
4
  current?: string;
5
5
  onEdit: (slug: string) => void;
@@ -7,4 +7,8 @@ export declare function PagesList({ pages, current, onEdit, onChange, onRenamed,
7
7
  /** Una rinomina cambia l'indirizzo: se la pagina è quella aperta nell'editor, la rotta punta
8
8
  * a uno slug che non esiste più e l'editor resterebbe vuoto. Lo sa solo chi tiene la rotta. */
9
9
  onRenamed?: (from: string, to: string) => void;
10
+ languages?: {
11
+ default: string;
12
+ available: readonly string[];
13
+ };
10
14
  }): import("react").JSX.Element;
@@ -20,12 +20,18 @@ import { Icon } from "../ui/icons";
20
20
  const ICONS = {
21
21
  gallery: Icon.gallery, page: Icon.doc, contact: Icon.mail,
22
22
  };
23
- function Row({ p, pages, current, onEdit, onChange, onRemove, onDuplicate }) {
23
+ function Row({ p, pages, current, onEdit, onChange, onRemove, onDuplicate, languages }) {
24
24
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: p.slug });
25
25
  const TypeIcon = ICONS[String(p.type)] ?? Icon.doc;
26
- return (_jsxs("li", { ref: setNodeRef, className: "pg-row" + (isDragging ? " is-dragging" : "") + (p.slug === current ? " is-on" : ""), style: { transform: CSS.Transform.toString(transform), transition }, children: [_jsx("button", { className: "pg-handle", ...attributes, ...listeners, title: "Trascina per riordinare", children: _jsx(Icon.grip, {}) }), _jsxs("button", { className: "pg-open", onClick: () => onEdit(p.slug), children: [_jsx("span", { className: "pg-icon", children: _jsx(TypeIcon, {}) }), _jsx("span", { className: "pg-title", children: String(p.title ?? p.slug) }), p.published === false && (_jsx("span", { className: "pg-off", title: "nascosta: non va online", children: _jsx(Icon.eyeOff, {}) }))] }), _jsx(RowMenu, { page: p, pages: pages, onChange: (next) => onChange(p.slug, next), onRemove: onRemove, onDuplicate: onDuplicate })] }));
26
+ return (_jsxs("li", { ref: setNodeRef, className: "pg-row" + (isDragging ? " is-dragging" : "") + (p.slug === current ? " is-on" : ""), style: { transform: CSS.Transform.toString(transform), transition }, children: [_jsx("button", { className: "pg-handle", ...attributes, ...listeners, title: "Trascina per riordinare", children: _jsx(Icon.grip, {}) }), _jsxs("button", { className: "pg-open", onClick: () => onEdit(p.slug), children: [_jsx("span", { className: "pg-icon", children: _jsx(TypeIcon, {}) }), _jsx("span", { className: "pg-title", children: String(p.title ?? p.slug) }), languages?.available.filter((language) => language !== languages.default).map((language) => {
27
+ const saved = p.translations?.[language]?.exists;
28
+ const draft = !!p._translations?.[language];
29
+ return (_jsx("span", { className: "pg-language" + (saved ? " is-saved" : draft ? " is-draft" : " is-inherited"), title: saved ? `${language.toUpperCase()} tradotta` : draft
30
+ ? `${language.toUpperCase()} modificata: salva per creare la traduzione`
31
+ : `${language.toUpperCase()} uguale alla lingua predefinita`, children: language.toUpperCase() }, language));
32
+ }), p.published === false && (_jsx("span", { className: "pg-off", title: "nascosta: non va online", children: _jsx(Icon.eyeOff, {}) }))] }), _jsx(RowMenu, { page: p, pages: pages, onChange: (next) => onChange(p.slug, next), onRemove: onRemove, onDuplicate: onDuplicate })] }));
27
33
  }
28
- function Group({ title, items, all, current, onEdit, onReorder, onChange, onRemove, onDuplicate, }) {
34
+ function Group({ title, items, all, current, onEdit, onReorder, onChange, onRemove, onDuplicate, languages, }) {
29
35
  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
30
36
  const onDragEnd = (e) => {
31
37
  const { active, over } = e;
@@ -33,9 +39,9 @@ function Group({ title, items, all, current, onEdit, onReorder, onChange, onRemo
33
39
  return;
34
40
  onReorder(items.findIndex((p) => p.slug === active.id), items.findIndex((p) => p.slug === over.id));
35
41
  };
36
- return (_jsxs("section", { className: "pg-group", children: [_jsx("h2", { className: "pg-group-title", children: title }), items.length === 0 && _jsx("p", { className: "pg-empty", children: "nessuna" }), _jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, modifiers: [restrictToVerticalAxis], onDragEnd: onDragEnd, children: _jsx(SortableContext, { items: items.map((p) => p.slug), strategy: verticalListSortingStrategy, children: _jsx("ul", { className: "pg-list", children: items.map((p) => (_jsx(Row, { p: p, pages: all, current: current, onEdit: onEdit, onChange: onChange, onRemove: () => onRemove(p.slug), onDuplicate: () => onDuplicate(p.slug) }, p.slug))) }) }) })] }));
42
+ return (_jsxs("section", { className: "pg-group", children: [_jsx("h2", { className: "pg-group-title", children: title }), items.length === 0 && _jsx("p", { className: "pg-empty", children: "nessuna" }), _jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, modifiers: [restrictToVerticalAxis], onDragEnd: onDragEnd, children: _jsx(SortableContext, { items: items.map((p) => p.slug), strategy: verticalListSortingStrategy, children: _jsx("ul", { className: "pg-list", children: items.map((p) => (_jsx(Row, { p: p, pages: all, current: current, onEdit: onEdit, onChange: onChange, languages: languages, onRemove: () => onRemove(p.slug), onDuplicate: () => onDuplicate(p.slug) }, p.slug))) }) }) })] }));
37
43
  }
38
- export function PagesList({ pages, current, onEdit, onChange, onRenamed, }) {
44
+ export function PagesList({ pages, current, onEdit, onChange, onRenamed, languages, }) {
39
45
  const inMenu = pages.filter((p) => !!p.inMenu);
40
46
  const notInMenu = pages.filter((p) => !p.inMenu);
41
47
  const reorder = (group, from, to) => {
@@ -71,6 +77,6 @@ export function PagesList({ pages, current, onEdit, onChange, onRenamed, }) {
71
77
  const i = pages.indexOf(src);
72
78
  onChange([...pages.slice(0, i + 1), copia, ...pages.slice(i + 1)]);
73
79
  };
74
- const shared = { all: pages, current, onEdit, onChange: replace, onRemove: remove, onDuplicate: duplicate };
80
+ const shared = { all: pages, current, onEdit, onChange: replace, onRemove: remove, onDuplicate: duplicate, languages };
75
81
  return (_jsxs("div", { className: "pg", children: [_jsx(Group, { title: "Nel menu", items: inMenu, ...shared, onReorder: (f, t) => reorder(inMenu, f, t) }), _jsx(Group, { title: "Fuori dal menu", items: notInMenu, ...shared, onReorder: (f, t) => reorder(notInMenu, f, t) })] }));
76
82
  }
@@ -10,6 +10,12 @@ export interface PageTypeConfig {
10
10
  payload: z.ZodType;
11
11
  hasBody: boolean;
12
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
+ }
13
19
  export interface SiteAdminConfig {
14
20
  /** Il nome mostrato in cima al rail, e il ripiego del titolo nell'anteprima SEO. */
15
21
  name: string;
@@ -28,6 +34,8 @@ export interface SiteAdminConfig {
28
34
  };
29
35
  /** Forme delle pagine della collezione, indicizzate dal loro `type` nel front matter. */
30
36
  pages: Record<string, PageTypeConfig>;
37
+ /** Dichiararla abilita il contenuto bilingue; assente significa sito monolingue. */
38
+ languages?: LanguagesConfig;
31
39
  /** Forma del singleton home: i suoi `group` diventano le voci della seconda colonna. */
32
40
  home: Shape;
33
41
  /** Peso di ordinamento di una pagina data la sua posizione nell'elenco. */
@@ -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;
@@ -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,8 +3,9 @@
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";
6
+ import { splitFrontMatter, deserializeItem, translationModel } from "../../../index.js";
7
7
  import { pageType } from "../../site.js";
8
+ import { languageConfig, pagePath } from "../../_shared/translations.js";
8
9
 
9
10
  function typeOf(md) {
10
11
  const m = (md || "").match(/^---\n([\s\S]*?)\n---/);
@@ -14,21 +15,37 @@ function typeOf(md) {
14
15
 
15
16
  export async function onRequestGet({ request, env }) {
16
17
  if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return json({ ok: false, error: "backend GitHub non configurato" }, 500);
17
- const slug = new URL(request.url).searchParams.get("slug") || "";
18
+ const url = new URL(request.url);
19
+ const slug = url.searchParams.get("slug") || "";
18
20
  if (!/^[a-z0-9-]+$/.test(slug)) return json({ ok: false, error: "slug non valido" }, 400);
19
21
 
20
- const text = await getText(env, `content/${slug}/index.md`);
21
- 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);
22
26
 
23
- const { body: rawBody } = splitFrontMatter(text);
24
- 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+$/, "");
25
35
 
26
36
  // Il front matter lo legge lo schema, non uno scanner di righe scritto qui: prima questo
27
37
  // file aveva la sua interpretazione, diversa da quella del serializzatore, e due letture
28
38
  // dello stesso formato sono due occasioni di divergere.
29
- const definition = pageType(typeOf(text));
39
+ const definition = pageType(typeOf(defaultText));
30
40
  if (!definition) return json({ ok: false, error: "tipo pagina non gestito" }, 404);
31
- const model = deserializeItem(text, definition.shape, { slug });
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+$/, "");
32
49
 
33
- return json({ ok: true, slug, body, page: model });
50
+ return json({ ok: true, slug, body, page: model, language: requested || undefined, exists: isDefault || !!text });
34
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";
19
+ import { serializeItem, deserializeItem, checkFidelity, translationModel, inheritedFieldPaths, withTranslationHeader } from "../../index.js";
20
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;
@@ -92,7 +95,16 @@ export async function onRequestGet({ env }) {
92
95
  const slug = t.path.split("/")[1];
93
96
  const definition = pageType(typeOf(text));
94
97
  if (!definition) return null;
95
- return { slug, url: `/${slug}/`, sha: t.sha, page: deserializeItem(text, definition.shape, { slug }) };
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
+ };
96
108
  }));
97
109
 
98
110
  const pages = letti.filter(Boolean)
@@ -196,6 +208,8 @@ async function commitPages(args, attempt = 0) {
196
208
  const managed = tree.filter((t) => site().isManagedPath(t.path));
197
209
  const existingSlugs = new Set(managed.map((t) => t.path.split("/")[1]));
198
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();
199
213
 
200
214
  /* CONTROLLO DI VERSIONE PER FILE. Il client dice da quale versione è partito (`_sha`, lo sha
201
215
  * del blob che gli ha dato l'elenco); se quel file nel frattempo è cambiato, la sua copia è
@@ -216,6 +230,17 @@ async function commitPages(args, attempt = 0) {
216
230
  const attuale = shaOf.get(sourceSlug);
217
231
  if (attuale && attuale !== p._sha) stale.push(sourceSlug);
218
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
+ }
219
244
  if (stale.length) {
220
245
  // Tutto o niente come gli altri rifiuti: il commit è atomico, e salvarne metà lascerebbe a
221
246
  // terra l'altra metà senza che nessuno sappia quale.
@@ -273,6 +298,43 @@ async function commitPages(args, attempt = 0) {
273
298
  : undefined,
274
299
  }),
275
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
+ }
276
338
  }
277
339
 
278
340
  if (invalidTypes.length) {
@@ -313,6 +375,12 @@ async function commitPages(args, attempt = 0) {
313
375
  const md = await readFile(env, repo, branch, `content/${slug}/index.md`);
314
376
  if (md && site().excludedTypes.includes(typeOf(md))) { skipped.push(slug); continue; }
315
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
+ }
316
384
  }
317
385
 
318
386
  // 5) tree, commit, ref
@@ -369,7 +437,20 @@ async function commitPages(args, attempt = 0) {
369
437
  const t = await gh(env, `/repos/${repo}/git/trees/${newTreeSha}?recursive=1`);
370
438
  if (t.ok) {
371
439
  for (const e of (await t.json()).tree || []) {
372
- 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
+ }
373
454
  }
374
455
  }
375
456
  } catch { /* il salvataggio è andato: senza gli sha il client ricarica, non perde niente */ }
@@ -42,3 +42,12 @@ export function moduleEnabled(name) {
42
42
  export function pageType(type) {
43
43
  return current?.pages?.[String(type ?? "")] ?? null;
44
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;
package/dist/i18n.js ADDED
@@ -0,0 +1,119 @@
1
+ /* Bilinguismo opzionale, interamente guidato dallo schema.
2
+ *
3
+ * Un campo traducibile non è una seconda forma di contenuto: è lo stesso campo, marcato nello
4
+ * schema. Il file della lingua secondaria resta completo, ma questo modulo ricopia dal modello
5
+ * della lingua predefinita ogni valore che non porta il marchio. Così i template Hugo leggono
6
+ * file ordinari e non devono conoscere alcuna logica di merge.
7
+ */
8
+ import * as z from "zod";
9
+ /** Registro separato da `ui`: la scelta influenza il Worker e il codec, non solo il form. */
10
+ export const i18n = z.registry();
11
+ /** Marca un campo (anche un array o un oggetto intero) come modificabile in una traduzione. */
12
+ export function translatable(field) {
13
+ // Zod esprime `register()` con un conditional type che non conserva il nostro generico T;
14
+ // il registro è comunque quello tipizzato appena sopra e l'operazione non trasforma il campo.
15
+ field.register(i18n, { translatable: true });
16
+ return field;
17
+ }
18
+ export function isTranslatable(schema) {
19
+ return i18n.get(schema)?.translatable === true;
20
+ }
21
+ function defOf(schema) {
22
+ return schema?._zod?.def ?? schema?._def ?? null;
23
+ }
24
+ function shapeOf(schema) {
25
+ const def = defOf(schema);
26
+ if (def?.type === "object" || def?.typeName === "ZodObject") {
27
+ return (typeof def.shape === "function" ? def.shape() : def.shape) ?? null;
28
+ }
29
+ return null;
30
+ }
31
+ function elementOf(schema) {
32
+ const def = defOf(schema);
33
+ if (def?.type === "array" || def?.typeName === "ZodArray")
34
+ return def.element ?? def.type ?? null;
35
+ return null;
36
+ }
37
+ function clone(value) {
38
+ if (Array.isArray(value))
39
+ return value.map(clone);
40
+ if (value && typeof value === "object") {
41
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, clone(v)]));
42
+ }
43
+ return value;
44
+ }
45
+ /** Unisce una traduzione al modello predefinito senza lasciarle modificare dati ereditati.
46
+ *
47
+ * Gli array non traducibili mantengono la loro struttura predefinita; i loro elementi possono
48
+ * però contenere campi foglia traducibili. Se un'intera lista deve essere traducibile (titolo
49
+ * spezzato in righe, paragrafi, ecc.), va marcata la lista stessa, non ogni sua stringa. */
50
+ export function mergeTranslation(schema, defaultValue, translationValue) {
51
+ if (isTranslatable(schema)) {
52
+ return clone(translationValue === undefined ? defaultValue : translationValue);
53
+ }
54
+ const shape = shapeOf(schema);
55
+ if (shape) {
56
+ const source = defaultValue && typeof defaultValue === "object" && !Array.isArray(defaultValue)
57
+ ? defaultValue : {};
58
+ const translated = translationValue && typeof translationValue === "object" && !Array.isArray(translationValue)
59
+ ? translationValue : {};
60
+ return Object.fromEntries(Object.entries(shape).map(([key, child]) => [
61
+ key,
62
+ mergeTranslation(child, source[key], translated[key]),
63
+ ]));
64
+ }
65
+ const element = elementOf(schema);
66
+ if (element) {
67
+ const source = Array.isArray(defaultValue) ? defaultValue : [];
68
+ const translated = Array.isArray(translationValue) ? translationValue : [];
69
+ return source.map((value, index) => mergeTranslation(element, value, translated[index]));
70
+ }
71
+ return clone(defaultValue);
72
+ }
73
+ /** Modello completo della traduzione: utile sia al primo salvataggio sia a ogni risalvataggio. */
74
+ export function translationModel(shape, defaultModel, translationModel = {}) {
75
+ return Object.fromEntries(Object.entries(shape).map(([key, schema]) => [
76
+ key,
77
+ mergeTranslation(schema, defaultModel[key], translationModel[key]),
78
+ ]));
79
+ }
80
+ /** Percorsi dei valori che saranno sempre ricopiati dalla lingua predefinita. */
81
+ export function inheritedFieldPaths(shape) {
82
+ const visit = (schema, path) => {
83
+ if (isTranslatable(schema))
84
+ return [];
85
+ const nested = shapeOf(schema);
86
+ if (nested)
87
+ return Object.entries(nested).flatMap(([key, child]) => visit(child, `${path}.${key}`));
88
+ const element = elementOf(schema);
89
+ if (element) {
90
+ const child = shapeOf(element);
91
+ return child
92
+ ? Object.entries(child).flatMap(([key, nestedSchema]) => visit(nestedSchema, `${path}[].${key}`))
93
+ : [path];
94
+ }
95
+ return [path];
96
+ };
97
+ return Object.entries(shape).flatMap(([key, schema]) => visit(schema, key));
98
+ }
99
+ const HEADER_START = "# adminkit: translation metadata (generated)";
100
+ const HEADER_END = "# adminkit: end translation metadata";
101
+ /** Inserisce (o aggiorna) l'avviso che impedisce modifiche manuali ingannevoli ai dati ereditati. */
102
+ export function withTranslationHeader(markdown, options) {
103
+ if (!markdown.startsWith("---\n"))
104
+ throw new Error("Una traduzione richiede front matter YAML.");
105
+ const inherited = options.inherited.length ? options.inherited.join(", ") : "nessuno";
106
+ const header = [
107
+ HEADER_START,
108
+ `# Lingua: ${options.language}. I campi non traducibili sono ereditati da ${options.defaultLanguage}.`,
109
+ `# Campi ereditati: ${inherited}. Modificarli qui non ha effetto: il pannello li risincronizza.`,
110
+ HEADER_END,
111
+ ].join("\n") + "\n";
112
+ let rest = markdown.slice(4);
113
+ if (rest.startsWith(`${HEADER_START}\n`)) {
114
+ const end = rest.indexOf(`${HEADER_END}\n`);
115
+ if (end >= 0)
116
+ rest = rest.slice(end + HEADER_END.length + 1);
117
+ }
118
+ return `---\n${header}${rest}`;
119
+ }
package/dist/index.d.ts CHANGED
@@ -8,3 +8,4 @@ export * from "./api.js";
8
8
  export * from "./prose.js";
9
9
  export * from "./gallery.js";
10
10
  export * from "./normalize.js";
11
+ export * from "./i18n.js";
package/dist/index.js CHANGED
@@ -8,3 +8,4 @@ export * from "./api.js";
8
8
  export * from "./prose.js";
9
9
  export * from "./gallery.js";
10
10
  export * from "./normalize.js";
11
+ export * from "./i18n.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardinali-studio/adminkit",
3
- "version": "2.0.0",
3
+ "version": "2.1.0-rc.0",
4
4
  "type": "module",
5
5
  "description": "Motore riusabile di un pannello di amministrazione per siti Hugo su Cloudflare Pages: codec del front matter guidato dallo schema, form React, libreria media su R2. NIENTE di specifico di un sito qui dentro.",
6
6
  "license": "UNLICENSED",