@cardinali-studio/adminkit 1.0.0 → 2.0.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.
@@ -14,7 +14,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
14
  * scomparire mentre la si rinomina fa perdere il segno di dove si era.
15
15
  */
16
16
  import { useMemo, useState } from "react";
17
- import { ui } from "../../index.js";
18
17
  import { site } from "../site.js";
19
18
  import { slugify, uniqueSlug } from "../lib/slug";
20
19
  import { Icon } from "../ui/icons";
@@ -33,7 +32,7 @@ import { Icon } from "../ui/icons";
33
32
  * modulo andava bene: è il genere di cosa che cambia quando una dipendenza smette di essere
34
33
  * statica e diventa iniettata. */
35
34
  function tipiDisponibili() {
36
- const tipi = ui.get(site().page.type)?.options ?? [];
35
+ const tipi = Object.entries(site().pages).map(([value, page]) => ({ value, label: page.label }));
37
36
  /* La preselezione: "Pagina" se c'è (è la scelta di gran lunga più frequente), altrimenti la
38
37
  * prima opzione — così questo elenco non può restare senza un valore valido. */
39
38
  const iniziale = tipi.some((t) => t.value === "page") ? "page" : (tipi[0]?.value ?? "page");
@@ -14,7 +14,7 @@ import { SortableContext, arrayMove, rectSortingStrategy, sortableKeyboardCoordi
14
14
  import { CSS } from "@dnd-kit/utilities";
15
15
  import { ProseField, SchemaForm } from "../../form/index.js";
16
16
  import { api } from "../../index.js";
17
- import { site } from "../site.js";
17
+ import { pageType } from "../site.js";
18
18
  import { uniqueSlug } from "../lib/slug";
19
19
  import { proseStats } from "../lib/text";
20
20
  import { Icon } from "../ui/icons";
@@ -163,7 +163,8 @@ function GalleryEditor({ value, onChange, pickMediaFiles, }) {
163
163
  }
164
164
  export function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pickMediaFiles, }) {
165
165
  const [bodyState, setBodyState] = useState("idle");
166
- const showBody = site().hasBody(page.type);
166
+ const definition = pageType(page.type);
167
+ const showBody = definition?.hasBody === true;
167
168
  const isGallery = page.type === "gallery";
168
169
  const isPortfolio = page.type === "portfolio";
169
170
  /* APRENDO UNA PAGINA SI RILEGGE DAL REPO, non dal manifest pubblicato. */
@@ -229,5 +230,5 @@ export function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pic
229
230
  il caso in cui la stessa griglia si vedeva due volte. */
230
231
  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 })] }));
231
232
  }
232
- return (_jsxs("div", { className: "ed", children: [_jsx(SchemaForm, { shape: site().page, 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 }) })] }))] }));
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 }) })] }))] }));
233
234
  }
@@ -1,5 +1,15 @@
1
1
  import type * as z from "zod";
2
2
  export type Shape = Record<string, z.ZodType>;
3
+ /** Contratto di UNA tipologia di pagina. Il tipo sceglie la forma prima che un file venga
4
+ * letto, validato o riscritto: non esiste più uno schema che contenga i campi di tutte le
5
+ * pagine del sito. */
6
+ export interface PageTypeConfig {
7
+ label: string;
8
+ shape: Shape;
9
+ object: z.ZodType;
10
+ payload: z.ZodType;
11
+ hasBody: boolean;
12
+ }
3
13
  export interface SiteAdminConfig {
4
14
  /** Il nome mostrato in cima al rail, e il ripiego del titolo nell'anteprima SEO. */
5
15
  name: string;
@@ -16,14 +26,12 @@ export interface SiteAdminConfig {
16
26
  name: string;
17
27
  email: string;
18
28
  };
19
- /** Forma del front matter di una pagina della collezione. */
20
- page: Shape;
29
+ /** Forme delle pagine della collezione, indicizzate dal loro `type` nel front matter. */
30
+ pages: Record<string, PageTypeConfig>;
21
31
  /** Forma del singleton home: i suoi `group` diventano le voci della seconda colonna. */
22
32
  home: Shape;
23
33
  /** Peso di ordinamento di una pagina data la sua posizione nell'elenco. */
24
34
  positionWeight: (item: Record<string, unknown>, index: number, all: Record<string, unknown>[]) => number;
25
- /** Quali tipi di pagina hanno un corpo markdown oltre al front matter. */
26
- hasBody: (type: unknown) => boolean;
27
35
  /**
28
36
  * Le funzioni aggiuntive accese su questo sito. Ciò che non è dichiarato è SPENTO: un
29
37
  * interruttore dimenticato deve dare una funzione assente, non una accesa per sbaglio.
@@ -35,3 +43,4 @@ export interface SiteAdminConfig {
35
43
  /** Chiamata da `mountAdmin` prima del primo render. */
36
44
  export declare function setSiteConfig(config: SiteAdminConfig): void;
37
45
  export declare function site(): SiteAdminConfig;
46
+ export declare function pageType(type: unknown): PageTypeConfig | null;
package/dist/app/site.js CHANGED
@@ -16,3 +16,6 @@ export function site() {
16
16
  }
17
17
  return current;
18
18
  }
