@cardinali-studio/adminkit 2.2.23 → 2.2.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +12 -0
- package/README.md +213 -0
- package/dist/functions/_shared/d1-media.js +3 -3
- package/dist/media.d.ts +3 -0
- package/dist/media.js +6 -1
- package/package.json +2 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Copyright (C) 2026 Emanuele Cardinali
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
4
|
+
purpose with or without fee is hereby granted.
|
|
5
|
+
|
|
6
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
7
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
8
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
9
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
10
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
11
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
12
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# AdminKit
|
|
2
|
+
|
|
3
|
+
AdminKit is the administration engine we use for small, content-driven websites built with Hugo and deployed on Cloudflare Pages.
|
|
4
|
+
|
|
5
|
+
It turns a Zod schema into three things that must agree:
|
|
6
|
+
|
|
7
|
+
- the model edited in the browser;
|
|
8
|
+
- the YAML front matter stored in the repository;
|
|
9
|
+
- the validation performed by the server.
|
|
10
|
+
|
|
11
|
+
There is no second list of fields hidden in the UI, and no second serializer hidden in a Worker. A field is declared once. The rest of the system reads that declaration.
|
|
12
|
+
|
|
13
|
+
The package also contains the React administration shell, a media library for R2, Cloudflare Pages Functions, and the persistence primitives used by the D1 publication path.
|
|
14
|
+
|
|
15
|
+
## The important property
|
|
16
|
+
|
|
17
|
+
An admin panel should not damage a file merely because it does not understand all of it.
|
|
18
|
+
|
|
19
|
+
AdminKit preserves the Markdown body, unknown front-matter keys, and their comments. An omitted managed field means “leave it alone”, not “delete it”. Before overwriting an existing file, `checkFidelity()` can prove that reading and writing it again is byte-for-byte stable.
|
|
20
|
+
|
|
21
|
+
If the codec cannot reproduce a file, it says so. Refusing a save is better than producing plausible-looking YAML with missing data.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install @cardinali-studio/adminkit zod
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The core codec needs Zod 4. The React application and form exports also use the optional peer dependencies listed in `package.json`; install the ones required by the entry points you use.
|
|
30
|
+
|
|
31
|
+
The package is ESM-only.
|
|
32
|
+
|
|
33
|
+
## A complete codec example
|
|
34
|
+
|
|
35
|
+
Each field has two pieces of metadata:
|
|
36
|
+
|
|
37
|
+
- `ui` describes how a human edits it;
|
|
38
|
+
- `fm` describes how it lives in the file.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import * as z from "zod";
|
|
42
|
+
import {
|
|
43
|
+
checkFidelity,
|
|
44
|
+
deserializeItem,
|
|
45
|
+
fm,
|
|
46
|
+
serializeItem,
|
|
47
|
+
ui,
|
|
48
|
+
yamlStr,
|
|
49
|
+
} from "@cardinali-studio/adminkit";
|
|
50
|
+
|
|
51
|
+
const Article = {
|
|
52
|
+
title: z.string().min(1).max(120)
|
|
53
|
+
.register(ui, {
|
|
54
|
+
label: "Title",
|
|
55
|
+
widget: "text",
|
|
56
|
+
group: "content",
|
|
57
|
+
})
|
|
58
|
+
.register(fm, {
|
|
59
|
+
keys: ["title"],
|
|
60
|
+
encode: (value) => [`title: ${yamlStr(value)}`],
|
|
61
|
+
decode: (raw) => String(raw.title ?? ""),
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
published: z.boolean()
|
|
65
|
+
.register(ui, {
|
|
66
|
+
label: "Published",
|
|
67
|
+
widget: "boolean",
|
|
68
|
+
group: "visibility",
|
|
69
|
+
})
|
|
70
|
+
.register(fm, {
|
|
71
|
+
keys: ["visible"],
|
|
72
|
+
encode: (value) => [`visible: ${value === true}`],
|
|
73
|
+
decode: (raw) => raw.visible === true,
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const markdown = `---
|
|
78
|
+
# Kept with the field.
|
|
79
|
+
title: "A small page"
|
|
80
|
+
visible: false
|
|
81
|
+
|
|
82
|
+
# AdminKit does not own this key, so it stays untouched.
|
|
83
|
+
build-note: "keep me"
|
|
84
|
+
---
|
|
85
|
+
The Markdown body stays untouched too.
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
if (!checkFidelity(markdown, Article).ok) {
|
|
89
|
+
throw new Error("This file cannot be edited without loss");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const article = deserializeItem(markdown, Article);
|
|
93
|
+
|
|
94
|
+
const updated = serializeItem(
|
|
95
|
+
{ ...article, published: true },
|
|
96
|
+
{ shape: Article, oldMd: markdown },
|
|
97
|
+
);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Notice that the model calls the boolean `published` while the file calls it `visible`. That conversion belongs to the schema. Hugo templates, React components, and API handlers should not each invent their own version of it.
|
|
101
|
+
|
|
102
|
+
### Deleting and retiring fields
|
|
103
|
+
|
|
104
|
+
Absence is not deletion. To clear a value, pass the empty value understood by its encoder and return no YAML lines.
|
|
105
|
+
|
|
106
|
+
When a key is obsolete, `retired(schema, keys)` keeps ownership of the old key while emitting nothing. Existing files then lose that key on their next intentional save, without maintaining a separate migration list.
|
|
107
|
+
|
|
108
|
+
## Mounting the admin application
|
|
109
|
+
|
|
110
|
+
The application is deliberately mounted as one unit:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { mountAdmin, type SiteAdminConfig } from "@cardinali-studio/adminkit/app";
|
|
114
|
+
import "@cardinali-studio/adminkit/theme.css";
|
|
115
|
+
import "./site-theme.css";
|
|
116
|
+
|
|
117
|
+
const site: SiteAdminConfig = {
|
|
118
|
+
// Site identity, page types, schemas, enabled modules and persistence mode.
|
|
119
|
+
// TypeScript checks the complete contract here.
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
mountAdmin({
|
|
123
|
+
site,
|
|
124
|
+
root: document.querySelector("#root"),
|
|
125
|
+
storagePrefix: "example-admin",
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Import the site's theme after AdminKit's theme. This lets a site replace the CSS custom properties without increasing selector specificity.
|
|
130
|
+
|
|
131
|
+
`storagePrefix` is part of the site's browser data. Changing it on a live installation silently resets saved media preferences, so migrations should keep the old value.
|
|
132
|
+
|
|
133
|
+
The shell currently speaks Italian. Content can be monolingual or multilingual; the language of the administration interface is a separate concern.
|
|
134
|
+
|
|
135
|
+
## Cloudflare Pages Functions
|
|
136
|
+
|
|
137
|
+
Cloudflare routes Functions by file, while an npm package cannot create files in a consuming project's `functions/` directory. A site therefore keeps two small adapters.
|
|
138
|
+
|
|
139
|
+
The API catch-all:
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
// functions/admin/api/[[path]].js
|
|
143
|
+
import { handleAdminApi } from "@cardinali-studio/adminkit/functions";
|
|
144
|
+
import { serverConfig } from "../../../src/admin/site-config.js";
|
|
145
|
+
|
|
146
|
+
export function onRequest(context) {
|
|
147
|
+
return handleAdminApi(context, serverConfig);
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
And the access middleware:
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
// functions/admin/_middleware.js
|
|
155
|
+
export { adminAccess as onRequest } from "@cardinali-studio/adminkit/functions";
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Site-specific endpoints may live beside these files. More specific Cloudflare routes take precedence over the catch-all.
|
|
159
|
+
|
|
160
|
+
The server configuration is site-owned. It supplies schemas, repository details, enabled modules, storage bindings and, in D1 mode, the complete document validator. Missing optional modules fail closed.
|
|
161
|
+
|
|
162
|
+
## Persistence and publication
|
|
163
|
+
|
|
164
|
+
The original persistence path writes Markdown through GitHub and lets Hugo build it. The newer path stores immutable revisions in D1 and publishes an exact release snapshot.
|
|
165
|
+
|
|
166
|
+
The D1 modules are intentionally lower-level than the admin shell. They provide:
|
|
167
|
+
|
|
168
|
+
- deterministic request checksums and idempotent saves;
|
|
169
|
+
- compare-and-swap protection for concurrent drafts;
|
|
170
|
+
- immutable, checksum-covered release exports;
|
|
171
|
+
- a single deployment lane for code and content;
|
|
172
|
+
- copy-first media moves and delete journals for R2;
|
|
173
|
+
- versioned site-access secrets;
|
|
174
|
+
- pure release materialization, with filesystem effects left to CI.
|
|
175
|
+
|
|
176
|
+
They do not decide what a site's document means. Validation and materialization remain site-owned.
|
|
177
|
+
|
|
178
|
+
## Entry points
|
|
179
|
+
|
|
180
|
+
| Import | Purpose |
|
|
181
|
+
| --- | --- |
|
|
182
|
+
| `@cardinali-studio/adminkit` | Schema registries, front-matter codec, API client, i18n, persistence and release primitives |
|
|
183
|
+
| `@cardinali-studio/adminkit/app` | Complete React administration shell |
|
|
184
|
+
| `@cardinali-studio/adminkit/form` | Schema form and media components |
|
|
185
|
+
| `@cardinali-studio/adminkit/functions` | Cloudflare Pages API router and access middleware |
|
|
186
|
+
| `@cardinali-studio/adminkit/materializer` | Pure release-to-files materialization |
|
|
187
|
+
| `@cardinali-studio/adminkit/preview` | Local preview component |
|
|
188
|
+
| `@cardinali-studio/adminkit/testing` | Content-stability checks for consuming sites |
|
|
189
|
+
| `@cardinali-studio/adminkit/theme.css` | Default design tokens |
|
|
190
|
+
|
|
191
|
+
## What this is not
|
|
192
|
+
|
|
193
|
+
AdminKit is not a generic headless CMS, a form builder, or a promise to support every YAML construct.
|
|
194
|
+
|
|
195
|
+
It is opinionated about the useful narrow case: Hugo content, React 19, Zod 4, Cloudflare Pages, R2 and optionally D1. The front-matter emitter supports the structures used by that system and throws on values it cannot represent safely.
|
|
196
|
+
|
|
197
|
+
This narrowness is intentional. A smaller system with explicit failure modes is easier to trust around content that people have edited by hand.
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
```sh
|
|
202
|
+
npm test
|
|
203
|
+
npm run build
|
|
204
|
+
npm run check
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`npm run check` rebuilds the package and runs the Node test suite. The tests include byte-identical codec round trips, malformed-input rejection, import resolution, concurrency conflicts, release invariants, media-operation transitions, and a guard against site-specific code entering the shared package.
|
|
208
|
+
|
|
209
|
+
Generated files live in `dist/` and are the only application files included in the npm tarball, in addition to npm's standard package metadata and this README.
|
|
210
|
+
|
|
211
|
+
## License
|
|
212
|
+
|
|
213
|
+
[0BSD](./LICENSE). Use it, copy it, change it, ship it. Attribution is appreciated but not required.
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* validator remains the authority for every draft revision generated by an
|
|
3
3
|
* explicit Media operation. Git is never consulted from this module. */
|
|
4
4
|
import {
|
|
5
|
-
canonicalChecksum,
|
|
5
|
+
canonicalChecksum, contentSaveRequestChecksum, D1ContentRepository,
|
|
6
6
|
} from "../../persistence.js";
|
|
7
7
|
import {
|
|
8
|
-
D1MediaOperationRepository, mediaPublicUrl, prepareMediaCopy, rewriteMediaValue,
|
|
8
|
+
canonicalR2Metadata, D1MediaOperationRepository, mediaPublicUrl, prepareMediaCopy, rewriteMediaValue,
|
|
9
9
|
} from "../../media.js";
|
|
10
10
|
import { validateContentDocument } from "../site.js";
|
|
11
11
|
import { isKey, moveToTrash, sanitizeName } from "./assets.js";
|
|
@@ -137,7 +137,7 @@ async function inspectSource(bucket, entry) {
|
|
|
137
137
|
...entry,
|
|
138
138
|
objectChecksum,
|
|
139
139
|
etag: object.etag ?? null,
|
|
140
|
-
metadataJson:
|
|
140
|
+
metadataJson: canonicalR2Metadata(object.httpMetadata ?? {}, object.customMetadata ?? {}),
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
143
|
|
package/dist/media.d.ts
CHANGED
|
@@ -177,6 +177,9 @@ export type MediaR2Bucket = {
|
|
|
177
177
|
};
|
|
178
178
|
export type MediaPreparationJournal = Pick<D1MediaOperationRepository, "recordPreparedEntry">;
|
|
179
179
|
export type MediaFinalizationJournal = Pick<D1MediaOperationRepository, "beginFinalization" | "recordFinalizedEntry">;
|
|
180
|
+
/** Cloudflare may expose unset R2 metadata fields as enumerable `undefined`
|
|
181
|
+
* values. Persist the JSON-visible representation, not the runtime wrapper. */
|
|
182
|
+
export declare function canonicalR2Metadata(httpMetadata?: MediaR2Object["httpMetadata"], customMetadata?: MediaR2Object["customMetadata"]): string;
|
|
180
183
|
/** Copy-first R2 effect. No existing destination is overwritten: an identical
|
|
181
184
|
* prior copy is an idempotent retry, while any opaque collision stops safely. */
|
|
182
185
|
export declare function prepareMediaCopy(input: {
|
package/dist/media.js
CHANGED
|
@@ -449,6 +449,11 @@ export class D1MediaOperationRepository {
|
|
|
449
449
|
}
|
|
450
450
|
}
|
|
451
451
|
const MEDIA_CHECKSUM_METADATA = "adminkit-sha256";
|
|
452
|
+
/** Cloudflare may expose unset R2 metadata fields as enumerable `undefined`
|
|
453
|
+
* values. Persist the JSON-visible representation, not the runtime wrapper. */
|
|
454
|
+
export function canonicalR2Metadata(httpMetadata = {}, customMetadata = {}) {
|
|
455
|
+
return canonicalJson(JSON.parse(JSON.stringify({ httpMetadata, customMetadata })));
|
|
456
|
+
}
|
|
452
457
|
async function bytesChecksum(bytes) {
|
|
453
458
|
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
454
459
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
@@ -477,7 +482,7 @@ export async function prepareMediaCopy(input) {
|
|
|
477
482
|
return input.journal.recordPreparedEntry({
|
|
478
483
|
operationId: input.operationId, entryId: input.entryId, now: input.now, objectChecksum: checksum,
|
|
479
484
|
etag: verified.etag ?? null,
|
|
480
|
-
metadataJson:
|
|
485
|
+
metadataJson: canonicalR2Metadata(source.httpMetadata ?? {}, source.customMetadata ?? {}),
|
|
481
486
|
});
|
|
482
487
|
}
|
|
483
488
|
/** Cleanup starts only after the release is published. Failures are recorded as
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cardinali-studio/adminkit",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.25",
|
|
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
|
-
"license": "
|
|
6
|
+
"license": "0BSD",
|
|
7
7
|
"main": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
9
|
"exports": {
|