emdash 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{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 +9 -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-Cg-rC4Gj.d.mts} +32 -24
- package/dist/index-Cg-rC4Gj.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-DiI8NOG_.mjs +16 -0
- package/dist/types-DiI8NOG_.mjs.map +1 -0
- package/dist/{types-D19uBYWn.d.mts → types-IN5z_S3P.d.mts} +19 -98
- package/dist/types-IN5z_S3P.d.mts.map +1 -0
- package/dist/{types-Dl1fgFjn.d.mts → types-IZSZfEwv.d.mts} +4 -3
- package/dist/types-IZSZfEwv.d.mts.map +1 -0
- package/dist/{validate-DHGwADqO.d.mts → validate-CO3JjFV5.d.mts} +7 -3
- package/dist/validate-CO3JjFV5.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-Bg31I_Ff.mjs +7 -0
- package/dist/{version-CMD42IRC.mjs.map → version-Bg31I_Ff.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/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
|
@@ -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;
|
package/src/seed/apply.ts
CHANGED
|
@@ -512,6 +512,8 @@ export async function applySeed(
|
|
|
512
512
|
if (seed.menus) {
|
|
513
513
|
// seed-local id -> resolved info, used to wire `translationOf` refs.
|
|
514
514
|
const menuSeedIdMap = new Map<string, { id: string; translationGroup: string }>();
|
|
515
|
+
// Shared across menus: translated items reference anchor items in sibling menus.
|
|
516
|
+
const itemSeedIdMap = new Map<string, { id: string; translationGroup: string }>();
|
|
515
517
|
const fallbackLocale = getI18nConfig()?.defaultLocale ?? "en";
|
|
516
518
|
|
|
517
519
|
for (const menu of seed.menus) {
|
|
@@ -569,6 +571,7 @@ export async function applySeed(
|
|
|
569
571
|
null, // parent_id
|
|
570
572
|
0, // sort_order
|
|
571
573
|
seedIdMap,
|
|
574
|
+
itemSeedIdMap,
|
|
572
575
|
);
|
|
573
576
|
result.menus.items += itemCount;
|
|
574
577
|
}
|
|
@@ -920,11 +923,10 @@ async function applyContentTaxonomies(
|
|
|
920
923
|
/**
|
|
921
924
|
* Apply menu items recursively.
|
|
922
925
|
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
* already holds the content's translation_group.
|
|
926
|
+
* When a `SeedMenuItem` carries `id`/`translationOf`, the import resolves the
|
|
927
|
+
* source item's `translation_group` so cross-locale "same nav entry" links
|
|
928
|
+
* survive export → apply. Items without `translationOf` get a fresh group
|
|
929
|
+
* (= their own id).
|
|
928
930
|
*/
|
|
929
931
|
async function applyMenuItems(
|
|
930
932
|
db: Kysely<Database>,
|
|
@@ -934,12 +936,14 @@ async function applyMenuItems(
|
|
|
934
936
|
parentId: string | null,
|
|
935
937
|
startOrder: number,
|
|
936
938
|
seedIdMap: Map<string, string>,
|
|
939
|
+
itemSeedIdMap: Map<string, { id: string; translationGroup: string }>,
|
|
937
940
|
): Promise<number> {
|
|
938
941
|
let count = 0;
|
|
939
942
|
let order = startOrder;
|
|
940
943
|
|
|
941
944
|
for (const item of items) {
|
|
942
945
|
const itemId = ulid();
|
|
946
|
+
const itemLocale = item.locale ?? locale;
|
|
943
947
|
|
|
944
948
|
// Resolve reference if needed
|
|
945
949
|
let referenceId: string | null = null;
|
|
@@ -955,6 +959,16 @@ async function applyMenuItems(
|
|
|
955
959
|
// If not in map, the content might not exist yet (will be broken link)
|
|
956
960
|
}
|
|
957
961
|
|
|
962
|
+
let translationGroup = itemId;
|
|
963
|
+
if (item.translationOf) {
|
|
964
|
+
const source = itemSeedIdMap.get(item.translationOf);
|
|
965
|
+
if (source) translationGroup = source.translationGroup;
|
|
966
|
+
else
|
|
967
|
+
console.warn(
|
|
968
|
+
`menu item "${item.label ?? item.url ?? item.ref ?? "(unlabeled)"}" (${itemLocale}): translationOf "${item.translationOf}" not found yet; minting a fresh group.`,
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
|
|
958
972
|
await db
|
|
959
973
|
.insertInto("_emdash_menu_items")
|
|
960
974
|
.values({
|
|
@@ -971,11 +985,13 @@ async function applyMenuItems(
|
|
|
971
985
|
target: item.target ?? null,
|
|
972
986
|
css_classes: item.cssClasses ?? null,
|
|
973
987
|
created_at: new Date().toISOString(),
|
|
974
|
-
locale,
|
|
975
|
-
translation_group:
|
|
988
|
+
locale: itemLocale,
|
|
989
|
+
translation_group: translationGroup,
|
|
976
990
|
})
|
|
977
991
|
.execute();
|
|
978
992
|
|
|
993
|
+
if (item.id) itemSeedIdMap.set(item.id, { id: itemId, translationGroup });
|
|
994
|
+
|
|
979
995
|
count++;
|
|
980
996
|
order++;
|
|
981
997
|
|
|
@@ -983,11 +999,12 @@ async function applyMenuItems(
|
|
|
983
999
|
const childCount = await applyMenuItems(
|
|
984
1000
|
db,
|
|
985
1001
|
menuId,
|
|
986
|
-
|
|
1002
|
+
itemLocale,
|
|
987
1003
|
item.children,
|
|
988
1004
|
itemId,
|
|
989
1005
|
0,
|
|
990
1006
|
seedIdMap,
|
|
1007
|
+
itemSeedIdMap,
|
|
991
1008
|
);
|
|
992
1009
|
count += childCount;
|
|
993
1010
|
}
|
package/src/seed/types.ts
CHANGED
|
@@ -134,6 +134,8 @@ export interface SeedMenu {
|
|
|
134
134
|
* Menu item in seed
|
|
135
135
|
*/
|
|
136
136
|
export interface SeedMenuItem {
|
|
137
|
+
/** Optional seed-local id, e.g. "item:primary:home:en". */
|
|
138
|
+
id?: string;
|
|
137
139
|
type: string;
|
|
138
140
|
label?: string;
|
|
139
141
|
url?: string; // For custom type
|
|
@@ -142,6 +144,8 @@ export interface SeedMenuItem {
|
|
|
142
144
|
target?: "_blank" | "_self";
|
|
143
145
|
titleAttr?: string;
|
|
144
146
|
cssClasses?: string;
|
|
147
|
+
locale?: string;
|
|
148
|
+
translationOf?: string;
|
|
145
149
|
children?: SeedMenuItem[];
|
|
146
150
|
}
|
|
147
151
|
|