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
package/src/fields/types.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { z } from "astro/zod";
|
|
2
2
|
|
|
3
|
+
import type { FieldValidation } from "../schema/types.js";
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* SQLite column types that map from field types
|
|
5
7
|
*/
|
|
@@ -19,6 +21,7 @@ export interface FieldDefinition<_T = unknown> {
|
|
|
19
21
|
schema: z.ZodTypeAny;
|
|
20
22
|
options?: unknown;
|
|
21
23
|
ui?: FieldUIHints;
|
|
24
|
+
validation?: FieldValidation;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
/**
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ export type {
|
|
|
27
27
|
export type { MediaItem, CreateMediaInput } from "./database/repositories/media.js";
|
|
28
28
|
|
|
29
29
|
// Fields
|
|
30
|
-
export { portableText, image, reference } from "./fields/index.js";
|
|
30
|
+
export { portableText, image, file, reference } from "./fields/index.js";
|
|
31
31
|
export { normalizeMediaValue } from "./media/normalize.js";
|
|
32
32
|
export { generatePlaceholder } from "./media/placeholder.js";
|
|
33
33
|
export type { PlaceholderData } from "./media/placeholder.js";
|
package/src/mcp/server.ts
CHANGED
|
@@ -1993,13 +1993,23 @@ export function createMcpServer(): McpServer {
|
|
|
1993
1993
|
description:
|
|
1994
1994
|
"Create a new navigation menu. The `name` is the stable identifier used " +
|
|
1995
1995
|
"by site templates (e.g. 'main', 'footer'); `label` is the human-readable " +
|
|
1996
|
-
"name shown in the admin.
|
|
1996
|
+
"name shown in the admin. Menus are per-locale, so pass `locale` when " +
|
|
1997
|
+
"the same menu name exists in multiple translations. Add items afterwards " +
|
|
1998
|
+
"with menu_set_items. If `translationOf` is set, `locale` must also be set.",
|
|
1999
|
+
// `locale`-when-`translationOf` is enforced inside handleMenuCreate
|
|
2000
|
+
// so REST/SDK callers get the same guard. The description above
|
|
2001
|
+
// documents the rule; the handler returns VALIDATION_ERROR.
|
|
1997
2002
|
inputSchema: z.object({
|
|
1998
2003
|
name: z
|
|
1999
2004
|
.string()
|
|
2000
2005
|
.regex(COLLECTION_SLUG_PATTERN)
|
|
2001
2006
|
.describe("Stable identifier (lowercase letters, numbers, underscores)"),
|
|
2002
2007
|
label: z.string().describe("Display name for the admin"),
|
|
2008
|
+
locale: z.string().optional().describe("Locale for this menu (e.g. 'fr-fr')"),
|
|
2009
|
+
translationOf: z
|
|
2010
|
+
.string()
|
|
2011
|
+
.optional()
|
|
2012
|
+
.describe("Existing menu id to create this locale variant from"),
|
|
2003
2013
|
}),
|
|
2004
2014
|
},
|
|
2005
2015
|
async (args, extra) => {
|
|
@@ -2008,7 +2018,14 @@ export function createMcpServer(): McpServer {
|
|
|
2008
2018
|
const ec = getEmDash(extra);
|
|
2009
2019
|
try {
|
|
2010
2020
|
const { handleMenuCreate } = await import("../api/handlers/menus.js");
|
|
2011
|
-
return unwrap(
|
|
2021
|
+
return unwrap(
|
|
2022
|
+
await handleMenuCreate(ec.db, {
|
|
2023
|
+
name: args.name,
|
|
2024
|
+
label: args.label,
|
|
2025
|
+
locale: args.locale,
|
|
2026
|
+
translationOf: args.translationOf,
|
|
2027
|
+
}),
|
|
2028
|
+
);
|
|
2012
2029
|
} catch (error) {
|
|
2013
2030
|
return respondHandlerError(error, "MENU_CREATE_ERROR");
|
|
2014
2031
|
}
|
|
@@ -2019,10 +2036,13 @@ export function createMcpServer(): McpServer {
|
|
|
2019
2036
|
"menu_update",
|
|
2020
2037
|
{
|
|
2021
2038
|
title: "Update Menu",
|
|
2022
|
-
description:
|
|
2039
|
+
description:
|
|
2040
|
+
"Update a menu's label. The `name` (stable identifier) cannot be changed. " +
|
|
2041
|
+
"On multi-locale installs, pass `locale` so the correct translation is updated.",
|
|
2023
2042
|
inputSchema: z.object({
|
|
2024
2043
|
name: z.string().describe("Menu name to update"),
|
|
2025
2044
|
label: z.string().describe("New display label"),
|
|
2045
|
+
locale: z.string().optional().describe("Locale of the menu to update"),
|
|
2026
2046
|
}),
|
|
2027
2047
|
},
|
|
2028
2048
|
async (args, extra) => {
|
|
@@ -2031,7 +2051,9 @@ export function createMcpServer(): McpServer {
|
|
|
2031
2051
|
const ec = getEmDash(extra);
|
|
2032
2052
|
try {
|
|
2033
2053
|
const { handleMenuUpdate } = await import("../api/handlers/menus.js");
|
|
2034
|
-
return unwrap(
|
|
2054
|
+
return unwrap(
|
|
2055
|
+
await handleMenuUpdate(ec.db, args.name, { label: args.label, locale: args.locale }),
|
|
2056
|
+
);
|
|
2035
2057
|
} catch (error) {
|
|
2036
2058
|
return respondHandlerError(error, "MENU_UPDATE_ERROR");
|
|
2037
2059
|
}
|
|
@@ -2042,9 +2064,12 @@ export function createMcpServer(): McpServer {
|
|
|
2042
2064
|
"menu_delete",
|
|
2043
2065
|
{
|
|
2044
2066
|
title: "Delete Menu",
|
|
2045
|
-
description:
|
|
2067
|
+
description:
|
|
2068
|
+
"Delete a menu. Items are also removed. Cannot be undone. On multi-locale " +
|
|
2069
|
+
"installs, pass `locale` so only the intended translation is removed.",
|
|
2046
2070
|
inputSchema: z.object({
|
|
2047
2071
|
name: z.string().describe("Menu name to delete"),
|
|
2072
|
+
locale: z.string().optional().describe("Locale of the menu to delete"),
|
|
2048
2073
|
}),
|
|
2049
2074
|
annotations: { destructiveHint: true },
|
|
2050
2075
|
},
|
|
@@ -2054,7 +2079,7 @@ export function createMcpServer(): McpServer {
|
|
|
2054
2079
|
const ec = getEmDash(extra);
|
|
2055
2080
|
try {
|
|
2056
2081
|
const { handleMenuDelete } = await import("../api/handlers/menus.js");
|
|
2057
|
-
return unwrap(await handleMenuDelete(ec.db, args.name));
|
|
2082
|
+
return unwrap(await handleMenuDelete(ec.db, args.name, { locale: args.locale }));
|
|
2058
2083
|
} catch (error) {
|
|
2059
2084
|
return respondHandlerError(error, "MENU_DELETE_ERROR");
|
|
2060
2085
|
}
|
|
@@ -2069,9 +2094,11 @@ export function createMcpServer(): McpServer {
|
|
|
2069
2094
|
"Replace the entire item list of a menu in one call. This is atomic: the " +
|
|
2070
2095
|
"existing items are deleted and the new list is inserted in the order " +
|
|
2071
2096
|
"provided. Use this rather than per-item add/remove tools so the resulting " +
|
|
2072
|
-
"order and parent links are unambiguous."
|
|
2097
|
+
"order and parent links are unambiguous. On multi-locale installs, pass " +
|
|
2098
|
+
"`locale` so only the intended translation is rewritten.",
|
|
2073
2099
|
inputSchema: z.object({
|
|
2074
2100
|
name: z.string().describe("Menu name to update"),
|
|
2101
|
+
locale: z.string().optional().describe("Locale of the menu to rewrite"),
|
|
2075
2102
|
items: z
|
|
2076
2103
|
.array(
|
|
2077
2104
|
z.object({
|
|
@@ -2115,7 +2142,9 @@ export function createMcpServer(): McpServer {
|
|
|
2115
2142
|
const ec = getEmDash(extra);
|
|
2116
2143
|
try {
|
|
2117
2144
|
const { handleMenuSetItems } = await import("../api/handlers/menus.js");
|
|
2118
|
-
return unwrap(
|
|
2145
|
+
return unwrap(
|
|
2146
|
+
await handleMenuSetItems(ec.db, args.name, args.items, { locale: args.locale }),
|
|
2147
|
+
);
|
|
2119
2148
|
} catch (error) {
|
|
2120
2149
|
return respondHandlerError(error, "MENU_SET_ITEMS_ERROR");
|
|
2121
2150
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export function normalizeMime(mime: string): string {
|
|
2
|
+
return mime.split(";")[0]!.trim().toLowerCase();
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function matchesMimeAllowlist(mime: string, allowList: readonly string[]): boolean {
|
|
6
|
+
const normalized = normalizeMime(mime);
|
|
7
|
+
for (const entry of allowList) {
|
|
8
|
+
if (!entry || !entry.includes("/")) continue;
|
|
9
|
+
const normalizedEntry = normalizeMime(entry);
|
|
10
|
+
if (normalizedEntry.endsWith("/")) {
|
|
11
|
+
if (normalized.startsWith(normalizedEntry)) return true;
|
|
12
|
+
} else if (normalized === normalizedEntry) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const EXTENSION_TO_MIME: Readonly<Record<string, string>> = {
|
|
20
|
+
".pdf": "application/pdf",
|
|
21
|
+
".png": "image/png",
|
|
22
|
+
".jpg": "image/jpeg",
|
|
23
|
+
".jpeg": "image/jpeg",
|
|
24
|
+
".gif": "image/gif",
|
|
25
|
+
".webp": "image/webp",
|
|
26
|
+
".svg": "image/svg+xml",
|
|
27
|
+
".mp3": "audio/mpeg",
|
|
28
|
+
".wav": "audio/wav",
|
|
29
|
+
".mp4": "video/mp4",
|
|
30
|
+
".webm": "video/webm",
|
|
31
|
+
".zip": "application/zip",
|
|
32
|
+
".tar": "application/x-tar",
|
|
33
|
+
".gz": "application/gzip",
|
|
34
|
+
".csv": "text/csv",
|
|
35
|
+
".doc": "application/msword",
|
|
36
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
37
|
+
".xls": "application/vnd.ms-excel",
|
|
38
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
39
|
+
".txt": "text/plain",
|
|
40
|
+
".rtf": "application/rtf",
|
|
41
|
+
".vtt": "text/vtt",
|
|
42
|
+
".srt": "application/x-subrip",
|
|
43
|
+
".woff": "font/woff",
|
|
44
|
+
".woff2": "font/woff2",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const VALID_MIME_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]*$/i;
|
|
48
|
+
|
|
49
|
+
export function expandExtensionShorthand(entry: string): string | null {
|
|
50
|
+
const trimmed = entry.trim();
|
|
51
|
+
if (!trimmed) return null;
|
|
52
|
+
if (trimmed.includes("/")) return VALID_MIME_RE.test(trimmed) ? trimmed : null;
|
|
53
|
+
if (trimmed.startsWith(".")) {
|
|
54
|
+
return EXTENSION_TO_MIME[trimmed.toLowerCase()] ?? null;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract the `allowedMimeTypes` list from a `_emdash_fields.validation` row
|
|
61
|
+
* (raw JSON string). Returns null when the value is missing, malformed, or the
|
|
62
|
+
* list is empty — callers treat that as "no field-specific constraint".
|
|
63
|
+
*/
|
|
64
|
+
export function parseAllowedMimeTypes(rawValidation: string | null | undefined): string[] | null {
|
|
65
|
+
if (!rawValidation) return null;
|
|
66
|
+
try {
|
|
67
|
+
const parsed: unknown = JSON.parse(rawValidation);
|
|
68
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
69
|
+
const list = (parsed as { allowedMimeTypes?: unknown }).allowedMimeTypes;
|
|
70
|
+
if (!Array.isArray(list) || list.length === 0) return null;
|
|
71
|
+
return list.filter((entry): entry is string => typeof entry === "string");
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/plugins/types.ts
CHANGED
|
@@ -10,187 +10,56 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { Element } from "@emdash-cms/blocks";
|
|
13
|
+
// The plugin capability vocabulary, the legacy-rename map, and the manifest
|
|
14
|
+
// shape are authored once in @emdash-cms/plugin-types and shared between core
|
|
15
|
+
// (the manifest reader at install/runtime) and @emdash-cms/registry-cli (the
|
|
16
|
+
// manifest writer at bundle/publish time).
|
|
17
|
+
//
|
|
18
|
+
// We import-and-re-export here so existing internal callers keep working
|
|
19
|
+
// (e.g. `import { PluginCapability } from "../plugins/types.js"`).
|
|
20
|
+
import {
|
|
21
|
+
CAPABILITY_RENAMES,
|
|
22
|
+
isDeprecatedCapability,
|
|
23
|
+
normalizeCapabilities,
|
|
24
|
+
normalizeCapability,
|
|
25
|
+
type CurrentPluginCapability,
|
|
26
|
+
type DeprecatedPluginCapability,
|
|
27
|
+
type ManifestHookEntry,
|
|
28
|
+
type ManifestRouteEntry,
|
|
29
|
+
type PluginCapability,
|
|
30
|
+
type PluginStorageConfig,
|
|
31
|
+
type StorageCollectionConfig,
|
|
32
|
+
} from "@emdash-cms/plugin-types";
|
|
13
33
|
import type { JSX } from "astro/jsx-runtime";
|
|
14
34
|
import type { z } from "astro/zod";
|
|
15
|
-
|
|
16
|
-
import type { FieldType } from "../schema/types.js";
|
|
17
|
-
|
|
18
35
|
// =============================================================================
|
|
19
36
|
// Core Types
|
|
20
37
|
// =============================================================================
|
|
21
38
|
|
|
22
|
-
|
|
23
|
-
* Plugin capabilities determine what APIs are available in context.
|
|
24
|
-
*
|
|
25
|
-
* Capabilities follow the formula `<resource>[.<sub-resource>]:<verb>[:<qualifier>]`
|
|
26
|
-
* — resource first, verb second, matching RBAC. The `unrestricted` qualifier
|
|
27
|
-
* (used by `network:request:unrestricted`) is intentionally verbose so that
|
|
28
|
-
* granting it stands out in manifest review.
|
|
29
|
-
*
|
|
30
|
-
* Hook-registration capabilities (`hooks.<family>:register`) are a distinct
|
|
31
|
-
* audit category from data-access capabilities — they gate which hooks a
|
|
32
|
-
* plugin is allowed to register, not which context APIs it gets.
|
|
33
|
-
*
|
|
34
|
-
* @see CAPABILITY_RENAMES for the legacy → current mapping, and
|
|
35
|
-
* `normalizeCapability()` for the runtime alias layer.
|
|
36
|
-
*/
|
|
37
|
-
export type PluginCapability =
|
|
38
|
-
// ── Network ─────────────────────────────────────────────────
|
|
39
|
-
| "network:request" // ctx.http is available (host-restricted via allowedHosts)
|
|
40
|
-
| "network:request:unrestricted" // ctx.http is available (unrestricted outbound — use for user-configured URLs)
|
|
41
|
-
// ── Content ─────────────────────────────────────────────────
|
|
42
|
-
| "content:read" // ctx.content.get/list available
|
|
43
|
-
| "content:write" // ctx.content.create/update/delete available
|
|
44
|
-
// ── Media ───────────────────────────────────────────────────
|
|
45
|
-
| "media:read" // ctx.media.get/list available
|
|
46
|
-
| "media:write" // ctx.media.getUploadUrl/delete available
|
|
47
|
-
// ── Users ───────────────────────────────────────────────────
|
|
48
|
-
| "users:read" // ctx.users is available
|
|
49
|
-
// ── Email ───────────────────────────────────────────────────
|
|
50
|
-
| "email:send" // ctx.email is available (when a provider is configured)
|
|
51
|
-
// ── Hook registration ───────────────────────────────────────
|
|
52
|
-
| "hooks.email-transport:register" // can register email:deliver exclusive hook (transport provider)
|
|
53
|
-
| "hooks.email-events:register" // can register email:beforeSend / email:afterSend hooks
|
|
54
|
-
| "hooks.page-fragments:register" // can register page:fragments hook (inject scripts/styles into pages)
|
|
55
|
-
// ── Deprecated (legacy aliases) ─────────────────────────────
|
|
56
|
-
// Kept in the union for one minor with @deprecated tags so existing
|
|
57
|
-
// plugins typecheck during migration. Normalized to current names at
|
|
58
|
-
// definition time via normalizeCapability(). Will be removed in the
|
|
59
|
-
// following minor.
|
|
60
|
-
/** @deprecated Use `network:request` instead. */
|
|
61
|
-
| "network:fetch"
|
|
62
|
-
/** @deprecated Use `network:request:unrestricted` instead. */
|
|
63
|
-
| "network:fetch:any"
|
|
64
|
-
/** @deprecated Use `content:read` instead. */
|
|
65
|
-
| "read:content"
|
|
66
|
-
/** @deprecated Use `content:write` instead. */
|
|
67
|
-
| "write:content"
|
|
68
|
-
/** @deprecated Use `media:read` instead. */
|
|
69
|
-
| "read:media"
|
|
70
|
-
/** @deprecated Use `media:write` instead. */
|
|
71
|
-
| "write:media"
|
|
72
|
-
/** @deprecated Use `users:read` instead. */
|
|
73
|
-
| "read:users"
|
|
74
|
-
/** @deprecated Use `hooks.email-transport:register` instead. */
|
|
75
|
-
| "email:provide"
|
|
76
|
-
/** @deprecated Use `hooks.email-events:register` instead. */
|
|
77
|
-
| "email:intercept"
|
|
78
|
-
/** @deprecated Use `hooks.page-fragments:register` instead. */
|
|
79
|
-
| "page:inject";
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Deprecated capability names that map to current names.
|
|
83
|
-
*
|
|
84
|
-
* These are accepted at every external boundary (manifest parse, definePlugin,
|
|
85
|
-
* adaptSandboxEntry) and silently normalized to the new names before reaching
|
|
86
|
-
* the runtime. The runtime never sees deprecated names.
|
|
87
|
-
*
|
|
88
|
-
* Authors are warned at `bundle` / `validate`, and hard-failed at `publish`.
|
|
89
|
-
*/
|
|
90
|
-
export type DeprecatedPluginCapability =
|
|
91
|
-
| "network:fetch"
|
|
92
|
-
| "network:fetch:any"
|
|
93
|
-
| "read:content"
|
|
94
|
-
| "write:content"
|
|
95
|
-
| "read:media"
|
|
96
|
-
| "write:media"
|
|
97
|
-
| "read:users"
|
|
98
|
-
| "email:provide"
|
|
99
|
-
| "email:intercept"
|
|
100
|
-
| "page:inject";
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Current (non-deprecated) capability names.
|
|
104
|
-
*/
|
|
105
|
-
export type CurrentPluginCapability = Exclude<PluginCapability, DeprecatedPluginCapability>;
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Mapping from deprecated capability names to their current replacements.
|
|
109
|
-
*
|
|
110
|
-
* Used by `normalizeCapability()` and the marketplace `diffCapabilities`
|
|
111
|
-
* helper to compare manifests across the rename without flagging spurious
|
|
112
|
-
* "capability changed" prompts on upgrade.
|
|
113
|
-
*/
|
|
114
|
-
export const CAPABILITY_RENAMES: Readonly<
|
|
115
|
-
Record<DeprecatedPluginCapability, CurrentPluginCapability>
|
|
116
|
-
> = Object.freeze({
|
|
117
|
-
"network:fetch": "network:request",
|
|
118
|
-
"network:fetch:any": "network:request:unrestricted",
|
|
119
|
-
"read:content": "content:read",
|
|
120
|
-
"write:content": "content:write",
|
|
121
|
-
"read:media": "media:read",
|
|
122
|
-
"write:media": "media:write",
|
|
123
|
-
"read:users": "users:read",
|
|
124
|
-
"email:provide": "hooks.email-transport:register",
|
|
125
|
-
"email:intercept": "hooks.email-events:register",
|
|
126
|
-
"page:inject": "hooks.page-fragments:register",
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Type guard: is this capability one of the deprecated legacy names?
|
|
131
|
-
*
|
|
132
|
-
* Uses an own-property check so that prototype keys like "toString" or
|
|
133
|
-
* "constructor" don't accidentally pass.
|
|
134
|
-
*/
|
|
135
|
-
export function isDeprecatedCapability(cap: string): cap is DeprecatedPluginCapability {
|
|
136
|
-
return Object.hasOwn(CAPABILITY_RENAMES, cap);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Normalize a capability string — deprecated names map to current names,
|
|
141
|
-
* current names pass through unchanged. Unknown strings are returned as-is
|
|
142
|
-
* so that downstream validators can produce a precise error.
|
|
143
|
-
*/
|
|
144
|
-
export function normalizeCapability(cap: string): string {
|
|
145
|
-
if (isDeprecatedCapability(cap)) {
|
|
146
|
-
return CAPABILITY_RENAMES[cap];
|
|
147
|
-
}
|
|
148
|
-
return cap;
|
|
149
|
-
}
|
|
39
|
+
import type { FieldType } from "../schema/types.js";
|
|
150
40
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
return out;
|
|
167
|
-
}
|
|
41
|
+
export {
|
|
42
|
+
CAPABILITY_RENAMES,
|
|
43
|
+
isDeprecatedCapability,
|
|
44
|
+
normalizeCapabilities,
|
|
45
|
+
normalizeCapability,
|
|
46
|
+
type CurrentPluginCapability,
|
|
47
|
+
type DeprecatedPluginCapability,
|
|
48
|
+
type ManifestHookEntry,
|
|
49
|
+
type ManifestRouteEntry,
|
|
50
|
+
type PluginCapability,
|
|
51
|
+
type PluginStorageConfig,
|
|
52
|
+
type StorageCollectionConfig,
|
|
53
|
+
};
|
|
168
54
|
|
|
169
55
|
// =============================================================================
|
|
170
56
|
// Storage Types
|
|
171
57
|
// =============================================================================
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Fields to index for querying.
|
|
179
|
-
* Each entry can be a single field name or an array for composite indexes.
|
|
180
|
-
*/
|
|
181
|
-
indexes: Array<string | string[]>;
|
|
182
|
-
/**
|
|
183
|
-
* Fields with unique constraints.
|
|
184
|
-
* Each entry can be a single field name or an array for composite unique indexes.
|
|
185
|
-
* Unique indexes are also queryable (no need to duplicate in `indexes`).
|
|
186
|
-
*/
|
|
187
|
-
uniqueIndexes?: Array<string | string[]>;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Plugin storage configuration
|
|
192
|
-
*/
|
|
193
|
-
export type PluginStorageConfig = Record<string, StorageCollectionConfig>;
|
|
58
|
+
//
|
|
59
|
+
// `StorageCollectionConfig` and `PluginStorageConfig` are re-exported above
|
|
60
|
+
// from `@emdash-cms/plugin-types`. The manifest carries these shapes
|
|
61
|
+
// verbatim; both this package (reader) and registry-cli (writer) agree on
|
|
62
|
+
// the same types via the shared package.
|
|
194
63
|
|
|
195
64
|
/**
|
|
196
65
|
* Query filter operators
|
|
@@ -1128,27 +997,14 @@ export interface PluginHooks {
|
|
|
1128
997
|
/**
|
|
1129
998
|
* Hook names
|
|
1130
999
|
*/
|
|
1131
|
-
export type HookName = keyof PluginHooks;
|
|
1132
|
-
|
|
1133
1000
|
/**
|
|
1134
|
-
* Hook
|
|
1135
|
-
*
|
|
1001
|
+
* Hook name in a manifest. Core's exhaustive union of recognised hook names,
|
|
1002
|
+
* derived from the `PluginHooks` registry. The serialised manifest carries
|
|
1003
|
+
* these as opaque strings; this stricter type is only used for type-checking
|
|
1004
|
+
* inside core. `ManifestHookEntry` is re-exported from
|
|
1005
|
+
* `@emdash-cms/plugin-types` near the top of this file.
|
|
1136
1006
|
*/
|
|
1137
|
-
export
|
|
1138
|
-
name: string;
|
|
1139
|
-
exclusive?: boolean;
|
|
1140
|
-
priority?: number;
|
|
1141
|
-
timeout?: number;
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
/**
|
|
1145
|
-
* Route metadata entry in a plugin manifest.
|
|
1146
|
-
* Replaces the plain route name string with structured metadata.
|
|
1147
|
-
*/
|
|
1148
|
-
export interface ManifestRouteEntry {
|
|
1149
|
-
name: string;
|
|
1150
|
-
public?: boolean;
|
|
1151
|
-
}
|
|
1007
|
+
export type HookName = keyof PluginHooks;
|
|
1152
1008
|
|
|
1153
1009
|
/**
|
|
1154
1010
|
* Resolved hook with normalized config
|
|
@@ -1543,8 +1399,16 @@ export interface PluginAdminExports {
|
|
|
1543
1399
|
// =============================================================================
|
|
1544
1400
|
|
|
1545
1401
|
/**
|
|
1546
|
-
* Plugin manifest
|
|
1547
|
-
*
|
|
1402
|
+
* Plugin manifest — the metadata portion of a plugin bundle, used for
|
|
1403
|
+
* sandboxed plugins loaded from the marketplace.
|
|
1404
|
+
*
|
|
1405
|
+
* This interface is core's stricter version of the manifest contract: it
|
|
1406
|
+
* uses the exhaustive `HookName` union and core's typed `PluginAdminConfig`.
|
|
1407
|
+
* The wire-shape lives in `@emdash-cms/plugin-types` as `PluginManifest`
|
|
1408
|
+
* with looser types (so the registry CLI can serialise hook names it
|
|
1409
|
+
* doesn't know about). Both must stay structurally compatible: every value
|
|
1410
|
+
* of this type must be assignable to the shared one. The static assertion
|
|
1411
|
+
* below catches any drift at compile time.
|
|
1548
1412
|
*/
|
|
1549
1413
|
export interface PluginManifest {
|
|
1550
1414
|
id: string;
|
|
@@ -1558,3 +1422,29 @@ export interface PluginManifest {
|
|
|
1558
1422
|
routes: Array<ManifestRouteEntry | string>;
|
|
1559
1423
|
admin: PluginAdminConfig;
|
|
1560
1424
|
}
|
|
1425
|
+
|
|
1426
|
+
// Type-level guard: core's `PluginManifest` is intentionally a SUBTYPE of
|
|
1427
|
+
// the shared wire shape (`@emdash-cms/plugin-types` `PluginManifest`). The
|
|
1428
|
+
// wire shape uses looser types like `string` for hook names so the registry
|
|
1429
|
+
// CLI can serialise plugins targeting hook versions this core doesn't yet
|
|
1430
|
+
// know about. Core narrows `string` to `HookName` and `Record<string,
|
|
1431
|
+
// unknown>` to `PluginAdminConfig` because core's loader actually executes
|
|
1432
|
+
// against those types.
|
|
1433
|
+
//
|
|
1434
|
+
// We assert one direction at compile time: `core extends shared`. The
|
|
1435
|
+
// reverse direction (`shared extends core`) intentionally does NOT hold
|
|
1436
|
+
// because shared is wider -- a manifest written against the wire shape
|
|
1437
|
+
// could carry a hook name core doesn't know. That runtime narrowing is the
|
|
1438
|
+
// job of `manifest-schema.ts` (zod-validated, called at every JSON.parse
|
|
1439
|
+
// of a manifest.json), not of the type system. The static check below
|
|
1440
|
+
// catches the OTHER failure mode: core adding a required field or
|
|
1441
|
+
// non-assignable type that the wire shape doesn't allow.
|
|
1442
|
+
//
|
|
1443
|
+
// `type X = never` is itself legal as a type alias, so the assertion has to
|
|
1444
|
+
// be in a value position (`const _check: T = true`) for the compiler to
|
|
1445
|
+
// error when T resolves to `never`. Don't replace this with a bare type
|
|
1446
|
+
// alias.
|
|
1447
|
+
type _AssertManifestCompat =
|
|
1448
|
+
PluginManifest extends import("@emdash-cms/plugin-types").PluginManifest ? true : never;
|
|
1449
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
1450
|
+
const _MANIFEST_COMPAT: _AssertManifestCompat = true;
|
package/src/request-cache.ts
CHANGED
|
@@ -48,8 +48,12 @@ export function requestCached<T>(key: string, fn: () => Promise<T>): Promise<T>
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const existing = cache.get(key);
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
if (existing) {
|
|
52
|
+
if (ctx.metrics) ctx.metrics.cacheHits += 1;
|
|
53
|
+
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- heterogeneous cache; key namespacing guarantees the stored promise resolves to T
|
|
54
|
+
return existing as Promise<T>;
|
|
55
|
+
}
|
|
56
|
+
if (ctx.metrics) ctx.metrics.cacheMisses += 1;
|
|
53
57
|
|
|
54
58
|
const promise = Promise.resolve()
|
|
55
59
|
.then(fn)
|
package/src/request-context.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* without requiring explicit parameter passing. The middleware wraps next()
|
|
6
6
|
* in als.run(), making the context available to all code during rendering.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Middleware always wraps each request in a context so per-request
|
|
9
|
+
* metrics (db.*, cache.*) can be surfaced via Server-Timing. The cost is
|
|
10
|
+
* one ALS frame per request — sub-microsecond, negligible compared to
|
|
11
|
+
* any real work.
|
|
10
12
|
*
|
|
11
13
|
* The AsyncLocalStorage instance is stored on globalThis with a Symbol key
|
|
12
14
|
* to guarantee a singleton even when bundlers duplicate this module across
|
|
@@ -19,6 +21,38 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
19
21
|
|
|
20
22
|
import type { QueryRecorder } from "./database/instrumentation.js";
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Lightweight always-on counters surfaced in Server-Timing.
|
|
26
|
+
*
|
|
27
|
+
* Bumped by the Kysely log hook (db queries) and by `requestCached`
|
|
28
|
+
* (cache hits/misses). Read by middleware after the response is
|
|
29
|
+
* generated to emit `db.*` and `cache.*` Server-Timing fields.
|
|
30
|
+
*
|
|
31
|
+
* Offsets are milliseconds from `start` (the request's entry into
|
|
32
|
+
* middleware), captured via `performance.now()`.
|
|
33
|
+
*/
|
|
34
|
+
export interface RequestMetrics {
|
|
35
|
+
start: number;
|
|
36
|
+
dbCount: number;
|
|
37
|
+
dbTotalMs: number;
|
|
38
|
+
dbFirstOffset: number | null;
|
|
39
|
+
dbLastOffset: number | null;
|
|
40
|
+
cacheHits: number;
|
|
41
|
+
cacheMisses: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createRequestMetrics(start: number): RequestMetrics {
|
|
45
|
+
return {
|
|
46
|
+
start,
|
|
47
|
+
dbCount: 0,
|
|
48
|
+
dbTotalMs: 0,
|
|
49
|
+
dbFirstOffset: null,
|
|
50
|
+
dbLastOffset: null,
|
|
51
|
+
cacheHits: 0,
|
|
52
|
+
cacheMisses: 0,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
22
56
|
export interface EmDashRequestContext {
|
|
23
57
|
/** Whether the current request is in visual editing mode */
|
|
24
58
|
editMode: boolean;
|
|
@@ -54,6 +88,12 @@ export interface EmDashRequestContext {
|
|
|
54
88
|
* to NDJSON after the response.
|
|
55
89
|
*/
|
|
56
90
|
queryRecorder?: QueryRecorder;
|
|
91
|
+
/**
|
|
92
|
+
* Per-request metrics for Server-Timing. Always attached by middleware
|
|
93
|
+
* for requests that emit timing headers; bumped by the Kysely log hook
|
|
94
|
+
* and `requestCached`.
|
|
95
|
+
*/
|
|
96
|
+
metrics?: RequestMetrics;
|
|
57
97
|
}
|
|
58
98
|
|
|
59
99
|
const ALS_KEY = Symbol.for("emdash:request-context");
|
package/src/schema/registry.ts
CHANGED
|
@@ -526,6 +526,10 @@ export class SchemaRegistry {
|
|
|
526
526
|
);
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
+
// `input.validation === undefined` means "no change" (keep existing);
|
|
530
|
+
// an explicit `null` clears the column.
|
|
531
|
+
const nextValidation = input.validation === undefined ? field.validation : input.validation;
|
|
532
|
+
|
|
529
533
|
return withTransaction(this.db, async (trx) => {
|
|
530
534
|
await trx
|
|
531
535
|
.updateTable("_emdash_fields")
|
|
@@ -550,11 +554,7 @@ export class SchemaRegistry {
|
|
|
550
554
|
: field.defaultValue !== undefined
|
|
551
555
|
? JSON.stringify(field.defaultValue)
|
|
552
556
|
: null,
|
|
553
|
-
validation:
|
|
554
|
-
? JSON.stringify(input.validation)
|
|
555
|
-
: field.validation
|
|
556
|
-
? JSON.stringify(field.validation)
|
|
557
|
-
: null,
|
|
557
|
+
validation: nextValidation ? JSON.stringify(nextValidation) : null,
|
|
558
558
|
widget: input.widget ?? field.widget ?? null,
|
|
559
559
|
options: input.options
|
|
560
560
|
? JSON.stringify(input.options)
|
package/src/schema/types.ts
CHANGED
|
@@ -131,6 +131,7 @@ export interface FieldValidation {
|
|
|
131
131
|
subFields?: RepeaterSubField[]; // For repeater fields
|
|
132
132
|
minItems?: number; // For repeater fields
|
|
133
133
|
maxItems?: number; // For repeater fields
|
|
134
|
+
allowedMimeTypes?: string[];
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
/**
|
|
@@ -238,7 +239,7 @@ export interface CreateFieldInput {
|
|
|
238
239
|
required?: boolean;
|
|
239
240
|
unique?: boolean;
|
|
240
241
|
defaultValue?: unknown;
|
|
241
|
-
validation?: FieldValidation;
|
|
242
|
+
validation?: FieldValidation | null;
|
|
242
243
|
widget?: string;
|
|
243
244
|
options?: FieldWidgetOptions;
|
|
244
245
|
sortOrder?: number;
|
|
@@ -256,7 +257,7 @@ export interface UpdateFieldInput {
|
|
|
256
257
|
required?: boolean;
|
|
257
258
|
unique?: boolean;
|
|
258
259
|
defaultValue?: unknown;
|
|
259
|
-
validation?: FieldValidation;
|
|
260
|
+
validation?: FieldValidation | null;
|
|
260
261
|
widget?: string;
|
|
261
262
|
options?: FieldWidgetOptions;
|
|
262
263
|
sortOrder?: number;
|