emdash 0.11.0 → 0.12.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.
Files changed (82) hide show
  1. package/dist/{apply-Ded_1vng.mjs → apply-C1ZORgcy.mjs} +6 -226
  2. package/dist/apply-C1ZORgcy.mjs.map +1 -0
  3. package/dist/astro/index.d.mts +3 -3
  4. package/dist/astro/index.mjs +1 -1
  5. package/dist/astro/middleware/auth.d.mts +3 -3
  6. package/dist/astro/middleware/auth.mjs +1 -1
  7. package/dist/astro/middleware.mjs +16 -12
  8. package/dist/astro/middleware.mjs.map +1 -1
  9. package/dist/astro/types.d.mts +3 -3
  10. package/dist/cli/index.mjs +4 -4
  11. package/dist/{error-DqnRMM5z.mjs → error-D6LuHLw9.mjs} +1 -1
  12. package/dist/{error-DqnRMM5z.mjs.map → error-D6LuHLw9.mjs.map} +1 -1
  13. package/dist/{index-Cg-rC4Gj.d.mts → index-Dlkzhb4C.d.mts} +5 -5
  14. package/dist/index-Dlkzhb4C.d.mts.map +1 -0
  15. package/dist/index.d.mts +4 -4
  16. package/dist/index.mjs +9 -9
  17. package/dist/{manifest-schema-CXAbd1vH.mjs → manifest-schema-Bp6d4d4n.mjs} +1 -1
  18. package/dist/{manifest-schema-CXAbd1vH.mjs.map → manifest-schema-Bp6d4d4n.mjs.map} +1 -1
  19. package/dist/media/local-runtime.d.mts +3 -3
  20. package/dist/media/local-runtime.d.mts.map +1 -1
  21. package/dist/media/local-runtime.mjs +6 -1
  22. package/dist/media/local-runtime.mjs.map +1 -1
  23. package/dist/page/index.d.mts +15 -4
  24. package/dist/page/index.d.mts.map +1 -1
  25. package/dist/page/index.mjs +16 -5
  26. package/dist/page/index.mjs.map +1 -1
  27. package/dist/plugins/adapt-sandbox-entry.d.mts +3 -3
  28. package/dist/plugins/adapt-sandbox-entry.mjs +2 -2
  29. package/dist/{query-8c_meo_K.mjs → query-yA3-rFji.mjs} +13 -2
  30. package/dist/query-yA3-rFji.mjs.map +1 -0
  31. package/dist/runtime.d.mts +3 -3
  32. package/dist/{search-DuWhx4NG.mjs → search-n-ZCMfr3.mjs} +33 -16
  33. package/dist/search-n-ZCMfr3.mjs.map +1 -0
  34. package/dist/seed/index.d.mts +1 -1
  35. package/dist/seed/index.mjs +2 -2
  36. package/dist/settings-nTXPRi3D.mjs +440 -0
  37. package/dist/settings-nTXPRi3D.mjs.map +1 -0
  38. package/dist/storage/local.mjs +1 -1
  39. package/dist/storage/s3.mjs +1 -1
  40. package/dist/{taxonomies-Bw76xAxo.mjs → taxonomies-JmQQZiG1.mjs} +2 -2
  41. package/dist/{taxonomies-Bw76xAxo.mjs.map → taxonomies-JmQQZiG1.mjs.map} +1 -1
  42. package/dist/{types-IZSZfEwv.d.mts → types-B1gLSAH2.d.mts} +13 -9
  43. package/dist/{types-IZSZfEwv.d.mts.map → types-B1gLSAH2.d.mts.map} +1 -1
  44. package/dist/{types-DiI8NOG_.mjs → types-Cug_RO3W.mjs} +1 -1
  45. package/dist/{types-DiI8NOG_.mjs.map → types-Cug_RO3W.mjs.map} +1 -1
  46. package/dist/{types-IN5z_S3P.d.mts → types-DgSc9Rpc.d.mts} +2 -2
  47. package/dist/{types-IN5z_S3P.d.mts.map → types-DgSc9Rpc.d.mts.map} +1 -1
  48. package/dist/{types-K-EkEQCI.mjs → types-PafqtQuM.mjs} +1 -1
  49. package/dist/{types-K-EkEQCI.mjs.map → types-PafqtQuM.mjs.map} +1 -1
  50. package/dist/{validate-CO3JjFV5.d.mts → validate-BcC3m2O7.d.mts} +2 -2
  51. package/dist/{validate-CO3JjFV5.d.mts.map → validate-BcC3m2O7.d.mts.map} +1 -1
  52. package/dist/version-BdP--J1g.mjs +7 -0
  53. package/dist/{version-Bg31I_Ff.mjs.map → version-BdP--J1g.mjs.map} +1 -1
  54. package/package.json +6 -6
  55. package/src/api/schemas/settings.ts +41 -9
  56. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +1 -1
  57. package/src/astro/routes/api/content/[collection]/[id]/duplicate.ts +1 -1
  58. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
  59. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +1 -1
  60. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +1 -1
  61. package/src/astro/routes/api/content/[collection]/[id]/schedule.ts +2 -2
  62. package/src/astro/routes/api/content/[collection]/[id]/unpublish.ts +1 -1
  63. package/src/astro/routes/api/content/[collection]/[id].ts +2 -2
  64. package/src/astro/routes/api/content/[collection]/index.ts +1 -1
  65. package/src/astro/routes/api/media/[id].ts +2 -1
  66. package/src/components/EmDashHead.astro +26 -5
  67. package/src/emdash-runtime.ts +21 -2
  68. package/src/media/local-runtime.ts +7 -0
  69. package/src/page/absolute-url.ts +146 -0
  70. package/src/page/jsonld.ts +10 -2
  71. package/src/page/seo-contributions.ts +17 -6
  72. package/src/plugins/context.ts +11 -1
  73. package/src/query.ts +12 -0
  74. package/src/settings/index.ts +20 -1
  75. package/src/settings/types.ts +12 -8
  76. package/dist/apply-Ded_1vng.mjs.map +0 -1
  77. package/dist/index-Cg-rC4Gj.d.mts.map +0 -1
  78. package/dist/media-1fFhub9c.mjs +0 -209
  79. package/dist/media-1fFhub9c.mjs.map +0 -1
  80. package/dist/query-8c_meo_K.mjs.map +0 -1
  81. package/dist/search-DuWhx4NG.mjs.map +0 -1
  82. package/dist/version-Bg31I_Ff.mjs +0 -7
