emdash 0.1.1 → 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/dist/{adapters-BLMa4JGD.d.mts → adapters-N6BF7RCD.d.mts} +1 -1
- package/dist/{adapters-BLMa4JGD.d.mts.map → adapters-N6BF7RCD.d.mts.map} +1 -1
- package/dist/{apply-kC39ev1Z.mjs → apply-wmVEOSbR.mjs} +56 -9
- package/dist/apply-wmVEOSbR.mjs.map +1 -0
- package/dist/astro/index.d.mts +6 -6
- package/dist/astro/index.mjs +80 -27
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +5 -5
- package/dist/astro/middleware/auth.d.mts.map +1 -1
- package/dist/astro/middleware/auth.mjs +127 -56
- package/dist/astro/middleware/auth.mjs.map +1 -1
- package/dist/astro/middleware/request-context.mjs +1 -1
- package/dist/astro/middleware/setup.mjs +1 -1
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +74 -39
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +30 -9
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-CL847F26.mjs → byline-1WQPlISL.mjs} +51 -29
- package/dist/byline-1WQPlISL.mjs.map +1 -0
- package/dist/{bylines-C2a-2TGt.mjs → bylines-BYdTYmia.mjs} +10 -8
- package/dist/{bylines-C2a-2TGt.mjs.map → bylines-BYdTYmia.mjs.map} +1 -1
- package/dist/cli/index.mjs +15 -12
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/cf-access.d.mts +1 -1
- package/dist/client/index.d.mts +1 -1
- package/dist/client/index.mjs +1 -1
- package/dist/{config-CKE8p9xM.mjs → config-Cq8H0SfX.mjs} +2 -10
- package/dist/{config-CKE8p9xM.mjs.map → config-Cq8H0SfX.mjs.map} +1 -1
- package/dist/{content-D6C2WsZC.mjs → content-BmXndhdi.mjs} +16 -3
- package/dist/content-BmXndhdi.mjs.map +1 -0
- package/dist/db/index.d.mts +3 -3
- package/dist/db/index.mjs +1 -1
- package/dist/db/libsql.d.mts +1 -1
- package/dist/db/postgres.d.mts +1 -1
- package/dist/db/sqlite.d.mts +1 -1
- package/dist/{default-Cyi4aAxu.mjs → default-WYlzADZL.mjs} +1 -1
- package/dist/{default-Cyi4aAxu.mjs.map → default-WYlzADZL.mjs.map} +1 -1
- package/dist/{error-Cxz0tQeO.mjs → error-DrxtnGPg.mjs} +1 -1
- package/dist/{error-Cxz0tQeO.mjs.map → error-DrxtnGPg.mjs.map} +1 -1
- package/dist/{index-CLBc4gw-.d.mts → index-UHEVQMus.d.mts} +55 -17
- package/dist/index-UHEVQMus.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +17 -17
- package/dist/{load-yOOlckBj.mjs → load-Veizk2cT.mjs} +1 -1
- package/dist/{load-yOOlckBj.mjs.map → load-Veizk2cT.mjs.map} +1 -1
- package/dist/{loader-fz8Q_3EO.mjs → loader-CHb2v0jm.mjs} +1 -1
- package/dist/{loader-fz8Q_3EO.mjs.map → loader-CHb2v0jm.mjs.map} +1 -1
- package/dist/{manifest-schema-CL8DWO9b.mjs → manifest-schema-CuMio1A9.mjs} +1 -1
- package/dist/{manifest-schema-CL8DWO9b.mjs.map → manifest-schema-CuMio1A9.mjs.map} +1 -1
- package/dist/media/index.d.mts +1 -1
- package/dist/media/local-runtime.d.mts +7 -7
- package/dist/{mode-C2EzN1uE.mjs → mode-CYeM2rPt.mjs} +1 -1
- package/dist/{mode-C2EzN1uE.mjs.map → mode-CYeM2rPt.mjs.map} +1 -1
- package/dist/page/index.d.mts +10 -1
- package/dist/page/index.d.mts.map +1 -1
- package/dist/page/index.mjs +8 -4
- package/dist/page/index.mjs.map +1 -1
- package/dist/{placeholder-SvFCKbz_.d.mts → placeholder-bOx1xCTY.d.mts} +1 -1
- package/dist/{placeholder-SvFCKbz_.d.mts.map → placeholder-bOx1xCTY.d.mts.map} +1 -1
- package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
- package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
- package/dist/{query-BVYN0PJ6.mjs → query-5Hcv_5ER.mjs} +20 -8
- package/dist/{query-BVYN0PJ6.mjs.map → query-5Hcv_5ER.mjs.map} +1 -1
- package/dist/{registry-BNYQKX_d.mjs → registry-1EvbAfsC.mjs} +6 -2
- package/dist/{registry-BNYQKX_d.mjs.map → registry-1EvbAfsC.mjs.map} +1 -1
- package/dist/{runner-BraqvGYk.mjs → runner-BoN0-FPi.mjs} +155 -130
- package/dist/runner-BoN0-FPi.mjs.map +1 -0
- package/dist/{runner-EAtf0ZIe.d.mts → runner-DTqkzOzc.d.mts} +2 -2
- package/dist/{runner-EAtf0ZIe.d.mts.map → runner-DTqkzOzc.d.mts.map} +1 -1
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +1 -1
- package/dist/{search-C1gg67nN.mjs → search-BsYMed12.mjs} +235 -105
- package/dist/search-BsYMed12.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +8 -8
- package/dist/seo/index.d.mts +1 -1
- package/dist/storage/local.d.mts +1 -1
- package/dist/storage/local.mjs +1 -1
- package/dist/storage/s3.d.mts +1 -1
- package/dist/storage/s3.mjs +1 -1
- package/dist/{tokens-DpgrkrXK.mjs → tokens-DrB-W6Q-.mjs} +1 -1
- package/dist/{tokens-DpgrkrXK.mjs.map → tokens-DrB-W6Q-.mjs.map} +1 -1
- package/dist/{transport-yxiQsi8I.mjs → transport-Bl8cTdYt.mjs} +1 -1
- package/dist/{transport-yxiQsi8I.mjs.map → transport-Bl8cTdYt.mjs.map} +1 -1
- package/dist/{transport-BFGblqwG.d.mts → transport-COOs9GSE.d.mts} +1 -1
- package/dist/{transport-BFGblqwG.d.mts.map → transport-COOs9GSE.d.mts.map} +1 -1
- package/dist/{types-BQo5JS0J.d.mts → types-6dqxBqsH.d.mts} +80 -106
- package/dist/types-6dqxBqsH.d.mts.map +1 -0
- package/dist/{types-DRjfYOEv.d.mts → types-7-UjSEyB.d.mts} +1 -1
- package/dist/{types-DRjfYOEv.d.mts.map → types-7-UjSEyB.d.mts.map} +1 -1
- package/dist/{types-CUBbjgmP.mjs → types-Bec-r_3_.mjs} +1 -1
- package/dist/{types-CUBbjgmP.mjs.map → types-Bec-r_3_.mjs.map} +1 -1
- package/dist/{types-DaNLHo_T.d.mts → types-BljtYPSd.d.mts} +1 -1
- package/dist/{types-DaNLHo_T.d.mts.map → types-BljtYPSd.d.mts.map} +1 -1
- package/dist/{types-BRuPJGdV.d.mts → types-CIsTnQvJ.d.mts} +3 -1
- package/dist/types-CIsTnQvJ.d.mts.map +1 -0
- package/dist/types-CMMN0pNg.mjs.map +1 -1
- package/dist/{types-DPfzHnjW.d.mts → types-CcreFIIH.d.mts} +1 -1
- package/dist/{types-DPfzHnjW.d.mts.map → types-CcreFIIH.d.mts.map} +1 -1
- package/dist/{types-CiA5Gac0.mjs → types-DuNbGKjF.mjs} +1 -1
- package/dist/{types-CiA5Gac0.mjs.map → types-DuNbGKjF.mjs.map} +1 -1
- package/dist/{validate-HtxZeaBi.d.mts → validate-B7KP7VLM.d.mts} +4 -4
- package/dist/{validate-HtxZeaBi.d.mts.map → validate-B7KP7VLM.d.mts.map} +1 -1
- package/dist/{validate-_rsF-Dx_.mjs → validate-CXnRKfJK.mjs} +2 -2
- package/dist/{validate-_rsF-Dx_.mjs.map → validate-CXnRKfJK.mjs.map} +1 -1
- package/package.json +6 -6
- package/src/api/csrf.ts +13 -2
- package/src/api/handlers/content.ts +7 -0
- package/src/api/handlers/dashboard.ts +4 -8
- package/src/api/handlers/device-flow.ts +55 -37
- package/src/api/handlers/index.ts +6 -1
- package/src/api/handlers/seo.ts +48 -21
- package/src/api/public-url.ts +84 -0
- package/src/api/schemas/content.ts +2 -2
- package/src/api/schemas/menus.ts +12 -2
- package/src/astro/integration/index.ts +30 -7
- package/src/astro/integration/routes.ts +13 -2
- package/src/astro/integration/runtime.ts +7 -5
- package/src/astro/integration/vite-config.ts +52 -9
- package/src/astro/middleware/auth.ts +60 -56
- package/src/astro/middleware/csp.ts +25 -0
- package/src/astro/middleware.ts +31 -3
- package/src/astro/routes/PluginRegistry.tsx +8 -2
- package/src/astro/routes/admin.astro +7 -2
- package/src/astro/routes/api/admin/users/[id]/disable.ts +18 -12
- package/src/astro/routes/api/admin/users/[id]/index.ts +26 -5
- package/src/astro/routes/api/auth/invite/complete.ts +3 -2
- package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +2 -1
- package/src/astro/routes/api/auth/oauth/[provider].ts +2 -1
- package/src/astro/routes/api/auth/passkey/options.ts +3 -2
- package/src/astro/routes/api/auth/passkey/register/options.ts +3 -2
- package/src/astro/routes/api/auth/passkey/register/verify.ts +3 -2
- package/src/astro/routes/api/auth/passkey/verify.ts +3 -2
- package/src/astro/routes/api/auth/signup/complete.ts +3 -2
- package/src/astro/routes/api/content/[collection]/index.ts +31 -3
- package/src/astro/routes/api/import/wordpress/execute.ts +9 -0
- package/src/astro/routes/api/import/wordpress-plugin/execute.ts +10 -0
- package/src/astro/routes/api/manifest.ts +1 -0
- package/src/astro/routes/api/media/providers/[providerId]/[itemId].ts +7 -2
- package/src/astro/routes/api/oauth/authorize.ts +12 -7
- package/src/astro/routes/api/oauth/device/code.ts +5 -1
- package/src/astro/routes/api/setup/admin-verify.ts +3 -2
- package/src/astro/routes/api/setup/admin.ts +3 -2
- package/src/astro/routes/api/setup/dev-bypass.ts +2 -1
- package/src/astro/routes/api/setup/index.ts +3 -2
- package/src/astro/routes/api/snapshot.ts +2 -1
- package/src/astro/routes/api/themes/preview.ts +2 -1
- package/src/astro/routes/api/well-known/auth.ts +1 -0
- package/src/astro/routes/api/well-known/oauth-authorization-server.ts +3 -2
- package/src/astro/routes/api/well-known/oauth-protected-resource.ts +3 -2
- package/src/astro/routes/robots.txt.ts +5 -1
- package/src/astro/routes/sitemap-[collection].xml.ts +104 -0
- package/src/astro/routes/sitemap.xml.ts +18 -23
- package/src/astro/types.ts +27 -1
- package/src/auth/passkey-config.ts +6 -10
- package/src/bylines/index.ts +11 -8
- package/src/cli/commands/login.ts +5 -2
- package/src/components/InlinePortableTextEditor.tsx +5 -3
- package/src/content/converters/portable-text-to-prosemirror.ts +50 -2
- package/src/database/migrations/034_published_at_index.ts +29 -0
- package/src/database/migrations/runner.ts +2 -0
- package/src/database/repositories/byline.ts +48 -42
- package/src/database/repositories/content.ts +23 -1
- package/src/database/repositories/options.ts +9 -3
- package/src/database/repositories/seo.ts +34 -17
- package/src/database/repositories/types.ts +2 -0
- package/src/emdash-runtime.ts +61 -18
- package/src/import/index.ts +1 -1
- package/src/import/sources/wxr.ts +45 -2
- package/src/index.ts +9 -1
- package/src/mcp/server.ts +85 -5
- package/src/menus/index.ts +2 -1
- package/src/page/context.ts +13 -1
- package/src/page/jsonld.ts +10 -6
- package/src/page/seo-contributions.ts +1 -1
- package/src/plugins/context.ts +145 -35
- package/src/plugins/manager.ts +12 -0
- package/src/plugins/types.ts +80 -4
- package/src/query.ts +18 -0
- package/src/schema/registry.ts +5 -0
- package/src/settings/index.ts +64 -0
- package/src/utils/chunks.ts +17 -0
- package/dist/apply-kC39ev1Z.mjs.map +0 -1
- package/dist/byline-CL847F26.mjs.map +0 -1
- package/dist/content-D6C2WsZC.mjs.map +0 -1
- package/dist/index-CLBc4gw-.d.mts.map +0 -1
- package/dist/runner-BraqvGYk.mjs.map +0 -1
- package/dist/search-C1gg67nN.mjs.map +0 -1
- package/dist/types-BQo5JS0J.d.mts.map +0 -1
- package/dist/types-BRuPJGdV.d.mts.map +0 -1
- /package/src/astro/routes/api/media/file/{[key].ts → [...key].ts} +0 -0
package/src/plugins/context.ts
CHANGED
|
@@ -12,7 +12,9 @@ import { ContentRepository } from "../database/repositories/content.js";
|
|
|
12
12
|
import { MediaRepository } from "../database/repositories/media.js";
|
|
13
13
|
import { OptionsRepository } from "../database/repositories/options.js";
|
|
14
14
|
import { PluginStorageRepository } from "../database/repositories/plugin-storage.js";
|
|
15
|
+
import { SeoRepository } from "../database/repositories/seo.js";
|
|
15
16
|
import { UserRepository } from "../database/repositories/user.js";
|
|
17
|
+
import { withTransaction } from "../database/transaction.js";
|
|
16
18
|
import type { Database } from "../database/types.js";
|
|
17
19
|
import { validateExternalUrl, SsrfError, stripCredentialHeaders } from "../import/ssrf.js";
|
|
18
20
|
import type { Storage } from "../storage/types.js";
|
|
@@ -36,6 +38,8 @@ import type {
|
|
|
36
38
|
UserAccess,
|
|
37
39
|
UserInfo,
|
|
38
40
|
ContentItem,
|
|
41
|
+
ContentItemSeoInput,
|
|
42
|
+
ContentWriteInput,
|
|
39
43
|
MediaItem,
|
|
40
44
|
PaginatedResult,
|
|
41
45
|
QueryOptions,
|
|
@@ -148,24 +152,66 @@ export function createStorageAccess<T extends PluginStorageConfig>(
|
|
|
148
152
|
// Content Access
|
|
149
153
|
// =============================================================================
|
|
150
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Extract `seo` from a plugin-supplied content write input and return both
|
|
157
|
+
* parts. Mutates nothing — returns a new field map without the `seo` key.
|
|
158
|
+
*/
|
|
159
|
+
function splitSeoFromInput(input: ContentWriteInput): {
|
|
160
|
+
fields: Record<string, unknown>;
|
|
161
|
+
seo: ContentItemSeoInput | undefined;
|
|
162
|
+
} {
|
|
163
|
+
const { seo, ...fields } = input;
|
|
164
|
+
// Reject non-object seo values rather than silently dropping them.
|
|
165
|
+
if (seo !== undefined && (seo === null || typeof seo !== "object" || Array.isArray(seo))) {
|
|
166
|
+
throw new Error("content.seo must be an object");
|
|
167
|
+
}
|
|
168
|
+
return { fields, seo };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Reject writing SEO to a collection that does not have it enabled.
|
|
173
|
+
* Matches the REST API behavior (VALIDATION_ERROR).
|
|
174
|
+
*/
|
|
175
|
+
async function assertSeoEnabled(
|
|
176
|
+
seoRepo: SeoRepository,
|
|
177
|
+
collection: string,
|
|
178
|
+
seo: ContentItemSeoInput | undefined,
|
|
179
|
+
): Promise<boolean> {
|
|
180
|
+
const hasSeo = await seoRepo.isEnabled(collection);
|
|
181
|
+
if (seo !== undefined && !hasSeo) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Collection "${collection}" does not have SEO enabled. ` +
|
|
184
|
+
`Remove the seo field or enable SEO on this collection.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return hasSeo;
|
|
188
|
+
}
|
|
189
|
+
|
|
151
190
|
/**
|
|
152
191
|
* Create read-only content access
|
|
153
192
|
*/
|
|
154
193
|
export function createContentAccess(db: Kysely<Database>): ContentAccess {
|
|
155
194
|
const contentRepo = new ContentRepository(db);
|
|
195
|
+
const seoRepo = new SeoRepository(db);
|
|
156
196
|
|
|
157
197
|
return {
|
|
158
198
|
async get(collection: string, id: string): Promise<ContentItem | null> {
|
|
159
199
|
const item = await contentRepo.findById(collection, id);
|
|
160
200
|
if (!item) return null;
|
|
161
201
|
|
|
162
|
-
|
|
202
|
+
const result: ContentItem = {
|
|
163
203
|
id: item.id,
|
|
164
204
|
type: item.type,
|
|
165
205
|
data: item.data,
|
|
166
206
|
createdAt: item.createdAt,
|
|
167
207
|
updatedAt: item.updatedAt,
|
|
168
208
|
};
|
|
209
|
+
|
|
210
|
+
if (await seoRepo.isEnabled(collection)) {
|
|
211
|
+
result.seo = await seoRepo.get(collection, item.id);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return result;
|
|
169
215
|
},
|
|
170
216
|
|
|
171
217
|
async list(
|
|
@@ -188,14 +234,27 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
|
|
|
188
234
|
orderBy,
|
|
189
235
|
});
|
|
190
236
|
|
|
237
|
+
const items: ContentItem[] = result.items.map((item) => ({
|
|
238
|
+
id: item.id,
|
|
239
|
+
type: item.type,
|
|
240
|
+
data: item.data,
|
|
241
|
+
createdAt: item.createdAt,
|
|
242
|
+
updatedAt: item.updatedAt,
|
|
243
|
+
}));
|
|
244
|
+
|
|
245
|
+
if (items.length > 0 && (await seoRepo.isEnabled(collection))) {
|
|
246
|
+
const seoMap = await seoRepo.getMany(
|
|
247
|
+
collection,
|
|
248
|
+
items.map((i) => i.id),
|
|
249
|
+
);
|
|
250
|
+
for (const item of items) {
|
|
251
|
+
const seo = seoMap.get(item.id);
|
|
252
|
+
if (seo) item.seo = seo;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
191
256
|
return {
|
|
192
|
-
items
|
|
193
|
-
id: item.id,
|
|
194
|
-
type: item.type,
|
|
195
|
-
data: item.data,
|
|
196
|
-
createdAt: item.createdAt,
|
|
197
|
-
updatedAt: item.updatedAt,
|
|
198
|
-
})),
|
|
257
|
+
items,
|
|
199
258
|
cursor: result.nextCursor,
|
|
200
259
|
hasMore: !!result.nextCursor,
|
|
201
260
|
};
|
|
@@ -204,47 +263,97 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
|
|
|
204
263
|
}
|
|
205
264
|
|
|
206
265
|
/**
|
|
207
|
-
* Create full content access with write operations
|
|
266
|
+
* Create full content access with write operations.
|
|
267
|
+
*
|
|
268
|
+
* `create` and `update` accept a reserved `seo` key in their `data`
|
|
269
|
+
* argument. When present, it is routed to the core SEO panel
|
|
270
|
+
* (`_emdash_seo`) via `SeoRepository.upsert`, in the same transaction as
|
|
271
|
+
* the content write. The returned `ContentItem.seo` reflects the resulting
|
|
272
|
+
* SEO state for SEO-enabled collections.
|
|
208
273
|
*/
|
|
209
274
|
export function createContentAccessWithWrite(db: Kysely<Database>): ContentAccessWithWrite {
|
|
210
|
-
const contentRepo = new ContentRepository(db);
|
|
211
275
|
const readAccess = createContentAccess(db);
|
|
212
276
|
|
|
213
277
|
return {
|
|
214
278
|
...readAccess,
|
|
215
279
|
|
|
216
|
-
async create(collection: string, data:
|
|
217
|
-
const
|
|
218
|
-
type: collection,
|
|
219
|
-
data,
|
|
220
|
-
});
|
|
280
|
+
async create(collection: string, data: ContentWriteInput): Promise<ContentItem> {
|
|
281
|
+
const { fields, seo } = splitSeoFromInput(data);
|
|
221
282
|
|
|
222
|
-
return {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
283
|
+
return withTransaction(db, async (trx) => {
|
|
284
|
+
const trxContentRepo = new ContentRepository(trx);
|
|
285
|
+
const trxSeoRepo = new SeoRepository(trx);
|
|
286
|
+
|
|
287
|
+
const hasSeo = await assertSeoEnabled(trxSeoRepo, collection, seo);
|
|
288
|
+
|
|
289
|
+
const item = await trxContentRepo.create({
|
|
290
|
+
type: collection,
|
|
291
|
+
data: fields,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const result: ContentItem = {
|
|
295
|
+
id: item.id,
|
|
296
|
+
type: item.type,
|
|
297
|
+
data: item.data,
|
|
298
|
+
createdAt: item.createdAt,
|
|
299
|
+
updatedAt: item.updatedAt,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
if (hasSeo) {
|
|
303
|
+
result.seo =
|
|
304
|
+
seo !== undefined
|
|
305
|
+
? await trxSeoRepo.upsert(collection, item.id, seo)
|
|
306
|
+
: await trxSeoRepo.get(collection, item.id);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return result;
|
|
310
|
+
});
|
|
229
311
|
},
|
|
230
312
|
|
|
231
|
-
async update(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
313
|
+
async update(collection: string, id: string, data: ContentWriteInput): Promise<ContentItem> {
|
|
314
|
+
const { fields, seo } = splitSeoFromInput(data);
|
|
315
|
+
|
|
316
|
+
return withTransaction(db, async (trx) => {
|
|
317
|
+
const trxContentRepo = new ContentRepository(trx);
|
|
318
|
+
const trxSeoRepo = new SeoRepository(trx);
|
|
319
|
+
|
|
320
|
+
const hasSeo = await assertSeoEnabled(trxSeoRepo, collection, seo);
|
|
321
|
+
|
|
322
|
+
// Pass the `data` payload to ContentRepository.update only when
|
|
323
|
+
// there are field updates — passing an empty object would still
|
|
324
|
+
// bump updated_at/version, but we want a seo-only call to touch
|
|
325
|
+
// only the SEO table. ContentRepository.update handles the no-op
|
|
326
|
+
// path by returning the current row.
|
|
327
|
+
const hasFieldUpdates = Object.keys(fields).length > 0;
|
|
328
|
+
const item = hasFieldUpdates
|
|
329
|
+
? await trxContentRepo.update(collection, id, { data: fields })
|
|
330
|
+
: await (async () => {
|
|
331
|
+
const existing = await trxContentRepo.findById(collection, id);
|
|
332
|
+
if (!existing) throw new Error("Content not found");
|
|
333
|
+
return existing;
|
|
334
|
+
})();
|
|
335
|
+
|
|
336
|
+
const result: ContentItem = {
|
|
337
|
+
id: item.id,
|
|
338
|
+
type: item.type,
|
|
339
|
+
data: item.data,
|
|
340
|
+
createdAt: item.createdAt,
|
|
341
|
+
updatedAt: item.updatedAt,
|
|
342
|
+
};
|
|
237
343
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
344
|
+
if (hasSeo) {
|
|
345
|
+
result.seo =
|
|
346
|
+
seo !== undefined
|
|
347
|
+
? await trxSeoRepo.upsert(collection, item.id, seo)
|
|
348
|
+
: await trxSeoRepo.get(collection, item.id);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return result;
|
|
352
|
+
});
|
|
245
353
|
},
|
|
246
354
|
|
|
247
355
|
async delete(collection: string, id: string): Promise<boolean> {
|
|
356
|
+
const contentRepo = new ContentRepository(db);
|
|
248
357
|
return contentRepo.delete(collection, id);
|
|
249
358
|
},
|
|
250
359
|
};
|
|
@@ -384,6 +493,7 @@ const MAX_PLUGIN_REDIRECTS = 5;
|
|
|
384
493
|
|
|
385
494
|
function isHostAllowed(host: string, allowedHosts: string[]): boolean {
|
|
386
495
|
return allowedHosts.some((pattern) => {
|
|
496
|
+
if (pattern === "*") return true;
|
|
387
497
|
if (pattern.startsWith("*.")) {
|
|
388
498
|
const suffix = pattern.slice(1); // ".example.com"
|
|
389
499
|
return host.endsWith(suffix) || host === pattern.slice(2);
|
package/src/plugins/manager.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { Storage } from "../storage/types.js";
|
|
|
18
18
|
import type { PluginContextFactoryOptions } from "./context.js";
|
|
19
19
|
import { setCronTasksEnabled } from "./cron.js";
|
|
20
20
|
import { definePlugin } from "./define-plugin.js";
|
|
21
|
+
import type { EmailPipeline } from "./email.js";
|
|
21
22
|
import {
|
|
22
23
|
HookPipeline,
|
|
23
24
|
type HookResult,
|
|
@@ -83,6 +84,17 @@ export class PluginManager {
|
|
|
83
84
|
};
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Set the email pipeline used when creating plugin contexts.
|
|
89
|
+
* Reinitializes routes/hooks if already initialized so ctx.email is available immediately.
|
|
90
|
+
*/
|
|
91
|
+
setEmailPipeline(pipeline: EmailPipeline): void {
|
|
92
|
+
this.factoryOptions.emailPipeline = pipeline;
|
|
93
|
+
if (this.initialized) {
|
|
94
|
+
this.reinitialize();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
86
98
|
// =========================================================================
|
|
87
99
|
// Plugin Registration
|
|
88
100
|
// =========================================================================
|
package/src/plugins/types.ts
CHANGED
|
@@ -161,6 +161,34 @@ export interface KVAccess {
|
|
|
161
161
|
list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* SEO metadata for a content item, as stored in the core SEO panel.
|
|
166
|
+
*
|
|
167
|
+
* Only present on items in collections with `has_seo = 1`. For collections
|
|
168
|
+
* without SEO enabled, `ContentItem.seo` is `undefined`.
|
|
169
|
+
*/
|
|
170
|
+
export interface ContentItemSeo {
|
|
171
|
+
title: string | null;
|
|
172
|
+
description: string | null;
|
|
173
|
+
image: string | null;
|
|
174
|
+
canonical: string | null;
|
|
175
|
+
noIndex: boolean;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* SEO input accepted by content write operations.
|
|
180
|
+
*
|
|
181
|
+
* All fields are optional — only fields that are present overwrite existing
|
|
182
|
+
* values. An empty object is treated as a no-op.
|
|
183
|
+
*/
|
|
184
|
+
export interface ContentItemSeoInput {
|
|
185
|
+
title?: string | null;
|
|
186
|
+
description?: string | null;
|
|
187
|
+
image?: string | null;
|
|
188
|
+
canonical?: string | null;
|
|
189
|
+
noIndex?: boolean;
|
|
190
|
+
}
|
|
191
|
+
|
|
164
192
|
/**
|
|
165
193
|
* Content item returned from content API
|
|
166
194
|
*/
|
|
@@ -168,6 +196,11 @@ export interface ContentItem {
|
|
|
168
196
|
id: string;
|
|
169
197
|
type: string;
|
|
170
198
|
data: Record<string, unknown>;
|
|
199
|
+
/**
|
|
200
|
+
* SEO metadata, populated when the collection has SEO enabled
|
|
201
|
+
* (`has_seo = 1`). `undefined` for non-SEO collections.
|
|
202
|
+
*/
|
|
203
|
+
seo?: ContentItemSeo;
|
|
171
204
|
createdAt: string;
|
|
172
205
|
updatedAt: string;
|
|
173
206
|
}
|
|
@@ -181,6 +214,18 @@ export interface ContentListOptions {
|
|
|
181
214
|
orderBy?: Record<string, "asc" | "desc">;
|
|
182
215
|
}
|
|
183
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Input accepted by `content.create` / `content.update`.
|
|
219
|
+
*
|
|
220
|
+
* Most entries are field slugs mapped to their values. The reserved `seo`
|
|
221
|
+
* key is extracted and routed to the core SEO panel (the `_emdash_seo`
|
|
222
|
+
* table), matching the shape accepted by the REST API. Passing `seo` for a
|
|
223
|
+
* collection that does not have SEO enabled throws a validation error.
|
|
224
|
+
*/
|
|
225
|
+
export type ContentWriteInput = Record<string, unknown> & {
|
|
226
|
+
seo?: ContentItemSeoInput;
|
|
227
|
+
};
|
|
228
|
+
|
|
184
229
|
/**
|
|
185
230
|
* Content access interface - capability-gated
|
|
186
231
|
*/
|
|
@@ -190,8 +235,8 @@ export interface ContentAccess {
|
|
|
190
235
|
list(collection: string, options?: ContentListOptions): Promise<PaginatedResult<ContentItem>>;
|
|
191
236
|
|
|
192
237
|
// Write operations (requires write:content) - optional on interface
|
|
193
|
-
create?(collection: string, data:
|
|
194
|
-
update?(collection: string, id: string, data:
|
|
238
|
+
create?(collection: string, data: ContentWriteInput): Promise<ContentItem>;
|
|
239
|
+
update?(collection: string, id: string, data: ContentWriteInput): Promise<ContentItem>;
|
|
195
240
|
delete?(collection: string, id: string): Promise<boolean>;
|
|
196
241
|
}
|
|
197
242
|
|
|
@@ -199,8 +244,8 @@ export interface ContentAccess {
|
|
|
199
244
|
* Full content access with write operations
|
|
200
245
|
*/
|
|
201
246
|
export interface ContentAccessWithWrite extends ContentAccess {
|
|
202
|
-
create(collection: string, data:
|
|
203
|
-
update(collection: string, id: string, data:
|
|
247
|
+
create(collection: string, data: ContentWriteInput): Promise<ContentItem>;
|
|
248
|
+
update(collection: string, id: string, data: ContentWriteInput): Promise<ContentItem>;
|
|
204
249
|
delete(collection: string, id: string): Promise<boolean>;
|
|
205
250
|
}
|
|
206
251
|
|
|
@@ -747,6 +792,17 @@ export type UninstallHandler = (event: UninstallEvent, ctx: PluginContext) => Pr
|
|
|
747
792
|
/** Placement targets for page fragment contributions */
|
|
748
793
|
export type PagePlacement = "head" | "body:start" | "body:end";
|
|
749
794
|
|
|
795
|
+
/**
|
|
796
|
+
* A single breadcrumb trail item. Used by `PublicPageContext.breadcrumbs`
|
|
797
|
+
* so themes can publish breadcrumb trails that SEO plugins consume.
|
|
798
|
+
*/
|
|
799
|
+
export interface BreadcrumbItem {
|
|
800
|
+
/** Display name for this crumb (e.g. "Home", "Blog", "My Post"). */
|
|
801
|
+
name: string;
|
|
802
|
+
/** Absolute or root-relative URL for this crumb. */
|
|
803
|
+
url: string;
|
|
804
|
+
}
|
|
805
|
+
|
|
750
806
|
/**
|
|
751
807
|
* Describes the page being rendered. Passed to page hooks so plugins
|
|
752
808
|
* can decide what to contribute without fetching content themselves.
|
|
@@ -757,7 +813,10 @@ export interface PublicPageContext {
|
|
|
757
813
|
locale: string | null;
|
|
758
814
|
kind: "content" | "custom";
|
|
759
815
|
pageType: string;
|
|
816
|
+
/** Full document title for the rendered page */
|
|
760
817
|
title: string | null;
|
|
818
|
+
/** Page-only title for OG/Twitter/JSON-LD headline output */
|
|
819
|
+
pageTitle?: string | null;
|
|
761
820
|
description: string | null;
|
|
762
821
|
canonical: string | null;
|
|
763
822
|
image: string | null;
|
|
@@ -781,6 +840,23 @@ export interface PublicPageContext {
|
|
|
781
840
|
};
|
|
782
841
|
/** Site name for structured data and og:site_name */
|
|
783
842
|
siteName?: string;
|
|
843
|
+
/**
|
|
844
|
+
* Optional breadcrumb trail for this page, root first. When set,
|
|
845
|
+
* SEO plugins should use this verbatim rather than deriving a trail
|
|
846
|
+
* from `path`. Themes typically populate this at the point they
|
|
847
|
+
* build the context (e.g. from a content hierarchy walk, taxonomy
|
|
848
|
+
* lookup, or per-`pageType` routing logic).
|
|
849
|
+
*
|
|
850
|
+
* Semantics for consumers:
|
|
851
|
+
* - `undefined` — theme has no opinion; consumer falls back to
|
|
852
|
+
* its own derivation.
|
|
853
|
+
* - `[]` — this page has no breadcrumbs (e.g. homepage); consumer
|
|
854
|
+
* should skip `BreadcrumbList` emission entirely.
|
|
855
|
+
* - Non-empty array — used verbatim for `BreadcrumbList` output.
|
|
856
|
+
*/
|
|
857
|
+
breadcrumbs?: BreadcrumbItem[];
|
|
858
|
+
/** Public-facing site URL (origin) for structured data */
|
|
859
|
+
siteUrl?: string;
|
|
784
860
|
}
|
|
785
861
|
|
|
786
862
|
// ── page:metadata ───────────────────────────────────────────────
|
package/src/query.ts
CHANGED
|
@@ -422,6 +422,24 @@ export async function getEmDashEntry<T extends string, D = InferCollectionData<T
|
|
|
422
422
|
|
|
423
423
|
if (!baseEntry) continue; // Try next locale in chain
|
|
424
424
|
|
|
425
|
+
// Preview tokens are item-scoped: verify the resolved entry matches.
|
|
426
|
+
// Edit mode (authenticated editors) has collection-wide draft access.
|
|
427
|
+
if (isPreviewMode && !isEditMode) {
|
|
428
|
+
const dbId = entryDatabaseId(baseEntry);
|
|
429
|
+
if (preview!.id !== dbId && preview!.id !== id) {
|
|
430
|
+
// Token doesn't match — serve only if publicly visible, without draft access
|
|
431
|
+
if (isVisible(baseEntry)) {
|
|
432
|
+
return successResult(wrapEntry(baseEntry), {
|
|
433
|
+
isPreview: false,
|
|
434
|
+
fallbackLocale,
|
|
435
|
+
cacheHint: cacheHint ?? {},
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
// Not visible — try next locale in fallback chain
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
425
443
|
// Check if entry has a draft revision — if so, re-fetch with revision data
|
|
426
444
|
const baseData = entryData(baseEntry);
|
|
427
445
|
const draftRevisionId = dataStr(baseData, "draftRevisionId") || undefined;
|
package/src/schema/registry.ts
CHANGED
|
@@ -595,6 +595,11 @@ export class SchemaRegistry {
|
|
|
595
595
|
CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_created_id`)}
|
|
596
596
|
ON ${sql.ref(tableName)} (deleted_at, created_at DESC, id DESC)
|
|
597
597
|
`.execute(conn);
|
|
598
|
+
|
|
599
|
+
await sql`
|
|
600
|
+
CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_published_id`)}
|
|
601
|
+
ON ${sql.ref(tableName)} (deleted_at, published_at DESC, id DESC)
|
|
602
|
+
`.execute(conn);
|
|
598
603
|
}
|
|
599
604
|
|
|
600
605
|
/**
|
package/src/settings/index.ts
CHANGED
|
@@ -201,3 +201,67 @@ export async function setSiteSettings(
|
|
|
201
201
|
|
|
202
202
|
await options.setMany(updates);
|
|
203
203
|
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get a single plugin setting by key.
|
|
207
|
+
*
|
|
208
|
+
* Plugin settings are stored in the options table under
|
|
209
|
+
* `plugin:<pluginId>:settings:<key>`.
|
|
210
|
+
*/
|
|
211
|
+
export async function getPluginSetting<T = unknown>(
|
|
212
|
+
pluginId: string,
|
|
213
|
+
key: string,
|
|
214
|
+
): Promise<T | undefined> {
|
|
215
|
+
const db = await getDb();
|
|
216
|
+
return getPluginSettingWithDb<T>(pluginId, key, db);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get a single plugin setting by key (with explicit db).
|
|
221
|
+
*
|
|
222
|
+
* @internal Use `getPluginSetting()` in templates and plugin rendering code.
|
|
223
|
+
*/
|
|
224
|
+
export async function getPluginSettingWithDb<T = unknown>(
|
|
225
|
+
pluginId: string,
|
|
226
|
+
key: string,
|
|
227
|
+
db: Kysely<Database>,
|
|
228
|
+
): Promise<T | undefined> {
|
|
229
|
+
const options = new OptionsRepository(db);
|
|
230
|
+
const value = await options.get<T>(`plugin:${pluginId}:settings:${key}`);
|
|
231
|
+
return value ?? undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get all persisted plugin settings for a plugin.
|
|
236
|
+
*
|
|
237
|
+
* Defaults declared in `admin.settingsSchema` are not materialized
|
|
238
|
+
* automatically; callers should apply their own fallback defaults.
|
|
239
|
+
*/
|
|
240
|
+
export async function getPluginSettings(pluginId: string): Promise<Record<string, unknown>> {
|
|
241
|
+
const db = await getDb();
|
|
242
|
+
return getPluginSettingsWithDb(pluginId, db);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get all persisted plugin settings for a plugin (with explicit db).
|
|
247
|
+
*
|
|
248
|
+
* @internal Use `getPluginSettings()` in templates and plugin rendering code.
|
|
249
|
+
*/
|
|
250
|
+
export async function getPluginSettingsWithDb(
|
|
251
|
+
pluginId: string,
|
|
252
|
+
db: Kysely<Database>,
|
|
253
|
+
): Promise<Record<string, unknown>> {
|
|
254
|
+
const prefix = `plugin:${pluginId}:settings:`;
|
|
255
|
+
const options = new OptionsRepository(db);
|
|
256
|
+
const allOptions = await options.getByPrefix(prefix);
|
|
257
|
+
|
|
258
|
+
const settings: Record<string, unknown> = {};
|
|
259
|
+
for (const [key, value] of allOptions) {
|
|
260
|
+
if (!key.startsWith(prefix)) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
settings[key.slice(prefix.length)] = value;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return settings;
|
|
267
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split an array into chunks of at most `size` elements.
|
|
3
|
+
*
|
|
4
|
+
* Used to keep SQL `IN (?, ?, …)` clauses within Cloudflare D1's
|
|
5
|
+
* bound-parameter limit (~100 per statement).
|
|
6
|
+
*/
|
|
7
|
+
export function chunks<T>(arr: T[], size: number): T[][] {
|
|
8
|
+
if (arr.length === 0) return [];
|
|
9
|
+
const result: T[][] = [];
|
|
10
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
11
|
+
result.push(arr.slice(i, i + size));
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Conservative default chunk size for SQL IN clauses (well within D1's limit). */
|
|
17
|
+
export const SQL_BATCH_SIZE = 50;
|