@cardinali-studio/adminkit 1.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/api.d.ts +195 -0
- package/dist/api.js +118 -0
- package/dist/app/App.d.ts +3 -0
- package/dist/app/App.js +320 -0
- package/dist/app/app.css +2068 -0
- package/dist/app/index.d.ts +18 -0
- package/dist/app/index.js +33 -0
- package/dist/app/lib/bookings.d.ts +132 -0
- package/dist/app/lib/bookings.js +79 -0
- package/dist/app/lib/deliveries.d.ts +95 -0
- package/dist/app/lib/deliveries.js +104 -0
- package/dist/app/lib/delivery-drop.d.ts +1 -0
- package/dist/app/lib/delivery-drop.js +72 -0
- package/dist/app/lib/delivery-upload.d.ts +78 -0
- package/dist/app/lib/delivery-upload.js +585 -0
- package/dist/app/lib/groups.d.ts +9 -0
- package/dist/app/lib/groups.js +32 -0
- package/dist/app/lib/home.d.ts +11 -0
- package/dist/app/lib/home.js +67 -0
- package/dist/app/lib/overview.d.ts +82 -0
- package/dist/app/lib/overview.js +68 -0
- package/dist/app/lib/pages.d.ts +24 -0
- package/dist/app/lib/pages.js +156 -0
- package/dist/app/lib/publish.d.ts +18 -0
- package/dist/app/lib/publish.js +63 -0
- package/dist/app/lib/reorder.d.ts +3 -0
- package/dist/app/lib/reorder.js +31 -0
- package/dist/app/lib/requests.d.ts +10 -0
- package/dist/app/lib/requests.js +67 -0
- package/dist/app/lib/router.d.ts +1 -0
- package/dist/app/lib/router.js +14 -0
- package/dist/app/lib/settings.d.ts +29 -0
- package/dist/app/lib/settings.js +82 -0
- package/dist/app/lib/slug.d.ts +3 -0
- package/dist/app/lib/slug.js +23 -0
- package/dist/app/lib/text.d.ts +1 -0
- package/dist/app/lib/text.js +13 -0
- package/dist/app/lib/theme.d.ts +7 -0
- package/dist/app/lib/theme.js +43 -0
- package/dist/app/lib/upload-queue.d.ts +35 -0
- package/dist/app/lib/upload-queue.js +167 -0
- package/dist/app/routes/AboutEditor.d.ts +15 -0
- package/dist/app/routes/AboutEditor.js +122 -0
- package/dist/app/routes/Bookings.d.ts +21 -0
- package/dist/app/routes/Bookings.js +134 -0
- package/dist/app/routes/Deliveries.d.ts +37 -0
- package/dist/app/routes/Deliveries.js +563 -0
- package/dist/app/routes/GallerySectionsEditor.d.ts +12 -0
- package/dist/app/routes/GallerySectionsEditor.js +107 -0
- package/dist/app/routes/HomeEditor.d.ts +10 -0
- package/dist/app/routes/HomeEditor.js +188 -0
- package/dist/app/routes/Overview.d.ts +17 -0
- package/dist/app/routes/Overview.js +318 -0
- package/dist/app/routes/PageActions.d.ts +12 -0
- package/dist/app/routes/PageActions.js +93 -0
- package/dist/app/routes/PageEditor.d.ts +23 -0
- package/dist/app/routes/PageEditor.js +233 -0
- package/dist/app/routes/PageSeoEditor.d.ts +13 -0
- package/dist/app/routes/PageSeoEditor.js +102 -0
- package/dist/app/routes/PagesList.d.ts +10 -0
- package/dist/app/routes/PagesList.js +76 -0
- package/dist/app/routes/PortfolioWorksEditor.d.ts +12 -0
- package/dist/app/routes/PortfolioWorksEditor.js +139 -0
- package/dist/app/routes/Requests.d.ts +13 -0
- package/dist/app/routes/Requests.js +30 -0
- package/dist/app/routes/SiteSettingsEditor.d.ts +21 -0
- package/dist/app/routes/SiteSettingsEditor.js +66 -0
- package/dist/app/routes/UploadDock.d.ts +1 -0
- package/dist/app/routes/UploadDock.js +66 -0
- package/dist/app/routes/WorkshopEditionsEditor.d.ts +12 -0
- package/dist/app/routes/WorkshopEditionsEditor.js +101 -0
- package/dist/app/site.d.ts +37 -0
- package/dist/app/site.js +18 -0
- package/dist/app/ui/MediaSlot.d.ts +10 -0
- package/dist/app/ui/MediaSlot.js +20 -0
- package/dist/app/ui/OpenCard.d.ts +18 -0
- package/dist/app/ui/OpenCard.js +20 -0
- package/dist/app/ui/ParagraphEditor.d.ts +8 -0
- package/dist/app/ui/ParagraphEditor.js +37 -0
- package/dist/app/ui/dnd.d.ts +1 -0
- package/dist/app/ui/dnd.js +11 -0
- package/dist/app/ui/icons.d.ts +26 -0
- package/dist/app/ui/icons.js +45 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +26 -0
- package/dist/form/MediaBrowser.d.ts +13 -0
- package/dist/form/MediaBrowser.js +24 -0
- package/dist/form/MediaPicker.d.ts +10 -0
- package/dist/form/MediaPicker.js +20 -0
- package/dist/form/SchemaForm.d.ts +21 -0
- package/dist/form/SchemaForm.js +67 -0
- package/dist/form/Trash.d.ts +1 -0
- package/dist/form/Trash.js +42 -0
- package/dist/form/fields/index.d.ts +1 -0
- package/dist/form/fields/index.js +110 -0
- package/dist/form/fields/objectList.d.ts +1 -0
- package/dist/form/fields/objectList.js +99 -0
- package/dist/form/index.d.ts +8 -0
- package/dist/form/index.js +8 -0
- package/dist/form/media/Lightbox.d.ts +4 -0
- package/dist/form/media/Lightbox.js +70 -0
- package/dist/form/media/MediaFolders.d.ts +20 -0
- package/dist/form/media/MediaFolders.js +116 -0
- package/dist/form/media/MediaGrid.d.ts +8 -0
- package/dist/form/media/MediaGrid.js +139 -0
- package/dist/form/media/UploadDialog.d.ts +4 -0
- package/dist/form/media/UploadDialog.js +37 -0
- package/dist/form/media/useMediaLibrary.d.ts +55 -0
- package/dist/form/media/useMediaLibrary.js +495 -0
- package/dist/form/prose/ProseBar.d.ts +21 -0
- package/dist/form/prose/ProseBar.js +111 -0
- package/dist/form/prose/ProseField.d.ts +11 -0
- package/dist/form/prose/ProseField.js +152 -0
- package/dist/form/prose/pages.d.ts +6 -0
- package/dist/form/prose/pages.js +13 -0
- package/dist/form/registry.d.ts +20 -0
- package/dist/form/registry.js +15 -0
- package/dist/frontmatter.d.ts +20 -0
- package/dist/frontmatter.js +202 -0
- package/dist/functions/_middleware.js +101 -0
- package/dist/functions/_shared/analytics-ga4.js +248 -0
- package/dist/functions/_shared/analytics-umami.js +119 -0
- package/dist/functions/_shared/assets.js +90 -0
- package/dist/functions/_shared/b2.js +300 -0
- package/dist/functions/_shared/delivery-crypto.js +99 -0
- package/dist/functions/_shared/delivery-purge.js +222 -0
- package/dist/functions/_shared/edge-cache.js +37 -0
- package/dist/functions/_shared/exif.js +53 -0
- package/dist/functions/_shared/github.js +138 -0
- package/dist/functions/_shared/imagesize.js +101 -0
- package/dist/functions/_shared/media-content.js +200 -0
- package/dist/functions/_shared/pending-media.js +98 -0
- package/dist/functions/_shared/refs.js +155 -0
- package/dist/functions/api/analytics.js +106 -0
- package/dist/functions/api/assets/delete.js +241 -0
- package/dist/functions/api/assets/folder.js +43 -0
- package/dist/functions/api/assets/folders.js +32 -0
- package/dist/functions/api/assets/move.js +253 -0
- package/dist/functions/api/assets/preview.js +17 -0
- package/dist/functions/api/assets/trash/delete.js +13 -0
- package/dist/functions/api/assets/trash/empty.js +16 -0
- package/dist/functions/api/assets/trash/restore.js +43 -0
- package/dist/functions/api/assets/trash.js +28 -0
- package/dist/functions/api/assets/upload.js +54 -0
- package/dist/functions/api/assets/usage.js +41 -0
- package/dist/functions/api/assets.js +94 -0
- package/dist/functions/api/deliveries.js +629 -0
- package/dist/functions/api/delivery/hero.js +21 -0
- package/dist/functions/api/delivery/image.js +35 -0
- package/dist/functions/api/delivery/multipart.js +261 -0
- package/dist/functions/api/pages/get.js +26 -0
- package/dist/functions/api/pages.js +354 -0
- package/dist/functions/api/publish.js +134 -0
- package/dist/functions/api/requests.js +57 -0
- package/dist/functions/api/settings.js +236 -0
- package/dist/functions/api/singleton.js +105 -0
- package/dist/functions/api/storage.js +125 -0
- package/dist/functions/index.js +124 -0
- package/dist/functions/site.js +38 -0
- package/dist/gallery.d.ts +28 -0
- package/dist/gallery.js +18 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/model.d.ts +2 -0
- package/dist/model.js +47 -0
- package/dist/normalize.d.ts +50 -0
- package/dist/normalize.js +120 -0
- package/dist/preview.d.ts +3 -0
- package/dist/preview.js +82 -0
- package/dist/prose.d.ts +17 -0
- package/dist/prose.js +205 -0
- package/dist/registries.d.ts +43 -0
- package/dist/registries.js +29 -0
- package/dist/serialize.d.ts +26 -0
- package/dist/serialize.js +136 -0
- package/dist/testing.d.ts +26 -0
- package/dist/testing.js +66 -0
- package/dist/theme.css +636 -0
- package/dist/yaml.d.ts +8 -0
- package/dist/yaml.js +90 -0
- package/package.json +97 -0
package/dist/prose.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/* La conversione fra il markdown salvato e il testo formattato che si vede nel pannello.
|
|
2
|
+
*
|
|
3
|
+
* ── Perché esiste ──────────────────────────────────────────────────────────────────────────
|
|
4
|
+
* Chi usa il pannello non scrive markdown e non deve vederlo: una parola in grassetto si vede
|
|
5
|
+
* in grassetto, non fra due asterischi. Il FILE però resta markdown — è il formato dei
|
|
6
|
+
* contenuti del sito, lo legge Hugo, e non cambia per far comodo a un'interfaccia.
|
|
7
|
+
*
|
|
8
|
+
* Queste due funzioni sono la cerniera, e sono l'unico punto in cui il testo cambia forma:
|
|
9
|
+
* `mdToHtml` per mostrarlo, `htmlToMd` per rimetterlo com'era.
|
|
10
|
+
*
|
|
11
|
+
* ── La regola che le tiene oneste ──────────────────────────────────────────────────────────
|
|
12
|
+
* **`htmlToMd(mdToHtml(x))` deve dare esattamente `x`**, per ogni testo che sta nei contenuti
|
|
13
|
+
* del sito. Non è un'aspirazione: è un test che gira sui file veri
|
|
14
|
+
* (`test/prose-roundtrip.test.js`). Senza, aprire una pagina e salvarla senza toccarla
|
|
15
|
+
* riscriverebbe i testi — cioè il difetto che la guardia di fedeltà del progetto esiste per
|
|
16
|
+
* impedire, spostato dentro l'editor.
|
|
17
|
+
*
|
|
18
|
+
* ── Cosa si converte, e perché solo questo ────────────────────────────────────────────────
|
|
19
|
+
* Grassetto, corsivo, collegamenti e paragrafi: esattamente ciò che la barra sa produrre e che
|
|
20
|
+
* i template rendono (`markdownify` per i campi, `.Content` per i corpi). Titoli, elenchi e
|
|
21
|
+
* citazioni NON si convertono: nei contenuti di questo sito non ce n'è nessuno, e ciò che non
|
|
22
|
+
* si riconosce resta testo letterale — quindi si rilegge e si riscrive identico invece di
|
|
23
|
+
* sparire. Meglio un `# ciao` mostrato com'è che un titolo che l'editor cancella.
|
|
24
|
+
*
|
|
25
|
+
* Nessuna delle due funzioni AGGIUNGE mai `\` di protezione. Un asterisco solitario scritto da
|
|
26
|
+
* chi usa il pannello resta un asterisco solitario nel file: proteggerlo vorrebbe dire
|
|
27
|
+
* cambiare i file esistenti al primo salvataggio, che è il male peggiore.
|
|
28
|
+
*/
|
|
29
|
+
/* ---- Markdown -> HTML ---------------------------------------------------------------------- */
|
|
30
|
+
const escapeHtml = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
31
|
+
/* Un tokenizer solo, con le alternative in ordine di lunghezza: `***` prima di `**` prima di
|
|
32
|
+
* `*`, altrimenti un grassetto verrebbe letto come due corsivi vuoti. Le parti sono chiuse
|
|
33
|
+
* (niente asterischi dentro) di proposito: è il sottoinsieme che la barra produce, e allargarlo
|
|
34
|
+
* significherebbe scrivere un parser markdown accanto a Goldmark. */
|
|
35
|
+
const INLINE = /\[([^\]]*)\]\(([^)\s]*)\)|\*\*\*([^*]+)\*\*\*|\*\*([^*]+)\*\*|\*([^*]+)\*/g;
|
|
36
|
+
function inlineToHtml(md) {
|
|
37
|
+
let out = "";
|
|
38
|
+
let last = 0;
|
|
39
|
+
/* Un'istanza NUOVA a ogni chiamata, non `INLINE` con `lastIndex` azzerato: questa funzione
|
|
40
|
+
richiama sé stessa sull'etichetta di un link, e una regex globale condivisa fra chiamante e
|
|
41
|
+
chiamata si sposta il segnaposto a vicenda — il ciclo esterno riparte da capo e non finisce
|
|
42
|
+
più. Costa un oggetto per paragrafo, non un ciclo infinito. */
|
|
43
|
+
const inline = new RegExp(INLINE.source, "g");
|
|
44
|
+
for (let m = inline.exec(md); m; m = inline.exec(md)) {
|
|
45
|
+
out += escapeHtml(md.slice(last, m.index));
|
|
46
|
+
const [all, linkText, href, both, strong, em] = m;
|
|
47
|
+
if (href !== undefined) {
|
|
48
|
+
// L'etichetta può contenere a sua volta grassetto o corsivo.
|
|
49
|
+
out += `<a href="${escapeHtml(href)}">${inlineToHtml(linkText ?? "")}</a>`;
|
|
50
|
+
}
|
|
51
|
+
else if (both !== undefined) {
|
|
52
|
+
out += `<strong><em>${escapeHtml(both)}</em></strong>`;
|
|
53
|
+
}
|
|
54
|
+
else if (strong !== undefined) {
|
|
55
|
+
out += `<strong>${escapeHtml(strong)}</strong>`;
|
|
56
|
+
}
|
|
57
|
+
else if (em !== undefined) {
|
|
58
|
+
out += `<em>${escapeHtml(em)}</em>`;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
out += escapeHtml(all);
|
|
62
|
+
}
|
|
63
|
+
last = m.index + all.length;
|
|
64
|
+
}
|
|
65
|
+
return out + escapeHtml(md.slice(last));
|
|
66
|
+
}
|
|
67
|
+
/** Il markdown come lo mostra il campo: un `<p>` per paragrafo, `<br>` per l'a capo singolo. */
|
|
68
|
+
export function mdToHtml(md) {
|
|
69
|
+
const paragraphs = String(md ?? "").split("\n\n");
|
|
70
|
+
return paragraphs
|
|
71
|
+
.map((paragraph) => {
|
|
72
|
+
const inner = paragraph.split("\n").map(inlineToHtml).join("<br>");
|
|
73
|
+
/* Un paragrafo vuoto ha comunque bisogno di un `<br>` dentro: un `<p></p>` non ha
|
|
74
|
+
altezza, quindi non si può nemmeno cliccarci dentro per scrivere. */
|
|
75
|
+
return `<p>${inner || "<br>"}</p>`;
|
|
76
|
+
})
|
|
77
|
+
.join("");
|
|
78
|
+
}
|
|
79
|
+
/* ---- HTML -> Markdown ---------------------------------------------------------------------- */
|
|
80
|
+
/* Un mini-parser invece del DOM del browser: così la conversione si prova con `node --test` sui
|
|
81
|
+
* contenuti veri, che è dove sta il rischio. Il sottoinsieme è chiuso — i tag che produciamo noi
|
|
82
|
+
* più quelli che un contenteditable genera da sé — e tutto il resto viene attraversato: di un
|
|
83
|
+
* `<span style=…>` incollato da un altro programma resta il testo, che è ciò che si vuole. */
|
|
84
|
+
const TAG = /<\/?([a-z0-9]+)((?:[^>"']|"[^"]*"|'[^']*')*)>/gi;
|
|
85
|
+
const ATTR = /([a-z-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi;
|
|
86
|
+
const unescapeHtml = (text) => text
|
|
87
|
+
.replace(/ /g, " ")
|
|
88
|
+
.replace(/</g, "<")
|
|
89
|
+
.replace(/>/g, ">")
|
|
90
|
+
.replace(/"/g, '"')
|
|
91
|
+
.replace(/'/g, "'")
|
|
92
|
+
.replace(/'/g, "'")
|
|
93
|
+
.replace(/&/g, "&");
|
|
94
|
+
function attrOf(raw, name) {
|
|
95
|
+
ATTR.lastIndex = 0;
|
|
96
|
+
for (let m = ATTR.exec(raw); m; m = ATTR.exec(raw)) {
|
|
97
|
+
if (m[1]?.toLowerCase() === name)
|
|
98
|
+
return unescapeHtml(m[3] ?? m[4] ?? m[5] ?? "");
|
|
99
|
+
}
|
|
100
|
+
return "";
|
|
101
|
+
}
|
|
102
|
+
/** Gli indirizzi che si possono scrivere in un collegamento.
|
|
103
|
+
*
|
|
104
|
+
* `javascript:` è la ragione per cui questo controllo esiste: il markdown dell'admin finisce in
|
|
105
|
+
* pagine renderizzate con `unsafe = true`, quindi un indirizzo non è un dato inerte. Si
|
|
106
|
+
* accettano gli schemi che servono e i percorsi interni; tutto il resto è rifiutato invece di
|
|
107
|
+
* essere ripulito, perché "ripulire" un indirizzo ostile è un gioco che si perde. */
|
|
108
|
+
export function isSafeHref(href) {
|
|
109
|
+
const value = href.trim();
|
|
110
|
+
if (!value)
|
|
111
|
+
return false;
|
|
112
|
+
if (/^(https?:|mailto:)/i.test(value))
|
|
113
|
+
return true;
|
|
114
|
+
// Interni: `/pagina/`, `#ancora`. Non `//host`, che è un indirizzo assoluto travestito.
|
|
115
|
+
return (value.startsWith("/") && !value.startsWith("//")) || value.startsWith("#");
|
|
116
|
+
}
|
|
117
|
+
/* Gli spazi ai bordi restano FUORI dai delimitatori: `**ciao **` non è grassetto per CommonMark
|
|
118
|
+
— la chiusura preceduta da uno spazio non chiude — e un doppio click si porta via lo spazio
|
|
119
|
+
dopo la parola senza che chi scrive se ne accorga. */
|
|
120
|
+
function wrap(inner, mark) {
|
|
121
|
+
const core = inner.trim();
|
|
122
|
+
if (!core)
|
|
123
|
+
return inner;
|
|
124
|
+
const left = inner.slice(0, inner.length - inner.trimStart().length);
|
|
125
|
+
const right = inner.slice(inner.trimEnd().length);
|
|
126
|
+
return `${left}${mark}${core}${mark}${right}`;
|
|
127
|
+
}
|
|
128
|
+
/** Il testo del campo com'è da salvare: markdown. */
|
|
129
|
+
export function htmlToMd(html) {
|
|
130
|
+
const blocks = [];
|
|
131
|
+
let current = "";
|
|
132
|
+
/* Cosa è aperto in questo momento, per sapere cosa chiudere. Solo la formattazione in linea:
|
|
133
|
+
i blocchi si chiudono da sé finendo. */
|
|
134
|
+
const open = [];
|
|
135
|
+
const emit = (text) => {
|
|
136
|
+
if (open.length)
|
|
137
|
+
open[open.length - 1].text += text;
|
|
138
|
+
else
|
|
139
|
+
current += text;
|
|
140
|
+
};
|
|
141
|
+
const closeBlock = () => {
|
|
142
|
+
// Mentre si chiude un blocco può restare della formattazione aperta (HTML malformato):
|
|
143
|
+
// si chiude anche quella, altrimenti il suo testo sparirebbe.
|
|
144
|
+
while (open.length)
|
|
145
|
+
closeInline(open[open.length - 1].tag);
|
|
146
|
+
const text = current.trim();
|
|
147
|
+
if (text)
|
|
148
|
+
blocks.push(text);
|
|
149
|
+
current = "";
|
|
150
|
+
};
|
|
151
|
+
function closeInline(tag) {
|
|
152
|
+
const index = open.map((item) => item.tag).lastIndexOf(tag);
|
|
153
|
+
if (index < 0)
|
|
154
|
+
return;
|
|
155
|
+
// Chiude anche ciò che è rimasto aperto dentro, dal più interno.
|
|
156
|
+
while (open.length > index) {
|
|
157
|
+
const item = open.pop();
|
|
158
|
+
let text = item.text;
|
|
159
|
+
if (item.tag === "strong")
|
|
160
|
+
text = wrap(text, "**");
|
|
161
|
+
else if (item.tag === "em")
|
|
162
|
+
text = wrap(text, "*");
|
|
163
|
+
else if (item.tag === "a") {
|
|
164
|
+
const href = item.href ?? "";
|
|
165
|
+
// Un indirizzo che non si può scrivere non diventa un link a metà: resta il testo.
|
|
166
|
+
text = isSafeHref(href) && text.trim() ? `[${text}](${href.trim()})` : text;
|
|
167
|
+
}
|
|
168
|
+
emit(text);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
let last = 0;
|
|
172
|
+
TAG.lastIndex = 0;
|
|
173
|
+
const source = String(html ?? "");
|
|
174
|
+
for (let m = TAG.exec(source); m; m = TAG.exec(source)) {
|
|
175
|
+
emit(unescapeHtml(source.slice(last, m.index)));
|
|
176
|
+
last = m.index + m[0].length;
|
|
177
|
+
const closing = m[0].startsWith("</");
|
|
178
|
+
const name = (m[1] ?? "").toLowerCase();
|
|
179
|
+
const selfClosing = m[0].endsWith("/>");
|
|
180
|
+
if (name === "br") {
|
|
181
|
+
emit("\n");
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (name === "p" || name === "div" || name === "li") {
|
|
185
|
+
// `div` e `li` non li produciamo noi: li produce il contenteditable, o un incollaggio.
|
|
186
|
+
closeBlock();
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (selfClosing)
|
|
190
|
+
continue;
|
|
191
|
+
const tag = name === "b" ? "strong" : name === "i" ? "em" : name;
|
|
192
|
+
if (tag !== "strong" && tag !== "em" && tag !== "a")
|
|
193
|
+
continue; // attraversato: resta il testo
|
|
194
|
+
if (closing)
|
|
195
|
+
closeInline(tag);
|
|
196
|
+
else
|
|
197
|
+
open.push({ tag, text: "", href: tag === "a" ? attrOf(m[2] ?? "", "href") : undefined });
|
|
198
|
+
}
|
|
199
|
+
emit(unescapeHtml(source.slice(last)));
|
|
200
|
+
closeBlock();
|
|
201
|
+
/* I blocchi vuoti spariscono: nel pannello sono righe premute per sbaglio, e nel file
|
|
202
|
+
sarebbero righe vuote in più a ogni salvataggio. È la stessa regola già applicata dai campi
|
|
203
|
+
che salvano un elenco di paragrafi. */
|
|
204
|
+
return blocks.join("\n\n");
|
|
205
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
/** Come il campo si presenta nell'admin. */
|
|
3
|
+
export interface UiMeta {
|
|
4
|
+
label: string;
|
|
5
|
+
hint?: string;
|
|
6
|
+
/** Raggruppamento visivo nel form (es. "identità", "visibilità"). */
|
|
7
|
+
group?: string;
|
|
8
|
+
widget: "text" | "textarea" | "paragraphs" | "number" | "boolean" | "select" | "menu" | "r2Image" | "r2Gallery" | "r2Curated" | "objectList" | "hidden";
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
maxLength?: number;
|
|
11
|
+
/** Lo schema Zod del campo. Lo inietta SchemaForm; serve a `objectList` per rendere i
|
|
12
|
+
* figli con lo stesso registro. Non va dichiarato a mano nello schema del sito. */
|
|
13
|
+
schema?: unknown;
|
|
14
|
+
/** Opzioni per `select`. */
|
|
15
|
+
options?: readonly {
|
|
16
|
+
value: string;
|
|
17
|
+
label: string;
|
|
18
|
+
}[];
|
|
19
|
+
}
|
|
20
|
+
/** Contesto passato a encode/decode. */
|
|
21
|
+
export interface CodecCtx {
|
|
22
|
+
/** L'elemento intero: serve ai campi che dipendono da altri (es. il peso dentro `menu`). */
|
|
23
|
+
item: Record<string, unknown>;
|
|
24
|
+
/** Il front matter grezzo del file esistente. */
|
|
25
|
+
raw: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
/** Come il campo vive nel file di contenuto. */
|
|
28
|
+
export interface FmMeta {
|
|
29
|
+
/** Chiavi di primo livello che questo campo POSSIEDE. Da qui si deriva `MANAGED`. */
|
|
30
|
+
keys: readonly string[];
|
|
31
|
+
/** Righe di commento emesse sopra la chiave, quando il file non ne ha già di suoi. */
|
|
32
|
+
preamble?: readonly string[];
|
|
33
|
+
/** Valore -> righe YAML. Array vuoto = non emettere nulla. */
|
|
34
|
+
encode(value: unknown, ctx: CodecCtx): string[];
|
|
35
|
+
/** Front matter grezzo -> valore. */
|
|
36
|
+
decode(raw: Record<string, unknown>, ctx?: Partial<CodecCtx>): unknown;
|
|
37
|
+
merge?: "replace" | "keepIfAbsent";
|
|
38
|
+
/** Valore calcolato dal server, che vince su quello del client (es. peso dalla posizione). */
|
|
39
|
+
derived?: (item: Record<string, unknown>, index: number, all: Record<string, unknown>[]) => unknown;
|
|
40
|
+
}
|
|
41
|
+
export declare const ui: z.core.$ZodRegistry<UiMeta, z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>;
|
|
42
|
+
export declare const fm: z.core.$ZodRegistry<FmMeta, z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>;
|
|
43
|
+
export declare function retired(schema: z.ZodType, keys: readonly string[]): z.ZodType;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* I due registri che accompagnano uno schema Zod.
|
|
2
|
+
*
|
|
3
|
+
* Sono DUE e non uno di proposito. `ui` e `fm` hanno consumatori diversi — il form React da una
|
|
4
|
+
* parte, il serializzatore e il validatore di CI dall'altra — e unirli significherebbe
|
|
5
|
+
* trascinare vocabolario React dentro codice che gira in un Worker e in uno script di CI.
|
|
6
|
+
*
|
|
7
|
+
* L'idea di fondo: un campo si dichiara UNA volta, e porta con sé entrambe le facce. Come si
|
|
8
|
+
* mostra, e come si scrive su disco. Oggi quelle due facce vivono in file diversi che nessuno
|
|
9
|
+
* modifica insieme (l'interruttore in app.js, il blocco YAML in pages.js, il calcolo in
|
|
10
|
+
* index.json), ed è da lì che nasce la possibilità che divergano.
|
|
11
|
+
*/
|
|
12
|
+
import * as z from "zod";
|
|
13
|
+
export const ui = z.registry();
|
|
14
|
+
export const fm = z.registry();
|
|
15
|
+
/* Campo RITIRATO: resta gestito — quindi il serializzatore lo toglie dai file — ma non emette
|
|
16
|
+
* più nulla. È il modo pulito per far sparire un campo che non serve più (`isHome`), perché i
|
|
17
|
+
* file si ripuliscono man mano che vengono salvati.
|
|
18
|
+
* È però un CANCELLATORE INVISIBILE: va usato in un commit dedicato, e il validatore di CI ha
|
|
19
|
+
* un report apposta che elenca cosa è ritirato e quali file lo contengono ancora. */
|
|
20
|
+
export function retired(schema, keys) {
|
|
21
|
+
schema.register(ui, { label: "(ritirato)", widget: "hidden" });
|
|
22
|
+
schema.register(fm, {
|
|
23
|
+
keys,
|
|
24
|
+
encode: () => [],
|
|
25
|
+
decode: () => undefined,
|
|
26
|
+
merge: "replace",
|
|
27
|
+
});
|
|
28
|
+
return schema;
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type * as z from "zod";
|
|
2
|
+
export type Shape = Record<string, z.ZodType>;
|
|
3
|
+
export type Model = Record<string, unknown>;
|
|
4
|
+
/** Le chiavi di front matter che lo schema possiede.
|
|
5
|
+
*
|
|
6
|
+
* Il valore non è la brevità: è che l'insieme delle chiavi gestite e l'insieme delle chiavi
|
|
7
|
+
* EMESSE nascono dalla stessa dichiarazione, quindi non possono divergere. I due modi di
|
|
8
|
+
* sbagliare, entrambi silenziosi, erano: una chiave in MANAGED che nessuno emette (viene
|
|
9
|
+
* cancellata da ogni file al salvataggio successivo) e una chiave emessa e non in MANAGED
|
|
10
|
+
* (chiave YAML duplicata, e Hugo tiene l'ultima). */
|
|
11
|
+
export declare function managedKeys(shape: Shape): Set<string>;
|
|
12
|
+
/** File markdown -> modello. */
|
|
13
|
+
export declare function deserializeItem(md: string | null, shape: Shape, extra?: Model): Model;
|
|
14
|
+
export interface SerializeOptions {
|
|
15
|
+
shape: Shape;
|
|
16
|
+
/** Testo del file esistente, o null se la pagina è nuova. */
|
|
17
|
+
oldMd?: string | null;
|
|
18
|
+
/** Corpo di ripiego per una pagina nuova. */
|
|
19
|
+
placeholderBody?: (model: Model) => string;
|
|
20
|
+
}
|
|
21
|
+
/** Modello -> file markdown. */
|
|
22
|
+
export declare function serializeItem(model: Model, opts: SerializeOptions): string;
|
|
23
|
+
export declare function checkFidelity(md: string, shape: Shape, extra?: Model): {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
got?: string;
|
|
26
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { fm } from "./registries.js";
|
|
2
|
+
import { splitFrontMatter, keptBlocks, parseRawFm, parseBlocks, replaceKeyLine, } from "./frontmatter.js";
|
|
3
|
+
/** Le chiavi di front matter che lo schema possiede.
|
|
4
|
+
*
|
|
5
|
+
* Il valore non è la brevità: è che l'insieme delle chiavi gestite e l'insieme delle chiavi
|
|
6
|
+
* EMESSE nascono dalla stessa dichiarazione, quindi non possono divergere. I due modi di
|
|
7
|
+
* sbagliare, entrambi silenziosi, erano: una chiave in MANAGED che nessuno emette (viene
|
|
8
|
+
* cancellata da ogni file al salvataggio successivo) e una chiave emessa e non in MANAGED
|
|
9
|
+
* (chiave YAML duplicata, e Hugo tiene l'ultima). */
|
|
10
|
+
export function managedKeys(shape) {
|
|
11
|
+
const out = new Set();
|
|
12
|
+
for (const field of Object.values(shape)) {
|
|
13
|
+
for (const k of fm.get(field)?.keys ?? [])
|
|
14
|
+
out.add(k);
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
function metaOf(field, name) {
|
|
19
|
+
const m = fm.get(field);
|
|
20
|
+
if (!m)
|
|
21
|
+
throw new Error(`Campo "${name}" senza metadati fm: va registrato in schema/site.ts`);
|
|
22
|
+
return m;
|
|
23
|
+
}
|
|
24
|
+
/** File markdown -> modello. */
|
|
25
|
+
export function deserializeItem(md, shape, extra = {}) {
|
|
26
|
+
const { fm: fmText, body } = splitFrontMatter(md);
|
|
27
|
+
const raw = parseRawFm(fmText);
|
|
28
|
+
const model = { ...extra };
|
|
29
|
+
for (const [name, field] of Object.entries(shape)) {
|
|
30
|
+
const v = metaOf(field, name).decode(raw, { raw });
|
|
31
|
+
if (v !== undefined)
|
|
32
|
+
model[name] = v;
|
|
33
|
+
}
|
|
34
|
+
if (body != null)
|
|
35
|
+
model.body = body;
|
|
36
|
+
return model;
|
|
37
|
+
}
|
|
38
|
+
/** Modello -> file markdown. */
|
|
39
|
+
export function serializeItem(model, opts) {
|
|
40
|
+
const { shape, oldMd = null } = opts;
|
|
41
|
+
const { fm: fmText, body: oldBody } = splitFrontMatter(oldMd);
|
|
42
|
+
const raw = parseRawFm(fmText);
|
|
43
|
+
const managed = managedKeys(shape);
|
|
44
|
+
const oldBlocks = parseBlocks(fmText);
|
|
45
|
+
const ctx = { item: model, raw };
|
|
46
|
+
const lines = ["---"];
|
|
47
|
+
for (const [name, field] of Object.entries(shape)) {
|
|
48
|
+
const meta = metaOf(field, name);
|
|
49
|
+
const strategy = meta.merge ?? "keepIfAbsent";
|
|
50
|
+
// Assenza != cancellazione. Se il client non ha mandato il campo, si rilegge dal file.
|
|
51
|
+
const present = Object.prototype.hasOwnProperty.call(model, name);
|
|
52
|
+
const value = present ? model[name]
|
|
53
|
+
: strategy === "keepIfAbsent" ? meta.decode(raw, ctx)
|
|
54
|
+
: undefined;
|
|
55
|
+
const out = meta.encode(value, ctx);
|
|
56
|
+
if (!out.length)
|
|
57
|
+
continue;
|
|
58
|
+
// Se il file aveva già questa chiave, si riusa il SUO blocco sostituendo la riga del
|
|
59
|
+
// valore: così restano al loro posto i commenti che la spiegano e la posizione originale.
|
|
60
|
+
// Saltare il blocco e riemettere la chiave altrove è precisamente il bug che ha cancellato
|
|
61
|
+
// la documentazione di `r2Prefix` dalle quattro gallery.
|
|
62
|
+
const existing = meta.keys
|
|
63
|
+
.map((k) => oldBlocks.find((b) => b.key === k))
|
|
64
|
+
.find(Boolean);
|
|
65
|
+
if (existing) {
|
|
66
|
+
const keyRe = new RegExp(`^${existing.key}\\s*:`);
|
|
67
|
+
const keyIdx = existing.lines.findIndex((l) => keyRe.test(l));
|
|
68
|
+
// I commenti che precedono la chiave le APPARTENGONO (li ha attaccati parseBlocks) e
|
|
69
|
+
// vanno riemessi in entrambi i casi. Perderli quando il valore è su più righe era un
|
|
70
|
+
// secondo modo di ripetere il bug che aveva cancellato la documentazione di `r2Prefix`:
|
|
71
|
+
// stessa causa — riscrivere un blocco invece di modificarlo — in una forma diversa.
|
|
72
|
+
const comments = keyIdx > 0 ? existing.lines.slice(0, keyIdx) : [];
|
|
73
|
+
if (out.length === 1 && keyIdx >= 0) {
|
|
74
|
+
// Valore scalare: si sostituisce la RIGA dentro il blocco, così restano al loro posto
|
|
75
|
+
// sia i commenti sia eventuali righe indentate che seguono.
|
|
76
|
+
lines.push(...replaceKeyLine(existing.lines, existing.key, out[0]));
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
// Blocco annidato: si riscrive per intero (la struttura può essere cambiata), ma
|
|
80
|
+
// commenti e righe vuote che lo circondano sopravvivono.
|
|
81
|
+
//
|
|
82
|
+
// Le righe vuote non sono estetica: senza conservarle, un file scritto a mano con le
|
|
83
|
+
// sezioni separate non si riprodurrebbe uguale, la guardia di fedeltà lo
|
|
84
|
+
// dichiarerebbe illeggibile e l'admin si RIFIUTEREBBE di salvarlo. Una spaziatura
|
|
85
|
+
// aggiunta da una persona non deve poter bloccare il pannello.
|
|
86
|
+
const trailing = [];
|
|
87
|
+
for (let k = existing.lines.length - 1; k >= 0 && /^\s*$/.test(existing.lines[k]); k--) {
|
|
88
|
+
trailing.unshift(existing.lines[k]);
|
|
89
|
+
}
|
|
90
|
+
lines.push(...comments, ...out, ...trailing);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
if (meta.preamble)
|
|
95
|
+
lines.push(...meta.preamble);
|
|
96
|
+
lines.push(...out);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Tutto ciò che lo schema non gestisce, verbatim.
|
|
100
|
+
for (const b of keptBlocks(fmText, managed))
|
|
101
|
+
lines.push(...b.lines);
|
|
102
|
+
lines.push("---");
|
|
103
|
+
const raw2 = typeof model.body === "string" && model.body !== ""
|
|
104
|
+
? model.body
|
|
105
|
+
: (oldBody != null && oldBody.trim() !== ""
|
|
106
|
+
? oldBody
|
|
107
|
+
: (opts.placeholderBody?.(model) ?? ""));
|
|
108
|
+
const testo = String(raw2).replace(/^\n+/, "").replace(/\s+$/, "");
|
|
109
|
+
// Senza corpo il file finisce con `---` e basta. Aggiungere comunque le due righe che
|
|
110
|
+
// separano front matter e testo lascerebbe due righe vuote in fondo, e il file non si
|
|
111
|
+
// riprodurrebbe uguale: per il singleton della home — che un corpo non ce l'ha — significa
|
|
112
|
+
// che la guardia di fedeltà lo dichiarerebbe illeggibile e il salvataggio verrebbe rifiutato.
|
|
113
|
+
const body = testo ? "\n" + testo + "\n" : "";
|
|
114
|
+
return lines.join("\n") + "\n" + body;
|
|
115
|
+
}
|
|
116
|
+
/* GUARDIA DI FEDELTÀ.
|
|
117
|
+
*
|
|
118
|
+
* Prima di sovrascrivere un file, si prova a riprodurlo partendo da se stesso. Se il risultato
|
|
119
|
+
* non combacia, vuol dire che quel file contiene qualcosa che non sappiamo rappresentare — un
|
|
120
|
+
* `---` dentro un valore quotato che tronca la regex del front matter, un `#` in colonna zero
|
|
121
|
+
* dentro un blocco di testo che finisce attaccato alla chiave sbagliata — e in quel caso NON lo
|
|
122
|
+
* si tocca.
|
|
123
|
+
*
|
|
124
|
+
* È la voce a più alto valore di tutto il progetto e costa venti righe: trasforma una
|
|
125
|
+
* corruzione silenziosa in un errore visibile, e sposta la sicurezza da "abbiamo controllato
|
|
126
|
+
* bene" a "non può succedere". */
|
|
127
|
+
export function checkFidelity(md, shape, extra = {}) {
|
|
128
|
+
try {
|
|
129
|
+
const model = deserializeItem(md, shape, extra);
|
|
130
|
+
const got = serializeItem(model, { shape, oldMd: md });
|
|
131
|
+
return got === md ? { ok: true } : { ok: false, got };
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
return { ok: false, got: `errore: ${e.message}` };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Una pagina del sito, già decodificata con lo schema del sito. */
|
|
2
|
+
export interface CheckedPage {
|
|
3
|
+
/** Come chiamarla nel messaggio d'errore: lo slug, o il percorso del file. */
|
|
4
|
+
label: string;
|
|
5
|
+
model: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
export interface StabilityIssue {
|
|
8
|
+
page: string;
|
|
9
|
+
field: string;
|
|
10
|
+
detail: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* I punti in cui normalizzare cambierebbe il contenuto già scritto.
|
|
14
|
+
*
|
|
15
|
+
* Un elenco vuoto è la risposta buona. Un elenco pieno significa che aprire quella pagina
|
|
16
|
+
* nell'editor e toccare un campo qualunque riscriverebbe in silenzio anche le voci che nessuno
|
|
17
|
+
* ha toccato — e nel diff comparirebbero righe inspiegabili.
|
|
18
|
+
*/
|
|
19
|
+
export declare function contentStabilityIssues(pages: CheckedPage[]): StabilityIssue[];
|
|
20
|
+
/**
|
|
21
|
+
* Quante pagine hanno davvero qualcosa da verificare.
|
|
22
|
+
*
|
|
23
|
+
* Serve a non farsi ingannare da un elenco vuoto: un test che gira su zero pagine passa sempre
|
|
24
|
+
* e non protegge niente. Ogni sito dovrebbe asserire che questo numero è maggiore di zero.
|
|
25
|
+
*/
|
|
26
|
+
export declare function checkedFieldCount(pages: CheckedPage[]): number;
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/* L'INGRESSO `./testing`: le verifiche che ogni sito deve poter fare sui PROPRI contenuti veri.
|
|
2
|
+
*
|
|
3
|
+
* Perché sta nel pacchetto invece che in ogni repo. La domanda «normalizzare i miei contenuti
|
|
4
|
+
* li cambia?» è per-sito: la risposta dipende dai file di QUEL sito. Ma il modo di porla è
|
|
5
|
+
* identico ovunque, e un controllo identico riscritto a mano in cinque repository è cinque
|
|
6
|
+
* copie che divergono — con l'aggravante che un test divergente non si limita a sbagliare:
|
|
7
|
+
* smette di proteggere e non lo dice.
|
|
8
|
+
*
|
|
9
|
+
* Non è un'ipotesi. Prima che questo esistesse, `scripts/validate-content.mjs` era già stato
|
|
10
|
+
* copiato in due siti e i due file divergevano di oltre duecento righe: lo stesso problema che
|
|
11
|
+
* questo pacchetto risolve un livello sopra, ripetuto un livello sotto dove nessuno guardava.
|
|
12
|
+
*
|
|
13
|
+
* React-free di proposito: gira in `node --test`, e i normalizzatori vivono in `normalize.ts`
|
|
14
|
+
* apposta per non trascinare l'interfaccia dentro una suite di test.
|
|
15
|
+
*/
|
|
16
|
+
import { normalizeCredits, normalizeWorks, normalizeWorkshops, normalizeSections, } from "./normalize.js";
|
|
17
|
+
/* I campi che un editor rimanda indietro NORMALIZZATI invece che com'erano. Aggiungerne uno qui
|
|
18
|
+
* lo fa verificare su tutti i siti insieme — che è tutto il punto di avere questo in un posto
|
|
19
|
+
* solo. */
|
|
20
|
+
const CHECKS = [
|
|
21
|
+
{ field: "credits", normalize: normalizeCredits },
|
|
22
|
+
{ field: "works", normalize: normalizeWorks },
|
|
23
|
+
{ field: "workshops", normalize: normalizeWorkshops },
|
|
24
|
+
{ field: "sections", normalize: normalizeSections },
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* I punti in cui normalizzare cambierebbe il contenuto già scritto.
|
|
28
|
+
*
|
|
29
|
+
* Un elenco vuoto è la risposta buona. Un elenco pieno significa che aprire quella pagina
|
|
30
|
+
* nell'editor e toccare un campo qualunque riscriverebbe in silenzio anche le voci che nessuno
|
|
31
|
+
* ha toccato — e nel diff comparirebbero righe inspiegabili.
|
|
32
|
+
*/
|
|
33
|
+
export function contentStabilityIssues(pages) {
|
|
34
|
+
const issues = [];
|
|
35
|
+
for (const { label, model } of pages) {
|
|
36
|
+
for (const { field, normalize } of CHECKS) {
|
|
37
|
+
const original = model[field];
|
|
38
|
+
if (original === undefined)
|
|
39
|
+
continue;
|
|
40
|
+
const normalized = normalize(original);
|
|
41
|
+
if (JSON.stringify(normalized) !== JSON.stringify(original)) {
|
|
42
|
+
issues.push({
|
|
43
|
+
page: label,
|
|
44
|
+
field,
|
|
45
|
+
detail: `normalizzare «${field}» cambia il contenuto già scritto`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return issues;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Quante pagine hanno davvero qualcosa da verificare.
|
|
54
|
+
*
|
|
55
|
+
* Serve a non farsi ingannare da un elenco vuoto: un test che gira su zero pagine passa sempre
|
|
56
|
+
* e non protegge niente. Ogni sito dovrebbe asserire che questo numero è maggiore di zero.
|
|
57
|
+
*/
|
|
58
|
+
export function checkedFieldCount(pages) {
|
|
59
|
+
let n = 0;
|
|
60
|
+
for (const { model } of pages) {
|
|
61
|
+
for (const { field } of CHECKS)
|
|
62
|
+
if (model[field] !== undefined)
|
|
63
|
+
n++;
|
|
64
|
+
}
|
|
65
|
+
return n;
|
|
66
|
+
}
|