emdash 0.2.0 → 0.4.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-N6BF7RCD.d.mts → adapters-C2BzVy0p.d.mts} +1 -1
- package/dist/{adapters-N6BF7RCD.d.mts.map → adapters-C2BzVy0p.d.mts.map} +1 -1
- package/dist/{apply-wmVEOSbR.mjs → apply-Cma_PiF6.mjs} +38 -23
- package/dist/apply-Cma_PiF6.mjs.map +1 -0
- package/dist/astro/index.d.mts +25 -11
- package/dist/astro/index.d.mts.map +1 -1
- package/dist/astro/index.mjs +38 -25
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +5 -5
- package/dist/astro/middleware/auth.mjs +2 -2
- package/dist/astro/middleware/redirect.d.mts.map +1 -1
- package/dist/astro/middleware/redirect.mjs +20 -8
- package/dist/astro/middleware/redirect.mjs.map +1 -1
- package/dist/astro/middleware/request-context.mjs +12 -2
- package/dist/astro/middleware/request-context.mjs.map +1 -1
- package/dist/astro/middleware/setup.mjs +1 -1
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +52 -45
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +9 -9
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-1WQPlISL.mjs → byline-WuOq9MFJ.mjs} +5 -4
- package/dist/byline-WuOq9MFJ.mjs.map +1 -0
- package/dist/{bylines-BYdTYmia.mjs → bylines-C_Wsnz4L.mjs} +38 -6
- package/dist/bylines-C_Wsnz4L.mjs.map +1 -0
- package/dist/cache-E3Dts-yT.mjs +56 -0
- package/dist/cache-E3Dts-yT.mjs.map +1 -0
- package/dist/cli/index.mjs +13 -13
- 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-Cq8H0SfX.mjs → config-DkxPrM9l.mjs} +1 -1
- package/dist/{config-Cq8H0SfX.mjs.map → config-DkxPrM9l.mjs.map} +1 -1
- package/dist/{content-BmXndhdi.mjs → content-BsBoyj8G.mjs} +20 -3
- package/dist/content-BsBoyj8G.mjs.map +1 -0
- package/dist/db/index.d.mts +3 -3
- package/dist/db/index.mjs +2 -2
- 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-WYlzADZL.mjs → default-PUx9RK6u.mjs} +1 -1
- package/dist/{default-WYlzADZL.mjs.map → default-PUx9RK6u.mjs.map} +1 -1
- package/dist/{dialect-helpers-B9uSp2GJ.mjs → dialect-helpers-DhTzaUxP.mjs} +4 -1
- package/dist/dialect-helpers-DhTzaUxP.mjs.map +1 -0
- package/dist/{error-DrxtnGPg.mjs → error-HBeQbVhV.mjs} +1 -1
- package/dist/{error-DrxtnGPg.mjs.map → error-HBeQbVhV.mjs.map} +1 -1
- package/dist/{index-UHEVQMus.d.mts → index-CRg3PWfZ.d.mts} +59 -33
- package/dist/index-CRg3PWfZ.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +20 -20
- package/dist/{load-Veizk2cT.mjs → load-BhSSm-TS.mjs} +1 -1
- package/dist/{load-Veizk2cT.mjs.map → load-BhSSm-TS.mjs.map} +1 -1
- package/dist/{loader-CHb2v0jm.mjs → loader-BYzwzORf.mjs} +4 -2
- package/dist/loader-BYzwzORf.mjs.map +1 -0
- package/dist/{manifest-schema-CuMio1A9.mjs → manifest-schema-BsXINkQD.mjs} +1 -1
- package/dist/{manifest-schema-CuMio1A9.mjs.map → manifest-schema-BsXINkQD.mjs.map} +1 -1
- package/dist/media/index.d.mts +1 -1
- package/dist/media/index.mjs +1 -1
- package/dist/media/local-runtime.d.mts +7 -7
- package/dist/{mode-CYeM2rPt.mjs → mode-CyPLdO3C.mjs} +1 -1
- package/dist/{mode-CYeM2rPt.mjs.map → mode-CyPLdO3C.mjs.map} +1 -1
- package/dist/page/index.d.mts +1 -1
- package/dist/patterns-CrCYkMBb.mjs +93 -0
- package/dist/patterns-CrCYkMBb.mjs.map +1 -0
- package/dist/{placeholder-bOx1xCTY.d.mts → placeholder-BBCtpTES.d.mts} +1 -1
- package/dist/{placeholder-bOx1xCTY.d.mts.map → placeholder-BBCtpTES.d.mts.map} +1 -1
- package/dist/{placeholder-aiCD8aSZ.mjs → placeholder-DntBEQo7.mjs} +1 -1
- package/dist/{placeholder-aiCD8aSZ.mjs.map → placeholder-DntBEQo7.mjs.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-5Hcv_5ER.mjs → query-B6Vu0d2i.mjs} +35 -16
- package/dist/{query-5Hcv_5ER.mjs.map → query-B6Vu0d2i.mjs.map} +1 -1
- package/dist/{redirect-DIfIni3r.mjs → redirect-7lGhLBNZ.mjs} +10 -93
- package/dist/redirect-7lGhLBNZ.mjs.map +1 -0
- package/dist/{registry-1EvbAfsC.mjs → registry-BgnP3ysR.mjs} +27 -37
- package/dist/registry-BgnP3ysR.mjs.map +1 -0
- package/dist/{runner-BoN0-FPi.mjs → runner-Cd-_WyDo.mjs} +18 -6
- package/dist/runner-Cd-_WyDo.mjs.map +1 -0
- package/dist/{runner-DTqkzOzc.d.mts → runner-DYv3rX8P.d.mts} +10 -3
- package/dist/runner-DYv3rX8P.d.mts.map +1 -0
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +2 -2
- package/dist/{search-BsYMed12.mjs → search-B5p9D36n.mjs} +108 -57
- package/dist/search-B5p9D36n.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +10 -10
- 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 +11 -3
- package/dist/storage/s3.d.mts.map +1 -1
- package/dist/storage/s3.mjs +76 -15
- package/dist/storage/s3.mjs.map +1 -1
- package/dist/{tokens-DrB-W6Q-.mjs → tokens-DKHiCYCB.mjs} +1 -1
- package/dist/{tokens-DrB-W6Q-.mjs.map → tokens-DKHiCYCB.mjs.map} +1 -1
- package/dist/transaction-Cn2rjY78.mjs +28 -0
- package/dist/transaction-Cn2rjY78.mjs.map +1 -0
- package/dist/{transport-Bl8cTdYt.mjs → transport-BtcQ-Z7T.mjs} +1 -1
- package/dist/{transport-Bl8cTdYt.mjs.map → transport-BtcQ-Z7T.mjs.map} +1 -1
- package/dist/{transport-COOs9GSE.d.mts → transport-CKQA_G44.d.mts} +1 -1
- package/dist/{transport-COOs9GSE.d.mts.map → transport-CKQA_G44.d.mts.map} +1 -1
- package/dist/{types-7-UjSEyB.d.mts → types-B6BzlZxx.d.mts} +1 -1
- package/dist/{types-7-UjSEyB.d.mts.map → types-B6BzlZxx.d.mts.map} +1 -1
- package/dist/{types-6dqxBqsH.d.mts → types-BYWYxLcp.d.mts} +109 -5
- package/dist/types-BYWYxLcp.d.mts.map +1 -0
- package/dist/{types-CIsTnQvJ.d.mts → types-BmkQR1En.d.mts} +1 -1
- package/dist/{types-CIsTnQvJ.d.mts.map → types-BmkQR1En.d.mts.map} +1 -1
- package/dist/{types-BljtYPSd.d.mts → types-DNZpaCBk.d.mts} +14 -6
- package/dist/types-DNZpaCBk.d.mts.map +1 -0
- package/dist/{types-Bec-r_3_.mjs → types-Dz9_WMS6.mjs} +1 -1
- package/dist/types-Dz9_WMS6.mjs.map +1 -0
- package/dist/{types-CcreFIIH.d.mts → types-gLYVCXCQ.d.mts} +1 -1
- package/dist/{types-CcreFIIH.d.mts.map → types-gLYVCXCQ.d.mts.map} +1 -1
- package/dist/{types-DuNbGKjF.mjs → types-xxCWI3j0.mjs} +1 -1
- package/dist/{types-DuNbGKjF.mjs.map → types-xxCWI3j0.mjs.map} +1 -1
- package/dist/{validate-B7KP7VLM.d.mts → validate-CcNRWH6I.d.mts} +4 -4
- package/dist/{validate-B7KP7VLM.d.mts.map → validate-CcNRWH6I.d.mts.map} +1 -1
- package/dist/{validate-CXnRKfJK.mjs → validate-DuZDIxfy.mjs} +2 -2
- package/dist/{validate-CXnRKfJK.mjs.map → validate-DuZDIxfy.mjs.map} +1 -1
- package/dist/{validate-CqRJb_xU.mjs → validate-VPnKoIzW.mjs} +11 -11
- package/dist/{validate-CqRJb_xU.mjs.map → validate-VPnKoIzW.mjs.map} +1 -1
- package/dist/version-DlTDRdpv.mjs +7 -0
- package/dist/version-DlTDRdpv.mjs.map +1 -0
- package/package.json +7 -5
- package/src/api/handlers/content.ts +36 -25
- package/src/api/handlers/menus.ts +19 -16
- package/src/api/handlers/redirects.ts +95 -3
- package/src/api/schemas/redirects.ts +1 -0
- package/src/astro/integration/index.ts +2 -3
- package/src/astro/integration/runtime.ts +8 -14
- package/src/astro/integration/vite-config.ts +14 -4
- package/src/astro/middleware/redirect.ts +30 -15
- package/src/astro/middleware.ts +11 -19
- package/src/astro/routes/admin.astro +2 -2
- package/src/astro/routes/api/admin/bylines/[id]/index.ts +3 -0
- package/src/astro/routes/api/admin/bylines/index.ts +2 -0
- package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -0
- package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +2 -0
- package/src/astro/routes/api/manifest.ts +3 -1
- package/src/astro/routes/api/redirects/[id].ts +3 -0
- package/src/astro/routes/api/redirects/index.ts +2 -0
- package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -0
- package/src/astro/routes/api/schema/collections/index.ts +1 -0
- package/src/astro/storage/adapters.ts +19 -5
- package/src/astro/storage/types.ts +12 -4
- package/src/astro/types.ts +1 -0
- package/src/bylines/index.ts +50 -2
- package/src/cleanup.ts +3 -3
- package/src/cli/commands/bundle-utils.ts +5 -5
- package/src/database/dialect-helpers.ts +3 -0
- package/src/database/migrations/011_sections.ts +2 -2
- package/src/database/migrations/runner.ts +23 -2
- package/src/database/repositories/byline.ts +2 -1
- package/src/database/repositories/content.ts +5 -0
- package/src/database/repositories/redirect.ts +13 -0
- package/src/database/validate.ts +10 -10
- package/src/emdash-runtime.ts +23 -9
- package/src/index.ts +3 -0
- package/src/loader.ts +2 -0
- package/src/mcp/server.ts +40 -67
- package/src/menus/index.ts +4 -0
- package/src/plugins/context.ts +28 -4
- package/src/plugins/cron.ts +29 -4
- package/src/plugins/hooks.ts +22 -10
- package/src/plugins/index.ts +1 -0
- package/src/plugins/manager.ts +6 -2
- package/src/plugins/marketplace.ts +33 -3
- package/src/plugins/routes.ts +3 -3
- package/src/plugins/types.ts +7 -0
- package/src/query.ts +37 -14
- package/src/redirects/cache.ts +68 -0
- package/src/redirects/loops.ts +318 -0
- package/src/schema/registry.ts +3 -0
- package/src/search/fts-manager.ts +24 -11
- package/src/search/query.ts +8 -9
- package/src/seed/apply.ts +49 -28
- package/src/storage/s3.ts +94 -25
- package/src/storage/types.ts +13 -5
- package/src/utils/slugify.ts +11 -0
- package/src/version.ts +12 -0
- package/src/visual-editing/toolbar.ts +11 -1
- package/dist/apply-wmVEOSbR.mjs.map +0 -1
- package/dist/byline-1WQPlISL.mjs.map +0 -1
- package/dist/bylines-BYdTYmia.mjs.map +0 -1
- package/dist/content-BmXndhdi.mjs.map +0 -1
- package/dist/dialect-helpers-B9uSp2GJ.mjs.map +0 -1
- package/dist/index-UHEVQMus.d.mts.map +0 -1
- package/dist/loader-CHb2v0jm.mjs.map +0 -1
- package/dist/redirect-DIfIni3r.mjs.map +0 -1
- package/dist/registry-1EvbAfsC.mjs.map +0 -1
- package/dist/runner-BoN0-FPi.mjs.map +0 -1
- package/dist/runner-DTqkzOzc.d.mts.map +0 -1
- package/dist/search-BsYMed12.mjs.map +0 -1
- package/dist/types-6dqxBqsH.d.mts.map +0 -1
- package/dist/types-Bec-r_3_.mjs.map +0 -1
- package/dist/types-BljtYPSd.d.mts.map +0 -1
package/src/mcp/server.ts
CHANGED
|
@@ -1257,26 +1257,8 @@ export function createMcpServer(): McpServer {
|
|
|
1257
1257
|
requireScope(extra, "content:read");
|
|
1258
1258
|
const ec = getEmDash(extra);
|
|
1259
1259
|
try {
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
.selectAll()
|
|
1263
|
-
.execute()) as Array<{
|
|
1264
|
-
id: string;
|
|
1265
|
-
name: string;
|
|
1266
|
-
label: string;
|
|
1267
|
-
label_singular: string | null;
|
|
1268
|
-
hierarchical: number;
|
|
1269
|
-
collections: string | null;
|
|
1270
|
-
}>;
|
|
1271
|
-
const taxonomies = rows.map((row) => ({
|
|
1272
|
-
id: row.id,
|
|
1273
|
-
name: row.name,
|
|
1274
|
-
label: row.label,
|
|
1275
|
-
labelSingular: row.label_singular ?? undefined,
|
|
1276
|
-
hierarchical: row.hierarchical === 1,
|
|
1277
|
-
collections: row.collections ? JSON.parse(row.collections) : [],
|
|
1278
|
-
}));
|
|
1279
|
-
return jsonResult(taxonomies);
|
|
1260
|
+
const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
|
|
1261
|
+
return unwrap(await handleTaxonomyList(ec.db));
|
|
1280
1262
|
} catch (error) {
|
|
1281
1263
|
return errorResult(error);
|
|
1282
1264
|
}
|
|
@@ -1302,32 +1284,44 @@ export function createMcpServer(): McpServer {
|
|
|
1302
1284
|
requireScope(extra, "content:read");
|
|
1303
1285
|
const ec = getEmDash(extra);
|
|
1304
1286
|
try {
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1287
|
+
// Verify taxonomy exists via handler layer
|
|
1288
|
+
const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
|
|
1289
|
+
const listResult = await handleTaxonomyList(ec.db);
|
|
1290
|
+
if (!listResult.success) return unwrap(listResult);
|
|
1291
|
+
|
|
1292
|
+
const taxonomies = (listResult.data as { taxonomies: Array<{ name: string; id?: string }> })
|
|
1293
|
+
.taxonomies;
|
|
1294
|
+
const taxonomy = taxonomies.find((t: { name: string }) => t.name === args.taxonomy);
|
|
1311
1295
|
if (!taxonomy) return errorResult(`Taxonomy '${args.taxonomy}' not found`);
|
|
1312
1296
|
|
|
1297
|
+
// Paginated term query via repository (avoids N+1 of handleTermList)
|
|
1298
|
+
const { TaxonomyRepository } = await import("../database/repositories/taxonomy.js");
|
|
1299
|
+
const repo = new TaxonomyRepository(ec.db);
|
|
1313
1300
|
const limit = Math.min(args.limit ?? 50, 100);
|
|
1314
|
-
|
|
1315
|
-
.selectFrom("_emdash_taxonomy_terms" as never)
|
|
1316
|
-
.selectAll()
|
|
1317
|
-
.where("taxonomy_id" as never, "=", taxonomy.id as never)
|
|
1318
|
-
.orderBy("label" as never, "asc")
|
|
1319
|
-
.limit(limit + 1);
|
|
1301
|
+
const terms = await repo.findByName(args.taxonomy);
|
|
1320
1302
|
|
|
1303
|
+
// Manual cursor pagination over the sorted results
|
|
1304
|
+
let startIdx = 0;
|
|
1321
1305
|
if (args.cursor) {
|
|
1322
|
-
|
|
1306
|
+
const cursorIdx = terms.findIndex((t) => t.id === args.cursor);
|
|
1307
|
+
if (cursorIdx >= 0) startIdx = cursorIdx + 1;
|
|
1323
1308
|
}
|
|
1324
1309
|
|
|
1325
|
-
const
|
|
1326
|
-
const hasMore =
|
|
1327
|
-
const
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1310
|
+
const page = terms.slice(startIdx, startIdx + limit);
|
|
1311
|
+
const hasMore = startIdx + limit < terms.length;
|
|
1312
|
+
const nextCursor = hasMore ? page.at(-1)?.id : undefined;
|
|
1313
|
+
|
|
1314
|
+
return jsonResult({
|
|
1315
|
+
items: page.map((t) => ({
|
|
1316
|
+
id: t.id,
|
|
1317
|
+
name: t.name,
|
|
1318
|
+
slug: t.slug,
|
|
1319
|
+
label: t.label,
|
|
1320
|
+
parentId: t.parentId,
|
|
1321
|
+
description: typeof t.data?.description === "string" ? t.data.description : undefined,
|
|
1322
|
+
})),
|
|
1323
|
+
nextCursor,
|
|
1324
|
+
});
|
|
1331
1325
|
} catch (error) {
|
|
1332
1326
|
return errorResult(error);
|
|
1333
1327
|
}
|
|
@@ -1354,36 +1348,15 @@ export function createMcpServer(): McpServer {
|
|
|
1354
1348
|
requireRole(extra, Role.EDITOR);
|
|
1355
1349
|
const ec = getEmDash(extra);
|
|
1356
1350
|
try {
|
|
1357
|
-
const {
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
.selectFrom("_emdash_taxonomy_defs" as never)
|
|
1361
|
-
.select("id" as never)
|
|
1362
|
-
.where("name" as never, "=", args.taxonomy as never)
|
|
1363
|
-
.executeTakeFirst()) as { id: string } | undefined;
|
|
1364
|
-
|
|
1365
|
-
if (!taxonomy) return errorResult(`Taxonomy '${args.taxonomy}' not found`);
|
|
1366
|
-
|
|
1367
|
-
const id = ulid();
|
|
1368
|
-
await ec.db
|
|
1369
|
-
.insertInto("_emdash_taxonomy_terms" as never)
|
|
1370
|
-
.values({
|
|
1371
|
-
id,
|
|
1372
|
-
taxonomy_id: taxonomy.id,
|
|
1351
|
+
const { handleTermCreate } = await import("../api/handlers/taxonomies.js");
|
|
1352
|
+
return unwrap(
|
|
1353
|
+
await handleTermCreate(ec.db, args.taxonomy, {
|
|
1373
1354
|
slug: args.slug,
|
|
1374
1355
|
label: args.label,
|
|
1375
|
-
|
|
1376
|
-
description: args.description
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
const term = await ec.db
|
|
1381
|
-
.selectFrom("_emdash_taxonomy_terms" as never)
|
|
1382
|
-
.selectAll()
|
|
1383
|
-
.where("id" as never, "=", id as never)
|
|
1384
|
-
.executeTakeFirstOrThrow();
|
|
1385
|
-
|
|
1386
|
-
return jsonResult(term);
|
|
1356
|
+
parentId: args.parentId,
|
|
1357
|
+
description: args.description,
|
|
1358
|
+
}),
|
|
1359
|
+
);
|
|
1387
1360
|
} catch (error) {
|
|
1388
1361
|
return errorResult(error);
|
|
1389
1362
|
}
|
package/src/menus/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { Kysely } from "kysely";
|
|
|
8
8
|
import { sql } from "kysely";
|
|
9
9
|
|
|
10
10
|
import type { Database } from "../database/types.js";
|
|
11
|
+
import { validateIdentifier } from "../database/validate.js";
|
|
11
12
|
import { getDb } from "../loader.js";
|
|
12
13
|
import { sanitizeHref } from "../utils/url.js";
|
|
13
14
|
import type { Menu, MenuItem, MenuItemRow } from "./types.js";
|
|
@@ -273,6 +274,9 @@ async function resolveContentUrl(
|
|
|
273
274
|
}
|
|
274
275
|
|
|
275
276
|
try {
|
|
277
|
+
// Validate collection name before interpolating into table reference
|
|
278
|
+
validateIdentifier(collection, "menu item collection");
|
|
279
|
+
|
|
276
280
|
// Dynamic content tables (ec_*) aren't in the Database type, so use sql
|
|
277
281
|
const result = await sql<{ slug: string }>`
|
|
278
282
|
SELECT slug FROM ${sql.ref(`ec_${collection}`)} WHERE id = ${entryId} LIMIT 1
|
package/src/plugins/context.ts
CHANGED
|
@@ -202,9 +202,13 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
|
|
|
202
202
|
const result: ContentItem = {
|
|
203
203
|
id: item.id,
|
|
204
204
|
type: item.type,
|
|
205
|
+
slug: item.slug,
|
|
206
|
+
status: item.status,
|
|
205
207
|
data: item.data,
|
|
206
208
|
createdAt: item.createdAt,
|
|
207
209
|
updatedAt: item.updatedAt,
|
|
210
|
+
locale: item.locale,
|
|
211
|
+
publishedAt: item.publishedAt,
|
|
208
212
|
};
|
|
209
213
|
|
|
210
214
|
if (await seoRepo.isEnabled(collection)) {
|
|
@@ -237,9 +241,13 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
|
|
|
237
241
|
const items: ContentItem[] = result.items.map((item) => ({
|
|
238
242
|
id: item.id,
|
|
239
243
|
type: item.type,
|
|
244
|
+
slug: item.slug,
|
|
245
|
+
status: item.status,
|
|
240
246
|
data: item.data,
|
|
241
247
|
createdAt: item.createdAt,
|
|
242
248
|
updatedAt: item.updatedAt,
|
|
249
|
+
locale: item.locale,
|
|
250
|
+
publishedAt: item.publishedAt,
|
|
243
251
|
}));
|
|
244
252
|
|
|
245
253
|
if (items.length > 0 && (await seoRepo.isEnabled(collection))) {
|
|
@@ -294,9 +302,13 @@ export function createContentAccessWithWrite(db: Kysely<Database>): ContentAcces
|
|
|
294
302
|
const result: ContentItem = {
|
|
295
303
|
id: item.id,
|
|
296
304
|
type: item.type,
|
|
305
|
+
slug: item.slug,
|
|
306
|
+
status: item.status,
|
|
297
307
|
data: item.data,
|
|
298
308
|
createdAt: item.createdAt,
|
|
299
309
|
updatedAt: item.updatedAt,
|
|
310
|
+
locale: item.locale,
|
|
311
|
+
publishedAt: item.publishedAt,
|
|
300
312
|
};
|
|
301
313
|
|
|
302
314
|
if (hasSeo) {
|
|
@@ -336,9 +348,13 @@ export function createContentAccessWithWrite(db: Kysely<Database>): ContentAcces
|
|
|
336
348
|
const result: ContentItem = {
|
|
337
349
|
id: item.id,
|
|
338
350
|
type: item.type,
|
|
351
|
+
slug: item.slug,
|
|
352
|
+
status: item.status,
|
|
339
353
|
data: item.data,
|
|
340
354
|
createdAt: item.createdAt,
|
|
341
355
|
updatedAt: item.updatedAt,
|
|
356
|
+
locale: item.locale,
|
|
357
|
+
publishedAt: item.publishedAt,
|
|
342
358
|
};
|
|
343
359
|
|
|
344
360
|
if (hasSeo) {
|
|
@@ -439,12 +455,13 @@ export function createMediaAccessWithWrite(
|
|
|
439
455
|
);
|
|
440
456
|
}
|
|
441
457
|
|
|
442
|
-
|
|
458
|
+
// Generate a storage key with a unique prefix
|
|
459
|
+
const keyPrefix = ulid();
|
|
443
460
|
// Extract extension from basename (ignore path separators)
|
|
444
461
|
const basename = filename.split("/").pop() ?? filename;
|
|
445
462
|
const dotIdx = basename.lastIndexOf(".");
|
|
446
463
|
const ext = dotIdx > 0 ? basename.slice(dotIdx).toLowerCase() : "";
|
|
447
|
-
const storageKey = `${
|
|
464
|
+
const storageKey = `${keyPrefix}${ext}`;
|
|
448
465
|
|
|
449
466
|
// Upload to storage first
|
|
450
467
|
await storage.upload({
|
|
@@ -454,8 +471,9 @@ export function createMediaAccessWithWrite(
|
|
|
454
471
|
});
|
|
455
472
|
|
|
456
473
|
// Create DB record — clean up storage on failure
|
|
474
|
+
let media;
|
|
457
475
|
try {
|
|
458
|
-
await mediaRepo.create({
|
|
476
|
+
media = await mediaRepo.create({
|
|
459
477
|
filename: basename,
|
|
460
478
|
mimeType: contentType,
|
|
461
479
|
size: bytes.byteLength,
|
|
@@ -472,7 +490,7 @@ export function createMediaAccessWithWrite(
|
|
|
472
490
|
}
|
|
473
491
|
|
|
474
492
|
return {
|
|
475
|
-
mediaId,
|
|
493
|
+
mediaId: media.id,
|
|
476
494
|
storageKey,
|
|
477
495
|
url: `/_emdash/api/media/file/${storageKey}`,
|
|
478
496
|
};
|
|
@@ -491,11 +509,17 @@ export function createMediaAccessWithWrite(
|
|
|
491
509
|
/** Maximum number of redirects to follow in plugin HTTP access */
|
|
492
510
|
const MAX_PLUGIN_REDIRECTS = 5;
|
|
493
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Check if a hostname matches any pattern in the allowed list.
|
|
514
|
+
* Patterns: "*" matches all, "*.example.com" matches subdomains AND bare "example.com",
|
|
515
|
+
* "api.example.com" matches exactly.
|
|
516
|
+
*/
|
|
494
517
|
function isHostAllowed(host: string, allowedHosts: string[]): boolean {
|
|
495
518
|
return allowedHosts.some((pattern) => {
|
|
496
519
|
if (pattern === "*") return true;
|
|
497
520
|
if (pattern.startsWith("*.")) {
|
|
498
521
|
const suffix = pattern.slice(1); // ".example.com"
|
|
522
|
+
// Match subdomains (foo.example.com) and bare domain (example.com)
|
|
499
523
|
return host.endsWith(suffix) || host === pattern.slice(2);
|
|
500
524
|
}
|
|
501
525
|
return host === pattern;
|
package/src/plugins/cron.ts
CHANGED
|
@@ -113,13 +113,38 @@ export class CronExecutor {
|
|
|
113
113
|
|
|
114
114
|
if (task.is_oneshot) {
|
|
115
115
|
if (hookFailed) {
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
// Retry metadata is namespaced under __emdash to avoid collisions
|
|
117
|
+
// with plugin-controlled data fields.
|
|
118
|
+
const meta =
|
|
119
|
+
parsedData?.__emdash != null && typeof parsedData.__emdash === "object"
|
|
120
|
+
? (parsedData.__emdash as Record<string, unknown>)
|
|
121
|
+
: undefined;
|
|
122
|
+
const raw = meta?.retryCount;
|
|
123
|
+
const retryCount =
|
|
124
|
+
typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
|
|
125
|
+
const MAX_ONESHOT_RETRIES = 5;
|
|
126
|
+
|
|
127
|
+
if (retryCount >= MAX_ONESHOT_RETRIES) {
|
|
128
|
+
console.error(
|
|
129
|
+
`[cron] One-shot task ${task.plugin_id}:${task.task_name} exceeded ${MAX_ONESHOT_RETRIES} retries, removing`,
|
|
130
|
+
);
|
|
131
|
+
await sql`
|
|
132
|
+
DELETE FROM _emdash_cron_tasks WHERE id = ${task.id}
|
|
133
|
+
`.execute(this.db);
|
|
134
|
+
} else {
|
|
135
|
+
// Retry with exponential backoff: 1m, 2m, 4m, 8m, 16m
|
|
136
|
+
const backoffMs = 60_000 * Math.pow(2, retryCount);
|
|
137
|
+
const retryAt = new Date(Date.now() + backoffMs).toISOString();
|
|
138
|
+
const updatedData = JSON.stringify({
|
|
139
|
+
...parsedData,
|
|
140
|
+
__emdash: { ...meta, retryCount: retryCount + 1 },
|
|
141
|
+
});
|
|
142
|
+
await sql`
|
|
119
143
|
UPDATE _emdash_cron_tasks
|
|
120
|
-
SET status = 'idle', locked_at = NULL, next_run_at = ${retryAt}
|
|
144
|
+
SET status = 'idle', locked_at = NULL, next_run_at = ${retryAt}, data = ${updatedData}
|
|
121
145
|
WHERE id = ${task.id}
|
|
122
146
|
`.execute(this.db);
|
|
147
|
+
}
|
|
123
148
|
} else {
|
|
124
149
|
// Success: delete the one-shot task
|
|
125
150
|
await sql`
|
package/src/plugins/hooks.ts
CHANGED
|
@@ -324,7 +324,11 @@ export class HookPipeline {
|
|
|
324
324
|
);
|
|
325
325
|
|
|
326
326
|
if (ready.length === 0) {
|
|
327
|
-
// Circular dependency or missing dependency -
|
|
327
|
+
// Circular dependency or missing dependency - log warning and fall back to priority
|
|
328
|
+
const pluginIds = remaining.map((h) => h.pluginId).join(", ");
|
|
329
|
+
console.warn(
|
|
330
|
+
`[hooks] Hook dependency cycle or missing dependency detected among plugins: ${pluginIds}. Falling back to priority order.`,
|
|
331
|
+
);
|
|
328
332
|
remaining.sort((a, b) => a.priority - b.priority);
|
|
329
333
|
sorted.push(...remaining);
|
|
330
334
|
break;
|
|
@@ -344,12 +348,16 @@ export class HookPipeline {
|
|
|
344
348
|
* Execute a hook with timeout
|
|
345
349
|
*/
|
|
346
350
|
private async executeWithTimeout<T>(fn: () => Promise<T>, timeout: number): Promise<T> {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
setTimeout(() => reject(new Error(`Hook timeout after ${timeout}ms`)), timeout),
|
|
351
|
-
|
|
352
|
-
|
|
351
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
352
|
+
const timeoutPromise = new Promise<T>(
|
|
353
|
+
(_, reject) =>
|
|
354
|
+
(timer = setTimeout(() => reject(new Error(`Hook timeout after ${timeout}ms`)), timeout)),
|
|
355
|
+
);
|
|
356
|
+
try {
|
|
357
|
+
return await Promise.race([fn(), timeoutPromise]);
|
|
358
|
+
} finally {
|
|
359
|
+
clearTimeout(timer!);
|
|
360
|
+
}
|
|
353
361
|
}
|
|
354
362
|
|
|
355
363
|
// =========================================================================
|
|
@@ -561,7 +569,7 @@ export class HookPipeline {
|
|
|
561
569
|
|
|
562
570
|
for (const hook of hooks) {
|
|
563
571
|
const { handler } = hook;
|
|
564
|
-
const event: ContentDeleteEvent = { id, collection };
|
|
572
|
+
const event: ContentDeleteEvent = { id, collection, permanent: false };
|
|
565
573
|
const ctx = this.getContext(hook.pluginId);
|
|
566
574
|
const start = Date.now();
|
|
567
575
|
|
|
@@ -597,13 +605,17 @@ export class HookPipeline {
|
|
|
597
605
|
/**
|
|
598
606
|
* Run content:afterDelete hooks
|
|
599
607
|
*/
|
|
600
|
-
async runContentAfterDelete(
|
|
608
|
+
async runContentAfterDelete(
|
|
609
|
+
id: string,
|
|
610
|
+
collection: string,
|
|
611
|
+
permanent: boolean,
|
|
612
|
+
): Promise<HookResult<void>[]> {
|
|
601
613
|
const hooks = this.getTypedHooks("content:afterDelete");
|
|
602
614
|
const results: HookResult<void>[] = [];
|
|
603
615
|
|
|
604
616
|
for (const hook of hooks) {
|
|
605
617
|
const { handler } = hook;
|
|
606
|
-
const event: ContentDeleteEvent = { id, collection };
|
|
618
|
+
const event: ContentDeleteEvent = { id, collection, permanent };
|
|
607
619
|
const ctx = this.getContext(hook.pluginId);
|
|
608
620
|
const start = Date.now();
|
|
609
621
|
|
package/src/plugins/index.ts
CHANGED
package/src/plugins/manager.ts
CHANGED
|
@@ -333,9 +333,13 @@ export class PluginManager {
|
|
|
333
333
|
/**
|
|
334
334
|
* Run content:afterDelete hooks across all active plugins
|
|
335
335
|
*/
|
|
336
|
-
async runContentAfterDelete(
|
|
336
|
+
async runContentAfterDelete(
|
|
337
|
+
id: string,
|
|
338
|
+
collection: string,
|
|
339
|
+
permanent: boolean,
|
|
340
|
+
): Promise<HookResult<void>[]> {
|
|
337
341
|
this.ensureInitialized();
|
|
338
|
-
return this.hookPipeline!.runContentAfterDelete(id, collection);
|
|
342
|
+
return this.hookPipeline!.runContentAfterDelete(id, collection, permanent);
|
|
339
343
|
}
|
|
340
344
|
|
|
341
345
|
/**
|
|
@@ -241,12 +241,42 @@ class MarketplaceClientImpl implements MarketplaceClient {
|
|
|
241
241
|
async downloadBundle(id: string, version: string): Promise<PluginBundle> {
|
|
242
242
|
const bundleUrl = `${this.baseUrl}/api/v1/plugins/${encodeURIComponent(id)}/versions/${encodeURIComponent(version)}/bundle`;
|
|
243
243
|
|
|
244
|
+
const marketplaceOrigin = new URL(this.baseUrl).origin;
|
|
245
|
+
const MAX_REDIRECTS = 5;
|
|
244
246
|
let response: Response;
|
|
245
247
|
try {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
248
|
+
let currentUrl = bundleUrl;
|
|
249
|
+
response = await fetch(currentUrl, { redirect: "manual" });
|
|
250
|
+
|
|
251
|
+
// Follow redirects manually, validating each target stays on the marketplace host
|
|
252
|
+
for (let i = 0; i < MAX_REDIRECTS; i++) {
|
|
253
|
+
if (response.status < 300 || response.status >= 400) break;
|
|
254
|
+
|
|
255
|
+
const location = response.headers.get("location");
|
|
256
|
+
if (!location) break;
|
|
257
|
+
|
|
258
|
+
const target = new URL(location, currentUrl);
|
|
259
|
+
if (target.origin !== marketplaceOrigin) {
|
|
260
|
+
throw new MarketplaceError(
|
|
261
|
+
`Bundle download redirected to untrusted host: ${target.origin}`,
|
|
262
|
+
response.status,
|
|
263
|
+
"BUNDLE_REDIRECT_UNTRUSTED",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
currentUrl = target.href;
|
|
267
|
+
response = await fetch(currentUrl, { redirect: "manual" });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// If still a redirect after MAX_REDIRECTS, fail explicitly
|
|
271
|
+
if (response.status >= 300 && response.status < 400) {
|
|
272
|
+
throw new MarketplaceError(
|
|
273
|
+
`Bundle download exceeded maximum redirects (${MAX_REDIRECTS})`,
|
|
274
|
+
response.status,
|
|
275
|
+
"BUNDLE_TOO_MANY_REDIRECTS",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
249
278
|
} catch (err) {
|
|
279
|
+
if (err instanceof MarketplaceError) throw err;
|
|
250
280
|
throw new MarketplaceUnavailableError(err);
|
|
251
281
|
}
|
|
252
282
|
|
package/src/plugins/routes.ts
CHANGED
|
@@ -124,13 +124,13 @@ export class PluginRouteHandler {
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
// Unknown error
|
|
128
|
-
|
|
127
|
+
// Unknown error -- log internally, return generic message
|
|
128
|
+
console.error(`[plugin:${this.plugin.id}] Route handler failed:`, error);
|
|
129
129
|
return {
|
|
130
130
|
success: false,
|
|
131
131
|
error: {
|
|
132
132
|
code: "INTERNAL_ERROR",
|
|
133
|
-
message:
|
|
133
|
+
message: "An internal error occurred",
|
|
134
134
|
},
|
|
135
135
|
status: 500,
|
|
136
136
|
};
|
package/src/plugins/types.ts
CHANGED
|
@@ -195,6 +195,9 @@ export interface ContentItemSeoInput {
|
|
|
195
195
|
export interface ContentItem {
|
|
196
196
|
id: string;
|
|
197
197
|
type: string;
|
|
198
|
+
slug: string | null;
|
|
199
|
+
status: string;
|
|
200
|
+
locale: string | null;
|
|
198
201
|
data: Record<string, unknown>;
|
|
199
202
|
/**
|
|
200
203
|
* SEO metadata, populated when the collection has SEO enabled
|
|
@@ -203,6 +206,7 @@ export interface ContentItem {
|
|
|
203
206
|
seo?: ContentItemSeo;
|
|
204
207
|
createdAt: string;
|
|
205
208
|
updatedAt: string;
|
|
209
|
+
publishedAt: string | null;
|
|
206
210
|
}
|
|
207
211
|
|
|
208
212
|
/**
|
|
@@ -702,6 +706,8 @@ export interface ContentHookEvent {
|
|
|
702
706
|
export interface ContentDeleteEvent {
|
|
703
707
|
id: string;
|
|
704
708
|
collection: string;
|
|
709
|
+
/** `true` when the content is permanently deleted (not just trashed). */
|
|
710
|
+
permanent: boolean;
|
|
705
711
|
}
|
|
706
712
|
|
|
707
713
|
/**
|
|
@@ -876,6 +882,7 @@ export type PageMetadataLinkRel =
|
|
|
876
882
|
| "alternate"
|
|
877
883
|
| "author"
|
|
878
884
|
| "license"
|
|
885
|
+
| "nlweb"
|
|
879
886
|
| "site.standard.document";
|
|
880
887
|
|
|
881
888
|
export type PageMetadataContribution =
|
package/src/query.ts
CHANGED
|
@@ -637,6 +637,22 @@ function patternToRegex(pattern: string): { regex: RegExp; paramNames: string[]
|
|
|
637
637
|
return { regex: new RegExp(`^${regexStr}$`), paramNames };
|
|
638
638
|
}
|
|
639
639
|
|
|
640
|
+
/** Cached compiled URL patterns for resolveEmDashPath */
|
|
641
|
+
interface CachedPattern {
|
|
642
|
+
slug: string;
|
|
643
|
+
regex: RegExp;
|
|
644
|
+
paramNames: string[];
|
|
645
|
+
}
|
|
646
|
+
let cachedUrlPatterns: CachedPattern[] | null = null;
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Invalidate the cached URL patterns used by resolveEmDashPath.
|
|
650
|
+
* Call when collection URL patterns change (schema updates).
|
|
651
|
+
*/
|
|
652
|
+
export function invalidateUrlPatternCache(): void {
|
|
653
|
+
cachedUrlPatterns = null;
|
|
654
|
+
}
|
|
655
|
+
|
|
640
656
|
/**
|
|
641
657
|
* Resolve a URL path to a content entry by matching against collection URL patterns.
|
|
642
658
|
*
|
|
@@ -659,32 +675,39 @@ function patternToRegex(pattern: string): { regex: RegExp; paramNames: string[]
|
|
|
659
675
|
export async function resolveEmDashPath<T = Record<string, unknown>>(
|
|
660
676
|
path: string,
|
|
661
677
|
): Promise<ResolvePathResult<T> | null> {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
678
|
+
// Build and cache compiled patterns on first call
|
|
679
|
+
if (!cachedUrlPatterns) {
|
|
680
|
+
const { getDb } = await import("./loader.js");
|
|
681
|
+
const { SchemaRegistry } = await import("./schema/registry.js");
|
|
682
|
+
const db = await getDb();
|
|
683
|
+
const registry = new SchemaRegistry(db);
|
|
684
|
+
const collections = await registry.listCollections();
|
|
685
|
+
|
|
686
|
+
cachedUrlPatterns = [];
|
|
687
|
+
for (const collection of collections) {
|
|
688
|
+
if (!collection.urlPattern) continue;
|
|
689
|
+
const { regex, paramNames } = patternToRegex(collection.urlPattern);
|
|
690
|
+
cachedUrlPatterns.push({ slug: collection.slug, regex, paramNames });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
670
693
|
|
|
671
|
-
|
|
672
|
-
const match = path.match(regex);
|
|
694
|
+
for (const pattern of cachedUrlPatterns) {
|
|
695
|
+
const match = path.match(pattern.regex);
|
|
673
696
|
if (!match) continue;
|
|
674
697
|
|
|
675
698
|
// Extract params
|
|
676
699
|
const params: Record<string, string> = {};
|
|
677
|
-
for (let i = 0; i < paramNames.length; i++) {
|
|
678
|
-
params[paramNames[i]] = match[i + 1];
|
|
700
|
+
for (let i = 0; i < pattern.paramNames.length; i++) {
|
|
701
|
+
params[pattern.paramNames[i]] = match[i + 1];
|
|
679
702
|
}
|
|
680
703
|
|
|
681
704
|
// Look up entry by slug (most common pattern)
|
|
682
705
|
const slug = params.slug;
|
|
683
706
|
if (!slug) continue;
|
|
684
707
|
|
|
685
|
-
const { entry } = await getEmDashEntry<string, T>(
|
|
708
|
+
const { entry } = await getEmDashEntry<string, T>(pattern.slug, slug);
|
|
686
709
|
if (entry) {
|
|
687
|
-
return { entry, collection:
|
|
710
|
+
return { entry, collection: pattern.slug, params };
|
|
688
711
|
}
|
|
689
712
|
}
|
|
690
713
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redirect pattern cache.
|
|
3
|
+
*
|
|
4
|
+
* Module-level cache for compiled redirect pattern rules. The middleware
|
|
5
|
+
* populates this on first request; route handlers invalidate it on writes.
|
|
6
|
+
*
|
|
7
|
+
* This module deliberately has NO Astro imports so it can be safely imported
|
|
8
|
+
* from handlers, seed, CLI, and tests without dragging in `astro:middleware`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Redirect } from "../database/repositories/redirect.js";
|
|
12
|
+
import type { CompiledPattern } from "./patterns.js";
|
|
13
|
+
import { compilePattern, interpolateDestination, matchPattern } from "./patterns.js";
|
|
14
|
+
|
|
15
|
+
export interface CachedRedirectRule {
|
|
16
|
+
redirect: Redirect;
|
|
17
|
+
compiled: CompiledPattern;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Cached pattern rules with compiled regexes.
|
|
22
|
+
* null = not yet populated, array = cached.
|
|
23
|
+
*/
|
|
24
|
+
let cachedPatternRules: CachedRedirectRule[] | null = null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Invalidate the cached redirect pattern rules.
|
|
28
|
+
* Call when redirects are created, updated, or deleted.
|
|
29
|
+
*/
|
|
30
|
+
export function invalidateRedirectCache(): void {
|
|
31
|
+
cachedPatternRules = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the cached compiled pattern rules, or null if the cache is cold.
|
|
36
|
+
*/
|
|
37
|
+
export function getCachedPatternRules(): CachedRedirectRule[] | null {
|
|
38
|
+
return cachedPatternRules;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Populate the pattern rules cache from a list of enabled pattern redirects.
|
|
43
|
+
*/
|
|
44
|
+
export function setCachedPatternRules(redirects: Redirect[]): CachedRedirectRule[] {
|
|
45
|
+
cachedPatternRules = redirects.map((r) => ({
|
|
46
|
+
redirect: r,
|
|
47
|
+
compiled: compilePattern(r.source),
|
|
48
|
+
}));
|
|
49
|
+
return cachedPatternRules;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Match a path against the cached pattern rules.
|
|
54
|
+
* Returns the resolved destination and matching redirect, or null.
|
|
55
|
+
*/
|
|
56
|
+
export function matchCachedPatterns(
|
|
57
|
+
rules: CachedRedirectRule[],
|
|
58
|
+
pathname: string,
|
|
59
|
+
): { redirect: Redirect; destination: string } | null {
|
|
60
|
+
for (const { redirect, compiled } of rules) {
|
|
61
|
+
const params = matchPattern(compiled, pathname);
|
|
62
|
+
if (params) {
|
|
63
|
+
const dest = interpolateDestination(redirect.destination, params);
|
|
64
|
+
return { redirect, destination: dest };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|