@@ -1,5 +1,5 @@
1
1
  import { t as Database } from "./types-BQx6ZXpR.mjs";
2
- import { i as SiteSettings, m as FieldType } from "./types-IZSZfEwv.mjs";
2
+ import { i as SiteSettings, m as FieldType } from "./types-B1gLSAH2.mjs";
3
3
  import { d as Storage } from "./types-C-aFbqmA.mjs";
4
4
  import { Kysely } from "kysely";
5
5
 
@@ -345,4 +345,4 @@ declare function loadUserSeed(): Promise<SeedFile | null>;
345
345
  declare function validateSeed(data: unknown): ValidationResult;
346
346
  //#endregion
347
347
  export { SeedTaxonomyTerm as _, applySeed as a, ValidationResult as b, SeedCollection as c, SeedFile as d, SeedMenu as f, SeedTaxonomy as g, SeedSection as h, defaultSeed as i, SeedContentEntry as l, SeedRedirect as m, loadSeed as n, SeedApplyOptions as o, SeedMenuItem as p, loadUserSeed as r, SeedApplyResult as s, validateSeed as t, SeedField as u, SeedWidget as v, SeedWidgetArea as y };
348
- //# sourceMappingURL=validate-CO3JjFV5.d.mts.map
348
+ //# sourceMappingURL=validate-BcC3m2O7.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"validate-CO3JjFV5.d.mts","names":[],"sources":["../src/seed/types.ts","../src/seed/apply.ts","../src/seed/default.ts","../src/seed/load.ts","../src/seed/validate.ts"],"mappings":"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"}
1
+ {"version":3,"file":"validate-BcC3m2O7.d.mts","names":[],"sources":["../src/seed/types.ts","../src/seed/apply.ts","../src/seed/default.ts","../src/seed/load.ts","../src/seed/validate.ts"],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ //#region src/version.ts
2
+ const VERSION = "0.12.0";
3
+ const COMMIT = "29eeee7";
4
+
5
+ //#endregion
6
+ export { VERSION as n, COMMIT as t };
7
+ //# sourceMappingURL=version-BdP--J1g.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"version-Bg31I_Ff.mjs","names":[],"sources":["../src/version.ts"],"sourcesContent":["/**\n * Build-time version constants, replaced by tsdown/Vite `define`.\n * Falls back to \"dev\" when running uncompiled (tests, dev).\n */\n\ndeclare const __EMDASH_VERSION__: string;\ndeclare const __EMDASH_COMMIT__: string;\n\nexport const VERSION: string =\n\ttypeof __EMDASH_VERSION__ !== \"undefined\" ? __EMDASH_VERSION__ : \"dev\";\n\nexport const COMMIT: string = typeof __EMDASH_COMMIT__ !== \"undefined\" ? __EMDASH_COMMIT__ : \"dev\";\n"],"mappings":";AAQA,MAAa;AAGb,MAAa"}
1
+ {"version":3,"file":"version-BdP--J1g.mjs","names":[],"sources":["../src/version.ts"],"sourcesContent":["/**\n * Build-time version constants, replaced by tsdown/Vite `define`.\n * Falls back to \"dev\" when running uncompiled (tests, dev).\n */\n\ndeclare const __EMDASH_VERSION__: string;\ndeclare const __EMDASH_COMMIT__: string;\n\nexport const VERSION: string =\n\ttypeof __EMDASH_VERSION__ !== \"undefined\" ? __EMDASH_VERSION__ : \"dev\";\n\nexport const COMMIT: string = typeof __EMDASH_COMMIT__ !== \"undefined\" ? __EMDASH_COMMIT__ : \"dev\";\n"],"mappings":";AAQA,MAAa;AAGb,MAAa"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emdash",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Astro-native CMS with WordPress migration support",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -194,9 +194,9 @@
194
194
  "ulidx": "^2.4.1",
195
195
  "upng-js": "^2.1.0",
196
196
  "zod": "^4.3.5",
197
- "@emdash-cms/admin": "0.11.0",
198
- "@emdash-cms/auth": "0.11.0",
199
- "@emdash-cms/gutenberg-to-portable-text": "0.11.0",
197
+ "@emdash-cms/gutenberg-to-portable-text": "0.12.0",
198
+ "@emdash-cms/auth": "0.12.0",
199
+ "@emdash-cms/admin": "0.12.0",
200
200
  "@emdash-cms/plugin-types": "0.0.1"
201
201
  },
202
202
  "optionalDependencies": {
@@ -205,7 +205,7 @@
205
205
  },
206
206
  "peerDependencies": {
207
207
  "@astrojs/react": ">=5.0.0-beta.0",
208
- "@emdash-cms/auth-atproto": ">=0.2.3",
208
+ "@emdash-cms/auth-atproto": ">=0.2.5",
209
209
  "astro": ">=6.0.0-beta.0",
210
210
  "react": ">=18.0.0",
211
211
  "react-dom": ">=18.0.0"
@@ -230,7 +230,7 @@
230
230
  "vite": "^6.0.0",
231
231
  "vitest": "^4.1.5",
232
232
  "zod-openapi": "^5.4.6",
233
- "@emdash-cms/blocks": "0.11.0"
233
+ "@emdash-cms/blocks": "0.12.0"
234
234
  },
235
235
  "repository": {
236
236
  "type": "git",
@@ -4,9 +4,14 @@ import { httpUrl } from "./common.js";
4
4
 
5
5
  // ---------------------------------------------------------------------------
6
6
  // Settings: Input schemas
7
+ //
8
+ // Media references on write are just `{ mediaId, alt? }` -- the resolved
9
+ // fields (`url`, `contentType`, `width`, `height`) are server-computed and
10
+ // stripped from any submitted body via Zod's default strip mode. See
11
+ // `packages/core/src/settings/types.ts` for the in-memory shape.
7
12
  // ---------------------------------------------------------------------------
8
13
 
9
- const mediaReference = z.object({
14
+ const mediaReferenceInput = z.object({
10
15
  mediaId: z.string(),
11
16
  alt: z.string().optional(),
12
17
  });
@@ -20,9 +25,9 @@ const socialSettings = z.object({
20
25
  youtube: z.string().optional(),
21
26
  });
22
27
 
23
- const seoSettings = z.object({
28
+ const seoSettingsInput = z.object({
24
29
  titleSeparator: z.string().max(10).optional(),
25
- defaultOgImage: mediaReference.optional(),
30
+ defaultOgImage: mediaReferenceInput.optional(),
26
31
  robotsTxt: z.string().max(5000).optional(),
27
32
  googleVerification: z.string().max(100).optional(),
28
33
  bingVerification: z.string().max(100).optional(),
@@ -32,32 +37,59 @@ export const settingsUpdateBody = z
32
37
  .object({
33
38
  title: z.string().optional(),
34
39
  tagline: z.string().optional(),
35
- logo: mediaReference.optional(),
36
- favicon: mediaReference.optional(),
40
+ logo: mediaReferenceInput.optional(),
41
+ favicon: mediaReferenceInput.optional(),
37
42
  url: z.union([httpUrl, z.literal("")]).optional(),
38
43
  postsPerPage: z.number().int().min(1).max(100).optional(),
39
44
  dateFormat: z.string().optional(),
40
45
  timezone: z.string().optional(),
41
46
  social: socialSettings.optional(),
42
- seo: seoSettings.optional(),
47
+ seo: seoSettingsInput.optional(),
43
48
  })
44
49
  .meta({ id: "SettingsUpdateBody" });
45
50
 
46
51
  // ---------------------------------------------------------------------------
47
52
  // Settings: Response schemas
53
+ //
54
+ // Responses carry the resolved fields populated by `resolveMediaReference`
55
+ // in `settings/index.ts`. Generated OpenAPI clients need to see them so
56
+ // they don't have to re-resolve the URL on the client. Fields stay
57
+ // optional because the resolver returns the bare ref if the underlying
58
+ // media row was deleted (orphaned reference).
48
59
  // ---------------------------------------------------------------------------
49
60
 
61
+ const mediaReferenceResponse = z.object({
62
+ mediaId: z.string(),
63
+ alt: z.string().optional(),
64
+ /** Resolved media file URL; absent if the underlying row is missing. */
65
+ url: z.string().optional(),
66
+ /** Stored MIME type (e.g. `image/svg+xml`). Populated alongside `url`. */
67
+ contentType: z.string().optional(),
68
+ /** Pixel width if known. Populated alongside `url`. */
69
+ width: z.number().int().optional(),
70
+ /** Pixel height if known. Populated alongside `url`. */
71
+ height: z.number().int().optional(),
72
+ });
73
+
74
+ const seoSettingsResponse = z.object({
75
+ titleSeparator: z.string().max(10).optional(),
76
+ defaultOgImage: mediaReferenceResponse.optional(),
77
+ robotsTxt: z.string().max(5000).optional(),
78
+ googleVerification: z.string().max(100).optional(),
79
+ bingVerification: z.string().max(100).optional(),
80
+ });
81
+
50
82
  export const siteSettingsSchema = z
51
83
  .object({
52
84
  title: z.string().optional(),
53
85
  tagline: z.string().optional(),
54
- logo: mediaReference.optional(),
55
- favicon: mediaReference.optional(),
86
+ logo: mediaReferenceResponse.optional(),
87
+ favicon: mediaReferenceResponse.optional(),
56
88
  url: z.string().optional(),
57
89
  postsPerPage: z.number().int().optional(),
58
90
  dateFormat: z.string().optional(),
59
91
  timezone: z.string().optional(),
60
92
  social: socialSettings.optional(),
61
- seo: seoSettings.optional(),
93
+ seo: seoSettingsResponse.optional(),
62
94
  })
63
95
  .meta({ id: "SiteSettings" });
@@ -50,7 +50,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
50
50
 
51
51
  if (!result.success) return unwrapResult(result);
52
52
 
53
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
53
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
54
54
 
55
55
  return unwrapResult(result);
56
56
  };
@@ -55,7 +55,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
55
55
 
56
56
  if (!result.success) return unwrapResult(result);
57
57
 
58
- if (cache.enabled) await cache.invalidate({ tags: [collection] });
58
+ if (cache?.enabled) await cache.invalidate({ tags: [collection] });
59
59
 
60
60
  return unwrapResult(result, 201);
61
61
  };
@@ -27,7 +27,7 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
27
27
 
28
28
  if (!result.success) return unwrapResult(result);
29
29
 
30
- if (cache.enabled) await cache.invalidate({ tags: [collection, id] });
30
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, id] });
31
31
 
32
32
  return unwrapResult(result);
33
33
  };
@@ -80,7 +80,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
80
80
 
81
81
  if (!result.success) return unwrapResult(result);
82
82
 
83
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
83
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
84
84
 
85
85
  return unwrapResult(result);
86
86
  };
@@ -50,7 +50,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
50
50
 
51
51
  if (!result.success) return unwrapResult(result);
52
52
 
53
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
53
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
54
54
 
55
55
  return unwrapResult(result);
56
56
  };
