@cardinali-studio/adminkit 2.2.21 → 2.2.23

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.
@@ -126,10 +126,27 @@ export async function onRequestPost({ request, env, data }) {
126
126
  const operable = keyList.filter((key) => !pending.has(key));
127
127
  const result = operable.length ? await d1DeleteMedia({ env, data, body: { ...body, keys: operable } }) : null;
128
128
  for (const keepKey of keepKeys) await env.MEDIA.delete(keepKey);
129
- return json({ ok: true, trashed: result?.trashed ?? keyList.length, operationId: result?.operationId, pagesUpdated: result?.documents.map((document) => document.kind) ?? [], deferred: Boolean(result), d1: true, warning: result ? "I file resteranno disponibili fino alla pubblicazione." : undefined });
129
+ return json({
130
+ ok: true,
131
+ trashed: result?.trashed ?? keyList.length,
132
+ operationId: result?.operationId,
133
+ pagesUpdated: result?.publishedDocuments?.map((document) => document.kind) ?? [],
134
+ requiresPublication: result?.requiresPublication === true,
135
+ deferred: result?.requiresPublication === true || Number(result?.failed ?? 0) > 0,
136
+ completed: result ? Number(result.completed ?? 0) : keyList.length,
137
+ failed: Number(result?.failed ?? 0),
138
+ d1: true,
139
+ warning: result?.requiresPublication ? "Le pagine verranno pubblicate senza questi Media prima di spostarli nel cestino." : undefined,
140
+ });
130
141
  } catch (error) {
131
- const code = error?.message === "unauthenticated" ? "unauthenticated" : error?.message === "media_source_busy" ? "media_source_busy" : "media_operation_unavailable";
132
- return json({ ok: false, code, error: code === "media_source_busy" ? "Questo Media è già in un'operazione in corso." : "Non riesco a registrare l'operazione Media D1." }, code === "unauthenticated" ? 401 : 409);
142
+ const code = error?.message === "unauthenticated" ? "unauthenticated"
143
+ : error?.message === "media_source_busy" ? "media_source_busy"
144
+ : error?.message === "invalid_document" ? "media_replacement_required"
145
+ : "media_operation_unavailable";
146
+ const message = code === "media_source_busy" ? "Questo Media è già in un'operazione in corso."
147
+ : code === "media_replacement_required" ? "Una pagina richiede questa immagine: sostituiscila prima di eliminarla."
148
+ : "Non riesco a registrare l'operazione Media D1.";
149
+ return json({ ok: false, code, error: message }, code === "unauthenticated" ? 401 : code === "media_replacement_required" ? 422 : 409);
133
150
  }
134
151
  }
135
152
 
@@ -1,9 +1,13 @@
1
1
  /* D1 Media operation status and bounded recovery. These routes exist only
2
2
  * after D1 cutover; Git-mode Media keeps its established pending-marker flow. */
3
- import { json } from "../../_shared/assets.js";
3
+ import { guessMime, isKey, json } from "../../_shared/assets.js";
4
4
  import { D1MediaOperationRepository } from "../../../media.js";
5
- import { contentPersistenceMode } from "../../site.js";
5
+ import { contentPersistenceMode, languages } from "../../site.js";
6
6
  import { cancelD1MediaOperation, resumeD1MediaOperation } from "../../_shared/d1-media.js";
7
+ import {
8
+ altLocales, mediaAltMetadataValue, normalizeMediaAlt,
9
+ MEDIA_ALT_METADATA_KEY, MEDIA_UPLOADED_METADATA_KEY,
10
+ } from "../../_shared/media-alt.js";
7
11
 
8
12
  function unavailable() { return json({ ok: false, code: "feature_inactive", error: "Journal Media D1 non attivo." }, 404); }
