MinDatabase - AI Agent Instructions
| Version: 4.13.0 |
Last Updated: 7 October 2026 |
Project Scope
MinDatabase is a scholarly, content-only repository of long-form art historical essays.
- Primary focus: medieval Italian art and related institutional contexts
- Output type: narrative academic prose in Markdown
- Publishing a scheda does touch the toolchain: regenerate the gallery index
locally (
python3 scripts/build_gallery_index.py) and commit it with the
scheda; six GitHub Actions workflows then run on the push (see Automazioni)
Canonical Repository Structure
Use this structure as authoritative:
MinDatabase/
├── Content/
│ ├── Artists/
│ │ ├── VII-c/
│ │ ├── VIII-c/
│ │ ├── IX-c/
│ │ ├── X-c/
│ │ ├── XI-c/
│ │ ├── XII-c/
│ │ ├── XIII-c/
│ │ │ └── [Artist-Name].md
│ │ └── XIV-c/
│ ├── Churches/
│ ├── Codex/
│ ├── Papers/
│ ├── Saints/
│ └── prompts/
├── Images/
└── MinDatabase - AI Agent Instructions.md
Naming and Placement Rules
- Use
Papers as the canonical folder name (never Papirer).
- Place artist files in the century folder matching the artist’s primary documented activity period.
- Supported artist folders:
VII-c, VIII-c, IX-c, X-c, XI-c, XII-c, XIII-c, XIV-c
(the -c suffix is the folder name on disk; rename_century_dirs.py renamed
the old <N> century/ form).
- Do not use
XIII-XIV as a folder convention.
File Naming
- Artist files:
[Name].md with historically standard naming, using hyphens in place of spaces (e.g., Giotto-di-Bondone.md).
- Anonymous masters: full scholarly label, hyphenated (e.g.,
Maestro-di-San-Martino.md).
- Churches, Codex, Papers, Saints: descriptive scholarly titles.
- Legacy
.html pages at the old, space-based paths are preserved as static redirect stubs pointing to the current hyphenated URL. Do not remove or modify these redirect files.
Writing Model
- Write coherent, multi-paragraph academic prose.
- Integrate archival evidence, historiographical uncertainty, and stylistic analysis.
- Maintain cross-file consistency for dates, family ties, patronage, and attributions.
- Prefer narrative flow over bullet-point decomposition inside article bodies.
Standard Artist Section Flow
Use and adapt as needed by discipline:
- Early life and family
- Patronage and commissions
- Artistic style / technical characteristics
- Artistic influences
- Travels and career geography
- Death and legacy
- Works / major works
Front Matter Policy
The repository currently includes YAML front matter in many content types, including Artist files.
- Preserve existing front matter that is read by layouts, scripts, or the search
index (
layout, title, subtitle, dates, century, scholars, meta,
thematic_keywords). Legacy top-level keys duplicating the meta block were
removed in this commit: do not reintroduce them.
- For new files, and for schede that are rewritten, follow the canonical form below; the other schede keep their current form until they are converted.
python3 scripts/frontmatter_census.py (--all for the per-section list) counts, read-only, how far the schede are from the canonical front matter (eight meta blocks in fixed order, scholars: as title/url pairs, no thematic_keywords, RELATED ENTRIES or Reference Links), with the markers T, K, S0, Sx, P, Rl, Rn, Rs, Rf; it writes nothing and is not a validator.
Canonical Front Matter
The form followed by the schede that frontmatter_census.py classes as new:
- Top-level keys:
layout, title, subtitle, dates, meta, scholars (plus century on the non-Artists schede where the gallery script reads it).
- Eight
meta blocks, in this order: CORE DATA · IDENTITY AND LIMITS · CHRONOLOGY · KEY WORKS · DISPUTED AND REJECTED ATTRIBUTIONS · STYLE AND FORMATION · PATRONAGE AND SETTING · RECEPTION AND LEGACY.
scholars: as title/url pairs, in alphabetical order of the displayed title, listing only scholars actually cited in the body.
- No
thematic_keywords, no RELATED ENTRIES, no Reference Links.
title in plain form, without parentheses, because it ends up in the newsletter subject.
Three content rules apply with the form:
- The front matter asserts nothing the body does not support, and dates and attributions agree between front matter, body and conclusion.
- DISPUTED AND REJECTED ATTRIBUTIONS records contested attributions and proposed identifications with their author, never as facts.
- A scholar is credited only with what the sources consulted attribute to them.
Strategy: schede not yet aligned are converted the first time they are touched for other reasons, in the same commit; scripts/frontmatter_census.py measures the distance.
All footnotes must use HTML anchor syntax and one ordered list block:
- Inline reference pattern:
id="fnref:N" linking to href="#fn:N"
- Footnote block pattern: a single
<ol class="footnotes"> at the end of the file
- Each item:
<li id="fn:N"> ... <a href="#fnref:N" class="footnote__back" ...>↩</a>
- Markdown footnotes (
[^1], [^1]: ...) are not allowed
Required Pattern
Inline note<a id="fnref:1" href="#fn:1" class="footnote"><sup>1</sup></a>
<ol class="footnotes">
<li id="fn:1">
<p>Footnote text. <a href="#fnref:1" class="footnote__back" aria-label="Back to reference">↩</a></p>
</li>
</ol>
References to endnotes.html
- References to shared entries in
endnotes.html use the numeric form Name<a href="/endnotes.html#fn-slug" class="footnote"><sup>N</sup></a>, numbered progressively in document order (1, 2, 3…), with the name left as plain text before the anchor; the named form [Name](/endnotes.html#fn-slug) is not used. Scholar links (/scholars.html#slug) stay named.
python3 scripts/endnote_forms.py lists, read-only, the schede that still carry named references (pure and mixed, with counts); it writes nothing and is not a validator.
- Strategy: a scheda is converted the first time it is touched for other reasons, in the same commit; the residual tail is closed with one dedicated round when it is small.
Endnote Conventions (Page-End Notes)
Use endnotes for supplemental notes that should appear after the main text and after any footnotes, but remain visually distinct from the footnote list.
- Inline reference pattern:
id="enref:N" linking to href="#en:N"
- Endnote block pattern: a single
<ol class="endnotes"> at the end of the content
- Each item:
<li id="en:N"> ... <a href="#enref:N" class="endnote__back" ...>↩</a>
- Endnotes may appear on the same page as footnotes, but they should use a separate list and separate class names
Required Pattern
Main text with a note<a id="enref:1" href="#en:1" class="endnote"><sup>1</sup></a>
<ol class="footnotes">
<li id="fn:1">
<p>Footnote text. <a href="#fnref:1" class="footnote__back" aria-label="Back to reference">↩</a></p>
</li>
</ol>
<ol class="endnotes">
<li id="en:1">
<p>Endnote text. <a href="#enref:1" class="endnote__back" aria-label="Back to endnote reference">↩</a></p>
</li>
</ol>
This repository already contains embedded images in content files.
- Keep existing
<figure>, <img>, and <figcaption> patterns where used.
- Use consistent image paths (typically
/Images/... in content markup, matching site conventions).
- Add clear alt text and concise scholarly captions.
- Prefer legally reusable/public-domain or properly credited sources.
Codex Image Names
Images of the schede in Content/Codex/ are named after what they show, not by a running number. The name is derived from the figcaption, which stays the source of truth: if the caption changes, the file is renamed.
Form: Images/<Scheda-Name>/<slug>-<locator>[-d01|-alt].<ext>, all lowercase, no spaces.
- Folder and slug. The folder carries the exact name of the scheda file (
Images/Book-of-Armagh/); the slug is that name in lowercase (book-of-armagh). No short prefixes.
- Leaves.
f, the number padded with zeros, r or v: f032v, f123r. The width is fixed per codex, as wide as its highest folio needs: three digits everywhere, four for the Codex Amiatinus (f0796v). The zeros keep the alphabetical order equal to the order of the book; they are never omitted, and one codex never mixes widths.
- Pages. For books numbered by page,
p and the number: p026.
- Openings. Two facing leaves in one image:
f033v-034r. A range without sides, such as a video: f099-103.
- Roman numerals. Flyleaves numbered in Roman:
f-i-v, with hyphens, because fiv would read as a single numeral.
- Several images of one leaf. The whole leaf takes no suffix; details are
-d01, -d02; a second reproduction of the same leaf from another source is -alt. Two captions that claim the same leaf without being a detail or a second source are a conflict to be settled in the scheda, not a naming problem.
- One foliation per codex. The one of the holding library, named in the scheda. The numbers of older binders or earlier catalogues are not used in names (the Mulling portrait of John is
f081v, not 53).
- No number. A closed list of labels:
cover-front, cover-back, spine, binding, shrine, satchel, and context-<subject> for objects that are not part of the codex (codex-amiatinus-context-jarrow-dedication.jpg). Anything else is added to this list here before it is used.
- Last resort.
<slug>-NN.jpg, two digits, only for an image that shows no leaf and no listed object.
- Videos. Same grammar, in
Video/.
The rule applies to Content/Codex/ only; the other sections keep <subject>-NN.jpg (see Convenzioni). When a scheda is touched for any reason, its images are brought into the form in the same commit; a whole-section round is run with a script in dry-run first.
| Codex |
Zero width |
| Codex Amiatinus |
4 |
| every other codex |
3 |
Cross-Reference Consistency
- Keep artist-to-artist and church-to-artist relationships consistent across files.
- When updating a key relationship (e.g., workshop lineage, marriage, patronage), verify related entries.
- Preserve uncertainty wording where evidence is debated (e.g., “likely”, “possibly”, “scholars debate”).
Editing and Commit Workflow
- Make focused, incremental commits by section or topic.
- Separate content edits from tooling edits when tooling changes are involved.
- Commit message style examples:
Add Early Life section to [Artist]
Update patronage chronology in [Artist]
Removed — this tool is no longer available in the repository. It was previously documented at Content/Artists/XIII-c/add_sections.py (purpose: insert ## headings into prose lacking explicit section markers via exact string replacements), but the file is no longer present. Do not reference or attempt to run it. Insert missing section headings via direct manual edits instead.
Tooling Note: validate_content_indexes.py
scripts/validate_content_indexes.py runs the twelve content checks listed in its own docstring: matching entries, resolvable hrefs, valid front matter, alphabetical ordering, basename rules, Saints reachability, inter-page links, anchors into the two containers, case-exact image references, slug ordering inside the sections of endnotes.html, display-name ordering inside the sections of scholars.html, and the leaf-based names of the images in Content/Codex/. It runs read-only and is executed both by the validate-content-indexes.yml workflow on every push touching Content/**, assets/data/*.json, or the script itself, and by validate.yml alongside the other three validators.
Content/Saints/ is, by editorial choice, an unindexed section: saint entries are reachable only via links from other entries, not from a listing page. The script excludes Saints/ from the “has a JSON entry” check, but flags any Saints entry that isn’t linked from any other entry, since such an entry would otherwise be unreachable.
Quality Checklist Before Finalizing
- Folder and filename match canonical structure (
Papers, current century folders).
- Dates, names, relationships, and attributions are internally consistent.
- Prose remains scholarly and readable, not flattened into outline form.
- Footnotes use the required
<ol class="footnotes"> format.
- Images/figures (if present) are formatted consistently and captioned clearly.
- Related entries affected by major factual edits are updated or reviewed.
- References to
endnotes.html use the numeric form, numbered in document order; if the scheda is listed by scripts/endnote_forms.py, convert it in the same commit.
Automazioni
Il repository si mantiene da solo attraverso sei workflow GitHub Actions.
Conoscerne l’ordine evita di inseguire problemi che hanno una causa nota.
La catena di pubblicazione
Un push che tocca Content/**/*.md avvia, in parallelo:
- Update Push Notices — genera
assets/data/push_notices.json con le
notizie delle schede pubblicate, poi invia la newsletter agli iscritti e
registra l’invio in newsletter_last_notified.json.
- Update Gallery Index — rigenera
assets/data/gallery-index.json
estraendo le <figure> da tutte le schede.
Entrambi committano e, in coda, invocano esplicitamente Deploy Site.
Che cosa produce notizia
Solo le schede: i markdown sotto Content/Artists/, Content/Churches/,
Content/Codex/, Content/Papers/ e Content/Saints/. La sezione compare
nel badge in homepage e nell’oggetto della newsletter.
Content/Saints/ e’ entrata in quell’elenco tardi. Il classificatore ne
ignorava l’esistenza e le otto schede dei santi cadevano fra i non-contenuti:
il difetto non si era mai visto perche’ nessun santo era stato pubblicato da
quando esistono le notizie, ma la prima pubblicazione sarebbe uscita
etichettata «Other». Non indicizzata non vuol dire non contenuto.
Tutto il resto non produce notizia, e viene saltato con una riga di log
skipping non-content file: <path> perche’ il salto resti osservabile: i
.md di radice — glossario, sistemi di datazione, README e questo stesso
file — e Content/prompts/. Senza il filtro finivano annunciati come schede;
il file delle istruzioni e’ comparso davvero fra le notizie candidate durante
una prova.
La regola e’ a tappeto e non una lista di eccezioni: una cartella nuova sotto
Content/ non produce notizie finche’ non la si dichiara in
CONTENT_SECTIONS. E’ il verso giusto in cui sbagliare — una scheda non
annunciata si rimedia, un’email no — ma va ricordato quando si aggiunge una
sezione.
Cosa va in homepage, cosa va per email
Sono due liste diverse, scritte nello stesso push_notices.json.
notices rotola: le notizie del push si fondono con quelle gia’
pubblicate, e le prime MAX_NOTICES riempiono la card «ultime novita’» in
homepage. Deve restare piena anche dopo un push che porta una scheda sola,
quindi il riporto e’ voluto.
latest_push porta le sole notizie di questo push, ed e’ quella che
spedisce la newsletter. Un’email annuncia quel che e’ appena successo: se
uscisse sulla lista fusa ripeterebbe fino a MAX_NOTICES - 1 voci gia’
spedite il giro prima, e l’oggetto di un push di una scheda sola diventerebbe
... and 2 more updates invece di [New] <titolo>. E’ successo: e’ la
ragione per cui le liste sono due.
Stanno nello stesso file e non in due perche’ nascono dallo stesso input nello
stesso run. Separarle in due artefatti significherebbe tenerli allineati, due
controlli git diff --quiet nel workflow, e la possibilita’ che uno dei due
commit riesca e l’altro no. La chiave e’ affiancata, cosi’ la homepage
continua a leggere notices senza sapere che l’altra esiste.
La firma anti-reinvio si calcola su latest_push, non sulla lista fusa:
deve rispondere a «c’e’ qualcosa di nuovo da mandare?», e a quella domanda
risponde il push. Un latest_push vuoto — push senza notizie proprie — non
spedisce nulla e lascia lo stato intatto; una chiave assente, cioe’ un file
scritto da una versione precedente alla separazione, nemmeno.
Quante notizie, e in che ordine
MAX_NOTICES = 3 vale per entrambe le liste: un push che tocca piu’ schede
le annuncia tutte e tre, in homepage e in newsletter. Prima ne usciva una
sola, e in un push misto la scheda pubblicata poteva restare fuori a favore
di un ritocco marginale.
L’ordine e’ a tre livelli: pushed_at decrescente, poi created prima di
modified, poi il path in ordine alfabetico. Il secondo livello e’ quello che
conta, perche’ i file di uno stesso push condividono il timestamp: una scheda
nuova precede sempre una ritoccata. Il timestamp e’ uno solo per push — quello
del commit piu’ recente, non l’ora corrente, perche’ la firma anti-reinvio lo
include e deve poter essere ricalcolata identica — anche quando il push porta
piu’ commit. Prima ogni notizia prendeva la data del proprio commit: in un
push di tre commit, created nel piu’ vecchio e modified nel piu’ recente,
la scheda nuova finiva dietro il ritocco e l’oggetto della newsletter la
annunciava come un aggiornamento. Il terzo chiude l’ordine e lo rende
totale, cosi’ i due rami da cui lo script ricava i file — il payload
dell’evento e il ripiego su git log — producono lo stesso risultato. Non era
vero prima: il payload elenca gli added prima dei modified, git log
--name-status elenca in ordine di path, e con una sola notizia la differenza
decideva da sola che cosa finiva in newsletter.
L’oggetto della newsletter si costruisce sulle notizie del push — quelle di
latest_push — e non e’ piu’ fisso. Una notizia sola porta titolo ed etichetta — `Medieval Visions — [New]
` —; due o piu' portano la prima, che l'ordinamento garantisce essere
la piu' rilevante, e il conteggio delle altre: `Medieval Visions — and
2 more updates`. I titoli oltre i 60 caratteri sono troncati con un'ellissi,
perche' il troncamento del client di posta cadrebbe dove capita e mangerebbe
il conteggio. `NEWSLETTER_SUBJECT`, se valorizzata, continua a forzare un
oggetto fisso. La firma anti-reinvio in `newsletter_last_notified.json` guarda
solo path, timestamp e tipo di modifica: l'oggetto non la tocca, quindi non
puo' provocare un reinvio.
`scripts/test_update_push_notices.py` fissa queste regole su tredici gruppi di
casi presi dalla storia del repository — 58 asserzioni, comprese quelle sul
range del ripiego, sulla firma dell'elenco intero e sulla separazione fra
lista rotolante e lista solo-push. Si esegue senza dipendenze:
`python3 scripts/test_update_push_notices.py`.
`send_newsletter_updates.py` ha un parser minimo: `--help` stampa l'uso, un
flag ignoto esce con codice 2 senza inviare (prima ogni argomento era ignorato
e `--help` lanciava l'invio vero) e `--dry-run` stampa stato, oggetto e
**numero** dei destinatari senza aprire SMTP ne' scrivere lo stato. Nessun
percorso dello script stampa indirizzi — i log di CI sono pubblici — e gli
errori di smtplib passano da `_redact_addresses`.
#### Il tag di soppressione
Un commit di manutenzione che tocca le schede senza aggiornarne il contenuto
— rinomine di campi, riformattazioni, aggiunte di frontmatter — va marcato con
`[skip notices]` **nell'oggetto**, cioe' nella prima riga del messaggio:
`update_push_notices.py` esce senza scrivere nulla, e di conseguenza non parte
neppure la newsletter.
Solo la prima riga. Il corpo del messaggio e' prosa e puo' nominare il tag per
esteso — spiegarlo, citarlo, motivare perche' non lo si usa — senza attivarlo.
La regola nasce da un caso reale: un commit che nel corpo scriveva «nessun
[skip notices]» per dire di non averlo usato ha soppresso notizia e newsletter
della scheda che stava pubblicando.
I commit con il tag si pushano da soli, o insieme ad altra sola manutenzione
— mai nello stesso push di aggiornamenti di contenuto: basta che compaia
nell'oggetto di un commit qualsiasi del push e sopprime le notizie dell'intero
push, comprese quelle delle schede aggiornate davvero.
I quattro validatori — indici e contenuti, frontmatter degli studiosi,
raggiungibilita' delle pagine non indicizzate, coerenza fra la nav condivisa e
le tre nav inline — girano in parallelo e non scrivono nulla. `validate.yml` li
lancia tutti e quattro; `validate-content-indexes.yml` e
`validate-scholars-frontmatter.yml` ne rilanciano due, con i propri filtri di
path.
Al quarto validatore `validate.yml` fa seguire un quinto passo,
**Push-notices regression battery**, che non valida contenuto ma la toolchain
delle notizie. Sta li' perche' `validate.yml` e' l'unico workflow senza
filtri di path: la batteria gira
quindi sullo stesso push che porta una modifica a `update_push_notices.py` o a
`send_newsletter_updates.py`, che nessun altro workflow intercetta. Per la
stessa ragione `update-push-notices.yml` **non** elenca `scripts/**` fra i
suoi path, e non deve farlo: un push di sola toolchain non ha schede nel
payload, farebbe scattare il ripiego su `git log` e riannuncerebbe contenuto
gia' annunciato — con una seconda email agli iscritti.
Accanto a quella, e per la stessa ragione, gira un sesto passo:
**Check-7 regression battery**, `scripts/test_validate_content_indexes.py`,
47 asserzioni in undici gruppi: 28 fissano il contratto dei link interni sul
caso che lo ha allargato, 19 il contratto dei nomi delle immagini dei codici
(check 12). I primi casi sono letteralmente i due rimandi morti
di Andrea di Bonaiuto: `.md` e `%20` verso un file inesistente devono uscire
rossi, i due bersagli veri in forma `.html` verdi, e un `.md` verso un file
che **esiste** rosso lo stesso. Gli altri gruppi coprono l'URL-decode, le
maiuscole esatte, l'ancora scartata, le tre sedi da cui si leggono i link — il
caso `- url:` in linea e' una regressione vera, `Maestro-di-Vico-lAbate.md`
scrive cosi' — e il verso opposto, cioe' che la prosa che nomina `Content/**`
non venga scambiata per un link.
#### Il settimo passo non e' Python
`validate.yml` chiude con **Gallery JS test battery**: 69 asserzioni che
caricano `gallery.html` in un DOM vero con jsdom — 44 sul lightbox, 25
sull'invito al click che compare all'hover.
La ragione e' che il sito ha JS non banale. `gallery.html` costruisce da sola
la griglia dall'indice, la raggruppa, la filtra, la pagina e apre il lightbox:
un validatore Python puo' leggerne il testo, non eseguirlo. Un repo conviene
che testi nel linguaggio di cio' che testa, e per la galleria quel linguaggio
e' JavaScript. Sta in `validate.yml` per la stessa ragione della batteria
sopra: e' l'unico workflow senza filtri di path, quindi il guardiano vede ogni
push che tocca la galleria.
Tre passi in coda ai sei Python: `actions/setup-node@v4` con
`node-version: '22'` e `cache: 'npm'`, poi `npm ci`, poi `npm test`. Nessun
`continue-on-error`: un'asserzione rossa ferma il workflow come la ferma un
validatore Python.
La versione di Node e' fissata di proposito. Senza `node-version` setup-node
prende quella preinstallata sul runner, che cambia senza preavviso; il 22 e'
LTS fino ad aprile 2027, mentre il 20 e' fuori manutenzione da aprile 2026.
`jsdom` 29 accetta `^20.19 || ^22.13 || >=24`.
`npm ci` e non `npm install`: installa esattamente `package-lock.json` e
fallisce se i due file divergono, cosi' il guardiano non cambia versione di
jsdom sotto i piedi. Il lockfile va committato — e' il prerequisito di
`npm ci`. `node_modules/` resta ignorato.
Lo script `test` lancia i due file **e poi** somma i codici di uscita, invece
di incatenarli con `&&`: con `&&` un rosso nel primo file impedirebbe al
secondo di partire, e in CI si vedrebbe meta' del quadro. Cosi' girano sempre
entrambi e il fallimento si propaga lo stesso.
Costo misurato sul primo run: **+11s** sul workflow, da 24s a 35s. Di questi,
4.6s sono i due passi veri — 1.8s `npm ci` e 2.8s `npm test` — e il resto e'
`setup-node`. Node 22 sta gia' nella tool cache del runner
(`/opt/hostedtoolcache/node/22.23.2`), quindi non si scarica; la cache npm
invece era vuota al primo giro (`npm cache is not found`) ed e' stata salvata
in coda, percio' dal secondo push in poi l'install costa meno.
**La convenzione zero-dipendenze resta vera dove e' nata.** Riguarda i
validatori Python e le due batterie, `test_update_push_notices.py` e
`test_validate_content_indexes.py`, che si lanciano con
`python3 scripts/...` e nient'altro: vale ancora, e va mantenuta. La batteria
JS e' l'eccezione motivata — un DOM vero non si simula a mano — ed e' l'unica
cosa nel repo che abbia bisogno di `npm install`.
In locale:
npm install && npm test
### I vincoli da ricordare
**I commit fatti con `GITHUB_TOKEN` non generano eventi.** È la protezione di
GitHub contro i cicli infiniti. Conseguenza pratica: quando un workflow
committa un indice aggiornato, il deploy *non* riparte da solo, e il sito
resta indietro di un giro. Per questo entrambi i workflow che scrivono
terminano con una chiamata esplicita:
gh workflow run deploy-pages.yml
Serve `actions: write` fra i permessi del workflow che la esegue.
**I workflow che scrivono condividono un gruppo di concorrenza.** Girando in
parallelo si contendevano il ramo, e uno dei due falliva con
`cannot lock ref 'refs/heads/main'`. Il gruppo `repo-writes` con
`cancel-in-progress: false` li mette in fila.
**Il payload dell'evento non elenca i file modificati, e il ripiego guarda il
range del push.** Le chiavi `added`/`modified` possono mancare del tutto: su
questo repository mancano sempre. Su 132 PushEvent osservati, `commits` e'
arrivato vuoto 132 volte, e nei tredici push di contenuto non soppressi dal
tag il ramo payload non ha mai prodotto una voce. Il ripiego su git non e'
quindi un'emergenza rara: e' il percorso normale, e va letto come tale.
Quel ripiego leggeva **gli ultimi trenta commit**, e cosi' rimetteva in lista
schede gia' annunciate. Il 16 settembre 2026 il push della Fontana Maggiore —
un commit solo — ripesco' il push di Pisa del 7 settembre, dieci commit piu'
indietro: tre notizie invece di una, e un oggetto
`... and 2 more updates` al posto di `[New] `.
Ora il ripiego guarda **i soli commit del push**, cioe' `before..after`
dell'evento. I due campi sono di primo livello e non dipendono da `commits`:
restano popolati esattamente nel caso in cui il ripiego serve — verificato su
tutti e 132 gli eventi. Sullo stesso range, il push della Fontana produce una
notizia sola.
I casi limite scelgono tutti il verso conservativo, perche' una scheda non
annunciata si rimedia con un push e un'email no:
- `before` a zeri (primo push di un ramo): si annuncia il solo commit di punta;
- `before` fuori dal checkout (push piu' lungo di `fetch-depth`): un solo
`git fetch --deepen=50`, e se ancora non basta si ripiega sul commit di punta;
- fuori da Actions, dove evento non c'e': vale il solo `HEAD`, cioe' l'analogo
locale del push di un commit. Prima l'esecuzione locale leggeva trenta
commit e dava un elenco che non corrispondeva a nessun push reale.
`fetch-depth: 30` resta, ma per una ragione diversa: non piu' «dare storia al
fallback», bensi' garantire che `before` sia nel checkout.
**La firma anti-reinvio guarda tutto l'elenco.** `send_newsletter_updates.py`
confronta con l'invio precedente una firma costruita su path, `pushed_at` e
`change_type` di **tutte** le notizie, nel loro ordine — non della sola testa.
Titoli e URL ne restano fuori di proposito: l'oggetto dell'email si costruisce
sui titoli, e legarli alla firma farebbe partire un reinvio a ogni ritocco di
un titolo.
La sfumatura: se la testa e' gia' stata notificata ma le compagne cambiano, la
newsletter riparte. E' il verso giusto, e lo e' diventato di piu' da quando il
ripiego e' limitato al range: una lista diversa non puo' piu' nascere dal
trascinamento di storia vecchia, quindi se l'elenco cambia e' perche' il push
ha toccato schede diverse.
**Il terzo trascinamento, chiuso.** `update_push_notices.py` continua a
fondere le notizie del push con quelle gia' pubblicate — la card in homepage
deve restare piena — ma la newsletter non legge piu' quella lista. Legge
`latest_push`, che porta le sole notizie del push, e su quella calcola anche
la firma anti-reinvio. Le voci del giro precedente restano in homepage e non
escono piu' per email. Vedi «Cosa va in homepage, cosa va per email».
### Il deploy
`deploy-pages.yml` costruisce con `bundle exec jekyll build`, non con
`actions/jekyll-build-pages`: quest'ultima ignora il `Gemfile` e usa le
versioni che GitHub decide. Con bundler la produzione usa le stesse versioni
fissate in `Gemfile.lock`, cioe' quelle con cui si costruisce in locale.
Conseguenza: in locale i comandi Jekyll vanno sempre preceduti da
`bundle exec`.
`vendor/` e' escluso in `_config.yml`: bundler vi installa le gem, e Jekyll vi
troverebbe il template di esempio con data segnaposto, che fa fallire la
build.
### Passi manuali rimasti
**L'iscrizione alla newsletter.** Formspree raccoglie gli indirizzi nella sua
interfaccia; vanno copiati a mano nel Secret `NEWSLETTER_SUBSCRIBERS` e in
`newsletter_subscribers.csv` (fuori dal repo, escluso anche dalla build).
Il sistema regge fino a una trentina di iscritti: oltre, il limite non e'
GitHub ma iCloud Mail, che non e' un servizio di invio in massa.
**L'indice della galleria.** Si rigenera in locale con
`python3 scripts/build_gallery_index.py` e si committa insieme alla scheda che
lo cambia, cosi' indice e contenuto restano allineati nello stesso commit. Il
workflow **Update Gallery Index** in CI resta attivo e fa da controprova:
trovando l'indice gia' aggiornato esce con «unchanged, nothing to commit».
### I dodici controlli
`scripts/validate_content_indexes.py` contiene tutti i controlli sul
contenuto; il docstring in cima li elenca. Sei meritano una nota:
- **7** verifica i link fra pagine, **8** le ancore ai due contenitori
(`endnotes.html`, `scholars.html`), **9** le immagini, **12** i nomi delle immagini dei codici, **10** l'ordine per
slug dentro le sezioni-lettera di `endnotes.html`, **11** l'ordine dentro
le sezioni di `scholars.html`.
- Il check 7 copre **qualunque** link che punti sotto `Content/`, non i soli
`.html`. Per mesi guardava solo quelli: la regex pretendeva l'estensione, e
ogni altra forma gli passava accanto senza lasciare traccia. La scheda di
Andrea di Bonaiuto ha portato online due rimandi morti —
`/Content/Churches/Santa%20Maria%20Novella.md` e
`/Content/Churches/Camposanto%20Monumentale%20Pisa.md` — rimasti 404 per
tutta la vita della scheda senza che nulla li segnalasse; la scansione che
ha esteso il check ne ha trovati altri sette della stessa famiglia, su tre
schede, nella vecchia forma con gli spazi e senza estensione, residuo della
rinomina a trattini. Il contratto ora e' uno solo, e ha tre conseguenze
che conviene ricordare quando si scrive un rimando:
- l'estensione dev'essere `.html`, **anche quando il `.md` esiste**. Un
link `.md` funziona su GitHub, che mostra i sorgenti, e non sul sito, che
pubblica pagine; un link senza estensione non e' servito da nessuna
parte. Quando il bersaglio e' deducibile il messaggio d'errore lo
propone gia' scritto;
- il confronto avviene dopo l'URL-decode (`%20` torna uno spazio) e con le
maiuscole esatte, con la stessa logica del check 9 e per la stessa
ragione: GitHub Pages distingue le maiuscole, il Mac no;
- l'ancora si scarta prima del confronto, perche' il bersaglio del check e'
il file: delle ancore risponde gia' il check 8.
I link si leggono dalle tre sedi in cui il repository li scrive davvero —
i campi `url:` del frontmatter, i link markdown e gli `href` inline — e non
dal testo grezzo. Serve in entrambi i versi: un `url:` di frontmatter puo'
contenere spazi, ed e' esattamente la forma dei sette morti, che una regex
delimitata dallo spazio troncherebbe a meta'; e la prosa di questo file
nomina percorsi come `Content/**` che non sono link e non vanno verificati.
Il grafo di raggiungibilita' del check 6 si costruisce sugli stessi link
validi, cosi' la sua nozione di «linkata» non diverge in silenzio da
quella del 7.
- Il 10 e l'11 sono gemelli con la chiave opposta, e la differenza e' voluta.
Le note si ordinano per slug; gli studiosi per titolo visualizzato, perche'
li' lo slug e' spesso una forma storica che non segue il titolo:
`max-seidel` per «Seidel, Max», `hendrik-willem-van-os` per «van Os,
Hendrik Willem». Ordinare per slug segnalerebbe quelle voci come fuori
posto; ordinare per titolo le assolve da solo, e nessuna lista di
eccezioni e' necessaria. La collazione riproduce l'uso del file, non le
regole bibliotecarie: cognome poi nome, diacritici e punteggiatura
ignorati, confronto parola per parola — «De Marchi» < «De Rossi» <
«Del Migliore» < «Delisle» < «Della Valle» — e una particella di una sola
lettera che fa corpo con la parola seguente, cosi' «Ó Floinn» si archivia
come OFloinn e sta dopo Offner, come O'Curry e O'Loughlin.
- Il check 8 esiste perche' i contenitori sono due: un link puo' nominare
un'ancora reale ma cercarla nel file sbagliato. Le note del mondo antico
stavano in un terzo contenitore, `ancient-world.html`, fuso in
`endnotes.html`: quel path ora e' solo uno stub di redirect che conserva
l'hash, e non va piu' usato nei rimandi.
- Il check 9 confronta le maiuscole in modo esplicito, senza affidarsi a
`exists()`: il filesystem di macOS non distingue maiuscole e minuscole,
GitHub Pages si'. Un `Armagh-01.jpg` che punta ad `armagh-01.jpg` funziona
sul Mac e produce un'immagine vuota in produzione.
- Il check 12 vale solo per `Content/Codex/` e confronta il nome di ogni
immagine con la didascalia della sua figura: cartella uguale al nome della
scheda, forma `-<foglio|etichetta>`, foglio e zeri uguali a quelli
dichiarati. La grammatica sta in `scripts/codex_image_names.py`, che lo
script `scripts/rename_codex_images.py` (dry-run per default) usa per
ricavare i nomi. Un doppione di foglio non risolto sta in
`OPEN_CONFLICTS` finche' la scheda non decide; oggi e' vuoto.
I controlli da 1 a 9 coprono `Content/**/*.md` piu' i `.md` nella radice
(glossary, dating-systems), esclusa la cartella `drafts/`. Il 10 e l'11 fanno
eccezione: non guardano i `.md`, leggono i due contenitori HTML,
`endnotes.html` e `scholars.html`.
### Convenzioni
Nessuno spazio nei nomi di file e cartelle: gli spazi diventano `%20` negli
URL e hanno gia' rotto centinaia di link. Le immagini seguono la forma
`-NN.jpg`, tutta minuscola, con lo zero iniziale per mantenere
l'ordinamento oltre la decima; per `Content/Codex/` vale la regola di
«Codex Image Names», che nomina le immagini per foglio.
I link interni sono sempre root-relative (`/Content/...`, `/endnotes.html`):
i percorsi relativi dipendono dalla profondita' della cartella e si rompono a
ogni riorganizzazione.
Le schede in lavorazione stanno in `drafts/`, esclusa dai controlli, dalla
build e — da questo giro — anche da git: la cartella e' in `.gitignore`. E'
spazio di lavorazione locale e non entra mai nel repository. Prima era solo
non tracciata, quindi compariva in `git status` e un `git add -A` l'avrebbe
messa in stage in blocco.
Le schede si spostano in `Content/` solo alla pubblicazione: da quel momento
ogni commit fa partire una notifica agli iscritti, e una email non si ritira.
Lo spostamento e' un `git mv` mancato per costruzione — il file sorgente e'
ignorato — quindi la scheda entra come nuova, che e' il verso giusto: il suo
`change_type` dev'essere `created`.