@@ -63,7 +63,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
63
63
 
64
64
  if (!result.success) return unwrapResult(result);
65
65
 
66
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId ?? id] });
66
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId ?? id] });
67
67
 
68
68
  return unwrapResult(result);
69
69
  };
@@ -95,7 +95,7 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
95
95
 
96
96
  if (!result.success) return unwrapResult(result);
97
97
 
98
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId ?? id] });
98
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId ?? id] });
99
99
 
100
100
  return unwrapResult(result);
101
101
  };
@@ -50,7 +50,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
50
50
 
51
51
  if (!result.success) return unwrapResult(result);
52
52
 
53
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
53
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
54
54
 
55
55
  return unwrapResult(result);
56
56
  };
@@ -125,7 +125,7 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => {
125
125
 
126
126
  if (!result.success) return unwrapResult(result);
127
127
 
128
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
128
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
129
129
 
130
130
  return unwrapResult(result);
131
131
  };
@@ -171,7 +171,7 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
171
171
 
172
172
  if (!result.success) return unwrapResult(result);
173
173
 
174
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
174
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
175
175
 
176
176
  return unwrapResult(result);
177
177
  };
@@ -91,7 +91,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
91
91
 
92
92
  if (!result.success) return unwrapResult(result);
93
93
 
