@tideworthy/tidysites-platform 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +17 -0
- package/package.json +2 -1
- package/src/client.ts +3 -2
- package/src/index.ts +1 -0
- package/src/lib/handlers.ts +119 -0
- package/src/lib/runtime.ts +28 -2
- package/src/routes/fill.ts +13 -0
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- New route `POST /_tidy/fill`: real content over the template's sample content. Settings, entries upserted by slug with field-level merge over the existing entry, `$ref` and `$media` resolved as in a seed, and a `remove` list for sample entries the fill did not replace. Client gains `fill()`.
|
|
6
|
+
|
|
3
7
|
## 0.1.7
|
|
4
8
|
|
|
5
9
|
- Bootstrap applies the seed with the site's configured storage, so `$media` references in a template seed are downloaded into the site's media library. Before this every seed photo was skipped with "no storage configured". The storage comes from EmDash's `virtual:emdash/config` and `virtual:emdash/storage` modules, the same way the setup wizard builds it.
|
package/README.md
CHANGED
|
@@ -86,6 +86,23 @@ The browser lands here. Signs the user in (an Astro session, exactly as EmDash's
|
|
|
86
86
|
|
|
87
87
|
Replaces the platform API token. `{ "email": "...", "tokenName": "platform" }`. Both optional: with no email, the token's current owner is used. Returns `{ ok, userId, token }`.
|
|
88
88
|
|
|
89
|
+
### `POST /_tidy/fill`
|
|
90
|
+
|
|
91
|
+
Writes real content over the template's sample content on a bootstrapped site. Same document shape as a seed's `content` section, so `$ref:<id>` and `$media: { url, alt, filename }` resolve the same way (media is downloaded into the site's storage).
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"settings": { "title": "Romney Pest Control", "tagline": "Pest control on demand" },
|
|
96
|
+
"content": {
|
|
97
|
+
"services": [{ "id": "ant-control", "data": { "title": "Ant control", "summary": "…" } }],
|
|
98
|
+
"reviews": [{ "id": "google-1", "data": { "title": "Dana R.", "quote": "…", "rating": 5, "service": "$ref:ant-control" } }]
|
|
99
|
+
},
|
|
100
|
+
"remove": { "services": ["mosquito-treatment"], "posts": ["fire-ants-after-rain"] }
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Entries are upserted by slug (`slug` defaults to `id`). An existing entry keeps every field the document does not name, so a fill can send only what it knows and the template's images and blocks stay underneath. `remove` deletes the named sample entries; slugs written by the same call are never removed, and unknown slugs are reported back in `missing`. Refuses with 409 before bootstrap. Returns `{ ok, settings, content: { created, updated, media }, removed, missing }`. Re-running the same document is safe.
|
|
105
|
+
|
|
89
106
|
### `POST /_tidy/maintenance?cron=…`
|
|
90
107
|
|
|
91
108
|
Runs EmDash's scheduled work now (scheduled publishing, cleanup) and returns what it published. Workers in a dispatch namespace never receive cron triggers, so the platform's cron calls this on every site each minute.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tideworthy/tidysites-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The Tidysites platform integration for EmDash sites: bootstrap, sessions, maintenance, health, and publish hooks under /_tidy/*.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"./routes/session-claim": "./src/routes/session-claim.ts",
|
|
25
25
|
"./routes/health": "./src/routes/health.ts",
|
|
26
26
|
"./routes/rotate-token": "./src/routes/rotate-token.ts",
|
|
27
|
+
"./routes/fill": "./src/routes/fill.ts",
|
|
27
28
|
"./routes/maintenance": "./src/routes/maintenance.ts"
|
|
28
29
|
},
|
|
29
30
|
"scripts": {
|
package/src/client.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* platform app. Pass `fetch` to route calls through a dispatch-namespace
|
|
4
4
|
* binding instead of the public internet.
|
|
5
5
|
*/
|
|
6
|
-
import type { BootstrapInput, BootstrapResult, HealthResult, MintSessionInput, MintSessionResult, RotateTokenInput } from "./lib/handlers";
|
|
6
|
+
import type { BootstrapInput, BootstrapResult, FillEntry, FillInput, FillResult, HealthResult, MintSessionInput, MintSessionResult, RotateTokenInput } from "./lib/handlers";
|
|
7
7
|
|
|
8
|
-
export type { BootstrapInput, BootstrapResult, HealthResult, MintSessionInput, MintSessionResult, RotateTokenInput };
|
|
8
|
+
export type { BootstrapInput, BootstrapResult, FillEntry, FillInput, FillResult, HealthResult, MintSessionInput, MintSessionResult, RotateTokenInput };
|
|
9
9
|
|
|
10
10
|
export interface MaintenanceResult {
|
|
11
11
|
ok: true;
|
|
@@ -56,6 +56,7 @@ export function createTidySiteClient(options: TidySiteClientOptions) {
|
|
|
56
56
|
mintSession: (input: MintSessionInput) => call<MintSessionResult>("POST", "/_tidy/session", input),
|
|
57
57
|
health: () => call<HealthResult>("GET", "/_tidy/health"),
|
|
58
58
|
rotateToken: (input: RotateTokenInput = {}) => call<{ ok: true; userId: string; token: string }>("POST", "/_tidy/rotate-token", input),
|
|
59
|
+
fill: (input: FillInput) => call<FillResult>("POST", "/_tidy/fill", input),
|
|
59
60
|
maintenance: (cron?: string) => call<MaintenanceResult>("POST", `/_tidy/maintenance${cron ? `?cron=${encodeURIComponent(cron)}` : ""}`)
|
|
60
61
|
};
|
|
61
62
|
}
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ export const PLATFORM_ROUTES = [
|
|
|
27
27
|
{ pattern: "/_tidy/session/claim", entrypoint: `${PACKAGE}/routes/session-claim` },
|
|
28
28
|
{ pattern: "/_tidy/health", entrypoint: `${PACKAGE}/routes/health` },
|
|
29
29
|
{ pattern: "/_tidy/rotate-token", entrypoint: `${PACKAGE}/routes/rotate-token` },
|
|
30
|
+
{ pattern: "/_tidy/fill", entrypoint: `${PACKAGE}/routes/fill` },
|
|
30
31
|
{ pattern: "/_tidy/maintenance", entrypoint: `${PACKAGE}/routes/maintenance` }
|
|
31
32
|
] as const;
|
|
32
33
|
|
package/src/lib/handlers.ts
CHANGED
|
@@ -12,6 +12,13 @@ export interface SeedOps {
|
|
|
12
12
|
load(): Promise<Record<string, unknown> & { settings?: Record<string, unknown> }>;
|
|
13
13
|
validate(seed: unknown): { valid: boolean; errors?: unknown };
|
|
14
14
|
apply(seed: Record<string, unknown>): Promise<{ collections?: unknown; content?: unknown }>;
|
|
15
|
+
/** Upsert content entries by slug (existing entries are updated), downloading `$media` URLs into the site's storage. */
|
|
16
|
+
upsertContent(content: Record<string, FillEntry[]>): Promise<{ created: number; updated: number; media: number }>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ContentOps {
|
|
20
|
+
findIdBySlug(collection: string, slug: string): Promise<string | null>;
|
|
21
|
+
delete(collection: string, id: string): Promise<boolean>;
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
export interface TokenOps {
|
|
@@ -24,6 +31,7 @@ export interface Deps {
|
|
|
24
31
|
store: PlatformStore;
|
|
25
32
|
seed: SeedOps;
|
|
26
33
|
tokens: TokenOps;
|
|
34
|
+
content: ContentOps;
|
|
27
35
|
now?: () => number;
|
|
28
36
|
}
|
|
29
37
|
|
|
@@ -113,6 +121,117 @@ export async function bootstrap(input: BootstrapInput, deps: Deps, requestOrigin
|
|
|
113
121
|
return { ok: true, userId: user.id, token: t.raw, steps };
|
|
114
122
|
}
|
|
115
123
|
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Fill: write real content over the template's sample content
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/** One entry in the seed's content shape: `data` holds field values, `$ref:<id>` and `$media` resolve as in a template seed. */
|
|
129
|
+
export interface FillEntry {
|
|
130
|
+
id: string;
|
|
131
|
+
slug?: string | null;
|
|
132
|
+
status?: "published" | "draft";
|
|
133
|
+
data: Record<string, unknown>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface FillInput {
|
|
137
|
+
/** Site title and tagline; either may be omitted. */
|
|
138
|
+
settings?: unknown;
|
|
139
|
+
/** Entries per collection slug, upserted by slug. */
|
|
140
|
+
content?: unknown;
|
|
141
|
+
/** Entry slugs per collection slug to delete (sample entries the fill did not replace). Unknown slugs are ignored. */
|
|
142
|
+
remove?: unknown;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface FillResult {
|
|
146
|
+
ok: true;
|
|
147
|
+
settings: string[];
|
|
148
|
+
content: { created: number; updated: number; media: number };
|
|
149
|
+
removed: number;
|
|
150
|
+
missing: Array<{ collection: string; slug: string }>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const COLLECTION_SLUG = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
154
|
+
const ENTRY_SLUG = /^[a-z0-9][a-z0-9_-]{0,127}$/i;
|
|
155
|
+
const MAX_ENTRIES = 2000;
|
|
156
|
+
|
|
157
|
+
function fillContent(value: unknown): Record<string, FillEntry[]> {
|
|
158
|
+
if (value === undefined) return {};
|
|
159
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new HttpError(400, "content must be an object of collection slug to entry list");
|
|
160
|
+
const out: Record<string, FillEntry[]> = {};
|
|
161
|
+
let count = 0;
|
|
162
|
+
for (const [collection, entries] of Object.entries(value as Record<string, unknown>)) {
|
|
163
|
+
if (!COLLECTION_SLUG.test(collection)) throw new HttpError(400, `content: "${collection}" is not a collection slug`);
|
|
164
|
+
if (!Array.isArray(entries)) throw new HttpError(400, `content.${collection} must be a list of entries`);
|
|
165
|
+
out[collection] = entries.map((e, i) => {
|
|
166
|
+
if (e === null || typeof e !== "object" || Array.isArray(e)) throw new HttpError(400, `content.${collection}[${i}] must be an entry object`);
|
|
167
|
+
const entry = e as Record<string, unknown>;
|
|
168
|
+
if (typeof entry.id !== "string" || !ENTRY_SLUG.test(entry.id)) throw new HttpError(400, `content.${collection}[${i}].id must be a slug-like id`);
|
|
169
|
+
if (entry.slug !== undefined && entry.slug !== null && (typeof entry.slug !== "string" || !ENTRY_SLUG.test(entry.slug))) throw new HttpError(400, `content.${collection}[${i}].slug must be a slug`);
|
|
170
|
+
if (entry.status !== undefined && entry.status !== "published" && entry.status !== "draft") throw new HttpError(400, `content.${collection}[${i}].status must be published or draft`);
|
|
171
|
+
if (entry.data === null || typeof entry.data !== "object" || Array.isArray(entry.data)) throw new HttpError(400, `content.${collection}[${i}].data must be an object`);
|
|
172
|
+
count++;
|
|
173
|
+
return { id: entry.id, slug: entry.slug === undefined ? entry.id : (entry.slug as string | null), status: entry.status as FillEntry["status"], data: entry.data as Record<string, unknown> };
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (count > MAX_ENTRIES) throw new HttpError(400, `content: at most ${MAX_ENTRIES} entries per call`);
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function fillRemove(value: unknown): Array<{ collection: string; slug: string }> {
|
|
181
|
+
if (value === undefined) return [];
|
|
182
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new HttpError(400, "remove must be an object of collection slug to slug list");
|
|
183
|
+
const out: Array<{ collection: string; slug: string }> = [];
|
|
184
|
+
for (const [collection, slugs] of Object.entries(value as Record<string, unknown>)) {
|
|
185
|
+
if (!COLLECTION_SLUG.test(collection)) throw new HttpError(400, `remove: "${collection}" is not a collection slug`);
|
|
186
|
+
if (!Array.isArray(slugs) || slugs.some((x) => typeof x !== "string" || !ENTRY_SLUG.test(x))) throw new HttpError(400, `remove.${collection} must be a list of slugs`);
|
|
187
|
+
for (const slug of slugs as string[]) out.push({ collection, slug });
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Write real content over a bootstrapped site: settings, entries upserted by
|
|
194
|
+
* slug (so re-running with the same document is safe), then sample entries
|
|
195
|
+
* the platform names for removal. Writes happen in that order so a removed
|
|
196
|
+
* sample is never a dangling reference target for an entry written here.
|
|
197
|
+
*/
|
|
198
|
+
export async function fill(input: FillInput, deps: Deps): Promise<FillResult> {
|
|
199
|
+
if (input.settings !== undefined && (input.settings === null || typeof input.settings !== "object" || Array.isArray(input.settings))) throw new HttpError(400, "settings must be an object");
|
|
200
|
+
const settings = (input.settings ?? {}) as Record<string, unknown>;
|
|
201
|
+
if (settings.title !== undefined && (typeof settings.title !== "string" || settings.title.length > 200)) throw new HttpError(400, "settings.title must be a string of at most 200 characters");
|
|
202
|
+
if (settings.tagline !== undefined && (typeof settings.tagline !== "string" || settings.tagline.length > 500)) throw new HttpError(400, "settings.tagline must be a string of at most 500 characters");
|
|
203
|
+
const content = fillContent(input.content);
|
|
204
|
+
const remove = fillRemove(input.remove);
|
|
205
|
+
if ((await deps.store.getOption("emdash:setup_complete")) !== true) throw new HttpError(409, "site is not bootstrapped yet");
|
|
206
|
+
|
|
207
|
+
const applied: string[] = [];
|
|
208
|
+
if (typeof settings.title === "string") {
|
|
209
|
+
await deps.store.setOption("emdash:site_title", settings.title);
|
|
210
|
+
applied.push("title");
|
|
211
|
+
}
|
|
212
|
+
if (typeof settings.tagline === "string") {
|
|
213
|
+
await deps.store.setOption("emdash:site_tagline", settings.tagline);
|
|
214
|
+
applied.push("tagline");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const written = Object.keys(content).length > 0 ? await deps.seed.upsertContent(content) : { created: 0, updated: 0, media: 0 };
|
|
218
|
+
|
|
219
|
+
// Never delete something this same call just wrote.
|
|
220
|
+
const kept = new Set(Object.entries(content).flatMap(([c, entries]) => entries.map((e) => `${c}/${e.slug ?? e.id}`)));
|
|
221
|
+
let removed = 0;
|
|
222
|
+
const missing: Array<{ collection: string; slug: string }> = [];
|
|
223
|
+
for (const r of remove) {
|
|
224
|
+
if (kept.has(`${r.collection}/${r.slug}`)) continue;
|
|
225
|
+
const id = await deps.content.findIdBySlug(r.collection, r.slug);
|
|
226
|
+
if (!id) {
|
|
227
|
+
missing.push(r);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (await deps.content.delete(r.collection, id)) removed++;
|
|
231
|
+
}
|
|
232
|
+
return { ok: true, settings: applied, content: written, removed, missing };
|
|
233
|
+
}
|
|
234
|
+
|
|
116
235
|
// ---------------------------------------------------------------------------
|
|
117
236
|
// Sessions: mint a one-time handoff, then a browser claims it
|
|
118
237
|
// ---------------------------------------------------------------------------
|
package/src/lib/runtime.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* This is the only file (besides src/plugin.ts) that imports EmDash. Keep it
|
|
7
7
|
* that way: it is the list of things to re-verify on an EmDash upgrade.
|
|
8
8
|
*/
|
|
9
|
-
import { OptionsRepository, UserRepository, applySeed, getMigrationStatus, ulid, validateSeed } from "emdash";
|
|
9
|
+
import { ContentRepository, OptionsRepository, UserRepository, applySeed, getMigrationStatus, ulid, validateSeed } from "emdash";
|
|
10
10
|
import { getDb } from "emdash/runtime";
|
|
11
11
|
import { loadSeed } from "emdash/seed";
|
|
12
12
|
import { VALID_SCOPES, generatePrefixedToken } from "@emdash-cms/auth";
|
|
@@ -38,12 +38,38 @@ async function storageProvider(): Promise<unknown> {
|
|
|
38
38
|
export async function buildDeps(): Promise<Deps> {
|
|
39
39
|
const [db, storage] = await Promise.all([getDb(), storageProvider()]);
|
|
40
40
|
const store = createEmdashStore(db, { OptionsRepository, UserRepository, ulid, getMigrationStatus });
|
|
41
|
+
const content = new ContentRepository(db);
|
|
42
|
+
const storageOpt = storage ? { storage: storage as never } : {};
|
|
41
43
|
return {
|
|
42
44
|
store,
|
|
43
45
|
seed: {
|
|
44
46
|
load: async () => (await loadSeed()) as unknown as Record<string, unknown> & { settings?: Record<string, unknown> },
|
|
45
47
|
validate: (seed) => validateSeed(seed),
|
|
46
|
-
apply: (seed) => applySeed(db, seed as unknown as Parameters<typeof applySeed>[1], { includeContent: true, onConflict: "skip", ...
|
|
48
|
+
apply: (seed) => applySeed(db, seed as unknown as Parameters<typeof applySeed>[1], { includeContent: true, onConflict: "skip", ...storageOpt }),
|
|
49
|
+
// A content-only seed document: no collections, menus, or settings, so
|
|
50
|
+
// applySeed touches nothing but entries. "update" makes it an upsert by
|
|
51
|
+
// slug. applySeed replaces an existing entry's data wholesale, so fields
|
|
52
|
+
// the caller did not send are carried over from the current entry first:
|
|
53
|
+
// a fill names only what it knows and the template's images and blocks
|
|
54
|
+
// survive underneath.
|
|
55
|
+
upsertContent: async (entries) => {
|
|
56
|
+
const merged: typeof entries = {};
|
|
57
|
+
for (const [collection, list] of Object.entries(entries)) {
|
|
58
|
+
merged[collection] = [];
|
|
59
|
+
for (const e of list) {
|
|
60
|
+
const slug = e.slug ?? e.id;
|
|
61
|
+
const existing = slug ? await content.findBySlug(collection, slug) : null;
|
|
62
|
+
const current = (existing?.data ?? {}) as Record<string, unknown>;
|
|
63
|
+
merged[collection].push({ ...e, data: existing ? { ...current, ...e.data } : e.data });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const r = await applySeed(db, { version: 1, content: merged } as unknown as Parameters<typeof applySeed>[1], { includeContent: true, onConflict: "update", ...storageOpt });
|
|
67
|
+
return { created: r.content?.created ?? 0, updated: r.content?.updated ?? 0, media: r.media?.created ?? 0 };
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
content: {
|
|
71
|
+
findIdBySlug: async (collection, slug) => (await content.findBySlug(collection, slug))?.id ?? null,
|
|
72
|
+
delete: (collection, id) => content.delete(collection, id)
|
|
47
73
|
},
|
|
48
74
|
tokens: {
|
|
49
75
|
generate: () => generatePrefixedToken("ec_pat_"),
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** POST /_tidy/fill: real content over the template's sample content. */
|
|
2
|
+
import { fill, type FillInput } from "../lib/handlers";
|
|
3
|
+
import { json, readJson } from "../lib/http";
|
|
4
|
+
import { methodNotAllowed, platformRoute } from "./_shared";
|
|
5
|
+
|
|
6
|
+
export const prerender = false;
|
|
7
|
+
|
|
8
|
+
export const POST = platformRoute(async (context, deps) => {
|
|
9
|
+
const body = await readJson<FillInput>(context.request);
|
|
10
|
+
return json(await fill(body, deps));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const ALL = methodNotAllowed("POST");
|
package/src/version.ts
CHANGED