emdash 0.10.0 → 0.11.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/dist/{apply-UsrFuO7l.mjs → apply-Ded_1vng.mjs} +36 -25
- package/dist/{apply-UsrFuO7l.mjs.map → apply-Ded_1vng.mjs.map} +1 -1
- package/dist/astro/index.d.mts +5 -5
- package/dist/astro/index.mjs +1 -1
- package/dist/astro/middleware/auth.d.mts +5 -5
- package/dist/astro/middleware/redirect.mjs +2 -2
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +83 -33
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +10 -7
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-C3vnhIpU.mjs → byline-gFn1r0vA.mjs} +2 -2
- package/dist/{byline-C3vnhIpU.mjs.map → byline-gFn1r0vA.mjs.map} +1 -1
- package/dist/{bylines-esI7ioa9.mjs → bylines-DTFI8nDM.mjs} +4 -4
- package/dist/{bylines-esI7ioa9.mjs.map → bylines-DTFI8nDM.mjs.map} +1 -1
- package/dist/{cache-fTzxgMFJ.mjs → cache-BAJbeoZ8.mjs} +2 -2
- package/dist/{cache-fTzxgMFJ.mjs.map → cache-BAJbeoZ8.mjs.map} +1 -1
- package/dist/{chunks-Da2-b-oA.mjs → chunks-BK1oZS-l.mjs} +2 -2
- package/dist/{chunks-Da2-b-oA.mjs.map → chunks-BK1oZS-l.mjs.map} +1 -1
- package/dist/cli/index.mjs +102 -27
- package/dist/cli/index.mjs.map +1 -1
- package/dist/{content-C7G4QXkK.mjs → content-CERxPUN0.mjs} +2 -2
- package/dist/{content-C7G4QXkK.mjs.map → content-CERxPUN0.mjs.map} +1 -1
- package/dist/database/instrumentation.d.mts +6 -4
- package/dist/database/instrumentation.d.mts.map +1 -1
- package/dist/database/instrumentation.mjs +19 -7
- package/dist/database/instrumentation.mjs.map +1 -1
- package/dist/db/index.d.mts +2 -2
- package/dist/db/index.mjs +1 -1
- package/dist/{index-DjPMOfO0.d.mts → index-BogfvE-z.d.mts} +32 -24
- package/dist/index-BogfvE-z.d.mts.map +1 -0
- package/dist/index.d.mts +7 -7
- package/dist/index.mjs +19 -19
- package/dist/{load-sXRuM7Us.mjs → load-DR1VwFXR.mjs} +2 -2
- package/dist/{load-sXRuM7Us.mjs.map → load-DR1VwFXR.mjs.map} +1 -1
- package/dist/{loader-Bx2_9-5e.mjs → loader-ou_PXAjg.mjs} +2 -2
- package/dist/{loader-Bx2_9-5e.mjs.map → loader-ou_PXAjg.mjs.map} +1 -1
- package/dist/media/local-runtime.d.mts +5 -5
- package/dist/media/local-runtime.mjs +1 -1
- package/dist/{media-D8FbNsl0.mjs → media-1fFhub9c.mjs} +21 -9
- package/dist/media-1fFhub9c.mjs.map +1 -0
- package/dist/page/index.d.mts +2 -2
- package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
- package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
- package/dist/{query-Bo-msrmu.mjs → query-8c_meo_K.mjs} +10 -10
- package/dist/{query-Bo-msrmu.mjs.map → query-8c_meo_K.mjs.map} +1 -1
- package/dist/{registry-Beb7wxFc.mjs → registry-Do34mz_P.mjs} +6 -5
- package/dist/registry-Do34mz_P.mjs.map +1 -0
- package/dist/{request-cache-C-tIpYIw.mjs → request-cache-D4I69LeL.mjs} +6 -2
- package/dist/request-cache-D4I69LeL.mjs.map +1 -0
- package/dist/request-context.d.mts +27 -1
- package/dist/request-context.d.mts.map +1 -1
- package/dist/request-context.mjs +16 -3
- package/dist/request-context.mjs.map +1 -1
- package/dist/{runner-DMnlIkh4.mjs → runner-DIcU2UCC.mjs} +174 -152
- package/dist/runner-DIcU2UCC.mjs.map +1 -0
- package/dist/{runner-Clwe4Mme.d.mts → runner-Iu3IZSDM.d.mts} +2 -2
- package/dist/{runner-Clwe4Mme.d.mts.map → runner-Iu3IZSDM.d.mts.map} +1 -1
- package/dist/runtime.d.mts +5 -5
- package/dist/runtime.mjs +1 -1
- package/dist/{search-DkN-BqsS.mjs → search-DuWhx4NG.mjs} +172 -30
- package/dist/search-DuWhx4NG.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +10 -10
- package/dist/{taxonomies-CTtewrSQ.mjs → taxonomies-Bw76xAxo.mjs} +6 -6
- package/dist/{taxonomies-CTtewrSQ.mjs.map → taxonomies-Bw76xAxo.mjs.map} +1 -1
- package/dist/{taxonomy-DSxx2K2L.mjs → taxonomy-D6NvlKo8.mjs} +3 -3
- package/dist/{taxonomy-DSxx2K2L.mjs.map → taxonomy-D6NvlKo8.mjs.map} +1 -1
- package/dist/{types-Eg829jj9.mjs → types-56BKbld_.mjs} +1 -1
- package/dist/types-56BKbld_.mjs.map +1 -0
- package/dist/{types-Dtx1mSMX.d.mts → types-BQx6ZXpR.d.mts} +2 -1
- package/dist/types-BQx6ZXpR.d.mts.map +1 -0
- package/dist/{types-Dl1fgFjn.d.mts → types-BTe41zL6.d.mts} +4 -3
- package/dist/types-BTe41zL6.d.mts.map +1 -0
- package/dist/types-DiI8NOG_.mjs +16 -0
- package/dist/types-DiI8NOG_.mjs.map +1 -0
- package/dist/{types-D19uBYWn.d.mts → types-IjUrQMVe.d.mts} +21 -245
- package/dist/types-IjUrQMVe.d.mts.map +1 -0
- package/dist/{validate-DHGwADqO.d.mts → validate-CcVQQpmH.d.mts} +7 -3
- package/dist/validate-CcVQQpmH.d.mts.map +1 -0
- package/dist/{validate-CBIbxM3L.mjs → validate-UK4Ja1uo.mjs} +3 -3
- package/dist/{validate-CBIbxM3L.mjs.map → validate-UK4Ja1uo.mjs.map} +1 -1
- package/dist/{validation-B1NYiEos.mjs → validation-Vc5DQkJa.mjs} +4 -4
- package/dist/{validation-B1NYiEos.mjs.map → validation-Vc5DQkJa.mjs.map} +1 -1
- package/dist/version-JjSqv90m.mjs +7 -0
- package/dist/{version-CMD42IRC.mjs.map → version-JjSqv90m.mjs.map} +1 -1
- package/dist/{zod-generator-BNJDQBSZ.mjs → zod-generator-CHnJUP2l.mjs} +1 -1
- package/dist/{zod-generator-BNJDQBSZ.mjs.map → zod-generator-CHnJUP2l.mjs.map} +1 -1
- package/package.json +9 -8
- package/src/api/errors.ts +5 -0
- package/src/api/handlers/content.ts +9 -0
- package/src/api/handlers/media-allowlist.ts +40 -0
- package/src/api/handlers/media.ts +1 -1
- package/src/api/handlers/menus.ts +158 -28
- package/src/api/handlers/validate-media-fields.ts +125 -0
- package/src/api/schemas/media.ts +23 -3
- package/src/api/schemas/schema.ts +11 -2
- package/src/astro/middleware.ts +46 -11
- package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/duplicate.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/publish.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/restore.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/schedule.ts +2 -2
- package/src/astro/routes/api/content/[collection]/[id]/unpublish.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id].ts +2 -2
- package/src/astro/routes/api/content/[collection]/index.ts +1 -1
- package/src/astro/routes/api/media/upload-url.ts +10 -4
- package/src/astro/routes/api/media.ts +12 -4
- package/src/astro/types.ts +5 -1
- package/src/auth/rate-limit.ts +3 -3
- package/src/cli/commands/bundle-utils.ts +81 -6
- package/src/cli/commands/bundle.ts +18 -15
- package/src/cli/commands/export-seed.ts +57 -3
- package/src/database/instrumentation.ts +22 -8
- package/src/database/migrations/016_api_tokens.ts +18 -3
- package/src/database/migrations/037_credential_algorithm.ts +18 -0
- package/src/database/migrations/runner.ts +2 -0
- package/src/database/repositories/media.ts +40 -10
- package/src/database/types.ts +2 -1
- package/src/emdash-runtime.ts +16 -3
- package/src/fields/file.ts +7 -6
- package/src/fields/image.ts +12 -11
- package/src/fields/types.ts +3 -0
- package/src/index.ts +1 -1
- package/src/mcp/server.ts +37 -8
- package/src/media/mime.ts +75 -0
- package/src/plugins/types.ts +81 -191
- package/src/request-cache.ts +6 -2
- package/src/request-context.ts +42 -2
- package/src/schema/registry.ts +5 -5
- package/src/schema/types.ts +3 -2
- package/src/seed/apply.ts +25 -8
- package/src/seed/types.ts +4 -0
- package/dist/index-DjPMOfO0.d.mts.map +0 -1
- package/dist/media-D8FbNsl0.mjs.map +0 -1
- package/dist/registry-Beb7wxFc.mjs.map +0 -1
- package/dist/request-cache-C-tIpYIw.mjs.map +0 -1
- package/dist/runner-DMnlIkh4.mjs.map +0 -1
- package/dist/search-DkN-BqsS.mjs.map +0 -1
- package/dist/types-CoO6mpV3.mjs +0 -68
- package/dist/types-CoO6mpV3.mjs.map +0 -1
- package/dist/types-D19uBYWn.d.mts.map +0 -1
- package/dist/types-Dl1fgFjn.d.mts.map +0 -1
- package/dist/types-Dtx1mSMX.d.mts.map +0 -1
- package/dist/types-Eg829jj9.mjs.map +0 -1
- package/dist/validate-DHGwADqO.d.mts.map +0 -1
- package/dist/version-CMD42IRC.mjs +0 -7
|
@@ -22,7 +22,12 @@ import type {
|
|
|
22
22
|
|
|
23
23
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// Bundle size caps per RFC 0001 §"Bundle size limits". These are decompressed
|
|
26
|
+
// sizes; the gzipped tarball is typically a fraction of MAX_BUNDLE_SIZE.
|
|
27
|
+
export const MAX_BUNDLE_SIZE = 256 * 1024;
|
|
28
|
+
export const MAX_FILE_SIZE = 128 * 1024;
|
|
29
|
+
export const MAX_FILE_COUNT = 20;
|
|
30
|
+
|
|
26
31
|
export const MAX_SCREENSHOTS = 5;
|
|
27
32
|
export const MAX_SCREENSHOT_WIDTH = 1920;
|
|
28
33
|
export const MAX_SCREENSHOT_HEIGHT = 1080;
|
|
@@ -251,23 +256,93 @@ export function findSourceExports(
|
|
|
251
256
|
// ── Directory helpers ────────────────────────────────────────────────────────
|
|
252
257
|
|
|
253
258
|
/**
|
|
254
|
-
*
|
|
259
|
+
* One file in a bundle: a tarball-relative path and its byte length.
|
|
260
|
+
* Produced by `collectBundleEntries` (from a staging dir) or by the publish
|
|
261
|
+
* flow (from tarball entries); consumed by `validateBundleSize`.
|
|
255
262
|
*/
|
|
256
|
-
export
|
|
257
|
-
|
|
263
|
+
export interface BundleFileEntry {
|
|
264
|
+
name: string;
|
|
265
|
+
bytes: number;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Recursively walk a staging directory and return a flat list of all files
|
|
270
|
+
* with sizes. Names are relative to `dir` so they match what would appear
|
|
271
|
+
* as the tarball entry name.
|
|
272
|
+
*/
|
|
273
|
+
export async function collectBundleEntries(dir: string): Promise<BundleFileEntry[]> {
|
|
274
|
+
const entries: BundleFileEntry[] = [];
|
|
275
|
+
await walkBundle(dir, "", entries);
|
|
276
|
+
return entries;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function walkBundle(dir: string, prefix: string, into: BundleFileEntry[]): Promise<void> {
|
|
258
280
|
const items = await readdir(dir, { withFileTypes: true });
|
|
259
281
|
for (const item of items) {
|
|
260
282
|
const fullPath = join(dir, item.name);
|
|
283
|
+
const relPath = prefix ? `${prefix}/${item.name}` : item.name;
|
|
261
284
|
if (item.isFile()) {
|
|
262
285
|
const s = await stat(fullPath);
|
|
263
|
-
|
|
286
|
+
into.push({ name: relPath, bytes: s.size });
|
|
264
287
|
} else if (item.isDirectory()) {
|
|
265
|
-
|
|
288
|
+
await walkBundle(fullPath, relPath, into);
|
|
266
289
|
}
|
|
267
290
|
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Sum the byte sizes of all entries.
|
|
295
|
+
*/
|
|
296
|
+
export function totalBundleBytes(entries: readonly BundleFileEntry[]): number {
|
|
297
|
+
let total = 0;
|
|
298
|
+
for (const e of entries) total += e.bytes;
|
|
268
299
|
return total;
|
|
269
300
|
}
|
|
270
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Check a bundle against the three size caps from RFC 0001:
|
|
304
|
+
* - total decompressed ≤ MAX_BUNDLE_SIZE
|
|
305
|
+
* - per-file decompressed ≤ MAX_FILE_SIZE
|
|
306
|
+
* - file count ≤ MAX_FILE_COUNT
|
|
307
|
+
*
|
|
308
|
+
* Returns a list of violation messages (empty if the bundle is within all
|
|
309
|
+
* caps). Messages are deterministic per input — the total/count violations
|
|
310
|
+
* come first, then oversized files in alphabetical order — so the same
|
|
311
|
+
* bundle always produces the same error text.
|
|
312
|
+
*/
|
|
313
|
+
export function validateBundleSize(entries: readonly BundleFileEntry[]): string[] {
|
|
314
|
+
const violations: string[] = [];
|
|
315
|
+
const total = totalBundleBytes(entries);
|
|
316
|
+
if (total > MAX_BUNDLE_SIZE) {
|
|
317
|
+
violations.push(
|
|
318
|
+
`Bundle size ${formatBytes(total)} exceeds maximum of ${formatBytes(MAX_BUNDLE_SIZE)}.`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
if (entries.length > MAX_FILE_COUNT) {
|
|
322
|
+
violations.push(
|
|
323
|
+
`Bundle contains ${entries.length} files, exceeds maximum of ${MAX_FILE_COUNT}.`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
const oversized = entries
|
|
327
|
+
.filter((e) => e.bytes > MAX_FILE_SIZE)
|
|
328
|
+
.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
329
|
+
for (const e of oversized) {
|
|
330
|
+
violations.push(
|
|
331
|
+
`File ${e.name} is ${formatBytes(e.bytes)}, exceeds per-file maximum of ${formatBytes(MAX_FILE_SIZE)}.`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return violations;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Render a byte count as a human-friendly string (e.g. "256.0 KB").
|
|
339
|
+
*/
|
|
340
|
+
export function formatBytes(n: number): string {
|
|
341
|
+
if (n < 1024) return `${n} B`;
|
|
342
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
343
|
+
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
|
344
|
+
}
|
|
345
|
+
|
|
271
346
|
// ── Tarball creation ─────────────────────────────────────────────────────────
|
|
272
347
|
|
|
273
348
|
/**
|
|
@@ -23,20 +23,22 @@ import consola from "consola";
|
|
|
23
23
|
import { CAPABILITY_RENAMES, isDeprecatedCapability } from "../../plugins/types.js";
|
|
24
24
|
import type { ResolvedPlugin } from "../../plugins/types.js";
|
|
25
25
|
import {
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
collectBundleEntries,
|
|
27
|
+
createTarball,
|
|
28
28
|
extractManifest,
|
|
29
|
-
|
|
29
|
+
fileExists,
|
|
30
30
|
findBuildOutput,
|
|
31
|
+
findNodeBuiltinImports,
|
|
31
32
|
findSourceExports,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
createTarball,
|
|
35
|
-
MAX_BUNDLE_SIZE,
|
|
33
|
+
formatBytes,
|
|
34
|
+
ICON_SIZE,
|
|
36
35
|
MAX_SCREENSHOTS,
|
|
37
36
|
MAX_SCREENSHOT_WIDTH,
|
|
38
37
|
MAX_SCREENSHOT_HEIGHT,
|
|
39
|
-
|
|
38
|
+
readImageDimensions,
|
|
39
|
+
resolveSourceEntry,
|
|
40
|
+
totalBundleBytes,
|
|
41
|
+
validateBundleSize,
|
|
40
42
|
} from "./bundle-utils.js";
|
|
41
43
|
|
|
42
44
|
const TS_EXT_RE = /\.(tsx?|[mc]?js)$/;
|
|
@@ -596,15 +598,16 @@ export const bundleCommand = defineCommand({
|
|
|
596
598
|
}
|
|
597
599
|
}
|
|
598
600
|
|
|
599
|
-
//
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
601
|
+
// Bundle size caps (RFC 0001 §"Bundle size limits").
|
|
602
|
+
const bundleEntries = await collectBundleEntries(bundleDir);
|
|
603
|
+
const sizeViolations = validateBundleSize(bundleEntries);
|
|
604
|
+
if (sizeViolations.length > 0) {
|
|
605
|
+
for (const v of sizeViolations) consola.error(v);
|
|
604
606
|
hasErrors = true;
|
|
605
607
|
} else {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
+
consola.info(
|
|
609
|
+
`Bundle size: ${formatBytes(totalBundleBytes(bundleEntries))} across ${bundleEntries.length} file${bundleEntries.length === 1 ? "" : "s"}`,
|
|
610
|
+
);
|
|
608
611
|
}
|
|
609
612
|
|
|
610
613
|
if (hasErrors) {
|
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
SeedWidget,
|
|
33
33
|
SeedContentEntry,
|
|
34
34
|
} from "../../seed/types.js";
|
|
35
|
+
import { slugify } from "../../utils/slugify.js";
|
|
35
36
|
|
|
36
37
|
const SETTINGS_PREFIX = "site:";
|
|
37
38
|
|
|
@@ -101,7 +102,7 @@ export const exportSeedCommand = defineCommand({
|
|
|
101
102
|
/**
|
|
102
103
|
* Export database to seed file format
|
|
103
104
|
*/
|
|
104
|
-
async function exportSeed(db: Kysely<Database>, withContent?: string): Promise<SeedFile> {
|
|
105
|
+
export async function exportSeed(db: Kysely<Database>, withContent?: string): Promise<SeedFile> {
|
|
105
106
|
const seed: SeedFile = {
|
|
106
107
|
$schema: "https://emdashcms.com/seed.schema.json",
|
|
107
108
|
version: "1",
|
|
@@ -317,6 +318,9 @@ async function exportMenus(db: Kysely<Database>): Promise<SeedMenu[]> {
|
|
|
317
318
|
const result: SeedMenu[] = [];
|
|
318
319
|
// translation_group -> seed-local id of the anchor menu in that group.
|
|
319
320
|
const groupToSeedId = new Map<string, string>();
|
|
321
|
+
// Shared across menus: translated items reference anchor items in sibling menus.
|
|
322
|
+
const itemGroupToSeedId = new Map<string, string>();
|
|
323
|
+
const usedItemSeedIds = new Set<string>();
|
|
320
324
|
|
|
321
325
|
for (const menu of menus) {
|
|
322
326
|
const seedId =
|
|
@@ -329,7 +333,13 @@ async function exportMenus(db: Kysely<Database>): Promise<SeedMenu[]> {
|
|
|
329
333
|
.orderBy("sort_order", "asc")
|
|
330
334
|
.execute();
|
|
331
335
|
|
|
332
|
-
const seedItems = buildMenuItemTree(items
|
|
336
|
+
const seedItems = buildMenuItemTree(items, {
|
|
337
|
+
i18nEnabled,
|
|
338
|
+
menuName: menu.name,
|
|
339
|
+
menuLocale: menu.locale ?? null,
|
|
340
|
+
itemGroupToSeedId,
|
|
341
|
+
usedItemSeedIds,
|
|
342
|
+
});
|
|
333
343
|
|
|
334
344
|
const seedMenu: SeedMenu = {
|
|
335
345
|
id: seedId,
|
|
@@ -376,7 +386,17 @@ function buildMenuItemTree(
|
|
|
376
386
|
target: string | null;
|
|
377
387
|
title_attr: string | null;
|
|
378
388
|
css_classes: string | null;
|
|
389
|
+
locale?: string | null;
|
|
390
|
+
translation_group?: string | null;
|
|
379
391
|
}>,
|
|
392
|
+
i18nCtx: {
|
|
393
|
+
i18nEnabled: boolean;
|
|
394
|
+
menuName: string;
|
|
395
|
+
menuLocale: string | null;
|
|
396
|
+
// translation_group -> seed-local id of the anchor item in that group.
|
|
397
|
+
itemGroupToSeedId: Map<string, string>;
|
|
398
|
+
usedItemSeedIds: Set<string>;
|
|
399
|
+
},
|
|
380
400
|
): SeedMenuItem[] {
|
|
381
401
|
// Build parent -> children map
|
|
382
402
|
const childMap = new Map<string | null, typeof items>();
|
|
@@ -389,10 +409,28 @@ function buildMenuItemTree(
|
|
|
389
409
|
childMap.get(parentId)!.push(item);
|
|
390
410
|
}
|
|
391
411
|
|
|
412
|
+
function makeSeedId(item: (typeof items)[number]): string {
|
|
413
|
+
const base = slugify(item.label || "") || item.id;
|
|
414
|
+
const locale = i18nCtx.i18nEnabled ? (item.locale ?? i18nCtx.menuLocale) : null;
|
|
415
|
+
const candidate = locale
|
|
416
|
+
? `item:${i18nCtx.menuName}:${base}:${locale}`
|
|
417
|
+
: `item:${i18nCtx.menuName}:${base}`;
|
|
418
|
+
if (!i18nCtx.usedItemSeedIds.has(candidate)) {
|
|
419
|
+
i18nCtx.usedItemSeedIds.add(candidate);
|
|
420
|
+
return candidate;
|
|
421
|
+
}
|
|
422
|
+
// Collision fallback: append DB id to disambiguate duplicate labels.
|
|
423
|
+
const fallback = locale
|
|
424
|
+
? `item:${i18nCtx.menuName}:${base}:${item.id}:${locale}`
|
|
425
|
+
: `item:${i18nCtx.menuName}:${base}:${item.id}`;
|
|
426
|
+
i18nCtx.usedItemSeedIds.add(fallback);
|
|
427
|
+
return fallback;
|
|
428
|
+
}
|
|
429
|
+
|
|
392
430
|
// Recursively build tree
|
|
393
431
|
function buildLevel(parentId: string | null): SeedMenuItem[] {
|
|
394
432
|
const children = childMap.get(parentId) || [];
|
|
395
|
-
|
|
433
|
+
const result = children.map((item) => {
|
|
396
434
|
const seedItem: SeedMenuItem = {
|
|
397
435
|
type: item.type,
|
|
398
436
|
label: item.label || undefined,
|
|
@@ -415,6 +453,18 @@ function buildMenuItemTree(
|
|
|
415
453
|
seedItem.cssClasses = item.css_classes;
|
|
416
454
|
}
|
|
417
455
|
|
|
456
|
+
if (i18nCtx.i18nEnabled) {
|
|
457
|
+
const itemLocale = item.locale ?? i18nCtx.menuLocale;
|
|
458
|
+
const seedId = makeSeedId(item);
|
|
459
|
+
seedItem.id = seedId;
|
|
460
|
+
if (itemLocale) seedItem.locale = itemLocale;
|
|
461
|
+
if (item.translation_group) {
|
|
462
|
+
const anchor = i18nCtx.itemGroupToSeedId.get(item.translation_group);
|
|
463
|
+
if (anchor && anchor !== seedId) seedItem.translationOf = anchor;
|
|
464
|
+
else if (!anchor) i18nCtx.itemGroupToSeedId.set(item.translation_group, seedId);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
418
468
|
// Add children
|
|
419
469
|
const itemChildren = buildLevel(item.id);
|
|
420
470
|
if (itemChildren.length > 0) {
|
|
@@ -423,6 +473,10 @@ function buildMenuItemTree(
|
|
|
423
473
|
|
|
424
474
|
return seedItem;
|
|
425
475
|
});
|
|
476
|
+
|
|
477
|
+
// Sibling order is preserved (maps to sort_order on import). Cross-menu
|
|
478
|
+
// `translationOf` already resolves because exportMenus sorts anchors first.
|
|
479
|
+
return result;
|
|
426
480
|
}
|
|
427
481
|
|
|
428
482
|
return buildLevel(null);
|
|
@@ -83,16 +83,30 @@ export function isInstrumentationEnabled(): boolean {
|
|
|
83
83
|
|
|
84
84
|
function kyselyLog(event: LogEvent): void {
|
|
85
85
|
if (event.level !== "query") return;
|
|
86
|
-
const
|
|
87
|
-
if (!
|
|
88
|
-
|
|
86
|
+
const ctx = getRequestContext();
|
|
87
|
+
if (!ctx) return;
|
|
88
|
+
const dur = event.queryDurationMillis;
|
|
89
|
+
if (ctx.metrics) {
|
|
90
|
+
const m = ctx.metrics;
|
|
91
|
+
m.dbCount += 1;
|
|
92
|
+
m.dbTotalMs += dur;
|
|
93
|
+
const finishedAt = performance.now() - m.start;
|
|
94
|
+
const startedAt = finishedAt - dur;
|
|
95
|
+
if (m.dbFirstOffset === null) m.dbFirstOffset = startedAt;
|
|
96
|
+
m.dbLastOffset = finishedAt;
|
|
97
|
+
}
|
|
98
|
+
if (ctx.queryRecorder) {
|
|
99
|
+
recordEvent(ctx.queryRecorder, event.query.sql, event.query.parameters, dur);
|
|
100
|
+
}
|
|
89
101
|
}
|
|
90
102
|
|
|
91
103
|
/**
|
|
92
|
-
* Returns a Kysely `log`
|
|
93
|
-
*
|
|
94
|
-
*
|
|
104
|
+
* Returns a Kysely `log` callback. Always returns a function so per-request
|
|
105
|
+
* counters (db.count, db.total, db.first, db.last) and the optional NDJSON
|
|
106
|
+
* recorder both get fed. The cost over the previous "undefined when off"
|
|
107
|
+
* behaviour is one `performance.now()` pair per query inside Kysely, which
|
|
108
|
+
* is in the noise compared to any real query.
|
|
95
109
|
*/
|
|
96
|
-
export function kyselyLogOption(): Logger
|
|
97
|
-
return
|
|
110
|
+
export function kyselyLogOption(): Logger {
|
|
111
|
+
return kyselyLog;
|
|
98
112
|
}
|
|
@@ -9,11 +9,20 @@ import { currentTimestamp } from "../dialect-helpers.js";
|
|
|
9
9
|
* 1. _emdash_api_tokens — Personal Access Tokens (ec_pat_...)
|
|
10
10
|
* 2. _emdash_oauth_tokens — OAuth access/refresh tokens (ec_oat_/ec_ort_...)
|
|
11
11
|
* 3. _emdash_device_codes — OAuth Device Flow state (RFC 8628)
|
|
12
|
+
*
|
|
13
|
+
* Every CREATE is guarded with `.ifNotExists()` so the migration is safe to
|
|
14
|
+
* re-run against a partially-applied schema. See #954 for the failure mode:
|
|
15
|
+
* if `up()` crashes mid-way (D1 subrequest limit, isolate cancellation,
|
|
16
|
+
* transient connection error), the migration record never gets inserted
|
|
17
|
+
* into `_emdash_migrations`, and the next request retries `up()` from the
|
|
18
|
+
* top. Without these guards, the retry crashed with `table ... already
|
|
19
|
+
* exists` and blocked every subsequent boot of the Worker.
|
|
12
20
|
*/
|
|
13
21
|
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
14
22
|
// ── Personal Access Tokens ───────────────────────────────────────
|
|
15
23
|
await db.schema
|
|
16
24
|
.createTable("_emdash_api_tokens")
|
|
25
|
+
.ifNotExists()
|
|
17
26
|
.addColumn("id", "text", (col) => col.primaryKey())
|
|
18
27
|
.addColumn("name", "text", (col) => col.notNull())
|
|
19
28
|
.addColumn("token_hash", "text", (col) => col.notNull().unique())
|
|
@@ -30,12 +39,14 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
|
|
30
39
|
|
|
31
40
|
await db.schema
|
|
32
41
|
.createIndex("idx_api_tokens_token_hash")
|
|
42
|
+
.ifNotExists()
|
|
33
43
|
.on("_emdash_api_tokens")
|
|
34
44
|
.column("token_hash")
|
|
35
45
|
.execute();
|
|
36
46
|
|
|
37
47
|
await db.schema
|
|
38
48
|
.createIndex("idx_api_tokens_user_id")
|
|
49
|
+
.ifNotExists()
|
|
39
50
|
.on("_emdash_api_tokens")
|
|
40
51
|
.column("user_id")
|
|
41
52
|
.execute();
|
|
@@ -43,6 +54,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
|
|
43
54
|
// ── OAuth Tokens ─────────────────────────────────────────────────
|
|
44
55
|
await db.schema
|
|
45
56
|
.createTable("_emdash_oauth_tokens")
|
|
57
|
+
.ifNotExists()
|
|
46
58
|
.addColumn("token_hash", "text", (col) => col.primaryKey())
|
|
47
59
|
.addColumn("token_type", "text", (col) => col.notNull()) // 'access' | 'refresh'
|
|
48
60
|
.addColumn("user_id", "text", (col) => col.notNull())
|
|
@@ -58,12 +70,14 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
|
|
58
70
|
|
|
59
71
|
await db.schema
|
|
60
72
|
.createIndex("idx_oauth_tokens_user_id")
|
|
73
|
+
.ifNotExists()
|
|
61
74
|
.on("_emdash_oauth_tokens")
|
|
62
75
|
.column("user_id")
|
|
63
76
|
.execute();
|
|
64
77
|
|
|
65
78
|
await db.schema
|
|
66
79
|
.createIndex("idx_oauth_tokens_expires")
|
|
80
|
+
.ifNotExists()
|
|
67
81
|
.on("_emdash_oauth_tokens")
|
|
68
82
|
.column("expires_at")
|
|
69
83
|
.execute();
|
|
@@ -71,6 +85,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
|
|
71
85
|
// ── Device Codes (OAuth Device Flow, RFC 8628) ───────────────────
|
|
72
86
|
await db.schema
|
|
73
87
|
.createTable("_emdash_device_codes")
|
|
88
|
+
.ifNotExists()
|
|
74
89
|
.addColumn("device_code", "text", (col) => col.primaryKey())
|
|
75
90
|
.addColumn("user_code", "text", (col) => col.notNull().unique())
|
|
76
91
|
.addColumn("scopes", "text", (col) => col.notNull()) // JSON array
|
|
@@ -83,7 +98,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
86
|
-
await db.schema.dropTable("_emdash_device_codes").execute();
|
|
87
|
-
await db.schema.dropTable("_emdash_oauth_tokens").execute();
|
|
88
|
-
await db.schema.dropTable("_emdash_api_tokens").execute();
|
|
101
|
+
await db.schema.dropTable("_emdash_device_codes").ifExists().execute();
|
|
102
|
+
await db.schema.dropTable("_emdash_oauth_tokens").ifExists().execute();
|
|
103
|
+
await db.schema.dropTable("_emdash_api_tokens").ifExists().execute();
|
|
89
104
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Kysely } from "kysely";
|
|
2
|
+
|
|
3
|
+
import { columnExists } from "../dialect-helpers.js";
|
|
4
|
+
|
|
5
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
6
|
+
if (!(await columnExists(db, "credentials", "algorithm"))) {
|
|
7
|
+
await db.schema
|
|
8
|
+
.alterTable("credentials")
|
|
9
|
+
.addColumn("algorithm", "integer", (col) => col.notNull().defaultTo(-7))
|
|
10
|
+
.execute();
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
15
|
+
if (await columnExists(db, "credentials", "algorithm")) {
|
|
16
|
+
await db.schema.alterTable("credentials").dropColumn("algorithm").execute();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -37,6 +37,7 @@ import * as m033 from "./033_optimize_content_indexes.js";
|
|
|
37
37
|
import * as m034 from "./034_published_at_index.js";
|
|
38
38
|
import * as m035 from "./035_bounded_404_log.js";
|
|
39
39
|
import * as m036 from "./036_i18n_menus_and_taxonomies.js";
|
|
40
|
+
import * as m037 from "./037_credential_algorithm.js";
|
|
40
41
|
|
|
41
42
|
const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
|
|
42
43
|
"001_initial": m001,
|
|
@@ -74,6 +75,7 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
|
|
|
74
75
|
"034_published_at_index": m034,
|
|
75
76
|
"035_bounded_404_log": m035,
|
|
76
77
|
"036_i18n_menus_and_taxonomies": m036,
|
|
78
|
+
"037_credential_algorithm": m037,
|
|
77
79
|
});
|
|
78
80
|
|
|
79
81
|
/** Total number of registered migrations. Exported for use in tests. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sql, type Kysely, type SqlBool } from "kysely";
|
|
1
|
+
import { sql, type ExpressionBuilder, type Kysely, type SqlBool } from "kysely";
|
|
2
2
|
import { ulid } from "ulidx";
|
|
3
3
|
|
|
4
4
|
import type { Database, MediaRow } from "../types.js";
|
|
@@ -10,6 +10,35 @@ function escapeLike(value: string): string {
|
|
|
10
10
|
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Normalize a mimeType filter (string or array) into a clean string[].
|
|
15
|
+
* Entries that are empty strings are dropped.
|
|
16
|
+
*/
|
|
17
|
+
function normalizeMimeFilter(input?: string | readonly string[]): string[] {
|
|
18
|
+
if (!input) return [];
|
|
19
|
+
const arr = Array.isArray(input) ? input : [input];
|
|
20
|
+
return arr
|
|
21
|
+
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
|
22
|
+
.map((entry) =>
|
|
23
|
+
entry.endsWith("/") ? entry.toLowerCase() : entry.split(";")[0]!.trim().toLowerCase(),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a WHERE clause that matches `mime_type` against any of the given
|
|
29
|
+
* filter entries — exact equality for full MIMEs, LIKE prefix for entries
|
|
30
|
+
* ending in "/".
|
|
31
|
+
*/
|
|
32
|
+
function mimeMatchExpr(eb: ExpressionBuilder<Database, "media">, filters: string[]) {
|
|
33
|
+
return eb.or(
|
|
34
|
+
filters.map((entry) =>
|
|
35
|
+
entry.endsWith("/")
|
|
36
|
+
? sql<SqlBool>`mime_type LIKE ${`${escapeLike(entry)}%`} ESCAPE '\\'`
|
|
37
|
+
: eb("mime_type", "=", entry),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
13
42
|
export type MediaStatus = "pending" | "ready" | "failed";
|
|
14
43
|
|
|
15
44
|
export interface MediaItem {
|
|
@@ -49,7 +78,8 @@ export interface CreateMediaInput {
|
|
|
49
78
|
export interface FindManyMediaOptions {
|
|
50
79
|
limit?: number;
|
|
51
80
|
cursor?: string;
|
|
52
|
-
|
|
81
|
+
/** Filter by MIME type. Pass a string for a single prefix/exact, or an array to match any. Strings ending with "/" are treated as LIKE prefix matches; others are exact equality. */
|
|
82
|
+
mimeType?: string | readonly string[];
|
|
53
83
|
status?: MediaStatus | "all"; // Filter by status, defaults to "ready"
|
|
54
84
|
}
|
|
55
85
|
|
|
@@ -215,9 +245,9 @@ export class MediaRepository {
|
|
|
215
245
|
);
|
|
216
246
|
}
|
|
217
247
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
query = query.where(
|
|
248
|
+
const mimeFilters = normalizeMimeFilter(options.mimeType);
|
|
249
|
+
if (mimeFilters.length > 0) {
|
|
250
|
+
query = query.where((eb) => mimeMatchExpr(eb, mimeFilters));
|
|
221
251
|
}
|
|
222
252
|
|
|
223
253
|
// Default to only showing ready items
|
|
@@ -276,12 +306,12 @@ export class MediaRepository {
|
|
|
276
306
|
/**
|
|
277
307
|
* Count media items
|
|
278
308
|
*/
|
|
279
|
-
async count(mimeType?: string): Promise<number> {
|
|
280
|
-
|
|
309
|
+
async count(mimeType?: string | readonly string[]): Promise<number> {
|
|
310
|
+
const filters = normalizeMimeFilter(mimeType);
|
|
311
|
+
let query = this.db.selectFrom("media").select((eb) => eb.fn.count<number>("id").as("count"));
|
|
281
312
|
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
query = query.where(sql<SqlBool>`mime_type LIKE ${pattern} ESCAPE '\\'`);
|
|
313
|
+
if (filters.length > 0) {
|
|
314
|
+
query = query.where((eb) => mimeMatchExpr(eb, filters));
|
|
285
315
|
}
|
|
286
316
|
|
|
287
317
|
const result = await query.executeTakeFirst();
|
package/src/database/types.ts
CHANGED
|
@@ -76,7 +76,8 @@ export interface UserTable {
|
|
|
76
76
|
export interface CredentialTable {
|
|
77
77
|
id: string; // Base64url credential ID
|
|
78
78
|
user_id: string;
|
|
79
|
-
public_key: Uint8Array; //
|
|
79
|
+
public_key: Uint8Array; // SEC1 or PKIX encoded public key
|
|
80
|
+
algorithm: number;
|
|
80
81
|
counter: number;
|
|
81
82
|
device_type: string; // 'singleDevice' | 'multiDevice'
|
|
82
83
|
backed_up: number; // 0 or 1
|
package/src/emdash-runtime.ts
CHANGED
|
@@ -1287,6 +1287,8 @@ export class EmDashRuntime {
|
|
|
1287
1287
|
// or arbitrary `Record<string, unknown>` for plugin field widgets that
|
|
1288
1288
|
// need per-field config (e.g. a checkbox grid receiving its column defs).
|
|
1289
1289
|
options?: Array<{ value: string; label: string }> | Record<string, unknown>;
|
|
1290
|
+
id?: string;
|
|
1291
|
+
validation?: Record<string, unknown>;
|
|
1290
1292
|
}
|
|
1291
1293
|
> = {};
|
|
1292
1294
|
|
|
@@ -1296,6 +1298,9 @@ export class EmDashRuntime {
|
|
|
1296
1298
|
label: field.label,
|
|
1297
1299
|
required: field.required,
|
|
1298
1300
|
};
|
|
1301
|
+
// Always include the field's database ID so the admin can forward it
|
|
1302
|
+
// to upload/media-list API calls for MIME allowlist widening.
|
|
1303
|
+
entry.id = field.id;
|
|
1299
1304
|
if (field.widget) entry.widget = field.widget;
|
|
1300
1305
|
// Plugin field widgets read their per-field config from `field.options`,
|
|
1301
1306
|
// which the seed schema types as `Record<string, unknown>`. Pass it
|
|
@@ -1312,8 +1317,12 @@ export class EmDashRuntime {
|
|
|
1312
1317
|
}));
|
|
1313
1318
|
}
|
|
1314
1319
|
// Include full validation for repeater fields (subFields, minItems, maxItems)
|
|
1315
|
-
|
|
1316
|
-
|
|
1320
|
+
// and for file/image fields (allowedMimeTypes).
|
|
1321
|
+
if (
|
|
1322
|
+
(field.type === "repeater" || field.type === "file" || field.type === "image") &&
|
|
1323
|
+
field.validation
|
|
1324
|
+
) {
|
|
1325
|
+
entry.validation = { ...field.validation };
|
|
1317
1326
|
}
|
|
1318
1327
|
fields[field.slug] = entry;
|
|
1319
1328
|
}
|
|
@@ -1980,7 +1989,11 @@ export class EmDashRuntime {
|
|
|
1980
1989
|
// Media Handlers
|
|
1981
1990
|
// =========================================================================
|
|
1982
1991
|
|
|
1983
|
-
async handleMediaList(params: {
|
|
1992
|
+
async handleMediaList(params: {
|
|
1993
|
+
cursor?: string;
|
|
1994
|
+
limit?: number;
|
|
1995
|
+
mimeType?: string | readonly string[];
|
|
1996
|
+
}) {
|
|
1984
1997
|
return handleMediaList(this.db, params);
|
|
1985
1998
|
}
|
|
1986
1999
|
|
package/src/fields/file.ts
CHANGED
|
@@ -5,13 +5,10 @@ import type { FieldDefinition, FieldUIHints, FileValue } from "./types.js";
|
|
|
5
5
|
export interface FileOptions {
|
|
6
6
|
required?: boolean;
|
|
7
7
|
maxSize?: number; // In bytes
|
|
8
|
-
allowedTypes?: string[]; // MIME types
|
|
8
|
+
allowedTypes?: string[]; // MIME types — exact (image/png) or prefix (image/)
|
|
9
9
|
helpText?: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* File field - file upload
|
|
14
|
-
*/
|
|
15
12
|
export function file(options: FileOptions = {}): FieldDefinition<FileValue> {
|
|
16
13
|
const fileObjSchema = z.object({
|
|
17
14
|
id: z.string(),
|
|
@@ -21,21 +18,25 @@ export function file(options: FileOptions = {}): FieldDefinition<FileValue> {
|
|
|
21
18
|
size: z.number(),
|
|
22
19
|
});
|
|
23
20
|
|
|
24
|
-
// Optional vs required
|
|
25
21
|
const schema: z.ZodTypeAny = options.required ? fileObjSchema : fileObjSchema.optional();
|
|
26
22
|
|
|
27
23
|
const ui: FieldUIHints = {
|
|
28
24
|
widget: "file",
|
|
29
25
|
helpText: options.helpText,
|
|
30
26
|
maxSize: options.maxSize,
|
|
31
|
-
allowedTypes: options.allowedTypes,
|
|
32
27
|
};
|
|
33
28
|
|
|
29
|
+
const validation =
|
|
30
|
+
options.allowedTypes && options.allowedTypes.length > 0
|
|
31
|
+
? { allowedMimeTypes: [...options.allowedTypes] }
|
|
32
|
+
: undefined;
|
|
33
|
+
|
|
34
34
|
return {
|
|
35
35
|
type: "file",
|
|
36
36
|
columnType: "TEXT",
|
|
37
37
|
schema,
|
|
38
38
|
options,
|
|
39
39
|
ui,
|
|
40
|
+
validation,
|
|
40
41
|
};
|
|
41
42
|
}
|
package/src/fields/image.ts
CHANGED
|
@@ -2,9 +2,6 @@ import { z } from "astro/zod";
|
|
|
2
2
|
|
|
3
3
|
import type { FieldDefinition, ImageValue } from "./types.js";
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* Image field schema
|
|
7
|
-
*/
|
|
8
5
|
const imageSchema = z.object({
|
|
9
6
|
id: z.string(),
|
|
10
7
|
src: z.string(),
|
|
@@ -13,22 +10,26 @@ const imageSchema = z.object({
|
|
|
13
10
|
height: z.number().optional(),
|
|
14
11
|
});
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
* Image field
|
|
18
|
-
* References media items from the media library
|
|
19
|
-
*/
|
|
20
|
-
export function image(options?: {
|
|
13
|
+
export interface ImageOptions {
|
|
21
14
|
required?: boolean;
|
|
22
15
|
maxSize?: number; // in bytes
|
|
23
|
-
allowedTypes?: string[]; // MIME types
|
|
24
|
-
}
|
|
16
|
+
allowedTypes?: string[]; // MIME types — exact or prefix
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function image(options: ImageOptions = {}): FieldDefinition<ImageValue | undefined> {
|
|
20
|
+
const validation =
|
|
21
|
+
options.allowedTypes && options.allowedTypes.length > 0
|
|
22
|
+
? { allowedMimeTypes: [...options.allowedTypes] }
|
|
23
|
+
: undefined;
|
|
24
|
+
|
|
25
25
|
return {
|
|
26
26
|
type: "image",
|
|
27
27
|
columnType: "TEXT",
|
|
28
|
-
schema: options
|
|
28
|
+
schema: options.required === false ? imageSchema.optional() : imageSchema,
|
|
29
29
|
options,
|
|
30
30
|
ui: {
|
|
31
31
|
widget: "image",
|
|
32
32
|
},
|
|
33
|
+
validation,
|
|
33
34
|
};
|
|
34
35
|
}
|