94
- if (cache.enabled) await cache.invalidate({ tags: [collection] });
94
+ if (cache?.enabled) await cache.invalidate({ tags: [collection] });
95
95
 
96
96
  return unwrapResult(result, 201);
97
97
  };
@@ -135,7 +135,8 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
135
135
  }
136
136
  }
137
137
 
138
- // Delete from database
138
+ // Delete from database — site-settings cache invalidation happens
139
+ // in `EmDashRuntime.handleMediaDelete` so MCP/plugin paths inherit it.
139
140
  const result = await emdash.handleMediaDelete(id);
140
141
 
141
142
  return unwrapResult(result);
@@ -3,8 +3,11 @@
3
3
  * Renders base SEO metadata, plugin-contributed metadata, and trusted head fragments.
4
4
  *
5
5
  * Base SEO metadata (meta tags, OG, Twitter Card, canonical, JSON-LD) is generated
6
- * from the page context's seo/articleMeta/siteName fields. Plugin contributions
7
- * come after, so they can override base tags via first-wins dedup in resolvePageMetadata().
6
+ * from the page context's seo/articleMeta/siteName fields. Contributions are
7
+ * composed in the order `[...plugin, ...site, ...base]` and resolved by
8
+ * `resolvePageMetadata()` with first-wins dedup. Plugins sit at the front of
9
+ * the array, so for any given key plugin contributions override site-level
10
+ * ones, which override base ones.
8
11
  *