9
13
  function failure(error) {
@@ -11,6 +15,57 @@ function failure(error) {
11
15
  return json({ ok: false, code, error: code === "unauthenticated" ? "Sessione non valida: ricarica la pagina per rientrare." : "L'operazione Media non può essere completata." }, code === "unauthenticated" ? 401 : 409);
12
16
  }
13
17
 
18
+ /** Aggiorna solo i metadata editoriali del file. R2 richiede di riscrivere l'oggetto per
19
+ * cambiare customMetadata: conserviamo quindi esplicitamente la data originaria, oltre a tutti
20
+ * i metadata HTTP/EXIF già presenti. */
21
+ export async function onRequestPatch({ request, env }) {
22
+ if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
23
+ let body;
24
+ try { body = await request.json(); } catch { return json({ ok: false, code: "media_alt_invalid", error: "JSON non valido." }, 400); }
25
+ const key = typeof body?.key === "string" ? body.key : "";
26
+ if (!isKey(key) || key.startsWith("_trash/") || key.startsWith("_adminkit/")) {
27
+ return json({ ok: false, code: "media_alt_invalid", error: "File Media non valido." }, 400);
28
+ }
29
+ const object = await env.MEDIA.get(key);
30
+ if (!object) return json({ ok: false, code: "media_not_found", error: "Il file non esiste più." }, 404);
31
+ const type = object.httpMetadata?.contentType || guessMime(key);
32
+ if (!type.startsWith("image/")) {
33
+ return json({ ok: false, code: "media_alt_not_applicable", error: "Il testo alternativo si applica alle immagini." }, 422);
34
+ }
35
+ let alt;
36
+ try { alt = normalizeMediaAlt(body.alt, altLocales(languages())); }
37
+ catch (error) {
38
+ const tooLarge = error?.message === "media_alt_too_large";
39
+ return json({
40
+ ok: false,
41
+ code: tooLarge ? "media_alt_too_large" : "media_alt_invalid",
42
+ error: tooLarge ? "I testi alternativi sono troppo estesi per i metadati del file." : "Testo alternativo non valido.",
43
+ }, 400);
44
+ }
45
+ const customMetadata = { ...(object.customMetadata || {}) };
46
+ const encoded = mediaAltMetadataValue(alt);
47
+ if (encoded) customMetadata[MEDIA_ALT_METADATA_KEY] = encoded;
48
+ else delete customMetadata[MEDIA_ALT_METADATA_KEY];
49
+ if (!customMetadata[MEDIA_UPLOADED_METADATA_KEY]) {
50
+ customMetadata[MEDIA_UPLOADED_METADATA_KEY] = object.uploaded instanceof Date
51
+ ? object.uploaded.toISOString()
52
+ : new Date().toISOString();
53
+ }
54
+ const saved = await env.MEDIA.put(key, object.body, {
55
+ httpMetadata: object.httpMetadata || {},
56
+ customMetadata,
57
+ ...(object.etag ? { onlyIf: { etagMatches: object.etag } } : {}),
58
+ });
59
+ if (object.etag && saved === null) {
60
+ return json({
61
+ ok: false,
62
+ code: "media_alt_conflict",
63
+ error: "Il file è cambiato durante il salvataggio. Riaprilo e riprova.",
64
+ }, 409);
65
+ }
66
+ return json({ ok: true, key, alt });
67
+ }
68
+
14
69
  export async function onRequestGet({ env }) {
15
70
  if (contentPersistenceMode() !== "d1") return unavailable();
16
71
  if (!env.CONTENT_DB) return json({ ok: false, code: "content_unavailable", error: "Persistenza contenuti non disponibile." }, 503);
@@ -145,7 +145,11 @@ export async function onRequestPost({ request, env, data }) {
145
145
  if (Object.hasOwn(body, "applyRefs")) return json({ ok: true, moved: 0, pagesUpdated: [], failed: [], d1: true });
146
146
  try {
147
147
  const result = await d1MoveMedia({ env, data, body });
148
- return json({ ok: true, ...result, pagesUpdated: result.documents.map((document) => document.kind), failed: [], warning: "Le sorgenti restano disponibili fino alla pubblicazione." });
148
+ return json({
149
+ ok: true, ...result,
150
+ pagesUpdated: result.publishedDocuments.map((document) => document.kind), failed: [],
151
+ warning: result.requiresPublication ? "Le pagine verranno pubblicate con i nuovi indirizzi prima di rimuovere le sorgenti." : undefined,
152
+ });
149
153
  } catch (error) {
150
154
  const code = error?.message === "unauthenticated" ? "unauthenticated" : error?.message === "media_source_busy" ? "media_source_busy" : "media_operation_unavailable";
151
155
  return json({ ok: false, code, error: code === "media_source_busy" ? "Questo Media è già in un'operazione in corso." : "Non riesco a registrare l'operazione Media D1." }, code === "unauthenticated" ? 401 : 409);
@@ -5,6 +5,7 @@
5
5
  import { isPrefix, json, guessMime, uniqueKey } from "../../_shared/assets.js";
6
6
  import { extractCapturedAt } from "../../_shared/exif.js";
7
7
  import { sizeFromBuffer } from "../../_shared/imagesize.js";
8
+ import { MEDIA_UPLOADED_METADATA_KEY } from "../../_shared/media-alt.js";
8
9
 
9
10
  export async function onRequestPost({ request, env }) {
10
11
  if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
@@ -21,7 +22,7 @@ export async function onRequestPost({ request, env }) {
21
22
  if (!ctype || ctype === "application/octet-stream") ctype = guessMime(key);
22
23
 
23
24
  // data di scatto (EXIF) per l'ordinamento "Data scatto"
24
- const custom = {};
25
+ const custom = { [MEDIA_UPLOADED_METADATA_KEY]: new Date().toISOString() };
25
26
  const suppliedCapturedAt = request.headers.get("X-Captured-At") || "";
26
27
  if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(suppliedCapturedAt)) {
27
28
  custom.capturedAt = suppliedCapturedAt;
@@ -52,6 +53,7 @@ export async function onRequestPost({ request, env }) {
52
53
  capturedAt: custom.capturedAt || null,
53
54
  w: wh ? wh[0] : null,
54
55
  h: wh ? wh[1] : null,
56
+ alt: { decorative: false, texts: {} },
55
57
  url: "/media/" + key.split("/").map(encodeURIComponent).join("/"),
56
58
  });
57
59
  }
@@ -4,8 +4,9 @@
4
4
  import { isPrefix, json, listKeysUnder } from "../_shared/assets.js";
5
5
  import { sizeFromBucket } from "../_shared/imagesize.js";
6
6
  import { pendingSourceKeys } from "../_shared/pending-media.js";
7
- import { contentPersistenceMode } from "../site.js";
7
+ import { contentPersistenceMode, languages } from "../site.js";
8
8
  import { d1PendingSourceKeys } from "../_shared/d1-media.js";
9
+ import { altLocales, mediaAltFromMetadata, MEDIA_UPLOADED_METADATA_KEY } from "../_shared/media-alt.js";
9
10
 
10
11
  const IMG = /\.(webp|jpe?g|png|avif|gif)$/i;
11
12
 
@@ -15,6 +16,7 @@ export async function onRequestGet({ request, env }) {
15
16
  let prefix = (url.searchParams.get("prefix") || "").trim();
16
17
  if (!isPrefix(prefix)) return json({ ok: false, error: `prefisso non valido: ${prefix}` }, 400);
17
18
  const pending = contentPersistenceMode() === "d1" ? await d1PendingSourceKeys(env.CONTENT_DB) : await pendingSourceKeys(env.MEDIA);
19
+ const locales = altLocales(languages());
18
20
 
19
21
  /* `recursive=1` guarda TUTTO quello che c'è sotto, a qualunque profondità: serve a chi deve
20
22
  * spostare o cancellare una cartella intera, che altrimenti si porterebbe dietro solo il primo
@@ -82,11 +84,12 @@ export async function onRequestGet({ request, env }) {
82
84
  name: o.key.slice(prefix.length),
83
85
  size: o.size,
84
86
  url: "/media/" + o.key.split("/").map(encodeURIComponent).join("/"),
85
- uploaded: o.uploaded,
87
+ uploaded: cm[MEDIA_UPLOADED_METADATA_KEY] || o.uploaded,
86
88
  capturedAt: cm.capturedAt || null,
87
89
  type: (o.httpMetadata && o.httpMetadata.contentType) || "",
88
90
  w: w > 0 ? w : null,
89
91
  h: h > 0 ? h : null,
92
+ alt: mediaAltFromMetadata(cm, locales),
90
93
  };
91
94
  }));
92
95
 
package/dist/gallery.d.ts CHANGED
@@ -3,6 +3,8 @@ export type GalleryImage = {
3
3
  w?: number | null;
4
4
  h?: number | null;
5
5
  hidden?: boolean;
6
+ /** Copia iniziale del default Media, modificabile nel contesto della singola gallery. */
7
+ alt?: string;
6
8
  };
7
9
  export type SectionType = "header" | "text" | "image" | "gallery";
8
10
  /** Una sezione della pagina gallery. Ha TUTTI i campi qualunque sia il tipo: Zod e React vogliono
package/dist/media.d.ts CHANGED
@@ -1,4 +1,16 @@
1
1
  import type { D1DatabaseLike } from "./persistence.js";
2
+ export declare function mediaPublicUrl(key: string): string;
3
+ /** Rewrites exactly the Media references owned by one operation. Exact array
4
+ * values are removed as array items; scalar slots become an empty string and
5
+ * are then accepted or rejected by the site's complete schema validator. */
6
+ export declare function rewriteMediaValue(value: unknown, input: {
7
+ replacements?: Readonly<Record<string, string>>;
8
+ removals?: readonly string[];
9
+ }): {
10
+ changed: boolean;
11
+ value?: unknown;
12
+ removed?: boolean;
13
+ };
2
14
  export declare const mediaOperationStates: readonly ["preparing", "ready", "blocked_in_use", "bound", "finalizing", "completed", "completed_with_warning", "cancelled", "failed"];
3
15
  export type MediaOperationState = typeof mediaOperationStates[number];
4
16
  export type MediaOperationType = "move" | "delete";
@@ -95,6 +107,11 @@ export declare class D1MediaOperationRepository {
95
107
  metadataJson: string | null;
96
108
  }): Promise<"pending" | "ready">;
97
109
  beginFinalization(operationId: string, releaseId: string, now: number): Promise<void>;
110
+ /** An operation whose sources occur in neither the public manifest nor any
111
+ * current draft needs no content release. The same guarded transition closes
112
+ * the last race before R2 cleanup; a concurrent reference makes it stay
113
+ * ready for the isolated-publication path instead. */
114
+ beginUnpublishedFinalization(operationId: string, now: number): Promise<boolean>;
98
115
  /** Individual cleanup writes are retryable. A single failed entry reaches
99
116
  * `completed_with_warning` only after every entry has a terminal result;
100
117
  * the already-live release is never rolled back. */
@@ -124,9 +141,11 @@ export declare class D1MediaOperationRepository {
124
141
  /** Work is named by its immutable release binding: there is intentionally no
125
142
  * "latest operation" cleanup path. */
126
143
  finalizationWork(releaseId: string): Promise<readonly BoundMediaEntry[]>;
144
+ unboundFinalizationWork(operationId: string): Promise<readonly BoundMediaEntry[]>;
127
145
  /** A warning is a live release with incomplete housekeeping. Retrying resets
128
146
  * only failed effects, never an already-completed entry. */
129
147
  resumeFinalization(operationId: string, releaseId: string, now: number): Promise<void>;
148
+ resumeUnboundFinalization(operationId: string, now: number): Promise<void>;
130
149
  readyDeleteEntry(input: {
131
150
  operationId: string;
132
151
  entryId: string;
package/dist/media.js CHANGED
@@ -1,4 +1,81 @@
1
1
  import { canonicalJson } from "./persistence.js";
2
+ const MEDIA_PREFIX = "/media/";
3
+ export function mediaPublicUrl(key) {
4
+ return MEDIA_PREFIX + key.split("/").map(encodeURIComponent).join("/");
5
+ }
6
+ function replaceBoundedMediaUrl(value, from, to) {
7
+ let cursor = 0;
8
+ let next = "";
9
+ while (cursor < value.length) {
10
+ const found = value.indexOf(from, cursor);
11
+ if (found < 0)
12
+ return next + value.slice(cursor);
13
+ const after = value[found + from.length];
14
+ /* A media key is a complete path, not a string prefix. Query/fragment
15
+ * suffixes and further URL/path characters belong to another reference. */
16
+ if (after && /[A-Za-z0-9._~%/?#-]/.test(after)) {
17
+ next += value.slice(cursor, found + from.length);
18
+ cursor = found + from.length;
19
+ continue;
20
+ }
21
+ next += value.slice(cursor, found) + to;
22
+ cursor = found + from.length;
23
+ }
24
+ return next;
25
+ }
26
+ /** Rewrites exactly the Media references owned by one operation. Exact array
27
+ * values are removed as array items; scalar slots become an empty string and
28
+ * are then accepted or rejected by the site's complete schema validator. */
29
+ export function rewriteMediaValue(value, input) {
30
+ const replacements = Object.fromEntries(Object.entries(input.replacements ?? {})
31
+ .map(([from, to]) => [mediaPublicUrl(from), mediaPublicUrl(to)]));
32
+ const removals = new Set((input.removals ?? []).map(mediaPublicUrl));
33
+ const visit = (item, inArray) => {
34
+ if (typeof item === "string") {
35
+ if (inArray && removals.has(item))
36
+ return { changed: true, removed: true };
37
+ let next = item;
38
+ for (const [from, to] of Object.entries(replacements))
39
+ next = replaceBoundedMediaUrl(next, from, to);
40
+ for (const from of removals)
41
+ next = replaceBoundedMediaUrl(next, from, "");
42
+ return { changed: next !== item, value: next };
43
+ }
44
+ if (Array.isArray(item)) {
45
+ let changed = false;
46
+ const next = [];
47
+ for (const child of item) {
48
+ const result = visit(child, true);
49
+ changed ||= result.changed;
50
+ if (!result.removed)
51
+ next.push(result.value);
52
+ }
53
+ return { changed, value: next };
54
+ }
55
+ if (item && typeof item === "object") {
56
+ /* Gallery entries conventionally carry the asset identity in `src` and
57
+ * exist only to describe that asset (dimensions/visibility may surround
58
+ * it). Removing the whole array item preserves a valid collection;
59
+ * broader object shapes remain intact and let the site validator decide
60
+ * whether an empty optional slot is acceptable or needs replacement. */
61
+ if (inArray && typeof item.src === "string"
62
+ && removals.has(item.src)) {
63
+ return { changed: true, removed: true };
64
+ }
65
+ let changed = false;
66
+ const next = {};
67
+ for (const [key, child] of Object.entries(item)) {
68
+ const result = visit(child, false);
69
+ changed ||= result.changed;
70
+ if (!result.removed)
71
+ next[key] = result.value;
72
+ }
73
+ return { changed, value: next };
74
+ }
75
+ return { changed: false, value: item };
76
+ };
77
+ return visit(value, false);
78
+ }
2
79
  export const mediaOperationStates = [
3
80
  "preparing", "ready", "blocked_in_use", "bound", "finalizing", "completed", "completed_with_warning", "cancelled", "failed",
4
81
  ];
@@ -171,6 +248,37 @@ export class D1MediaOperationRepository {
171
248
  if (result.meta?.changes !== 1)
172
249
  throw new Error("media_operation_unavailable");
173
250
  }
251
+ /** An operation whose sources occur in neither the public manifest nor any
252
+ * current draft needs no content release. The same guarded transition closes
253
+ * the last race before R2 cleanup; a concurrent reference makes it stay
254
+ * ready for the isolated-publication path instead. */
255
+ async beginUnpublishedFinalization(operationId, now) {
256
+ const result = await this.db.prepare(`UPDATE media_operations SET state = 'finalizing', updated_at = ?
257
+ WHERE operation_id = ? AND state = 'ready'
258
+ AND NOT EXISTS (SELECT 1 FROM content_release_media_operations WHERE operation_id = ?)
259
+ AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
260
+ JOIN content_site_state state ON state.singleton = 1
261
+ JOIN content_release_entries published_entry ON published_entry.release_id = state.current_release_id
262
+ JOIN content_revisions published ON published.document_id = published_entry.document_id
263
+ AND published.revision_id = published_entry.revision_id
264
+ WHERE source.operation_id = media_operations.operation_id AND published.payload_json IS NOT NULL
265
+ AND instr(published.payload_json, '/media/' || source.source_key) > 0)
266
+ AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
267
+ JOIN content_releases release ON release.status = 'publishing'
268
+ JOIN content_release_entries candidate_entry ON candidate_entry.release_id = release.release_id
269
+ JOIN content_revisions candidate ON candidate.document_id = candidate_entry.document_id
270
+ AND candidate.revision_id = candidate_entry.revision_id
271
+ WHERE source.operation_id = media_operations.operation_id AND candidate.payload_json IS NOT NULL
272
+ AND instr(candidate.payload_json, '/media/' || source.source_key) > 0)
273
+ AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
274
+ JOIN content_documents document
275
+ JOIN content_revisions draft ON draft.document_id = document.document_id
276
+ AND draft.revision_id = document.draft_revision_id
277
+ WHERE source.operation_id = media_operations.operation_id AND draft.payload_json IS NOT NULL
278
+ AND instr(draft.payload_json, '/media/' || source.source_key) > 0)`)
279
+ .bind(now, operationId, operationId).run();
280
+ return result.meta?.changes === 1;
281
+ }
174
282
  /** Individual cleanup writes are retryable. A single failed entry reaches
175
283
  * `completed_with_warning` only after every entry has a terminal result;
176
284
  * the already-live release is never rolled back. */
@@ -284,6 +392,17 @@ export class D1MediaOperationRepository {
284
392
  ORDER BY operation.operation_id, entry.entry_id`).bind(releaseId).all()).results ?? [];
285
393
  return rows;
286
394
  }
395
+ async unboundFinalizationWork(operationId) {
396
+ return (await this.db.prepare(`SELECT operation.operation_id AS operationId, operation.type,
397
+ operation.state AS operationState, entry.entry_id AS entryId, entry.source_key AS sourceKey,
398
+ entry.destination_key AS destinationKey, entry.finalization_state AS finalizationState
399
+ FROM media_operations operation
400
+ JOIN media_operation_entries entry ON entry.operation_id = operation.operation_id
401
+ WHERE operation.operation_id = ? AND operation.state IN ('finalizing', 'completed_with_warning')
402
+ AND NOT EXISTS (SELECT 1 FROM content_release_media_operations WHERE operation_id = operation.operation_id)
403
+ AND entry.finalization_state IN ('pending', 'failed')
404
+ ORDER BY entry.entry_id`).bind(operationId).all()).results ?? [];
405
+ }
287
406
  /** A warning is a live release with incomplete housekeeping. Retrying resets
288
407
  * only failed effects, never an already-completed entry. */
289
408
  async resumeFinalization(operationId, releaseId, now) {
@@ -300,6 +419,19 @@ export class D1MediaOperationRepository {
300
419
  if (results[0]?.meta?.changes !== 1)
301
420
  throw new Error("media_operation_unavailable");
302
421
  }
422
+ async resumeUnboundFinalization(operationId, now) {
423
+ const resume = this.db.prepare(`UPDATE media_operations SET state = 'finalizing', updated_at = ?, retry_count = retry_count + 1
424
+ WHERE operation_id = ? AND state = 'completed_with_warning'
425
+ AND NOT EXISTS (SELECT 1 FROM content_release_media_operations WHERE operation_id = ?)`)
426
+ .bind(now, operationId, operationId);
427
+ const reset = this.db.prepare(`UPDATE media_operation_entries SET finalization_state = 'pending', safe_error = NULL
428
+ WHERE operation_id = ? AND finalization_state = 'failed'
429
+ AND EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'finalizing')`)
430
+ .bind(operationId, operationId);
431
+ const results = await this.db.batch([resume, reset]);
432
+ if (results[0]?.meta?.changes !== 1)
433
+ throw new Error("media_operation_unavailable");
434
+ }
303
435
  async readyDeleteEntry(input) {
304
436
  const entry = this.db.prepare(`UPDATE media_operation_entries
305
437
  SET object_checksum = ?, etag = ?, metadata_json = ?, preparation_state = 'ready'
@@ -20,6 +20,7 @@ export type WorkCard = {
20
20
  export type Work = {
21
21
  title: string;
22
22
  cover: string;
23
+ coverAlt: string;
23
24
  description: string[];
24
25
  info: WorkInfo[];
25
26
  cards: WorkCard[];
package/dist/normalize.js CHANGED
@@ -33,7 +33,7 @@ export const CARD_KINDS = [
33
33
  { type: "frosted", label: "Testo su immagine", hint: "Parole sopra una fotografia" },
34
34
  ];
35
35
  export function emptyWork() {
36
- return { title: "", cover: "", description: [], info: [], cards: [] };
36
+ return { title: "", cover: "", coverAlt: "", description: [], info: [], cards: [] };
37
37
  }
38
38
  /* Il tipo di una card nuova è SEMPRE esplicito, mai stringa vuota: un `select` mostra comunque
39
39
  * la prima opzione quando il valore non combacia con nessuna, quindi una card salvata senza
@@ -48,6 +48,7 @@ export function normalizeWorks(value) {
48
48
  return {
49
49
  title: str(item.title),
50
50
  cover: str(item.cover),
51
+ coverAlt: str(item.coverAlt),
51
52
  description: list(item.description).map(str).filter(Boolean),
52
53
  info: list(item.info).map((entry) => {
53
54
  const o = obj(entry);
@@ -103,6 +103,13 @@ export type SaveInput = Revision & {
103
103
  authorSubject: string;
104
104
  authorLabel: string;
105
105
  };
106
+ /** A system-owned revision that may enter one release without becoming the
107
+ * document's editorial draft. Its ancestry is the exact revision in the
108
+ * current public manifest; an existing draft is deliberately neither selected
109
+ * nor cleared. */
110
+ export type DetachedRevisionInput = Omit<SaveInput, "expectedDraftRevisionId"> & {
111
+ baseRevisionId: string;
112
+ };
106
113
  export type ReleaseInput = {
107
114
  releaseId: string;
108
115
  requestChecksum: string;
@@ -122,6 +129,7 @@ export type ReleaseInput = {
122
129
  siteAccessVersionId?: string | null;
123
130
  };
124
131
  export type ReleaseDeploymentInput = ReleaseInput & {
132
+ detachedRevisions?: readonly DetachedRevisionInput[];
125
133
  attempt: {
126
134
  attemptId: string;
127
135
  intentKind: string;
@@ -332,11 +332,19 @@ export class D1ContentRepository {
332
332
  : [];
333
333
  const manifest = new Map(inherited.map((entry) => [entry.document_id, entry.revision_id]));
334
334
  const selected = [...input.entries];
335
+ const detached = [...(input.detachedRevisions ?? [])];
335
336
  const mediaOperationIds = [...new Set(input.mediaOperationIds ?? [])];
336
337
  if (!selected.length && !mediaOperationIds.length && !input.siteAccessVersionId)
337
338
  throw new Error("invalid_document");
339
+ if (new Set(detached.map((entry) => entry.documentId)).size !== detached.length
340
+ || new Set(detached.map((entry) => entry.revisionId)).size !== detached.length
341
+ || detached.some((entry) => selected.some((draft) => draft.documentId === entry.documentId)
342
+ || (entry.payload === null) === !entry.tombstone))
343
+ throw new Error("invalid_document");
338
344
  for (const entry of selected)
339
345
  manifest.set(entry.documentId, entry.revisionId);
346
+ for (const entry of detached)
347
+ manifest.set(entry.documentId, entry.revisionId);
340
348
  const selectionGuard = selected.length ? selected.map(() => "EXISTS (SELECT 1 FROM content_documents WHERE document_id = ? AND draft_revision_id = ?)").join(" AND ") : "1 = 1";
341
349
  const accessGuard = input.siteAccessVersionId ? `AND EXISTS (SELECT 1 FROM site_access_versions version JOIN site_access_state access_state ON access_state.singleton = 1
342
350
  WHERE version.version_id = ? AND version.state IN ('draft','active') AND version.version_id IN (access_state.draft_version_id, access_state.active_version_id))` : "";
@@ -346,6 +354,22 @@ export class D1ContentRepository {
346
354
  AND NOT EXISTS (SELECT 1 FROM production_deployment_attempts WHERE active_slot = 1) AND ${selectionGuard} ${accessGuard}`)
347
355
  .bind(input.releaseId, input.requestChecksum, input.manifestChecksum, input.schemaPackageVersion, input.materializerVersion, input.authorSubject, input.now, state.current_release_id, ...selected.flatMap((entry) => [entry.documentId, entry.revisionId]), ...(input.siteAccessVersionId ? [input.siteAccessVersionId] : []));
348
356
  const statements = [];
357
+ if (detached.length) {
358
+ const eligible = detached.map(() => `(
359
+ EXISTS (SELECT 1 FROM content_documents document WHERE document.document_id = ? AND document.kind = ? AND document.locale = ?)
360
+ AND EXISTS (SELECT 1 FROM content_site_state state JOIN content_release_entries entry
361
+ ON entry.release_id = state.current_release_id WHERE state.singleton = 1
362
+ AND entry.document_id = ? AND entry.revision_id = ?)
363
+ AND NOT EXISTS (SELECT 1 FROM content_revisions WHERE revision_id = ?)
364
+ )`).join(" AND ");
365
+ statements.push(this.db.prepare(`INSERT INTO content_site_state
366
+ (singleton, current_release_id, schema_generation, updated_at)
367
+ SELECT 2, NULL, 0, ? WHERE NOT (${eligible})`)
368
+ .bind(input.now, ...detached.flatMap((entry) => [
369
+ entry.documentId, entry.kind, entry.locale, entry.documentId, entry.baseRevisionId, entry.revisionId,
370
+ ])));
371
+ }
372
+ let mediaEligibility = null;
349
373
  if (mediaOperationIds.length) {
350
374
  const revisionIds = [...manifest.values()];
351
375
  const eligible = mediaOperationIds.map(() => `(
@@ -353,17 +377,45 @@ export class D1ContentRepository {
353
377
  AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
354
378
  JOIN content_revisions revision ON revision.revision_id IN (${revisionIds.map(() => "?").join(",")})
355
379
  WHERE source.operation_id = ? AND revision.payload_json IS NOT NULL
356
- AND instr(revision.payload_json, '/media/' || source.source_key) > 0))`).join(" AND ");
380
+ AND instr(revision.payload_json, '/media/' || source.source_key) > 0)
381
+ AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
382
+ JOIN content_documents document
383
+ JOIN content_revisions draft ON draft.document_id = document.document_id
384
+ AND draft.revision_id = document.draft_revision_id
385
+ WHERE source.operation_id = ? AND draft.payload_json IS NOT NULL
386
+ AND instr(draft.payload_json, '/media/' || source.source_key) > 0))`).join(" AND ");
357
387
  /* As with multi-document Save, zero affected rows do not abort a D1
358
388
  * batch. Convert an ineligible Media binding into a CHECK violation
359
- * before the candidate exists, so no release/attempt can commit alone. */
360
- statements.push(this.db.prepare(`INSERT INTO content_site_state
389
+ * inside the same transaction, after detached revisions exist but before
390
+ * any dependent binding can become durable. */
391
+ mediaEligibility = this.db.prepare(`INSERT INTO content_site_state
361
392
  (singleton, current_release_id, schema_generation, updated_at)
362
393
  SELECT 2, NULL, 0, ? WHERE NOT (${eligible})`)
363
- .bind(input.now, ...mediaOperationIds.flatMap((operationId) => [operationId, ...revisionIds, operationId])));
394
+ .bind(input.now, ...mediaOperationIds.flatMap((operationId) => [operationId, ...revisionIds, operationId, operationId]));
364
395
  }
365
396
  const candidateIndex = statements.length;
366
397
  statements.push(candidate);
398
+ /* Detached revisions are guarded by the candidate itself. If the lane is
399
+ * busy or the base release moved, they affect zero rows together with the
400
+ * rest of the release instead of becoming orphan system revisions. */
401
+ for (const entry of detached) {
402
+ statements.push(this.db.prepare(`INSERT INTO content_revisions
403
+ (revision_id, document_id, base_revision_id, payload_json, is_tombstone, schema_version,
404
+ payload_checksum, request_checksum, author_subject, author_label, created_at)
405
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM content_documents document
406
+ JOIN content_site_state state ON state.singleton = 1
407
+ JOIN content_release_entries published ON published.release_id = state.current_release_id
408
+ AND published.document_id = document.document_id
409
+ WHERE document.document_id = ? AND document.kind = ? AND document.locale = ?
410
+ AND published.revision_id = ? AND NOT EXISTS
411
+ (SELECT 1 FROM content_revisions existing WHERE existing.revision_id = ?)
412
+ AND EXISTS (SELECT 1 FROM content_releases release
413
+ WHERE release.release_id = ? AND release.status = 'publishing')`)
414
+ .bind(entry.revisionId, entry.documentId, entry.baseRevisionId, entry.payload === null ? null : canonicalJson(entry.payload), Number(entry.tombstone), entry.schemaVersion, entry.payloadChecksum, entry.requestChecksum, entry.authorSubject, entry.authorLabel, entry.now, entry.documentId, entry.kind, entry.locale, entry.baseRevisionId, entry.revisionId, input.releaseId));
415
+ }
416
+ const mediaEligibilityIndex = mediaEligibility ? statements.length : -1;
417
+ if (mediaEligibility)
418
+ statements.push(mediaEligibility);
367
419
  /* Every dependent write is guarded by the exact candidate. D1 batches are
368
420
  * atomic on SQL errors, but a guarded INSERT that affects zero rows is not
369
421
  * an error: without this predicate, a busy lane made these FK inserts
@@ -391,7 +443,13 @@ export class D1ContentRepository {
391
443
  JOIN content_release_entries entry ON entry.release_id = ?
392
444
  JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
393
445
  WHERE source.operation_id = media_operations.operation_id
394
- AND revision.payload_json IS NOT NULL AND instr(revision.payload_json, '/media/' || source.source_key) > 0)`)
446
+ AND revision.payload_json IS NOT NULL AND instr(revision.payload_json, '/media/' || source.source_key) > 0)
447
+ AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
448
+ JOIN content_documents document
449
+ JOIN content_revisions draft ON draft.document_id = document.document_id
450
+ AND draft.revision_id = document.draft_revision_id
451
+ WHERE source.operation_id = media_operations.operation_id AND draft.payload_json IS NOT NULL
452
+ AND instr(draft.payload_json, '/media/' || source.source_key) > 0)`)
395
453
  .bind(input.now, operationId, input.releaseId));
396
454
  statements.push(this.db.prepare(`INSERT INTO content_release_media_operations (release_id, operation_id)
397
455
  SELECT ?, ? WHERE EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'publishing')
@@ -405,7 +463,9 @@ export class D1ContentRepository {
405
463
  AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'publishing')`)
406
464
  .bind(input.attempt.attemptId, input.attempt.intentKind, input.attempt.requestedReleaseId, input.attempt.requestedCodeSha, input.attempt.codeSha, input.releaseId, input.attempt.expectedFingerprint, input.now, input.now, input.releaseId));
407
465
  const results = await this.db.batch(statements);
408
- if (results[candidateIndex]?.meta?.changes !== 1 || results.slice(candidateIndex + 1, -1).some((result) => result.meta?.changes !== 1)
466
+ if (results[candidateIndex]?.meta?.changes !== 1
467
+ || results.slice(candidateIndex + 1, -1).some((result, offset) => candidateIndex + 1 + offset !== mediaEligibilityIndex
468
+ && result.meta?.changes !== 1)
409
469
  || results.at(-1)?.meta?.changes !== 1)
410
470
  throw new Error("deployment_lane_busy");
411
471
  return "created";
@@ -424,6 +484,18 @@ export class D1ContentRepository {
424
484
  SET phase = 'failed', active_slot = NULL, error_code = ?, error_stage = ?, safe_message = ?, updated_at = ?, finished_at = ?
425
485
  WHERE attempt_id = ? AND release_id = ? AND phase = 'waiting' AND ci_claim_token_checksum IS NULL AND ci_claim_expires_at IS NULL`)
426
486
  .bind(input.code, input.stage, input.message, input.now, input.now, input.attemptId, input.releaseId),
487
+ /* Dispatch failed before CI could claim the immutable candidate. Its
488
+ * Media intents must become publishable again in this same recovery
489
+ * transaction; otherwise one transient GitHub error strands them bound
490
+ * forever to a failed release. */
491
+ this.db.prepare(`UPDATE media_operations SET state = 'ready', updated_at = ?
492
+ WHERE state = 'bound' AND operation_id IN
493
+ (SELECT operation_id FROM content_release_media_operations WHERE release_id = ?)
494
+ AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'failed')`)
495
+ .bind(input.now, input.releaseId, input.releaseId),
496
+ this.db.prepare(`DELETE FROM content_release_media_operations WHERE release_id = ?
497
+ AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'failed')`)
498
+ .bind(input.releaseId, input.releaseId),
427
499
  ]);
428
500
  if (results[0]?.meta?.changes !== 1 || results[1]?.meta?.changes !== 1)
429
501
  throw new Error("attempt_unavailable");
package/dist/theme.css CHANGED
@@ -405,6 +405,13 @@ a.ak-btn { text-decoration: none; display: inline-flex; align-items: center; }
405
405
  max-width: 100%; max-height: 100%; width: auto; height: auto; display: block;
406
406
  background: var(--ak-line); box-shadow: 0 1px 4px rgba(0, 0, 0, .16);
407
407
  }
408
+ .mg-alt-badge {
409
+ position: absolute; top: 5px; right: 5px; z-index: 2; pointer-events: none;
410
+ padding: 4px 6px; border-radius: 999px; background: rgba(20, 20, 20, .78); color: #fff;
411
+ font: 600 9px/1 var(--ak-font); text-transform: uppercase; letter-spacing: .04em;
412
+ }
413
+ .mg-alt-badge.is-missing { background: var(--ak-danger); }
414
+ .mg-alt-badge.is-decorative { background: rgba(80, 80, 80, .82); }
408
415
  /* La cornice della selezione è DECORAZIONE: `pointer-events: none` o si prende lei il click.
409
416
  Essendo un ::after del frame viene disegnata dopo la casella, quindi le stava sopra: premere
410
417
  la spunta di un elemento già selezionato non lo deselezionava, lo apriva. */
@@ -494,6 +501,41 @@ a.ak-btn { text-decoration: none; display: inline-flex; align-items: center; }
494
501
  .lb-info dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 5px 14px; font: 400 12px/1.4 var(--ak-font); }
495
502
  .lb-info dt { color: #9b9b98; }
496
503
  .lb-info dd { margin: 0; word-break: break-word; }
504
+ .lb-alt-button { font: 700 11px/1 var(--ak-font); letter-spacing: .04em; }
505
+ .lb-alt {
506
+ position: absolute; right: 12px; top: 58px; z-index: 3; width: min(390px, calc(100vw - 24px));
507
+ max-height: calc(100vh - 76px); overflow: auto; display: flex; flex-direction: column; gap: 14px;
508
+ background: rgba(22, 22, 22, .97); border: 1px solid rgba(255, 255, 255, .16);
509
+ border-radius: 9px; padding: 16px; color: #eaeaea; box-shadow: 0 18px 48px rgba(0, 0, 0, .4);
510
+ }
511
+ .lb-alt > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
512
+ .lb-alt > header div { display: flex; flex-direction: column; gap: 3px; }
513
+ .lb-alt > header strong { font: 600 15px/1.2 var(--ak-font); }
514
+ .lb-alt > header span { color: #a9a9a5; font: 400 11px/1.3 var(--ak-font); }
515
+ .lb-alt > header button {
516
+ border: 0; background: transparent; color: #fff; cursor: pointer; padding: 0 2px;
517
+ font: 300 22px/1 var(--ak-font);
518
+ }
519
+ .lb-alt-decorative { display: flex; align-items: flex-start; gap: 9px; cursor: pointer; }
520
+ .lb-alt-decorative input { margin: 2px 0 0; }
521
+ .lb-alt-decorative span { display: flex; flex-direction: column; gap: 3px; }
522
+ .lb-alt-decorative strong { font: 500 13px/1.3 var(--ak-font); }
523
+ .lb-alt-decorative small, .lb-alt-guidance { color: #adada8; font: 400 11px/1.45 var(--ak-font); }
524
+ .lb-alt-decorative code { color: #fff; }
525
+ .lb-alt-fields { display: flex; flex-direction: column; gap: 11px; }
526
+ .lb-alt-fields label { display: flex; flex-direction: column; gap: 6px; }
527
+ .lb-alt-fields label > span { display: flex; justify-content: space-between; gap: 12px; font: 500 12px/1.2 var(--ak-font); }
528
+ .lb-alt-fields label > span small { color: #8f8f8b; font-weight: 400; }
529
+ .lb-alt-fields textarea {
530
+ width: 100%; resize: vertical; box-sizing: border-box; padding: 9px 10px;
531
+ border: 1px solid #555; border-radius: 6px; background: #111; color: #fff;
532
+ font: 400 13px/1.45 var(--ak-font);
533
+ }
534
+ .lb-alt-fields textarea:focus { outline: 2px solid var(--ak-accent); outline-offset: 1px; }
535
+ .lb-alt-guidance, .lb-alt-error, .lb-alt-saved { margin: 0; }
536
+ .lb-alt-error { color: #ff9b9b; font: 500 11px/1.4 var(--ak-font); }
537
+ .lb-alt-saved { color: #b9e6bd; font: 500 11px/1.4 var(--ak-font); }
538
+ .lb-alt footer { display: flex; justify-content: flex-end; }
497
539
 
498
540
  /* ---- Media: il dialogo di caricamento ------------------------------------------------- */
499
541
  .up { position: fixed; inset: 0; z-index: 70; background: rgba(0, 0, 0, .42); display: grid; place-items: center; padding: 5vh 5vw; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardinali-studio/adminkit",
3
- "version": "2.2.21",
3
+ "version": "2.2.23",
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",