@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.
Files changed (181) hide show
  1. package/dist/api.d.ts +195 -0
  2. package/dist/api.js +118 -0
  3. package/dist/app/App.d.ts +3 -0
  4. package/dist/app/App.js +320 -0
  5. package/dist/app/app.css +2068 -0
  6. package/dist/app/index.d.ts +18 -0
  7. package/dist/app/index.js +33 -0
  8. package/dist/app/lib/bookings.d.ts +132 -0
  9. package/dist/app/lib/bookings.js +79 -0
  10. package/dist/app/lib/deliveries.d.ts +95 -0
  11. package/dist/app/lib/deliveries.js +104 -0
  12. package/dist/app/lib/delivery-drop.d.ts +1 -0
  13. package/dist/app/lib/delivery-drop.js +72 -0
  14. package/dist/app/lib/delivery-upload.d.ts +78 -0
  15. package/dist/app/lib/delivery-upload.js +585 -0
  16. package/dist/app/lib/groups.d.ts +9 -0
  17. package/dist/app/lib/groups.js +32 -0
  18. package/dist/app/lib/home.d.ts +11 -0
  19. package/dist/app/lib/home.js +67 -0
  20. package/dist/app/lib/overview.d.ts +82 -0
  21. package/dist/app/lib/overview.js +68 -0
  22. package/dist/app/lib/pages.d.ts +24 -0
  23. package/dist/app/lib/pages.js +156 -0
  24. package/dist/app/lib/publish.d.ts +18 -0
  25. package/dist/app/lib/publish.js +63 -0
  26. package/dist/app/lib/reorder.d.ts +3 -0
  27. package/dist/app/lib/reorder.js +31 -0
  28. package/dist/app/lib/requests.d.ts +10 -0
  29. package/dist/app/lib/requests.js +67 -0
  30. package/dist/app/lib/router.d.ts +1 -0
  31. package/dist/app/lib/router.js +14 -0
  32. package/dist/app/lib/settings.d.ts +29 -0
  33. package/dist/app/lib/settings.js +82 -0
  34. package/dist/app/lib/slug.d.ts +3 -0
  35. package/dist/app/lib/slug.js +23 -0
  36. package/dist/app/lib/text.d.ts +1 -0
  37. package/dist/app/lib/text.js +13 -0
  38. package/dist/app/lib/theme.d.ts +7 -0
  39. package/dist/app/lib/theme.js +43 -0
  40. package/dist/app/lib/upload-queue.d.ts +35 -0
  41. package/dist/app/lib/upload-queue.js +167 -0
  42. package/dist/app/routes/AboutEditor.d.ts +15 -0
  43. package/dist/app/routes/AboutEditor.js +122 -0
  44. package/dist/app/routes/Bookings.d.ts +21 -0
  45. package/dist/app/routes/Bookings.js +134 -0
  46. package/dist/app/routes/Deliveries.d.ts +37 -0
  47. package/dist/app/routes/Deliveries.js +563 -0
  48. package/dist/app/routes/GallerySectionsEditor.d.ts +12 -0
  49. package/dist/app/routes/GallerySectionsEditor.js +107 -0
  50. package/dist/app/routes/HomeEditor.d.ts +10 -0
  51. package/dist/app/routes/HomeEditor.js +188 -0
  52. package/dist/app/routes/Overview.d.ts +17 -0
  53. package/dist/app/routes/Overview.js +318 -0
  54. package/dist/app/routes/PageActions.d.ts +12 -0
  55. package/dist/app/routes/PageActions.js +93 -0
  56. package/dist/app/routes/PageEditor.d.ts +23 -0
  57. package/dist/app/routes/PageEditor.js +233 -0
  58. package/dist/app/routes/PageSeoEditor.d.ts +13 -0
  59. package/dist/app/routes/PageSeoEditor.js +102 -0
  60. package/dist/app/routes/PagesList.d.ts +10 -0
  61. package/dist/app/routes/PagesList.js +76 -0
  62. package/dist/app/routes/PortfolioWorksEditor.d.ts +12 -0
  63. package/dist/app/routes/PortfolioWorksEditor.js +139 -0
  64. package/dist/app/routes/Requests.d.ts +13 -0
  65. package/dist/app/routes/Requests.js +30 -0
  66. package/dist/app/routes/SiteSettingsEditor.d.ts +21 -0
  67. package/dist/app/routes/SiteSettingsEditor.js +66 -0
  68. package/dist/app/routes/UploadDock.d.ts +1 -0
  69. package/dist/app/routes/UploadDock.js +66 -0
  70. package/dist/app/routes/WorkshopEditionsEditor.d.ts +12 -0
  71. package/dist/app/routes/WorkshopEditionsEditor.js +101 -0
  72. package/dist/app/site.d.ts +37 -0
  73. package/dist/app/site.js +18 -0
  74. package/dist/app/ui/MediaSlot.d.ts +10 -0
  75. package/dist/app/ui/MediaSlot.js +20 -0
  76. package/dist/app/ui/OpenCard.d.ts +18 -0
  77. package/dist/app/ui/OpenCard.js +20 -0
  78. package/dist/app/ui/ParagraphEditor.d.ts +8 -0
  79. package/dist/app/ui/ParagraphEditor.js +37 -0
  80. package/dist/app/ui/dnd.d.ts +1 -0
  81. package/dist/app/ui/dnd.js +11 -0
  82. package/dist/app/ui/icons.d.ts +26 -0
  83. package/dist/app/ui/icons.js +45 -0
  84. package/dist/config.d.ts +5 -0
  85. package/dist/config.js +26 -0
  86. package/dist/form/MediaBrowser.d.ts +13 -0
  87. package/dist/form/MediaBrowser.js +24 -0
  88. package/dist/form/MediaPicker.d.ts +10 -0
  89. package/dist/form/MediaPicker.js +20 -0
  90. package/dist/form/SchemaForm.d.ts +21 -0
  91. package/dist/form/SchemaForm.js +67 -0
  92. package/dist/form/Trash.d.ts +1 -0
  93. package/dist/form/Trash.js +42 -0
  94. package/dist/form/fields/index.d.ts +1 -0
  95. package/dist/form/fields/index.js +110 -0
  96. package/dist/form/fields/objectList.d.ts +1 -0
  97. package/dist/form/fields/objectList.js +99 -0
  98. package/dist/form/index.d.ts +8 -0
  99. package/dist/form/index.js +8 -0
  100. package/dist/form/media/Lightbox.d.ts +4 -0
  101. package/dist/form/media/Lightbox.js +70 -0
  102. package/dist/form/media/MediaFolders.d.ts +20 -0
  103. package/dist/form/media/MediaFolders.js +116 -0
  104. package/dist/form/media/MediaGrid.d.ts +8 -0
  105. package/dist/form/media/MediaGrid.js +139 -0
  106. package/dist/form/media/UploadDialog.d.ts +4 -0
  107. package/dist/form/media/UploadDialog.js +37 -0
  108. package/dist/form/media/useMediaLibrary.d.ts +55 -0
  109. package/dist/form/media/useMediaLibrary.js +495 -0
  110. package/dist/form/prose/ProseBar.d.ts +21 -0
  111. package/dist/form/prose/ProseBar.js +111 -0
  112. package/dist/form/prose/ProseField.d.ts +11 -0
  113. package/dist/form/prose/ProseField.js +152 -0
  114. package/dist/form/prose/pages.d.ts +6 -0
  115. package/dist/form/prose/pages.js +13 -0
  116. package/dist/form/registry.d.ts +20 -0
  117. package/dist/form/registry.js +15 -0
  118. package/dist/frontmatter.d.ts +20 -0
  119. package/dist/frontmatter.js +202 -0
  120. package/dist/functions/_middleware.js +101 -0
  121. package/dist/functions/_shared/analytics-ga4.js +248 -0
  122. package/dist/functions/_shared/analytics-umami.js +119 -0
  123. package/dist/functions/_shared/assets.js +90 -0
  124. package/dist/functions/_shared/b2.js +300 -0
  125. package/dist/functions/_shared/delivery-crypto.js +99 -0
  126. package/dist/functions/_shared/delivery-purge.js +222 -0
  127. package/dist/functions/_shared/edge-cache.js +37 -0
  128. package/dist/functions/_shared/exif.js +53 -0
  129. package/dist/functions/_shared/github.js +138 -0
  130. package/dist/functions/_shared/imagesize.js +101 -0
  131. package/dist/functions/_shared/media-content.js +200 -0
  132. package/dist/functions/_shared/pending-media.js +98 -0
  133. package/dist/functions/_shared/refs.js +155 -0
  134. package/dist/functions/api/analytics.js +106 -0
  135. package/dist/functions/api/assets/delete.js +241 -0
  136. package/dist/functions/api/assets/folder.js +43 -0
  137. package/dist/functions/api/assets/folders.js +32 -0
  138. package/dist/functions/api/assets/move.js +253 -0
  139. package/dist/functions/api/assets/preview.js +17 -0
  140. package/dist/functions/api/assets/trash/delete.js +13 -0
  141. package/dist/functions/api/assets/trash/empty.js +16 -0
  142. package/dist/functions/api/assets/trash/restore.js +43 -0
  143. package/dist/functions/api/assets/trash.js +28 -0
  144. package/dist/functions/api/assets/upload.js +54 -0
  145. package/dist/functions/api/assets/usage.js +41 -0
  146. package/dist/functions/api/assets.js +94 -0
  147. package/dist/functions/api/deliveries.js +629 -0
  148. package/dist/functions/api/delivery/hero.js +21 -0
  149. package/dist/functions/api/delivery/image.js +35 -0
  150. package/dist/functions/api/delivery/multipart.js +261 -0
  151. package/dist/functions/api/pages/get.js +26 -0
  152. package/dist/functions/api/pages.js +354 -0
  153. package/dist/functions/api/publish.js +134 -0
  154. package/dist/functions/api/requests.js +57 -0
  155. package/dist/functions/api/settings.js +236 -0
  156. package/dist/functions/api/singleton.js +105 -0
  157. package/dist/functions/api/storage.js +125 -0
  158. package/dist/functions/index.js +124 -0
  159. package/dist/functions/site.js +38 -0
  160. package/dist/gallery.d.ts +28 -0
  161. package/dist/gallery.js +18 -0
  162. package/dist/index.d.ts +10 -0
  163. package/dist/index.js +10 -0
  164. package/dist/model.d.ts +2 -0
  165. package/dist/model.js +47 -0
  166. package/dist/normalize.d.ts +50 -0
  167. package/dist/normalize.js +120 -0
  168. package/dist/preview.d.ts +3 -0
  169. package/dist/preview.js +82 -0
  170. package/dist/prose.d.ts +17 -0
  171. package/dist/prose.js +205 -0
  172. package/dist/registries.d.ts +43 -0
  173. package/dist/registries.js +29 -0
  174. package/dist/serialize.d.ts +26 -0
  175. package/dist/serialize.js +136 -0
  176. package/dist/testing.d.ts +26 -0
  177. package/dist/testing.js +66 -0
  178. package/dist/theme.css +636 -0
  179. package/dist/yaml.d.ts +8 -0
  180. package/dist/yaml.js +90 -0
  181. package/package.json +97 -0
