emdash 0.1.0 → 0.1.1

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.
Files changed (111) hide show
  1. package/LICENSE +9 -0
  2. package/dist/{apply-Bjfq_b4-.mjs → apply-kC39ev1Z.mjs} +4 -4
  3. package/dist/{apply-Bjfq_b4-.mjs.map → apply-kC39ev1Z.mjs.map} +1 -1
  4. package/dist/astro/index.d.mts +3 -3
  5. package/dist/astro/index.mjs +16 -1
  6. package/dist/astro/index.mjs.map +1 -1
  7. package/dist/astro/middleware/auth.d.mts +3 -3
  8. package/dist/astro/middleware/request-context.mjs +84 -22
  9. package/dist/astro/middleware/request-context.mjs.map +1 -1
  10. package/dist/astro/middleware.mjs +41 -12
  11. package/dist/astro/middleware.mjs.map +1 -1
  12. package/dist/astro/types.d.mts +5 -4
  13. package/dist/astro/types.d.mts.map +1 -1
  14. package/dist/cli/index.mjs +65 -6
  15. package/dist/cli/index.mjs.map +1 -1
  16. package/dist/db/index.mjs +1 -1
  17. package/dist/{index-C1xF3OGh.d.mts → index-CLBc4gw-.d.mts} +42 -11
  18. package/dist/{index-C1xF3OGh.d.mts.map → index-CLBc4gw-.d.mts.map} +1 -1
  19. package/dist/index.d.mts +5 -5
  20. package/dist/index.mjs +9 -9
  21. package/dist/{manifest-schema-Dcl0R6nM.mjs → manifest-schema-CL8DWO9b.mjs} +5 -2
  22. package/dist/manifest-schema-CL8DWO9b.mjs.map +1 -0
  23. package/dist/media/index.d.mts +1 -1
  24. package/dist/media/index.mjs +1 -1
  25. package/dist/media/local-runtime.d.mts +4 -4
  26. package/dist/page/index.d.mts +1 -1
  27. package/dist/{placeholder-CmGAmqeO.d.mts → placeholder-SvFCKbz_.d.mts} +10 -2
  28. package/dist/{placeholder-CmGAmqeO.d.mts.map → placeholder-SvFCKbz_.d.mts.map} +1 -1
  29. package/dist/{placeholder-SmpOx-_v.mjs → placeholder-aiCD8aSZ.mjs} +27 -2
  30. package/dist/placeholder-aiCD8aSZ.mjs.map +1 -0
  31. package/dist/plugins/adapt-sandbox-entry.d.mts +3 -3
  32. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  33. package/dist/{query-CS_iSj34.mjs → query-BVYN0PJ6.mjs} +2 -2
  34. package/dist/{query-CS_iSj34.mjs.map → query-BVYN0PJ6.mjs.map} +1 -1
  35. package/dist/{registry-D_w5HW4G.mjs → registry-BNYQKX_d.mjs} +23 -38
  36. package/dist/registry-BNYQKX_d.mjs.map +1 -0
  37. package/dist/{runner-C0hCbYnD.mjs → runner-BraqvGYk.mjs} +251 -158
  38. package/dist/runner-BraqvGYk.mjs.map +1 -0
  39. package/dist/runner-EAtf0ZIe.d.mts.map +1 -1
  40. package/dist/runtime.d.mts +4 -4
  41. package/dist/{search-DG603UrT.mjs → search-C1gg67nN.mjs} +125 -18
  42. package/dist/search-C1gg67nN.mjs.map +1 -0
  43. package/dist/seed/index.d.mts +1 -1
  44. package/dist/seed/index.mjs +3 -3
  45. package/dist/{types-DvhsUmSJ.d.mts → types-BQo5JS0J.d.mts} +15 -2
  46. package/dist/{types-DvhsUmSJ.d.mts.map → types-BQo5JS0J.d.mts.map} +1 -1
  47. package/dist/{types-DY5zk5HN.mjs → types-CiA5Gac0.mjs} +5 -3
  48. package/dist/types-CiA5Gac0.mjs.map +1 -0
  49. package/dist/{types-C4-fAxN3.d.mts → types-DPfzHnjW.d.mts} +13 -2
  50. package/dist/types-DPfzHnjW.d.mts.map +1 -0
  51. package/dist/{validate-CpBtVMsD.d.mts → validate-HtxZeaBi.d.mts} +2 -2
  52. package/dist/{validate-CpBtVMsD.d.mts.map → validate-HtxZeaBi.d.mts.map} +1 -1
  53. package/dist/{validate-O7PWmlnq.mjs → validate-_rsF-Dx_.mjs} +2 -2
  54. package/dist/{validate-O7PWmlnq.mjs.map → validate-_rsF-Dx_.mjs.map} +1 -1
  55. package/package.json +6 -4
  56. package/src/api/handlers/marketplace.ts +7 -4
  57. package/src/api/schemas/schema.ts +12 -0
  58. package/src/astro/integration/index.ts +17 -0
  59. package/src/astro/integration/runtime.ts +13 -0
  60. package/src/astro/integration/virtual-modules.ts +13 -1
  61. package/src/astro/routes/admin.astro +1 -1
  62. package/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts +3 -1
  63. package/src/astro/routes/api/auth/invite/complete.ts +2 -1
  64. package/src/astro/routes/api/auth/passkey/options.ts +2 -1
  65. package/src/astro/routes/api/auth/passkey/register/options.ts +2 -1
  66. package/src/astro/routes/api/auth/passkey/register/verify.ts +2 -1
  67. package/src/astro/routes/api/auth/passkey/verify.ts +2 -1
  68. package/src/astro/routes/api/auth/signup/complete.ts +2 -1
  69. package/src/astro/routes/api/import/wordpress/analyze.ts +24 -3
  70. package/src/astro/routes/api/import/wordpress/execute.ts +5 -1
  71. package/src/astro/routes/api/import/wordpress/prepare.ts +2 -2
  72. package/src/astro/routes/api/media.ts +16 -4
  73. package/src/astro/routes/api/search/index.ts +1 -5
  74. package/src/astro/routes/api/search/suggest.ts +1 -5
  75. package/src/astro/routes/api/setup/admin-verify.ts +2 -1
  76. package/src/astro/routes/api/setup/admin.ts +2 -1
  77. package/src/astro/types.ts +1 -0
  78. package/src/auth/passkey-config.ts +24 -3
  79. package/src/cli/commands/bundle-utils.ts +26 -0
  80. package/src/cli/commands/bundle.ts +15 -0
  81. package/src/cli/commands/content.ts +11 -1
  82. package/src/cli/commands/login.ts +2 -0
  83. package/src/cli/commands/media.ts +5 -1
  84. package/src/cli/commands/menu.ts +3 -1
  85. package/src/cli/commands/schema.ts +7 -1
  86. package/src/cli/commands/search-cmd.ts +2 -1
  87. package/src/cli/commands/taxonomy.ts +4 -1
  88. package/src/cli/output.ts +14 -0
  89. package/src/components/InlinePortableTextEditor.tsx +33 -3
  90. package/src/database/migrations/033_optimize_content_indexes.ts +113 -0
  91. package/src/database/migrations/runner.ts +40 -33
  92. package/src/database/repositories/comment.ts +32 -20
  93. package/src/emdash-runtime.ts +64 -2
  94. package/src/media/placeholder.ts +31 -0
  95. package/src/media/thumbnail.ts +32 -0
  96. package/src/plugins/hooks.ts +91 -0
  97. package/src/plugins/manager.ts +22 -0
  98. package/src/plugins/manifest-schema.ts +3 -0
  99. package/src/plugins/marketplace.ts +25 -12
  100. package/src/plugins/types.ts +24 -0
  101. package/src/schema/registry.ts +23 -27
  102. package/src/schema/types.ts +27 -1
  103. package/src/search/fts-manager.ts +1 -18
  104. package/src/visual-editing/toolbar.ts +84 -22
  105. package/dist/manifest-schema-Dcl0R6nM.mjs.map +0 -1
  106. package/dist/placeholder-SmpOx-_v.mjs.map +0 -1
  107. package/dist/registry-D_w5HW4G.mjs.map +0 -1
  108. package/dist/runner-C0hCbYnD.mjs.map +0 -1
  109. package/dist/search-DG603UrT.mjs.map +0 -1
  110. package/dist/types-C4-fAxN3.d.mts.map +0 -1
  111. package/dist/types-DY5zk5HN.mjs.map +0 -1
