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.
- package/LICENSE +9 -0
- package/dist/{apply-Bjfq_b4-.mjs → apply-kC39ev1Z.mjs} +4 -4
- package/dist/{apply-Bjfq_b4-.mjs.map → apply-kC39ev1Z.mjs.map} +1 -1
- package/dist/astro/index.d.mts +3 -3
- package/dist/astro/index.mjs +16 -1
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +3 -3
- package/dist/astro/middleware/request-context.mjs +84 -22
- package/dist/astro/middleware/request-context.mjs.map +1 -1
- package/dist/astro/middleware.mjs +41 -12
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +5 -4
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/cli/index.mjs +65 -6
- package/dist/cli/index.mjs.map +1 -1
- package/dist/db/index.mjs +1 -1
- package/dist/{index-C1xF3OGh.d.mts → index-CLBc4gw-.d.mts} +42 -11
- package/dist/{index-C1xF3OGh.d.mts.map → index-CLBc4gw-.d.mts.map} +1 -1
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +9 -9
- package/dist/{manifest-schema-Dcl0R6nM.mjs → manifest-schema-CL8DWO9b.mjs} +5 -2
- package/dist/manifest-schema-CL8DWO9b.mjs.map +1 -0
- package/dist/media/index.d.mts +1 -1
- package/dist/media/index.mjs +1 -1
- package/dist/media/local-runtime.d.mts +4 -4
- package/dist/page/index.d.mts +1 -1
- package/dist/{placeholder-CmGAmqeO.d.mts → placeholder-SvFCKbz_.d.mts} +10 -2
- package/dist/{placeholder-CmGAmqeO.d.mts.map → placeholder-SvFCKbz_.d.mts.map} +1 -1
- package/dist/{placeholder-SmpOx-_v.mjs → placeholder-aiCD8aSZ.mjs} +27 -2
- package/dist/placeholder-aiCD8aSZ.mjs.map +1 -0
- package/dist/plugins/adapt-sandbox-entry.d.mts +3 -3
- package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
- package/dist/{query-CS_iSj34.mjs → query-BVYN0PJ6.mjs} +2 -2
- package/dist/{query-CS_iSj34.mjs.map → query-BVYN0PJ6.mjs.map} +1 -1
- package/dist/{registry-D_w5HW4G.mjs → registry-BNYQKX_d.mjs} +23 -38
- package/dist/registry-BNYQKX_d.mjs.map +1 -0
- package/dist/{runner-C0hCbYnD.mjs → runner-BraqvGYk.mjs} +251 -158
- package/dist/runner-BraqvGYk.mjs.map +1 -0
- package/dist/runner-EAtf0ZIe.d.mts.map +1 -1
- package/dist/runtime.d.mts +4 -4
- package/dist/{search-DG603UrT.mjs → search-C1gg67nN.mjs} +125 -18
- package/dist/search-C1gg67nN.mjs.map +1 -0
- package/dist/seed/index.d.mts +1 -1
- package/dist/seed/index.mjs +3 -3
- package/dist/{types-DvhsUmSJ.d.mts → types-BQo5JS0J.d.mts} +15 -2
- package/dist/{types-DvhsUmSJ.d.mts.map → types-BQo5JS0J.d.mts.map} +1 -1
- package/dist/{types-DY5zk5HN.mjs → types-CiA5Gac0.mjs} +5 -3
- package/dist/types-CiA5Gac0.mjs.map +1 -0
- package/dist/{types-C4-fAxN3.d.mts → types-DPfzHnjW.d.mts} +13 -2
- package/dist/types-DPfzHnjW.d.mts.map +1 -0
- package/dist/{validate-CpBtVMsD.d.mts → validate-HtxZeaBi.d.mts} +2 -2
- package/dist/{validate-CpBtVMsD.d.mts.map → validate-HtxZeaBi.d.mts.map} +1 -1
- package/dist/{validate-O7PWmlnq.mjs → validate-_rsF-Dx_.mjs} +2 -2
- package/dist/{validate-O7PWmlnq.mjs.map → validate-_rsF-Dx_.mjs.map} +1 -1
- package/package.json +6 -4
- package/src/api/handlers/marketplace.ts +7 -4
- package/src/api/schemas/schema.ts +12 -0
- package/src/astro/integration/index.ts +17 -0
- package/src/astro/integration/runtime.ts +13 -0
- package/src/astro/integration/virtual-modules.ts +13 -1
- package/src/astro/routes/admin.astro +1 -1
- package/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts +3 -1
- package/src/astro/routes/api/auth/invite/complete.ts +2 -1
- package/src/astro/routes/api/auth/passkey/options.ts +2 -1
- package/src/astro/routes/api/auth/passkey/register/options.ts +2 -1
- package/src/astro/routes/api/auth/passkey/register/verify.ts +2 -1
- package/src/astro/routes/api/auth/passkey/verify.ts +2 -1
- package/src/astro/routes/api/auth/signup/complete.ts +2 -1
- package/src/astro/routes/api/import/wordpress/analyze.ts +24 -3
- package/src/astro/routes/api/import/wordpress/execute.ts +5 -1
- package/src/astro/routes/api/import/wordpress/prepare.ts +2 -2
- package/src/astro/routes/api/media.ts +16 -4
- package/src/astro/routes/api/search/index.ts +1 -5
- package/src/astro/routes/api/search/suggest.ts +1 -5
- package/src/astro/routes/api/setup/admin-verify.ts +2 -1
- package/src/astro/routes/api/setup/admin.ts +2 -1
- package/src/astro/types.ts +1 -0
- package/src/auth/passkey-config.ts +24 -3
- package/src/cli/commands/bundle-utils.ts +26 -0
- package/src/cli/commands/bundle.ts +15 -0
- package/src/cli/commands/content.ts +11 -1
- package/src/cli/commands/login.ts +2 -0
- package/src/cli/commands/media.ts +5 -1
- package/src/cli/commands/menu.ts +3 -1
- package/src/cli/commands/schema.ts +7 -1
- package/src/cli/commands/search-cmd.ts +2 -1
- package/src/cli/commands/taxonomy.ts +4 -1
- package/src/cli/output.ts +14 -0
- package/src/components/InlinePortableTextEditor.tsx +33 -3
- package/src/database/migrations/033_optimize_content_indexes.ts +113 -0
- package/src/database/migrations/runner.ts +40 -33
- package/src/database/repositories/comment.ts +32 -20
- package/src/emdash-runtime.ts +64 -2
- package/src/media/placeholder.ts +31 -0
- package/src/media/thumbnail.ts +32 -0
- package/src/plugins/hooks.ts +91 -0
- package/src/plugins/manager.ts +22 -0
- package/src/plugins/manifest-schema.ts +3 -0
- package/src/plugins/marketplace.ts +25 -12
- package/src/plugins/types.ts +24 -0
- package/src/schema/registry.ts +23 -27
- package/src/schema/types.ts +27 -1
- package/src/search/fts-manager.ts +1 -18
- package/src/visual-editing/toolbar.ts +84 -22
- package/dist/manifest-schema-Dcl0R6nM.mjs.map +0 -1
- package/dist/placeholder-SmpOx-_v.mjs.map +0 -1
- package/dist/registry-D_w5HW4G.mjs.map +0 -1
- package/dist/runner-C0hCbYnD.mjs.map +0 -1
- package/dist/search-DG603UrT.mjs.map +0 -1
- package/dist/types-C4-fAxN3.d.mts.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/emdash-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
package/src/media/placeholder.ts
CHANGED
|
@@ -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
|
+
}
|