@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.
- package/dist/app/routes/PageActions.js +1 -2
- package/dist/app/routes/PageEditor.js +4 -3
- package/dist/app/site.d.ts +13 -4
- package/dist/app/site.js +3 -0
- package/dist/functions/_shared/media-content.js +10 -2
- package/dist/functions/api/pages/get.js +10 -2
- package/dist/functions/api/pages.js +33 -6
- package/dist/functions/site.js +6 -0
- package/package.json +1 -1
|
@@ -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 =
|
|
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 {
|
|
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
|
|
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:
|
|
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
|
}
|
package/dist/app/site.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
20
|
-
|
|
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
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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:
|
|
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:
|
|
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,
|
package/dist/functions/site.js
CHANGED
|
@@ -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": "
|
|
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",
|