@@ -0,0 +1,152 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /* Il campo in cui si scrive la prosa del sito: testo formattato, non markdown.
3
+ *
4
+ * ── Cosa è cambiato, e perché ──────────────────────────────────────────────────────────────
5
+ * Era una `<textarea>` che mostrava `**parola**`. Chi usa il pannello non scrive markdown e non
6
+ * ha ragione di imparare cosa significhino quegli asterischi: adesso una parola in grassetto si
7
+ * vede in grassetto, un link si vede come un link. Il FILE resta markdown — è il formato dei
8
+ * contenuti, lo legge Hugo — e la conversione nei due sensi vive in `prose.ts`, provata sui
9
+ * testi veri del sito (`test/prose-roundtrip.test.js`).
10
+ *
11
+ * Il prezzo, dichiarato: un `contenteditable` è un pezzo di DOM che l'utente modifica
12
+ * direttamente, quindi il testo deve essere riletto da lì a ogni battuta e ritradotto. È la cosa
13
+ * che questo componente fa, e l'unica ragione per cui esiste.
14
+ *
15
+ * ── Il valore fuori e il DOM dentro ────────────────────────────────────────────────────────
16
+ * Il DOM NON si riscrive a ogni render: si tocca solo quando il valore che arriva da fuori dice
17
+ * qualcosa di diverso da quello che c'è dentro (confronto fra markdown, non fra HTML). Scrivere
18
+ * `innerHTML` a ogni battuta rimonterebbe i nodi sotto il cursore, che salterebbe alla fine del
19
+ * campo a ogni lettera — il difetto classico di questo tipo di editor.
20
+ *
21
+ * ── Le decisioni ───────────────────────────────────────────────────────────────────────────
22
+ * - **Si incolla come testo semplice.** Copiando da un elaboratore di testi arriverebbe HTML
23
+ * con font, colori e classi: `htmlToMd` lo attraverserebbe comunque, ma tanto vale non farlo
24
+ * entrare. Quello che si vuole incollando è il testo.
25
+ * - **I comandi li esegue il browser** (`execCommand`), non il nostro codice. È un'API
26
+ * deprecata e lo sappiamo; è anche l'unica che spezza e ricuce i nodi di una selezione
27
+ * qualunque senza che si debba scrivere un editor. Il risultato passa comunque da `htmlToMd`,
28
+ * che accetta sia `<b>` sia `<strong>` proprio perché i browser non concordano su quale usare.
29
+ * - **`maxLength` si applica prima dell'inserimento** (`beforeinput`), non tagliando dopo:
30
+ * troncare un testo già scritto è il modo di far sparire una frase senza dirlo.
31
+ */
32
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
33
+ import { htmlToMd, mdToHtml } from "../../prose.js";
34
+ import { ProseBar } from "./ProseBar.js";
35
+ function linkAt(node, root) {
36
+ for (let cursor = node; cursor && cursor !== root; cursor = cursor.parentNode) {
37
+ if (cursor.tagName === "A")
38
+ return cursor.getAttribute("href");
39
+ }
40
+ return null;
41
+ }
42
+ export function ProseField({ value, onChange, className, rows = 6, placeholder, maxLength }) {
43
+ const ref = useRef(null);
44
+ const [spot, setSpot] = useState(null);
45
+ const [marks, setMarks] = useState({ bold: false, italic: false, href: null });
46
+ /* Mentre il dialogo del collegamento è aperto il campo NON ha il fuoco: la selezione va
47
+ tenuta in vita, altrimenti al momento di scrivere il link non c'è più niente di selezionato. */
48
+ const holding = useRef(false);
49
+ const held = useRef(null);
50
+ /* Il DOM si riallinea al valore esterno solo quando i due dicono cose diverse. Il confronto è
51
+ fra MARKDOWN: `mdToHtml(value)` e l'HTML attuale differiscono quasi sempre per dettagli che
52
+ non cambiano il testo (un `<b>` invece di `<strong>`, un `<div>` invece di un `<p>`), e
53
+ confrontare quelli vorrebbe dire riscrivere il campo di continuo. */
54
+ useLayoutEffect(() => {
55
+ const field = ref.current;
56
+ if (!field)
57
+ return;
58
+ if (htmlToMd(field.innerHTML) === value)
59
+ return;
60
+ field.innerHTML = mdToHtml(value);
61
+ }, [value]);
62
+ const readSelection = useCallback(() => {
63
+ const field = ref.current;
64
+ if (!field || holding.current)
65
+ return;
66
+ const selection = document.getSelection();
67
+ if (!selection || !selection.rangeCount || !field.contains(selection.anchorNode)) {
68
+ setSpot(null);
69
+ return;
70
+ }
71
+ const range = selection.getRangeAt(0);
72
+ if (range.collapsed) {
73
+ // Niente di selezionato: niente barra. Lo stato dei comandi serve solo quando c'è.
74
+ setSpot(null);
75
+ return;
76
+ }
77
+ /* Il PRIMO rettangolo: una selezione su tre righe ne ha tre, e la barra va sopra la prima
78
+ riga — non sopra la media delle tre. */
79
+ const rects = range.getClientRects();
80
+ const first = rects[0] ?? range.getBoundingClientRect();
81
+ const box = field.getBoundingClientRect();
82
+ if (first.bottom < box.top || first.top > box.bottom) {
83
+ setSpot(null); // selezione scorsa fuori dal campo: non c'è niente da sormontare
84
+ return;
85
+ }
86
+ setSpot({
87
+ x: first.left + first.width / 2,
88
+ top: Math.max(first.top, box.top),
89
+ bottom: Math.min(first.bottom, box.bottom),
90
+ });
91
+ setMarks({
92
+ bold: document.queryCommandState("bold"),
93
+ italic: document.queryCommandState("italic"),
94
+ href: linkAt(selection.anchorNode, field),
95
+ });
96
+ }, []);
97
+ /* `selectionchange` sta sul documento — non esiste un evento di selezione sull'elemento — e
98
+ scorrendo la barra segue la selezione o sparisce con lei. */
99
+ useEffect(() => {
100
+ const update = () => readSelection();
101
+ document.addEventListener("selectionchange", update);
102
+ addEventListener("scroll", update, true);
103
+ addEventListener("resize", update);
104
+ return () => {
105
+ document.removeEventListener("selectionchange", update);
106
+ removeEventListener("scroll", update, true);
107
+ removeEventListener("resize", update);
108
+ };
109
+ }, [readSelection]);
110
+ const publish = () => {
111
+ const field = ref.current;
112
+ if (!field)
113
+ return;
114
+ onChange(htmlToMd(field.innerHTML));
115
+ };
116
+ /* Il comando agisce sulla selezione che c'era prima che il dialogo prendesse il fuoco: la si
117
+ rimette prima di eseguirlo, altrimenti `execCommand` non trova niente su cui lavorare. */
118
+ const restore = () => {
119
+ const field = ref.current;
120
+ const range = held.current;
121
+ if (!field || !range)
122
+ return;
123
+ field.focus();
124
+ const selection = document.getSelection();
125
+ selection?.removeAllRanges();
126
+ selection?.addRange(range);
127
+ };
128
+ const run = (command, argument) => {
129
+ restore();
130
+ document.execCommand(command, false, argument);
131
+ publish();
132
+ readSelection();
133
+ };
134
+ const empty = !value.trim();
135
+ return (_jsxs(_Fragment, { children: [_jsx("div", { ref: ref, className: [className, "ak-prose-field", empty ? "is-empty" : ""].filter(Boolean).join(" "), contentEditable: true, suppressContentEditableWarning: true, role: "textbox", "aria-multiline": "true", "data-placeholder": placeholder, style: { minHeight: `${rows * 1.75}em` }, onInput: publish, onBlur: () => { if (!holding.current)
136
+ setSpot(null); }, onPaste: (event) => {
137
+ event.preventDefault();
138
+ const text = event.clipboardData.getData("text/plain");
139
+ if (text)
140
+ document.execCommand("insertText", false, text);
141
+ }, onBeforeInput: (event) => {
142
+ if (!maxLength || !ref.current)
143
+ return;
144
+ const inserting = !String(event.inputType ?? "").startsWith("delete");
145
+ if (inserting && htmlToMd(ref.current.innerHTML).length >= maxLength)
146
+ event.preventDefault();
147
+ } }), spot && (_jsx(ProseBar, { spot: spot, bold: marks.bold, italic: marks.italic, href: marks.href, onBold: () => run("bold"), onItalic: () => run("italic"), onLink: (href) => run("createLink", href), onUnlink: () => run("unlink"), onHold: (value_) => {
148
+ holding.current = value_;
149
+ const selection = document.getSelection();
150
+ held.current = value_ && selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null;
151
+ } }))] }));
152
+ }
@@ -0,0 +1,6 @@
1
+ /** Una destinazione interna: come si chiama e dove porta. */
2
+ export interface ProsePageLink {
3
+ label: string;
4
+ href: string;
5
+ }
6
+ export declare const ProsePages: import("react").Context<ProsePageLink[]>;
@@ -0,0 +1,13 @@
1
+ /* Le pagine a cui un link può puntare.
2
+ *
3
+ * Un contesto e non una prop: la barra di formattazione compare dentro widget annidati anche di
4
+ * tre livelli (le card di un lavoro, i blocchi di un'edizione), e farsi passare l'elenco di mano
5
+ * in mano vorrebbe dire aggiungere un parametro a ogni componente attraversato — compresi quelli
6
+ * che con i link non c'entrano niente.
7
+ *
8
+ * Il valore predefinito è l'elenco vuoto, e non è un ripiego: significa che la scheda «Pagina»
9
+ * dice che non ci sono pagine invece di rompersi. Un form montato fuori dal pannello (un test,
10
+ * un altro contesto) continua a funzionare, senza link interni.
11
+ */
12
+ import { createContext } from "react";
13
+ export const ProsePages = createContext([]);
@@ -0,0 +1,20 @@
1
+ import type { UiMeta } from "../registries.js";
2
+ import type { AssetFile } from "../api.js";
3
+ export interface FieldProps {
4
+ name: string;
5
+ meta: UiMeta;
6
+ value: unknown;
7
+ onChange: (v: unknown) => void;
8
+ error?: string;
9
+ /** Aperto dai widget che scelgono media e vogliono solo il percorso (r2Image, r2Gallery). */
10
+ pickMedia?: (opts: {
11
+ multiple: boolean;
12
+ }) => Promise<string[] | null>;
13
+ pickMediaFiles?: (opts: {
14
+ multiple: boolean;
15
+ }) => Promise<AssetFile[] | null>;
16
+ }
17
+ export type FieldComponent = (props: FieldProps) => React.ReactElement | null;
18
+ export declare function registerField(widget: string, comp: FieldComponent): void;
19
+ export declare function fieldFor(widget: string): FieldComponent;
20
+ export declare function knownWidgets(): string[];
@@ -0,0 +1,15 @@
1
+ const REGISTRY = new Map();
2
+ export function registerField(widget, comp) {
3
+ REGISTRY.set(widget, comp);
4
+ }
5
+ export function fieldFor(widget) {
6
+ const c = REGISTRY.get(widget);
7
+ if (!c) {
8
+ throw new Error(`Widget "${widget}" non registrato. I widget disponibili sono: ${[...REGISTRY.keys()].join(", ")}. ` +
9
+ `Se ne serve uno nuovo va aggiunto a adminkit — così lo ottengono tutti i siti.`);
10
+ }
11
+ return c;
12
+ }
13
+ export function knownWidgets() {
14
+ return [...REGISTRY.keys()].sort();
15
+ }
@@ -0,0 +1,20 @@
1
+ /** Un blocco = una chiave di primo livello con tutte le righe che le appartengono:
2
+ * i commenti che la precedono, la riga stessa, e le righe indentate o di lista che seguono. */
3
+ export type FmBlock = {
4
+ key: string;
5
+ lines: string[];
6
+ };
7
+ /** Separa front matter e corpo. `fm` è null se il file non ne ha uno. */
8
+ export declare function splitFrontMatter(md: string | null | undefined): {
9
+ fm: string | null;
10
+ body: string | null;
11
+ };
12
+ /** Spezza il front matter in blocchi, nell'ordine in cui compaiono. */
13
+ export declare function parseBlocks(fm: string | null): FmBlock[];
14
+ /** I blocchi che lo schema NON gestisce, da ricopiare tali e quali.
15
+ * È l'invariante del merge: ciò che l'admin non conosce sopravvive per costruzione. */
16
+ export declare function keptBlocks(fm: string | null, managed: ReadonlySet<string>): FmBlock[];
17
+ export declare function trimTrailingBlank(lines: string[]): string[];
18
+ export declare function replaceKeyLine(lines: string[], key: string, newLine: string): string[];
19
+ export type RawFm = Record<string, unknown>;
20
+ export declare function parseRawFm(fm: string | null): RawFm;
@@ -0,0 +1,202 @@
1
+ /* Lettura del front matter dei file di contenuto.
2
+ *
3
+ * NON è un parser YAML, ed è deliberato. Un parser vero perde due cose che qui contano più
4
+ * della correttezza formale: i COMMENTI e l'ORDINE delle chiavi. I file di contenuto sono
5
+ * scritti e riletti da persone, e un campo senza la riga che lo spiega è un campo che qualcuno
6
+ * romperà. Qui serve solo decidere quali righe tenere e quali rigenerare, e la struttura di
7
+ * questi file è piatta e la scriviamo noi.
8
+ *
9
+ * Il codice viene da functions/admin/api/pages.js, dove ha già retto due incidenti; è spostato
10
+ * con la stessa logica, non riscritto. L'unica differenza è che `keptFrontMatter` non chiude
11
+ * più su una costante di modulo ma riceve l'insieme delle chiavi gestite, che ora è DERIVATO
12
+ * dallo schema (vedi serialize.ts).
13
+ */
14
+ const FM_RE = /^---\n([\s\S]*?)\n---/;
15
+ /** Separa front matter e corpo. `fm` è null se il file non ne ha uno. */
16
+ export function splitFrontMatter(md) {
17
+ if (!md)
18
+ return { fm: null, body: null };
19
+ const m = md.match(FM_RE);
20
+ if (!m)
21
+ return { fm: null, body: md };
22
+ const after = md.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
23
+ return { fm: m[1], body: after ? after[1] : null };
24
+ }
25
+ /** Spezza il front matter in blocchi, nell'ordine in cui compaiono. */
26
+ export function parseBlocks(fm) {
27
+ if (!fm)
28
+ return [];
29
+ const blocks = [];
30
+ let cur = null;
31
+ let pending = [];
32
+ for (const line of fm.split("\n")) {
33
+ if (/^\s*$/.test(line)) {
34
+ if (cur)
35
+ cur.lines.push(line);
36
+ else
37
+ pending.push(line);
38
+ continue;
39
+ }
40
+ // Un commento appartiene a ciò che SEGUE: è lì per spiegare la chiave sotto di sé.
41
+ if (/^\s*#/.test(line)) {
42
+ pending.push(line);
43
+ continue;
44
+ }
45
+ const top = line.match(/^([A-Za-z_][\w-]*)\s*:/);
46
+ if (top) {
47
+ cur = { key: top[1], lines: pending.concat([line]) };
48
+ pending = [];
49
+ blocks.push(cur);
50
+ }
51
+ else if (cur) {
52
+ cur.lines.push(line); // riga indentata o elemento di lista
53
+ }
54
+ }
55
+ return blocks;
56
+ }
57
+ /** I blocchi che lo schema NON gestisce, da ricopiare tali e quali.
58
+ * È l'invariante del merge: ciò che l'admin non conosce sopravvive per costruzione. */
59
+ export function keptBlocks(fm, managed) {
60
+ return parseBlocks(fm)
61
+ .filter((b) => !managed.has(b.key))
62
+ // via le righe vuote in coda, che altrimenti si accumulerebbero a ogni salvataggio
63
+ .map((b) => ({ key: b.key, lines: trimTrailingBlank(b.lines) }));
64
+ }
65
+ export function trimTrailingBlank(lines) {
66
+ const out = lines.slice();
67
+ while (out.length && /^\s*$/.test(out[out.length - 1]))
68
+ out.pop();
69
+ return out;
70
+ }
71
+ /* Sostituisce la riga della CHIAVE dentro un blocco, lasciando dov'erano i commenti che la
72
+ * precedono e le righe indentate che la seguono. Serve a cambiare un valore senza rimuovere la
73
+ * sua documentazione — saltare il blocco e riemettere la chiave altrove costava esattamente
74
+ * quei commenti, ed è stato un bug vero. */
75
+ /* Sostituisce la RIGA di una chiave dentro il suo blocco, lasciando al loro posto i commenti
76
+ * che la precedono e le righe vuote che la seguono.
77
+ *
78
+ * Le righe INDENTATE subito sotto la chiave appartengono al suo vecchio valore e vanno via con
79
+ * esso. Non era così, e il caso si vedeva solo passando da un valore su più righe a uno su una
80
+ * riga sola — che succede la prima volta che si tolgono tutte le foto da una gallery:
81
+ *
82
+ * images: -> images: []
83
+ * - src: "…" - src: "…" <- orfane
84
+ * w: 1708 w: 1708
85
+ *
86
+ * cioè front matter rotto, scritto sul repo. Un valore scalare non ha righe indentate sotto di
87
+ * sé, quindi per tutti gli altri campi non cambia niente. */
88
+ export function replaceKeyLine(lines, key, newLine) {
89
+ const re = new RegExp(`^${key}\\s*:`);
90
+ const out = [];
91
+ for (let i = 0; i < lines.length; i++) {
92
+ if (!re.test(lines[i])) {
93
+ out.push(lines[i]);
94
+ continue;
95
+ }
96
+ out.push(newLine);
97
+ // Le righe vuote NON si saltano: separano le sezioni e vanno conservate, o il file non si
98
+ // riprodurrebbe uguale e la guardia di fedeltà rifiuterebbe il salvataggio.
99
+ while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1]))
100
+ i++;
101
+ }
102
+ return out;
103
+ }
104
+ function tokenize(fm) {
105
+ const out = [];
106
+ for (const l of fm.split("\n")) {
107
+ if (/^\s*$/.test(l) || /^\s*#/.test(l))
108
+ continue; // vuote e commenti non portano dati
109
+ out.push({ indent: l.match(/^ */)[0].length, text: l.trim() });
110
+ }
111
+ return out;
112
+ }
113
+ const KV = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/;
114
+ function parseMap(t, i, indent) {
115
+ const out = {};
116
+ while (i < t.length && t[i].indent >= indent) {
117
+ if (t[i].indent > indent) {
118
+ i++;
119
+ continue;
120
+ } // riga orfana: si salta
121
+ const m = t[i].text.match(KV);
122
+ if (!m)
123
+ break;
124
+ const key = m[1], inline = m[2].trim();
125
+ if (inline !== "") {
126
+ out[key] = scalar(inline);
127
+ i++;
128
+ continue;
129
+ }
130
+ i++; // il valore sta nel blocco indentato che segue
131
+ if (i < t.length && t[i].indent > indent) {
132
+ if (t[i].text.startsWith("-")) {
133
+ const [list, ni] = parseList(t, i, t[i].indent);
134
+ out[key] = list;
135
+ i = ni;
136
+ }
137
+ else {
138
+ const [map, ni] = parseMap(t, i, t[i].indent);
139
+ out[key] = map;
140
+ i = ni;
141
+ }
142
+ }
143
+ else {
144
+ out[key] = ""; // chiave senza valore né blocco
145
+ }
146
+ }
147
+ return [out, i];
148
+ }
149
+ function parseList(t, i, indent) {
150
+ const out = [];
151
+ while (i < t.length && t[i].indent === indent && t[i].text.startsWith("-")) {
152
+ const rest = t[i].text.replace(/^-\s*/, "");
153
+ const kv = rest.match(KV);
154
+ if (kv) {
155
+ // Elemento-mappa: la PRIMA chiave sta sulla riga del trattino, le altre sotto e più
156
+ // indentate. È la forma che emette toYamlLines.
157
+ const item = {};
158
+ item[kv[1]] = kv[2].trim() === "" ? "" : scalar(kv[2].trim());
159
+ i++;
160
+ if (i < t.length && t[i].indent > indent) {
161
+ const [more, ni] = parseMap(t, i, t[i].indent);
162
+ Object.assign(item, more);
163
+ i = ni;
164
+ }
165
+ out.push(item);
166
+ }
167
+ else {
168
+ out.push(scalar(rest));
169
+ i++;
170
+ }
171
+ }
172
+ return [out, i];
173
+ }
174
+ export function parseRawFm(fm) {
175
+ if (!fm)
176
+ return {};
177
+ return parseMap(tokenize(fm), 0, 0)[0];
178
+ }
179
+ function scalar(s) {
180
+ if (s === "[]")
181
+ return [];
182
+ if (s === "true")
183
+ return true;
184
+ if (s === "false")
185
+ return false;
186
+ if (s === "null" || s === "~")
187
+ return null;
188
+ if (/^-?\d+(\.\d+)?$/.test(s))
189
+ return Number(s);
190
+ const dq = s.match(/^"([\s\S]*)"$/);
191
+ if (dq) {
192
+ // Un solo passaggio da sinistra a destra, non due replace in fila: con `\\"` due passaggi
193
+ // sequenziali si scambiano l'ordine e restituiscono la stringa sbagliata. È il verso
194
+ // opposto di quello che fa yamlStr, e i due devono combaciare esattamente o un titolo con
195
+ // backslash e virgolette non sopravvive al giro.
196
+ return dq[1].replace(/\\(["\\])/g, "$1");
197
+ }
198
+ const sq = s.match(/^'([\s\S]*)'$/);
199
+ if (sq)
200
+ return sq[1].replace(/''/g, "'"); // in YAML gli apici singoli si raddoppiano
201
+ return s;
202
+ }
@@ -0,0 +1,101 @@
1
+ /* Guardia di /admin/* — pagina E API.
2
+ *
3
+ * Prima non c'era: l'unico riferimento ad Access nel codice era la lettura dell'header
4
+ * `Cf-Access-Authenticated-User-Email` in api/pages.js, usata solo per firmare il commit. Un
5
+ * header si scrive a mano con curl, quindi tutta la sicurezza stava nella configurazione
6
+ * dell'edge: se una policy non copriva un hostname (ogni deploy di preview è un hostname
7
+ * nuovo), `/admin/api/*` era aperto a chiunque — cioè scrittura sul repo e cancellazione su R2.
8
+ *
9
+ * Qui il JWT di Access viene VERIFICATO davvero, contro le chiavi pubbliche del tenant. Access
10
+ * resta la prima linea, questa diventa la seconda: una policy sbagliata non è più sufficiente.
11
+ *
12
+ * Vale per tutto ciò che sta sotto /admin/, compresi gli asset statici del pannello: il file
13
+ * si chiama `_middleware` e sta in `functions/admin/`, quindi Pages lo applica all'intero
14
+ * sottoalbero della rotta.
15
+ */
16
+ import cloudflareAccessPlugin from "@cloudflare/pages-plugin-cloudflare-access";
17
+
18
+ export const adminAccess = async (context) => {
19
+ const { env, next, request } = context;
20
+
21
+ // Anteprima locale: qui Access non esiste, e senza questa via d'uscita il plugin manderebbe
22
+ // al login per `localhost` — che Access rifiuta ("Invalid redirect URL").
23
+ // Va valutata PER PRIMA. Annidarla dentro il ramo "variabili mancanti" (com'era) funziona
24
+ // solo finché quelle variabili sono vuote: riempite, il ramo diventa irraggiungibile e
25
+ // l'anteprima locale si rompe senza che in produzione si veda niente.
26
+ // DUE condizioni, non una: il flag E un hostname locale. Il flag lo passa il comando
27
+ // wrangler dichiarato in `regia.json` (--binding ADMIN_LOCAL=1) e non sta in wrangler.toml,
28
+ // ma essendo questo ora il primo controllo del middleware, non voglio che una riga copiata
29
+ // per sbaglio fra le vars di un progetto basti ad aprire l'admin sull'hostname vero.
30
+ // Effetto collaterale utile: quando Regia espone l'anteprima sulla rete locale, dal telefono
31
+ // si vede il sito ma NON il pannello, perche' 192.168.x.x e nome.local non sono hostname
32
+ // locali. E' voluto.
33
+ const host = new URL(request.url).hostname;
34
+ const isLocalHost = host === "localhost" || host === "127.0.0.1" || host.endsWith(".localhost");
35
+
36
+ /* Le credenziali di Access viaggiano in COOKIE, e un cookie il browser lo manda da solo —
37
+ * anche quando la richiesta parte da un'altra pagina. Senza questo controllo, un sito
38
+ * qualunque aperto in un'altra scheda può far partire una POST verso `/admin/api/*` e la
39
+ * sessione già aperta la autentica: cancellare foto, riscrivere pagine, pubblicare.
40
+ *
41
+ * Il controllo sta PRIMA della via d'uscita locale di proposito: in locale non c'è Access,
42
+ * ma non c'è neanche una ragione per cui una mutazione debba arrivare da un'altra origine, e
43
+ * un controllo che vale solo in produzione è un controllo che non si prova mai.
44
+ *
45
+ * `Sec-Fetch-Site` si verifica solo se presente: i browser che non lo mandano restano coperti
46
+ * dal confronto sull'`Origin`. */
47
+ const method = request.method.toUpperCase();
48
+ const isMutation = !["GET", "HEAD", "OPTIONS"].includes(method);
49
+ if (isMutation && new URL(request.url).pathname.startsWith("/admin/api/")) {
50
+ const origin = request.headers.get("Origin");
51
+ const fetchSite = request.headers.get("Sec-Fetch-Site");
52
+ if (origin !== new URL(request.url).origin || (fetchSite && fetchSite !== "same-origin")) {
53
+ return new Response(
54
+ JSON.stringify({ ok: false, error: "Origine della richiesta non valida.", code: "forbidden_origin" }),
55
+ { status: 403, headers: { "Content-Type": "application/json" } },
56
+ );
57
+ }
58
+ }
59
+
60
+ if (env.ADMIN_LOCAL === "1" && isLocalHost) return next();
61
+
62
+ // FALLIRE CHIUSO. La forma sbagliata, e la ragione per cui non è scritta così, è:
63
+ // if (env.CF_ACCESS_AUD) proteggi(); else next();
64
+ // con quella, dimenticare una variabile su un progetto Pages non dà un errore: dà un admin
65
+ // pubblico, silenzioso e indistinguibile da uno funzionante. Meglio un 503 evidente.
66
+ if (!env.CF_ACCESS_AUD || !env.CF_ACCESS_DOMAIN) {
67
+ return new Response(
68
+ "Admin non configurato: mancano CF_ACCESS_DOMAIN e CF_ACCESS_AUD fra le [vars] di wrangler.toml.",
69
+ { status: 503, headers: { "Content-Type": "text/plain; charset=utf-8" } }
70
+ );
71
+ }
72
+
73
+ const res = await cloudflareAccessPlugin({
74
+ domain: env.CF_ACCESS_DOMAIN,
75
+ aud: env.CF_ACCESS_AUD,
76
+ })(context);
77
+
78
+ // Il plugin non risponde 403 come dice la sua documentazione: REINDIRIZZA al login di Access
79
+ // (302), e lo fa anche sulle chiamate API. Verificato, non dedotto.
80
+ // Per una pagina è il comportamento giusto — si finisce sulla schermata di login. Per
81
+ // `/admin/api/*` no: il `fetch()` dell'admin seguirebbe il redirect cross-origin verso
82
+ // cloudflareaccess.com e riceverebbe HTML, così una sessione SCADUTA si presenterebbe
83
+ // all'utente come "backend non collegato" — che è un'altra cosa e porta a cercare il guasto
84
+ // nel posto sbagliato. Qui SOLTANTO il redirect verso il tenant Access diventa un 401 JSON:
85
+ // i redirect applicativi (OAuth Google e ritorno al pannello) devono invece attraversare
86
+ // il middleware intatti.
87
+ const isApi = new URL(context.request.url).pathname.startsWith("/admin/api/");
88
+ const location = res.headers.get("Location") || "";
89
+ let accessRedirect = false;
90
+ try {
91
+ accessRedirect = !!location &&
92
+ new URL(location, context.request.url).hostname === new URL(env.CF_ACCESS_DOMAIN).hostname;
93
+ } catch { /* un Location malformato non è un redirect di Access affidabile */ }
94
+ if (isApi && res.status >= 300 && res.status < 400 && accessRedirect) {
95
+ return new Response(
96
+ JSON.stringify({ ok: false, error: "Sessione scaduta: ricarica la pagina per rientrare.", code: "unauthenticated" }),
97
+ { status: 401, headers: { "Content-Type": "application/json" } }
98
+ );
99
+ }
100
+ return res;
101
+ };