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.
- package/dist/{apply-Ded_1vng.mjs → apply-C1ZORgcy.mjs} +6 -226
- package/dist/apply-C1ZORgcy.mjs.map +1 -0
- package/dist/astro/index.d.mts +3 -3
- package/dist/astro/index.mjs +1 -1
- package/dist/astro/middleware/auth.d.mts +3 -3
- package/dist/astro/middleware/auth.mjs +1 -1
- package/dist/astro/middleware.mjs +16 -12
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +3 -3
- package/dist/cli/index.mjs +4 -4
- package/dist/{error-DqnRMM5z.mjs → error-D6LuHLw9.mjs} +1 -1
- package/dist/{error-DqnRMM5z.mjs.map → error-D6LuHLw9.mjs.map} +1 -1
- package/dist/{index-Cg-rC4Gj.d.mts → index-Dlkzhb4C.d.mts} +5 -5
- package/dist/index-Dlkzhb4C.d.mts.map +1 -0
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +9 -9
- package/dist/{manifest-schema-CXAbd1vH.mjs → manifest-schema-Bp6d4d4n.mjs} +1 -1
- package/dist/{manifest-schema-CXAbd1vH.mjs.map → manifest-schema-Bp6d4d4n.mjs.map} +1 -1
- package/dist/media/local-runtime.d.mts +3 -3
- package/dist/media/local-runtime.d.mts.map +1 -1
- package/dist/media/local-runtime.mjs +6 -1
- package/dist/media/local-runtime.mjs.map +1 -1
- package/dist/page/index.d.mts +15 -4
- package/dist/page/index.d.mts.map +1 -1
- package/dist/page/index.mjs +16 -5
- package/dist/page/index.mjs.map +1 -1
- package/dist/plugins/adapt-sandbox-entry.d.mts +3 -3
- package/dist/plugins/adapt-sandbox-entry.mjs +2 -2
- package/dist/{query-8c_meo_K.mjs → query-yA3-rFji.mjs} +13 -2
- package/dist/query-yA3-rFji.mjs.map +1 -0
- package/dist/runtime.d.mts +3 -3
- package/dist/{search-DuWhx4NG.mjs → search-n-ZCMfr3.mjs} +33 -16
- package/dist/search-n-ZCMfr3.mjs.map +1 -0
- package/dist/seed/index.d.mts +1 -1
- package/dist/seed/index.mjs +2 -2
- package/dist/settings-nTXPRi3D.mjs +440 -0
- package/dist/settings-nTXPRi3D.mjs.map +1 -0
- package/dist/storage/local.mjs +1 -1
- package/dist/storage/s3.mjs +1 -1
- package/dist/{taxonomies-Bw76xAxo.mjs → taxonomies-JmQQZiG1.mjs} +2 -2
- package/dist/{taxonomies-Bw76xAxo.mjs.map → taxonomies-JmQQZiG1.mjs.map} +1 -1
- package/dist/{types-IZSZfEwv.d.mts → types-B1gLSAH2.d.mts} +13 -9
- package/dist/{types-IZSZfEwv.d.mts.map → types-B1gLSAH2.d.mts.map} +1 -1
- package/dist/{types-DiI8NOG_.mjs → types-Cug_RO3W.mjs} +1 -1
- package/dist/{types-DiI8NOG_.mjs.map → types-Cug_RO3W.mjs.map} +1 -1
- package/dist/{types-IN5z_S3P.d.mts → types-DgSc9Rpc.d.mts} +2 -2
- package/dist/{types-IN5z_S3P.d.mts.map → types-DgSc9Rpc.d.mts.map} +1 -1
- package/dist/{types-K-EkEQCI.mjs → types-PafqtQuM.mjs} +1 -1
- package/dist/{types-K-EkEQCI.mjs.map → types-PafqtQuM.mjs.map} +1 -1
- package/dist/{validate-CO3JjFV5.d.mts → validate-BcC3m2O7.d.mts} +2 -2
- package/dist/{validate-CO3JjFV5.d.mts.map → validate-BcC3m2O7.d.mts.map} +1 -1
- package/dist/version-BdP--J1g.mjs +7 -0
- package/dist/{version-Bg31I_Ff.mjs.map → version-BdP--J1g.mjs.map} +1 -1
- package/package.json +6 -6
- package/src/api/schemas/settings.ts +41 -9
- 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/[id].ts +2 -1
- package/src/components/EmDashHead.astro +26 -5
- package/src/emdash-runtime.ts +21 -2
- package/src/media/local-runtime.ts +7 -0
- package/src/page/absolute-url.ts +146 -0
- package/src/page/jsonld.ts +10 -2
- package/src/page/seo-contributions.ts +17 -6
- package/src/plugins/context.ts +11 -1
- package/src/query.ts +12 -0
- package/src/settings/index.ts +20 -1
- package/src/settings/types.ts +12 -8
- package/dist/apply-Ded_1vng.mjs.map +0 -1
- package/dist/index-Cg-rC4Gj.d.mts.map +0 -1
- package/dist/media-1fFhub9c.mjs +0 -209
- package/dist/media-1fFhub9c.mjs.map +0 -1
- package/dist/query-8c_meo_K.mjs.map +0 -1
- package/dist/search-DuWhx4NG.mjs.map +0 -1
- 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-
|
|
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-
|
|
348
|
+
//# sourceMappingURL=validate-BcC3m2O7.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate-
|
|
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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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"version-
|
|
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.
|
|
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/
|
|
198
|
-
"@emdash-cms/auth": "0.
|
|
199
|
-
"@emdash-cms/
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
28
|
+
const seoSettingsInput = z.object({
|
|
24
29
|
titleSeparator: z.string().max(10).optional(),
|
|
25
|
-
defaultOgImage:
|
|
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:
|
|
36
|
-
favicon:
|
|
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:
|
|
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:
|
|
55
|
-
favicon:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
7
|
-
*
|
|
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
|
}
|
package/src/emdash-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/page/jsonld.ts
CHANGED
|
@@ -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(
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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(
|
|
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
|
}
|