19
+ export function pageType(type) {
20
+ return site().pages[String(type ?? "")] ?? null;
21
+ }
@@ -5,7 +5,7 @@
5
5
  * quando possibile, riscritti durante delete/move dei media.
6
6
  */
7
7
  import { deserializeItem, checkFidelity } from "../../index.js";
8
- import { site } from "../site.js";
8
+ import { site, pageType } from "../site.js";
9
9
  import {
10
10
  getRepoSnapshot, getText, commitUpserts, GitHubConflictError,
11
11
  } from "./github.js";
@@ -36,6 +36,12 @@ function jsonSource(path, text, label) {
36
36
  return { kind: "settings", path, text, label };
37
37
  }
38
38
 
39
+ function typeOf(md) {
40
+ const m = (md || "").match(/^---\n([\s\S]*?)\n---/);
41
+ const t = m && m[1].match(/^type:\s*(.+)$/m);
42
+ return t ? t[1].trim().replace(/^["']|["']$/g, "") : "";
43
+ }
44
+
39
45
  /** Legge tutte le sorgenti dallo stesso commit, senza trasformare errori in file vuoti. */
40
46
  export async function readMediaSources(env) {
41
47
  const snapshot = await getRepoSnapshot(env);
@@ -61,7 +67,9 @@ export async function readMediaSources(env) {
61
67
  }
62
68
  const slug = path.split("/")[1] || "";
63
69
  const extra = { slug };
64
- return frontmatterSource(path, text, pageLabel(text, path, site().page, extra), site().page, site().pageObject, extra);
70
+ const definition = pageType(typeOf(text));
71
+ if (!definition) throw new Error(`${path}: tipo pagina non gestito`);
72
+ return frontmatterSource(path, text, pageLabel(text, path, definition.shape, extra), definition.shape, definition.object, extra);
65
73
  });
66
74
  return { base: { commitSha: snapshot.commitSha, treeSha: snapshot.treeSha }, sources };
67
75
  }
@@ -4,7 +4,13 @@
4
4
  import { json } from "../../_shared/assets.js";
5
5
  import { getText } from "../../_shared/github.js";
6
6
  import { splitFrontMatter, deserializeItem } from "../../../index.js";