9
12
  * Usage:
10
13
  * ```astro
@@ -23,6 +26,7 @@ import {
23
26
  import { renderSiteIdentity } from "../page/site-identity.js";
24
27
  import { getPageRuntime } from "../page/index.js";
25
28
  import { getSiteSettings } from "../settings/index.js";
29
+ import { absolutizeMediaUrl } from "../page/absolute-url.js";
26
30
 
27
31
  interface Props {
28
32
  page: PublicPageContext;
@@ -31,9 +35,6 @@ interface Props {
31
35
  const { page } = Astro.props;
32
36
  const runtime = getPageRuntime(Astro.locals as Record<string, unknown>);
33
37
 
34
- // Base SEO contributions from page context (always generated)
35
- const baseContributions: PageMetadataContribution[] = generateBaseSeoContributions(page);
36
-
37
38
  let metadataHtml = "";
38
39
  let siteIdentityHtml = "";
39
40
  let fragmentsHtml = "";
@@ -56,6 +57,25 @@ if (runtime) {
56
57
  runtime.collectPageFragments(page),
57
58
  ]);
58
59
 
60
+ // Site-level default OG image: applied per-page in base contributions
61
+ // rather than emitted unconditionally, so per-content images still win.
62
+ // `resolveMediaReference` populates `.url` on read; only the mediaId is
63
+ // stored, so an empty/orphaned reference safely yields undefined here.
64
+ //
65
+ // Absolutize so `og:image` / `twitter:image` / JSON-LD `image` carry a
66
+ // fully-qualified URL: many social-card scrapers (Slack, LinkedIn) refuse
67
+ // to follow relative paths even when the rest of the page provides
68
+ // canonical context.
69
+ const defaultOgImage = absolutizeMediaUrl(
70
+ siteSettings.seo?.defaultOgImage?.url,
71
+ siteSettings.url,
72
+ page,
73
+ );
74
+ const baseContributions: PageMetadataContribution[] = generateBaseSeoContributions(
75
+ page,
76
+ defaultOgImage,
77
+ );
78
+
59
79
  const siteContributions = generateSiteSeoContributions(siteSettings.seo);
60
80
  const allContributions = [...pluginContributions, ...siteContributions, ...baseContributions];
61
81
  const resolved = resolvePageMetadata(allContributions);
@@ -64,6 +84,7 @@ if (runtime) {
64
84
  fragmentsHtml = renderFragments(fragments, "head");
65
85
  } else {
66
86
  // No runtime (EmDash not initialized) — still render base SEO
87
+ const baseContributions: PageMetadataContribution[] = generateBaseSeoContributions(page);
67
88
  const resolved = resolvePageMetadata(baseContributions);
68
89
  metadataHtml = renderPageMetadata(resolved);
69
90
  }
@@ -164,6 +164,7 @@ import { PluginStateRepository } from "./plugins/state.js";
164
164
  import { requestCached } from "./request-cache.js";
165
165
  import { getRequestContext } from "./request-context.js";
166
166
  import { FTSManager } from "./search/fts-manager.js";
167
+ import { invalidateSiteSettingsCache } from "./settings/index.js";
167
168
 
168
169
  /**
169
170
  * Map schema field types to editor field kinds
@@ -2055,11 +2056,29 @@ export class EmDashRuntime {
2055
2056
  id: string,
2056
2057
  input: { alt?: string; caption?: string; width?: number; height?: number },
2057
2058
  ) {
2058
- return handleMediaUpdate(this.db, id, input);
2059
+ const result = await handleMediaUpdate(this.db, id, input);
2060
+ // Resolved media references in site settings (`logo`, `favicon`,
2061
+ // `seo.defaultOgImage`) bake in the media row's `contentType`,
2062
+ // `width`, and `height`. A metadata edit invalidates that snapshot
2063
+ // for every entry point: REST routes, MCP tools, plugin code, and
2064
+ // any future caller of `handleMediaUpdate`. Cross-isolate staleness
2065
+ // remains bounded by isolate lifetime.
2066
+ if (result.success) {
2067
+ invalidateSiteSettingsCache();
2068
+ }
2069
+ return result;
2059
2070
  }
2060
2071
 
2061
2072
  async handleMediaDelete(id: string) {
2062
- return handleMediaDelete(this.db, id);
2073
+ const result = await handleMediaDelete(this.db, id);
2074
+ // Same reasoning as `handleMediaUpdate`: if the deleted media row
2075
+ // was referenced by a setting, the cached resolved URL now points
2076
+ // at a 404. Invalidation is unconditional on success — cheaper than
2077
+ // querying which settings reference the id.
2078
+ if (result.success) {
2079
+ invalidateSiteSettingsCache();
2080
+ }
2081
+ return result;
2063
2082
  }
2064
2083
 
2065
2084
  // =========================================================================
@@ -14,6 +14,7 @@ import type { Kysely } from "kysely";
14
14
  import { MediaRepository } from "../database/repositories/media.js";
15
15
  import type { Database } from "../database/types.js";
16
16
  import type { Storage } from "../index.js";
17
+ import { invalidateSiteSettingsCache } from "../settings/index.js";
17
18
  import type {
18
19
  CreateMediaProviderFn,
19
20
  MediaProvider,
@@ -120,6 +121,12 @@ export const createMediaProvider: CreateMediaProviderFn<LocalMediaRuntimeConfig>
120
121
  }
121
122
 
122
123
  await repo.delete(id);
124
+
125
+ // If this row was referenced by `logo`, `favicon`, or
126
+ // `seo.defaultOgImage`, the worker-scoped settings cache now
127
+ // holds a stale URL. The provider routes (and any future caller)
128
+ // bypass `handleMediaDelete`, so we invalidate here too.
129
+ invalidateSiteSettingsCache();
123
130
  },
124
131
 
125
132
  getEmbed(value: MediaValue, _options?: EmbedOptions): EmbedResult {
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Helpers for resolving relative media URLs to absolute URLs for SEO output.
3
+ *
4
+ * Social-card scrapers (Facebook, LinkedIn, Slack, Twitter) and JSON-LD
5
+ * consumers expect absolute URLs in `og:image`, `twitter:image`, and
6
+ * structured-data `image` fields. EmDash's media file route returns a
7
+ * site-relative path (`/_emdash/api/media/file/...`), so anywhere the
8
+ * resolved URL feeds into crawler-facing markup we have to join it with
9
+ * the public site origin.
10
+ */
11
+
12
+ import type { PublicPageContext } from "../plugins/types.js";
13
+
14
+ const HTTP_URL_RE = /^https?:\/\//i;
15
+ /**
16
+ * Protocol-relative URLs (`//cdn.example.com/x.png`) are dropped outright.
17
+ * They have no legitimate use in `og:image` (scrapers want a full URL) and
18
+ * are a well-known SSRF vector when reflected through server-side
19
+ * fetchers. Anything starting with `//` returns `null`.
20
+ */
21
+ const PROTOCOL_RELATIVE_RE = /^\/\//;
22
+ /**
23
+ * URL schemes we pass through unchanged because they are legitimately
24
+ * useful as OG image values. `data:image/*` is sometimes used for inline
25
+ * social cards (rare, but legal). Everything else with a scheme
26
+ * (`mailto:`, `tel:`, `file:`, `blob:`, custom protocols) would be garbage
27
+ * in an `og:image`; we return `null` so the caller can decide whether to
28
+ * fall back or drop the tag.
29
+ */
30
+ const PASSTHROUGH_SCHEME_RE = /^data:image\//i;
31
+ /**
32
+ * Detects URLs that have a scheme other than http/https (and other than
33
+ * the data:image/ form we pass through). Used to short-circuit garbage
34
+ * input rather than treating it as a relative path.
35
+ */
36
+ const OTHER_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
37
+ /**
38
+ * Any ASCII whitespace or C0/C1 control character anywhere in the URL is
39
+ * an injection signal — legitimate media URLs never contain them. Without
40
+ * this guard, an input like `" https://attacker/x"` would slip past the
41
+ * scheme regexes (which are anchored at offset 0) and get joined as a
42
+ * relative path with the site origin, producing
43
+ * `https://site.example/ https://attacker/x` — confusing but not
44
+ * exploitable, plus more pathological shapes like leading newlines that
45
+ * could inject across header boundaries downstream.
46
+ */
47
+ // eslint-disable-next-line eslint(no-control-regex) -- intentional: rejecting control chars is the whole point of this regex
48
+ const WHITESPACE_OR_CONTROL_RE = /[\s\u0000-\u001f\u007f-\u009f]/;
49
+ const TRAILING_SLASH_RE = /\/$/;
50
+
51
+ /**
52
+ * `URL.origin` returns the literal string `"null"` (not the `null` value)
53
+ * for opaque origins like `data:`, `blob:`, and `about:blank`. Treating
54
+ * that as a valid origin would produce `null/og.png` in the output.
55
+ */
56
+ function isUsableOrigin(origin: string): boolean {
57
+ return origin !== "null" && origin !== "";
58
+ }
59
+
60
+ /**
61
+ * Resolve the public origin to use when absolutizing a media URL.
62
+ *
63
+ * Precedence:
64
+ * 1. The configured `SiteSettings.url` (admin-controlled, canonical).
65
+ * 2. `PublicPageContext.siteUrl` (set by themes that override the origin,
66
+ * e.g. when running behind a reverse proxy).
67
+ * 3. The origin parsed from `page.url`, which is the live request URL.
68
+ *
69
+ * Only `http:` and `https:` candidates count — anything else (e.g. `file:`,
70
+ * `data:`, `blob:`) would yield an unusable origin and is skipped. Returns
71
+ * `null` if no candidate parses to a usable HTTP(S) origin; callers should
72
+ * treat that as "leave the URL relative" rather than throw.
73
+ */
74
+ export function resolveSiteOrigin(
75
+ configuredSiteUrl: string | undefined,
76
+ page: PublicPageContext,
77
+ ): string | null {
78
+ const candidates = [configuredSiteUrl, page.siteUrl, page.url];
79
+ for (const candidate of candidates) {
80
+ if (!candidate || typeof candidate !== "string") continue;
81
+ try {
82
+ const parsed = new URL(candidate);
83
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") continue;
84
+ if (!isUsableOrigin(parsed.origin)) continue;
85
+ return parsed.origin;
86
+ } catch {
87
+ // Fall through to the next candidate. Configured URLs and page
88
+ // URLs can be malformed (e.g. an admin pasted "example.com"
89
+ // without a scheme); we don't want a bad config to break head
90
+ // rendering.
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Absolutize a media URL using the best available site origin.
98
+ *
99
+ * - Returns `null` for missing/empty input.
100
+ * - Passes through already-absolute `http(s):` URLs unchanged.
101
+ * - Passes through `data:image/*` URLs unchanged (rare but legal as OG
102
+ * image content).
103
+ * - Returns `null` for protocol-relative URLs (`//cdn.com/x`): no
104
+ * legitimate `og:image` use case, and a known SSRF vector when reflected
105
+ * through server-side fetchers.
106
+ * - Returns `null` for any other scheme (`mailto:`, `blob:`, `file:`,
107
+ * custom protocols): emitting those into `og:image` is worse than
108
+ * omitting the tag.
109
+ * - Returns the original (relative) URL when no origin can be resolved —
110
+ * preferable to dropping `og:image` outright because scrapers that follow
111
+ * relative URLs are better off than ones that get nothing.
112
+ *
113
+ * @param url - The (possibly relative) media URL, e.g. `/_emdash/api/media/file/abc.jpg`.
114
+ * @param configuredSiteUrl - `SiteSettings.url` value (admin-controlled).
115
+ * @param page - The page context providing `siteUrl` and `url` fallbacks.
116
+ */
117
+ export function absolutizeMediaUrl(
118
+ url: string | undefined,
119
+ configuredSiteUrl: string | undefined,
120
+ page: PublicPageContext,
121
+ ): string | null {
122
+ if (!url) return null;
123
+
124
+ // Any whitespace or control character means this isn't a real media URL.
125
+ // Rejecting up front prevents scheme-regex evasion (` https://x` would
126
+ // otherwise fall through to the relative-path join below).
127
+ if (WHITESPACE_OR_CONTROL_RE.test(url)) return null;
128
+
129
+ if (HTTP_URL_RE.test(url)) return url;
130
+ if (PASSTHROUGH_SCHEME_RE.test(url)) return url;
131
+
132
+ // Reject protocol-relative URLs before any other handling. Order
133
+ // matters: `OTHER_SCHEME_RE` wouldn't match `//x` (no leading scheme),
134
+ // so a missing check here would fall through to the relative-path
135
+ // join below and produce `https://site.example//cdn.evil.com/x`.
136
+ if (PROTOCOL_RELATIVE_RE.test(url)) return null;
137
+
138
+ // Any remaining `<scheme>:` form is something we'd silently mangle by
139
+ // prepending an origin. Drop it.
140
+ if (OTHER_SCHEME_RE.test(url)) return null;
141
+
142
+ const origin = resolveSiteOrigin(configuredSiteUrl, page);
143
+ if (!origin) return url;
144
+ const safePath = url.startsWith("/") ? url : `/${url}`;
145
+ return `${origin.replace(TRAILING_SLASH_RE, "")}${safePath}`;
146
+ }
@@ -29,13 +29,21 @@ export function cleanJsonLd(obj: Record<string, unknown>): Record<string, unknow
29
29
  /**
30
30
  * Build a BlogPosting JSON-LD graph from page context.
31
31
  * Used for article-type content pages.
32
+ *
33
+ * @param page - Page context for the current request.
34
+ * @param defaultOgImage - Optional site-wide fallback image URL, used when
35
+ * the page has no own OG image. Matches the fallback applied to `og:image`
36
+ * in `generateBaseSeoContributions`.
32
37
  */
33
- export function buildBlogPostingJsonLd(page: PublicPageContext): Record<string, unknown> | null {
38
+ export function buildBlogPostingJsonLd(
39
+ page: PublicPageContext,
40
+ defaultOgImage?: string | null,
41
+ ): Record<string, unknown> | null {
34
42
  if (page.pageType !== "article" || !page.canonical) return null;
35
43
 
36
44
  const ogTitle = page.seo?.ogTitle ?? page.pageTitle ?? page.title;
37
45
  const description = page.seo?.ogDescription || page.description;
38
- const ogImage = page.seo?.ogImage || page.image;
46
+ const ogImage = page.seo?.ogImage || page.image || defaultOgImage || null;
39
47
  const publishedTime = page.articleMeta?.publishedTime;
40
48
  const modifiedTime = page.articleMeta?.modifiedTime;
41
49
  const author = page.articleMeta?.author;
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Generate base SEO metadata contributions from PublicPageContext.
3
3
  *
4
- * These contributions are prepended BEFORE plugin contributions in
5
- * resolvePageMetadata(), which uses first-wins dedup. This means
6
- * plugins can override any base SEO tag by contributing the same key.
4
+ * EmDashHead.astro composes the final contribution list as
5
+ * `[...plugin, ...site, ...base]` and feeds it to `resolvePageMetadata()`,
6
+ * which is first-wins. That ordering means plugin contributions override
7
+ * site-level ones override base ones for any given key — base values are
8
+ * the fallback, not the source of truth.
7
9
  *
8
10
  * This replaces the per-template SEO.astro components, eliminating
9
11
  * the class of XSS bugs where templates hand-rolled JSON-LD serialization.
@@ -15,15 +17,24 @@ import { buildBlogPostingJsonLd, buildWebSiteJsonLd } from "./jsonld.js";
15
17
 
16
18
  /**
17
19
  * Generate base metadata contributions from a page context's SEO data.
20
+ *
21
+ * @param page - Page context produced by the runtime for the current request.
22
+ * @param defaultOgImage - Optional site-wide fallback OG image URL, used when
23
+ * the page has no own OG image (i.e., neither `seo.ogImage` nor `image`).
24
+ * Sourced from `SiteSettings.seo.defaultOgImage` by `EmDashHead`.
25
+ *
18
26
  * Returns an empty array if no SEO-relevant data is present.
19
27
  */
20
- export function generateBaseSeoContributions(page: PublicPageContext): PageMetadataContribution[] {
28
+ export function generateBaseSeoContributions(
29
+ page: PublicPageContext,
30
+ defaultOgImage?: string | null,
31
+ ): PageMetadataContribution[] {
21
32
  const contributions: PageMetadataContribution[] = [];
22
33
 
23
34
  const description = page.description;
24
35
  const ogTitle = page.seo?.ogTitle ?? page.pageTitle ?? page.title;
25
36
  const ogDescription = page.seo?.ogDescription || description;
26
- const ogImage = page.seo?.ogImage || page.image;
37
+ const ogImage = page.seo?.ogImage || page.image || defaultOgImage || null;
27
38
  const robots = page.seo?.robots;
28
39
  const canonical = page.canonical;
29
40
  const siteName = page.siteName;
@@ -122,7 +133,7 @@ export function generateBaseSeoContributions(page: PublicPageContext): PageMetad
122
133
  // -- JSON-LD --
123
134
 
124
135
  if (page.pageType === "article") {
125
- const blogPosting = buildBlogPostingJsonLd(page);
136
+ const blogPosting = buildBlogPostingJsonLd(page, defaultOgImage ?? null);
126
137
  if (blogPosting) {
127
138
  contributions.push({ kind: "jsonld", id: "primary", graph: blogPosting });
128
139
  }