@@ -8,7 +8,7 @@ import { defineCommand } from "citty";
8
8
  import { consola } from "consola";
9
9
 
10
10
  import { connectionArgs as commonArgs, createClientFromArgs } from "../client-factory.js";
11
- import { output } from "../output.js";
11
+ import { configureOutputMode, output } from "../output.js";
12
12
 
13
13
  const listCommand = defineCommand({
14
14
  meta: {
@@ -19,6 +19,7 @@ const listCommand = defineCommand({
19
19
  ...commonArgs,
20
20
  },
21
21
  async run({ args }) {
22
+ configureOutputMode(args);
22
23
  try {
23
24
  const client = createClientFromArgs(args);
24
25
  const collections = await client.collections();
@@ -44,6 +45,7 @@ const getCommand = defineCommand({
44
45
  ...commonArgs,
45
46
  },
46
47
  async run({ args }) {
48
+ configureOutputMode(args);
47
49
  try {
48
50
  const client = createClientFromArgs(args);
49
51
  const collection = await client.collection(args.collection);
@@ -82,6 +84,7 @@ const createCommand = defineCommand({
82
84
  ...commonArgs,
83
85
  },
84
86
  async run({ args }) {
87
+ configureOutputMode(args);
85
88
  try {
86
89
  const client = createClientFromArgs(args);
87
90
  const data = await client.createCollection({
@@ -117,6 +120,7 @@ const deleteCommand = defineCommand({
117
120
  ...commonArgs,
118
121
  },
119
122
  async run({ args }) {
123
+ configureOutputMode(args);
120
124
  try {
121
125
  if (!args.force) {
122
126
  const confirmed = await consola.prompt(`Delete collection "${args.collection}"?`, {
@@ -170,6 +174,7 @@ const addFieldCommand = defineCommand({
170
174
  ...commonArgs,
171
175
  },
172
176
  async run({ args }) {
177
+ configureOutputMode(args);
173
178
  try {
174
179
  const client = createClientFromArgs(args);
175
180
  const data = await client.createField(args.collection, {
@@ -206,6 +211,7 @@ const removeFieldCommand = defineCommand({
206
211
  ...commonArgs,
207
212
  },
208
213
  async run({ args }) {
214
+ configureOutputMode(args);
209
215
  try {
210
216
  const client = createClientFromArgs(args);
211
217
  await client.deleteField(args.collection, args.field);
@@ -8,7 +8,7 @@ import { defineCommand } from "citty";
8
8
  import { consola } from "consola";
9
9
 
10
10
  import { connectionArgs, createClientFromArgs } from "../client-factory.js";
11
- import { output } from "../output.js";
11
+ import { configureOutputMode, output } from "../output.js";
12
12
 
13
13
  export const searchCommand = defineCommand({
14
14
  meta: {
@@ -38,6 +38,7 @@ export const searchCommand = defineCommand({
38
38
  ...connectionArgs,
39
39
  },
40
40
  async run({ args }) {
41
+ configureOutputMode(args);
41
42
  try {
42
43
  const client = createClientFromArgs(args);
43
44
  const results = await client.search(args.query, {
@@ -8,7 +8,7 @@ import { defineCommand } from "citty";
8
8
  import { consola } from "consola";
9
9
 
10
10
  import { connectionArgs, createClientFromArgs } from "../client-factory.js";
11
- import { output } from "../output.js";
11
+ import { configureOutputMode, output } from "../output.js";
12
12
 
13
13
  /** Pattern to replace whitespace with hyphens for slug generation */
14
14
  const WHITESPACE_PATTERN = /\s+/g;
@@ -22,6 +22,7 @@ const listCommand = defineCommand({
22
22
  ...connectionArgs,
23
23
  },
24
24
  async run({ args }) {
25
+ configureOutputMode(args);
25
26
  try {
26
27
  const client = createClientFromArgs(args);
27
28
  const taxonomies = await client.taxonomies();
@@ -56,6 +57,7 @@ const termsCommand = defineCommand({
56
57
  ...connectionArgs,
57
58
  },
58
59
  async run({ args }) {
60
+ configureOutputMode(args);
59
61
  try {
60
62
  const client = createClientFromArgs(args);
61
63
  const result = await client.terms(args.name, {
@@ -97,6 +99,7 @@ const addTermCommand = defineCommand({
97
99
  ...connectionArgs,
98
100
  },
99
101
  async run({ args }) {
102
+ configureOutputMode(args);
100
103
  try {
101
104
  const client = createClientFromArgs(args);
102
105
  const label = args.name;
package/src/cli/output.ts CHANGED
@@ -4,6 +4,20 @@ interface OutputArgs {
4
4
  json?: boolean;
5
5
  }
6
6
 
7
+ /**
8
+ * Redirect consola output to stderr so it doesn't pollute JSON on stdout.
9
+ *
10
+ * Call this early in any command that uses `output()` with `--json`.
11
+ * Safe to call multiple times — only applies the redirect once.
12
+ */
13
+ export function configureOutputMode(args: OutputArgs): void {
14
+ if (args.json || !process.stdout.isTTY) {
15
+ // Send all consola output to stderr so stdout is clean JSON
16
+ consola.options.stdout = process.stderr;
17
+ consola.options.stderr = process.stderr;
18
+ }
19
+ }
20
+
7
21
  /**
8
22
  * Output data as JSON or pretty-printed.
9
23
  *
@@ -25,6 +25,8 @@ import Suggestion from "@tiptap/suggestion";
25
25
  import * as React from "react";
26
26
  import { createPortal } from "react-dom";
27
27
 
28
+ import { computeThumbnailSize } from "../media/thumbnail.js";
29
+
28
30
  // ── Portable Text types ────────────────────────────────────────────
29
31
 
30
32
  interface PTSpan {
@@ -1112,13 +1114,40 @@ function InlineMediaPicker({
1112
1114
  const handleUpload = async (file: File) => {
1113
1115
  setUploading(true);
1114
1116
  try {
1115
- // Detect dimensions
1116
- const dims = await new Promise<{ width?: number; height?: number }>((resolve) => {
1117
+ // Detect dimensions and generate a thumbnail for large images to
1118
+ // avoid OOM in server-side blurhash generation on Workers.
1119
+ const dims = await new Promise<{
1120
+ width?: number;
1121
+ height?: number;
1122
+ thumbnail?: Blob;
1123
+ }>((resolve) => {
1117
1124
  if (!file.type.startsWith("image/")) return resolve({});
1118
1125
  const img = new window.Image();
1119
1126
  img.onload = () => {
1120
- resolve({ width: img.naturalWidth, height: img.naturalHeight });
1127
+ const w = img.naturalWidth;
1128
+ const h = img.naturalHeight;
1129
+ // 32 MB RGBA threshold — matches server MAX_DECODED_BYTES
1130
+ if (w * h * 4 > 32 * 1024 * 1024) {
1131
+ const { width: thumbW, height: thumbH } = computeThumbnailSize(w, h);
1132
+ try {
1133
+ const canvas = document.createElement("canvas");
1134
+ canvas.width = thumbW;
1135
+ canvas.height = thumbH;
1136
+ const ctx = canvas.getContext("2d");
1137
+ if (ctx) {
1138
+ ctx.drawImage(img, 0, 0, thumbW, thumbH);
1139
+ canvas.toBlob((blob) => {
1140
+ URL.revokeObjectURL(img.src);
1141
+ resolve({ width: w, height: h, thumbnail: blob ?? undefined });
1142
+ }, "image/png");
1143
+ return;
1144
+ }
1145
+ } catch {
1146
+ // Canvas allocation or draw failed — fall through to no-thumbnail path
1147
+ }
1148
+ }
1121
1149
  URL.revokeObjectURL(img.src);
1150
+ resolve({ width: w, height: h });
1122
1151
  };
1123
1152
  img.onerror = () => {
1124
1153
  resolve({});
@@ -1134,6 +1163,7 @@ function InlineMediaPicker({
1134
1163
  formData.append("file", file);
1135
1164
  if (dims.width) formData.append("width", String(dims.width));
1136
1165
  if (dims.height) formData.append("height", String(dims.height));
1166
+ if (dims.thumbnail) formData.append("thumbnail", dims.thumbnail, "thumb.png");
1137
1167
  const res = await ecFetch(`${API_BASE}/media`, { method: "POST", body: formData });
1138
1168
  const data = await res.json();
1139
1169
  const unwrapped = data.data ?? data;
@@ -0,0 +1,113 @@
1
+ import type { Kysely } from "kysely";
2
+ import { sql } from "kysely";
3
+
4
+ import { listTablesLike } from "../dialect-helpers.js";
5
+
6
+ /**
7
+ * Migration: Optimize content table indexes for D1 performance
8
+ *
9
+ * Addresses GitHub issue #131: Full table scans causing massive D1 row reads.
10
+ *
11
+ * Changes:
12
+ * 1. Replaces single-column indexes with composite indexes on ec_* tables
13
+ * 2. Adds partial indexes for _emdash_comments status counting
14
+ *
15
+ * Impact: Reduces D1 row reads by 90%+ for admin panel operations.
16
+ */
17
+ export async function up(db: Kysely<unknown>): Promise<void> {
18
+ const tableNames = await listTablesLike(db, "ec_%");
19
+
20
+ for (const tableName of tableNames) {
21
+ const table = { name: tableName };
22
+
23
+ // Drop redundant single-column indexes that will be replaced by composites
24
+ await sql`DROP INDEX IF EXISTS ${sql.ref(`idx_${table.name}_status`)}`.execute(db);
25
+ await sql`DROP INDEX IF EXISTS ${sql.ref(`idx_${table.name}_created`)}`.execute(db);
26
+ await sql`DROP INDEX IF EXISTS ${sql.ref(`idx_${table.name}_deleted`)}`.execute(db);
27
+ await sql`DROP INDEX IF EXISTS ${sql.ref(`idx_${table.name}_updated`)}`.execute(db);
28
+
29
+ // Composite index for listing queries: WHERE deleted_at IS NULL ORDER BY updated_at DESC
30
+ await sql`
31
+ CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${table.name}_deleted_updated_id`)}
32
+ ON ${sql.ref(table.name)} (deleted_at, updated_at DESC, id DESC)
33
+ `.execute(db);
34
+
35
+ // Composite index for count-by-status queries: WHERE deleted_at IS NULL AND status = ?
36
+ await sql`
37
+ CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${table.name}_deleted_status`)}
38
+ ON ${sql.ref(table.name)} (deleted_at, status)
39
+ `.execute(db);
40
+
41
+ // Composite index for created-at ordering: WHERE deleted_at IS NULL ORDER BY created_at DESC
42
+ await sql`
43
+ CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${table.name}_deleted_created_id`)}
44
+ ON ${sql.ref(table.name)} (deleted_at, created_at DESC, id DESC)
45
+ `.execute(db);
46
+ }
47
+
48
+ // Add partial indexes for efficient comment status counting
49
+ // Each index contains only rows for one status, enabling fast COUNT queries
50
+ await sql`
51
+ CREATE INDEX IF NOT EXISTS idx_comments_pending
52
+ ON _emdash_comments (id)
53
+ WHERE status = 'pending'
54
+ `.execute(db);
55
+
56
+ await sql`
57
+ CREATE INDEX IF NOT EXISTS idx_comments_approved
58
+ ON _emdash_comments (id)
59
+ WHERE status = 'approved'
60
+ `.execute(db);
61
+
62
+ await sql`
63
+ CREATE INDEX IF NOT EXISTS idx_comments_spam
64
+ ON _emdash_comments (id)
65
+ WHERE status = 'spam'
66
+ `.execute(db);
67
+
68
+ await sql`
69
+ CREATE INDEX IF NOT EXISTS idx_comments_trash
70
+ ON _emdash_comments (id)
71
+ WHERE status = 'trash'
72
+ `.execute(db);
73
+ }
74
+
75
+ export async function down(db: Kysely<unknown>): Promise<void> {
76
+ const tableNames = await listTablesLike(db, "ec_%");
77
+
78
+ for (const tableName of tableNames) {
79
+ const table = { name: tableName };
80
+
81
+ // Drop composite indexes
82
+ await sql`DROP INDEX IF EXISTS ${sql.ref(`idx_${table.name}_deleted_updated_id`)}`.execute(db);
83
+ await sql`DROP INDEX IF EXISTS ${sql.ref(`idx_${table.name}_deleted_status`)}`.execute(db);
84
+ await sql`DROP INDEX IF EXISTS ${sql.ref(`idx_${table.name}_deleted_created_id`)}`.execute(db);
85
+
86
+ // Restore original single-column indexes
87
+ await sql`
88
+ CREATE INDEX ${sql.ref(`idx_${table.name}_status`)}
89
+ ON ${sql.ref(table.name)} (status)
90
+ `.execute(db);
91
+
92
+ await sql`
93
+ CREATE INDEX ${sql.ref(`idx_${table.name}_created`)}
94
+ ON ${sql.ref(table.name)} (created_at)
95
+ `.execute(db);
96
+
97
+ await sql`
98
+ CREATE INDEX ${sql.ref(`idx_${table.name}_deleted`)}
99
+ ON ${sql.ref(table.name)} (deleted_at)
100
+ `.execute(db);
101
+
102
+ await sql`
103
+ CREATE INDEX ${sql.ref(`idx_${table.name}_updated`)}
104
+ ON ${sql.ref(table.name)} (updated_at)
105
+ `.execute(db);
106
+ }
107
+
108
+ // Drop partial indexes for comments
109
+ await sql`DROP INDEX IF EXISTS idx_comments_pending`.execute(db);
110
+ await sql`DROP INDEX IF EXISTS idx_comments_approved`.execute(db);
111
+ await sql`DROP INDEX IF EXISTS idx_comments_spam`.execute(db);
112
+ await sql`DROP INDEX IF EXISTS idx_comments_trash`.execute(db);
113
+ }
@@ -33,6 +33,45 @@ import * as m029 from "./029_redirects.js";
33
33
  import * as m030 from "./030_widen_scheduled_index.js";
34
34
  import * as m031 from "./031_bylines.js";
35
35
  import * as m032 from "./032_rate_limits.js";
36
+ import * as m033 from "./033_optimize_content_indexes.js";
37
+
38
+ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
39
+ "001_initial": m001,
40
+ "002_media_status": m002,
41
+ "003_schema_registry": m003,
42
+ "004_plugins": m004,
43
+ "005_menus": m005,
44
+ "006_taxonomy_defs": m006,
45
+ "007_widgets": m007,
46
+ "008_auth": m008,
47
+ "009_user_disabled": m009,
48
+ "011_sections": m011,
49
+ "012_search": m012,
50
+ "013_scheduled_publishing": m013,
51
+ "014_draft_revisions": m014,
52
+ "015_indexes": m015,
53
+ "016_api_tokens": m016,
54
+ "017_authorization_codes": m017,
55
+ "018_seo": m018,
56
+ "019_i18n": m019,
57
+ "020_collection_url_pattern": m020,
58
+ "021_remove_section_categories": m021,
59
+ "022_marketplace_plugin_state": m022,
60
+ "023_plugin_metadata": m023,
61
+ "024_media_placeholders": m024,
62
+ "025_oauth_clients": m025,
63
+ "026_cron_tasks": m026,
64
+ "027_comments": m027,
65
+ "028_drop_author_url": m028,
66
+ "029_redirects": m029,
67
+ "030_widen_scheduled_index": m030,
68
+ "031_bylines": m031,
69
+ "032_rate_limits": m032,
70
+ "033_optimize_content_indexes": m033,
71
+ });
72
+
73
+ /** Total number of registered migrations. Exported for use in tests. */
74
+ export const MIGRATION_COUNT = Object.keys(MIGRATIONS).length;
36
75
 
37
76
  /**
38
77
  * Migration provider that uses statically imported migrations.
@@ -40,39 +79,7 @@ import * as m032 from "./032_rate_limits.js";
40
79
  */
41
80
  class StaticMigrationProvider implements MigrationProvider {
42
81
  async getMigrations(): Promise<Record<string, Migration>> {
43
- return {
44
- "001_initial": m001,
45
- "002_media_status": m002,
46
- "003_schema_registry": m003,
47
- "004_plugins": m004,
48
- "005_menus": m005,
49
- "006_taxonomy_defs": m006,
50
- "007_widgets": m007,
51
- "008_auth": m008,
52
- "009_user_disabled": m009,
53
- "011_sections": m011,
54
- "012_search": m012,
55
- "013_scheduled_publishing": m013,
56
- "014_draft_revisions": m014,
57
- "015_indexes": m015,
58
- "016_api_tokens": m016,
59
- "017_authorization_codes": m017,
60
- "018_seo": m018,
61
- "019_i18n": m019,
62
- "020_collection_url_pattern": m020,
63
- "021_remove_section_categories": m021,
64
- "022_marketplace_plugin_state": m022,
65
- "023_plugin_metadata": m023,
66
- "024_media_placeholders": m024,
67
- "025_oauth_clients": m025,
68
- "026_cron_tasks": m026,
69
- "027_comments": m027,
70
- "028_drop_author_url": m028,
71
- "029_redirects": m029,
72
- "030_widen_scheduled_index": m030,
73
- "031_bylines": m031,
74
- "032_rate_limits": m032,
75
- };
82
+ return MIGRATIONS;
76
83
  }
77
84
  }
78
85
 
@@ -324,30 +324,42 @@ export class CommentRepository {
324
324
 
325
325
  /**
326
326
  * Count comments grouped by status (for inbox badges)
327
+ *
328
+ * Uses four parallel COUNT queries with WHERE filters to leverage partial indexes
329
+ * (idx_comments_pending, idx_comments_approved, idx_comments_spam, idx_comments_trash)
330
+ * instead of a full table GROUP BY scan.
327
331
  */
328
332
  async countByStatus(): Promise<Record<CommentStatus, number>> {
329
- const rows = await this.db
330
- .selectFrom("_emdash_comments")
331
- .select(["status"])
332
- .select((eb) => eb.fn.count("id").as("count"))
333
- .groupBy("status")
334
- .execute();
333
+ // Execute four parallel COUNT queries, each using its partial index
334
+ const [pending, approved, spam, trash] = await Promise.all([
335
+ this.db
336
+ .selectFrom("_emdash_comments")
337
+ .select((eb) => eb.fn.count("id").as("count"))
338
+ .where("status", "=", "pending")
339
+ .executeTakeFirst(),
340
+ this.db
341
+ .selectFrom("_emdash_comments")
342
+ .select((eb) => eb.fn.count("id").as("count"))
343
+ .where("status", "=", "approved")
344
+ .executeTakeFirst(),
345
+ this.db
346
+ .selectFrom("_emdash_comments")
347
+ .select((eb) => eb.fn.count("id").as("count"))
348
+ .where("status", "=", "spam")
349
+ .executeTakeFirst(),
350
+ this.db
351
+ .selectFrom("_emdash_comments")
352
+ .select((eb) => eb.fn.count("id").as("count"))
353
+ .where("status", "=", "trash")
354
+ .executeTakeFirst(),
355
+ ]);
335
356
 
336
- const counts: Record<CommentStatus, number> = {
337
- pending: 0,
338
- approved: 0,
339
- spam: 0,
340
- trash: 0,
357
+ return {
358
+ pending: Number(pending?.count ?? 0),
359
+ approved: Number(approved?.count ?? 0),
360
+ spam: Number(spam?.count ?? 0),
361
+ trash: Number(trash?.count ?? 0),
341
362
  };
342
-
343
- for (const row of rows) {
344
- const status = row.status as CommentStatus;
345
- if (status in counts) {
346
- counts[status] = Number(row.count);
347
- }
348
- }
349
-
350
- return counts;
351
363
  }
352
364
 
353
365
  /**
@@ -160,6 +160,7 @@ const FIELD_TYPE_TO_KIND: Record<FieldType, string> = {
160
160
  file: "file",
161
161
  reference: "reference",
162
162
  json: "json",
163
+ repeater: "repeater",
163
164
  };
164
165
 
165
166
  /**
@@ -1171,6 +1172,10 @@ export class EmDashRuntime {
1171
1172
  label: v.charAt(0).toUpperCase() + v.slice(1),
1172
1173
  }));
1173
1174
  }
1175
+ // Include full validation for repeater fields (subFields, minItems, maxItems)
1176
+ if (field.type === "repeater" && field.validation) {
1177
+ (entry as Record<string, unknown>).validation = field.validation;
1178
+ }
1174
1179
  fields[field.slug] = entry;
1175
1180
  }
1176
1181
  }
@@ -1180,6 +1185,7 @@ export class EmDashRuntime {
1180
1185
  labelSingular: collection.labelSingular || collection.label,
1181
1186
  supports: collection.supports || [],
1182
1187
  hasSeo: collection.hasSeo,
1188
+ urlPattern: collection.urlPattern,
1183
1189
  fields,
1184
1190
  };
1185
1191
  }
@@ -1601,11 +1607,25 @@ export class EmDashRuntime {
1601
1607
  // =========================================================================
1602
1608
 
1603
1609
  async handleContentPublish(collection: string, id: string) {
1604
- return handleContentPublish(this.db, collection, id);
1610
+ const result = await handleContentPublish(this.db, collection, id);
1611
+
1612
+ // Run afterPublish hooks (fire-and-forget)
1613
+ if (result.success && result.data) {
1614
+ this.runAfterPublishHooks(contentItemToRecord(result.data.item), collection);
1615
+ }
1616
+
1617
+ return result;
1605
1618
  }
1606
1619
 
1607
1620
  async handleContentUnpublish(collection: string, id: string) {
1608
- return handleContentUnpublish(this.db, collection, id);
1621
+ const result = await handleContentUnpublish(this.db, collection, id);
1622
+
1623
+ // Run afterUnpublish hooks (fire-and-forget)
1624
+ if (result.success && result.data) {
1625
+ this.runAfterUnpublishHooks(contentItemToRecord(result.data.item), collection);
1626
+ }
1627
+
1628
+ return result;
1609
1629
  }
1610
1630
 
1611
1631
  async handleContentSchedule(collection: string, id: string, scheduledAt: string) {
@@ -1964,6 +1984,48 @@ export class EmDashRuntime {
1964
1984
  }
1965
1985
  }
1966
1986
 
1987
+ private runAfterPublishHooks(content: Record<string, unknown>, collection: string): void {
1988
+ // Trusted plugins
1989
+ if (this.hooks.hasHooks("content:afterPublish")) {
1990
+ this.hooks
1991
+ .runContentAfterPublish(content, collection)
1992
+ .catch((err) => console.error("EmDash afterPublish hook error:", err));
1993
+ }
1994
+
1995
+ // Sandboxed plugins
1996
+ for (const [pluginKey, plugin] of this.sandboxedPlugins) {
1997
+ const [pluginId] = pluginKey.split(":");
1998
+ if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
1999
+
2000
+ plugin
2001
+ .invokeHook("content:afterPublish", { content, collection })
2002
+ .catch((err) =>
2003
+ console.error(`EmDash: Sandboxed plugin ${pluginId} afterPublish error:`, err),
2004
+ );
2005
+ }
2006
+ }
2007
+
2008
+ private runAfterUnpublishHooks(content: Record<string, unknown>, collection: string): void {
2009
+ // Trusted plugins
2010
+ if (this.hooks.hasHooks("content:afterUnpublish")) {
2011
+ this.hooks
2012
+ .runContentAfterUnpublish(content, collection)
2013
+ .catch((err) => console.error("EmDash afterUnpublish hook error:", err));
2014
+ }
2015
+
2016
+ // Sandboxed plugins
2017
+ for (const [pluginKey, plugin] of this.sandboxedPlugins) {
2018
+ const [pluginId] = pluginKey.split(":");
2019
+ if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
2020
+
2021
+ plugin
2022
+ .invokeHook("content:afterUnpublish", { content, collection })
2023
+ .catch((err) =>
2024
+ console.error(`EmDash: Sandboxed plugin ${pluginId} afterUnpublish error:`, err),
2025
+ );
2026
+ }
2027
+ }
2028
+
1967
2029
  private async handleSandboxedRoute(
1968
2030
  plugin: SandboxedPlugin,
1969
2031
  path: string,
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { encode } from "blurhash";
10
+ import { imageSize } from "image-size";
10
11
 
11
12
  export interface PlaceholderData {
12
13
  blurhash: string;
@@ -22,6 +23,9 @@ const SUPPORTED_TYPES: Record<string, "jpeg" | "png"> = {
22
23
  /** Max width for blurhash input. Encode is O(w*h*components), so downsample first. */
23
24
  const MAX_ENCODE_WIDTH = 32;
24
25
 
26
+ /** Max decoded RGBA size (32 MB). Images exceeding this skip placeholder generation. */
27
+ const MAX_DECODED_BYTES = 32 * 1024 * 1024;
28
+
25
29
  interface DecodedImage {
26
30
  width: number;
27
31
  height: number;
@@ -79,18 +83,45 @@ function extractDominantColor(data: Uint8Array, width: number, height: number):
79
83
  return `rgb(${avgR},${avgG},${avgB})`;
80
84
  }
81
85
 
86
+ /**
87
+ * Read image dimensions from headers without decoding pixel data.
88
+ */
89
+ function getImageDimensions(buffer: Uint8Array): { width: number; height: number } | null {
90
+ try {
91
+ const result = imageSize(buffer);
92
+ if (result.width != null && result.height != null) {
93
+ return { width: result.width, height: result.height };
94
+ }
95
+ return null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
82
101
  /**
83
102
  * Generate blurhash and dominant color from an image buffer.
84
103
  * Returns null for non-image MIME types or on failure.
104
+ *
105
+ * @param dimensions - Optional pre-known dimensions. Used as a fallback when
106
+ * image-size cannot parse the buffer (e.g. truncated headers). When the
107
+ * decoded size (width * height * 4) exceeds MAX_DECODED_BYTES, placeholder
108
+ * generation is skipped to avoid OOM on memory-constrained runtimes.
85
109
  */
86
110
  export async function generatePlaceholder(
87
111
  buffer: Uint8Array,
88
112
  mimeType: string,
113
+ dimensions?: { width: number; height: number },
89
114
  ): Promise<PlaceholderData | null> {
90
115
  const format = SUPPORTED_TYPES[mimeType];
91
116
  if (!format) return null;
92
117
 
93
118
  try {
119
+ // Safety net: skip decode if the image would exceed the memory budget
120
+ const dims = getImageDimensions(buffer) ?? dimensions;
121
+ if (dims && dims.width * dims.height * 4 > MAX_DECODED_BYTES) {
122
+ return null;
123
+ }
124
+
94
125
  const imageData = format === "jpeg" ? await decodeJpeg(buffer) : await decodePng(buffer);
95
126
  const { width, height, data } = imageData;
96
127
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Thumbnail sizing for client-side placeholder generation.
3
+ *
4
+ * When the browser generates a thumbnail to send to the server for blurhash
5
+ * generation, the thumbnail dimensions must fit within a bounded box. Naively
6
+ * fixing one dimension and deriving the other from the aspect ratio can
7
+ * explode for extreme aspect ratios (e.g. a 100×840000 image would produce a
8
+ * 64×537600 canvas), defeating the purpose of the thumbnail.
9
+ */
10
+
11
+ /** Max dimension (px) for client-generated upload thumbnails. */
12
+ export const THUMBNAIL_MAX_DIMENSION = 64;
13
+
14
+ /**
15
+ * Compute thumbnail dimensions that fit within a THUMBNAIL_MAX_DIMENSION box,
16
+ * preserving aspect ratio. Both output dimensions are clamped to at least 1.
17
+ * Never upscales (scale is capped at 1).
18
+ */
19
+ export function computeThumbnailSize(
20
+ width: number,
21
+ height: number,
22
+ ): { width: number; height: number } {
23
+ if (width <= 0 || height <= 0) {
24
+ return { width: 1, height: 1 };
25
+ }
26
+ const maxDim = Math.max(width, height);
27
+ const scale = Math.min(1, THUMBNAIL_MAX_DIMENSION / maxDim);
28
+ return {
29
+ width: Math.max(1, Math.round(width * scale)),
30
+ height: Math.max(1, Math.round(height * scale)),
31
+ };
32
+ }