7
- import { site } from "../../site.js";
7
+ import { pageType } from "../../site.js";
8
+
9
+ function typeOf(md) {
10
+ const m = (md || "").match(/^---\n([\s\S]*?)\n---/);
11
+ const t = m && m[1].match(/^type:\s*(.+)$/m);
12
+ return t ? t[1].trim().replace(/^["']|["']$/g, "") : "";
13
+ }
8
14
 
9
15
  export async function onRequestGet({ request, env }) {
10
16
  if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return json({ ok: false, error: "backend GitHub non configurato" }, 500);
@@ -20,7 +26,9 @@ export async function onRequestGet({ request, env }) {
20
26
  // Il front matter lo legge lo schema, non uno scanner di righe scritto qui: prima questo
21
27
  // file aveva la sua interpretazione, diversa da quella del serializzatore, e due letture
22
28
  // dello stesso formato sono due occasioni di divergere.
23
- const model = deserializeItem(text, site().page, { slug });
29
+ const definition = pageType(typeOf(text));
30
+ if (!definition) return json({ ok: false, error: "tipo pagina non gestito" }, 404);
31
+ const model = deserializeItem(text, definition.shape, { slug });
24
32
 
25
33
  return json({ ok: true, slug, body, page: model });
26
34
  }
@@ -17,7 +17,7 @@
17
17
  * Secret/vars: GITHUB_TOKEN (secret), GITHUB_REPO, GITHUB_BRANCH.
18
18
  */
19
19
  import { serializeItem, deserializeItem, checkFidelity } from "../../index.js";
20
- import { site } from "../site.js";
20
+ import { site, pageType } from "../site.js";
21
21
  import { getRepoSnapshot, getText, DRAFT } from "../_shared/github.js";
22
22
 
23
23
  const GH = "https://api.github.com";
@@ -90,7 +90,9 @@ export async function onRequestGet({ env }) {
90
90
  // due criteri erano scritti in due posti e coincidevano solo per caso.
91
91
  if (site().excludedTypes.includes(typeOf(text))) return null;
92
92
  const slug = t.path.split("/")[1];
93
- return { slug, url: `/${slug}/`, sha: t.sha, page: deserializeItem(text, site().page, { slug }) };
93
+ const definition = pageType(typeOf(text));
94
+ if (!definition) return null;
95
+ return { slug, url: `/${slug}/`, sha: t.sha, page: deserializeItem(text, definition.shape, { slug }) };
94
96
  }));
95
97
 
96
98
  const pages = letti.filter(Boolean)
@@ -140,7 +142,17 @@ export async function onRequestPost({ request, env, data }) {
140
142
  * risultato del parse la romperebbe in silenzio. Si valida l'oggetto e si serializza quello. */
141
143
  const problems = [];
142
144
  for (const p of pages) {
143
- const r = site().pagePayload.safeParse(p);
145
+ const definition = pageType(p?.type);
146
+ if (!definition) {
147
+ /* Le pagine lasciate intatte viaggiano intenzionalmente come solo riferimento
148
+ * (`slug`, `_orig`, `_sha`, `inMenu`): il loro tipo si ricava dal file esistente più
149
+ * avanti, quando lo stesso giro carica il contenuto da preservare. Una pagina NUOVA,
150
+ * invece, non ha nessun file da cui scoprirlo e deve dichiararlo qui. */
151
+ if (p?.type === undefined && p?._orig) continue;
152
+ problems.push({ slug: typeof p?.slug === "string" ? p.slug : "?", detail: "tipo pagina non gestito" });
153
+ continue;
154
+ }
155
+ const r = definition.payload.safeParse(p);
144
156
  if (r.success) continue;
145
157
  const detail = r.error.issues.slice(0, 5)
146
158
  .map((i) => `${i.path.join(".") || "?"} ${i.message}`).join("; ");
@@ -226,6 +238,7 @@ async function commitPages(args, attempt = 0) {
226
238
  const entries = [];
227
239
  const desiredSlugs = new Set();
228
240
  const rejected = [];
241
+ const invalidTypes = [];
229
242
 
230
243
  for (const p of pages) {
231
244
  desiredSlugs.add(p.slug);
@@ -235,12 +248,14 @@ async function commitPages(args, attempt = 0) {
235
248
  const oldMd = sourceSlug
236
249
  ? await readFile(env, repo, branch, `content/${sourceSlug}/index.md`)
237
250
  : null;
251
+ const definition = pageType(p.type || typeOf(oldMd));
252
+ if (!definition) { invalidTypes.push(p.slug); continue; }
238
253
 
239
254
  // GUARDIA DI FEDELTÀ: se non sappiamo riprodurre il file che stiamo per sovrascrivere,
240
255
  // non lo sovrascriviamo. Intercetta i costrutti che il lettore del front matter non regge
241
256
  // e trasforma una corruzione silenziosa in un rifiuto visibile.
242
257
  if (oldMd) {
243
- const f = checkFidelity(oldMd, site().page, { slug: sourceSlug });
258
+ const f = checkFidelity(oldMd, definition.shape, { slug: sourceSlug });
244
259
  if (!f.ok) { rejected.push(sourceSlug); continue; }
245
260
  }
246
261
 
@@ -249,17 +264,29 @@ async function commitPages(args, attempt = 0) {
249
264
  mode: "100644",
250
265
  type: "blob",
251
266
  content: serializeItem(p, {
252
- shape: site().page,
267
+ shape: definition.shape,
253
268
  oldMd,
254
269
  // Solo per i tipi che un corpo ce l'hanno: su una gallery il segnaposto sarebbe
255
270
  // testo scritto nel repo che non compare da nessuna parte (vedi hasBody).
256
- placeholderBody: site().hasBody(p.type)
271
+ placeholderBody: definition.hasBody
257
272
  ? (m) => `Placeholder content for **${m.title || p.slug}**.`
258
273
  : undefined,
259
274
  }),
260
275
  });
261
276
  }
262
277
 
278
+ if (invalidTypes.length) {
279
+ return {
280
+ status: 400,
281
+ body: {
282
+ ok: false,
283
+ code: "invalid",
284
+ error: `Tipo pagina non gestito per: ${invalidTypes.join(", ")}. Non ho salvato nulla.`,
285
+ slugs: invalidTypes,
286
+ },
287
+ };
288
+ }
289
+
263
290
  if (rejected.length) {
264
291
  return {
265
292
  status: 409,
@@ -36,3 +36,9 @@ export function site() {
36
36
  export function moduleEnabled(name) {
37
37
  return current?.modules?.[name] === true;
38
38
  }
39
+
40
+ /** La stessa scelta di tipo che fa la shell. Tenerla qui evita che endpoint diversi
41
+ * deserializzino, validino e serializzino una pagina con forme differenti. */
42
+ export function pageType(type) {
43
+ return current?.pages?.[String(type ?? "")] ?? null;
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardinali-studio/adminkit",
3
- "version": "1.0.0",
3
+ "version": "2.0.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",