emdash 0.4.0 → 0.6.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/{adapters-C2BzVy0p.d.mts → adapters-Di31kZ28.d.mts} +16 -1
- package/dist/adapters-Di31kZ28.d.mts.map +1 -0
- package/dist/{apply-Cma_PiF6.mjs → apply-B4MsLM-w.mjs} +27 -12
- package/dist/apply-B4MsLM-w.mjs.map +1 -0
- package/dist/astro/index.d.mts +6 -6
- package/dist/astro/index.d.mts.map +1 -1
- package/dist/astro/index.mjs +208 -34
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +5 -5
- package/dist/astro/middleware/auth.d.mts.map +1 -1
- package/dist/astro/middleware/auth.mjs +34 -9
- package/dist/astro/middleware/auth.mjs.map +1 -1
- package/dist/astro/middleware/redirect.mjs +1 -1
- package/dist/astro/middleware/request-context.d.mts.map +1 -1
- package/dist/astro/middleware/request-context.mjs +5 -3
- package/dist/astro/middleware/request-context.mjs.map +1 -1
- package/dist/astro/middleware/setup.mjs +1 -1
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +460 -180
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +8 -8
- package/dist/{byline-WuOq9MFJ.mjs → byline-C4OVd8b3.mjs} +3 -19
- package/dist/byline-C4OVd8b3.mjs.map +1 -0
- package/dist/{bylines-C_Wsnz4L.mjs → bylines-hPTW79hw.mjs} +20 -33
- package/dist/bylines-hPTW79hw.mjs.map +1 -0
- package/dist/{cache-E3Dts-yT.mjs → cache-BkKBuIvS.mjs} +1 -1
- package/dist/{cache-E3Dts-yT.mjs.map → cache-BkKBuIvS.mjs.map} +1 -1
- package/dist/chunks-HGz06Soa.mjs +19 -0
- package/dist/chunks-HGz06Soa.mjs.map +1 -0
- package/dist/cli/index.mjs +9 -8
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/cf-access.d.mts +1 -1
- package/dist/client/index.d.mts +1 -1
- package/dist/client/index.mjs +1 -1
- package/dist/{config-DkxPrM9l.mjs → config-BXwuX8Bx.mjs} +1 -1
- package/dist/{config-DkxPrM9l.mjs.map → config-BXwuX8Bx.mjs.map} +1 -1
- package/dist/{connection-B4zVnQIa.mjs → connection-2igzM-AT.mjs} +19 -2
- package/dist/connection-2igzM-AT.mjs.map +1 -0
- package/dist/database/instrumentation.d.mts +45 -0
- package/dist/database/instrumentation.d.mts.map +1 -0
- package/dist/database/instrumentation.mjs +61 -0
- package/dist/database/instrumentation.mjs.map +1 -0
- package/dist/db/index.d.mts +3 -3
- package/dist/db/index.mjs.map +1 -1
- package/dist/db/libsql.d.mts +1 -1
- package/dist/db/postgres.d.mts +1 -1
- package/dist/db/sqlite.d.mts +1 -1
- package/dist/db-errors-D0UT85nC.mjs +41 -0
- package/dist/db-errors-D0UT85nC.mjs.map +1 -0
- package/dist/{default-PUx9RK6u.mjs → default-CME5YdZ3.mjs} +1 -1
- package/dist/{default-PUx9RK6u.mjs.map → default-CME5YdZ3.mjs.map} +1 -1
- package/dist/{error-HBeQbVhV.mjs → error-CiYn9yDu.mjs} +1 -1
- package/dist/{error-HBeQbVhV.mjs.map → error-CiYn9yDu.mjs.map} +1 -1
- package/dist/{index-CRg3PWfZ.d.mts → index-BYv0mB9g.d.mts} +135 -19
- package/dist/index-BYv0mB9g.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +20 -18
- package/dist/{load-BhSSm-TS.mjs → load-CBcmDIot.mjs} +1 -1
- package/dist/{load-BhSSm-TS.mjs.map → load-CBcmDIot.mjs.map} +1 -1
- package/dist/{loader-BYzwzORf.mjs → loader-DeiBJEMe.mjs} +18 -12
- package/dist/loader-DeiBJEMe.mjs.map +1 -0
- package/dist/{manifest-schema-BsXINkQD.mjs → manifest-schema-V30qsMft.mjs} +1 -1
- package/dist/{manifest-schema-BsXINkQD.mjs.map → manifest-schema-V30qsMft.mjs.map} +1 -1
- package/dist/media/index.d.mts +1 -1
- package/dist/media/index.mjs +1 -1
- package/dist/media/local-runtime.d.mts +7 -7
- package/dist/{mode-CyPLdO3C.mjs → mode-CpNnGkPz.mjs} +1 -1
- package/dist/{mode-CyPLdO3C.mjs.map → mode-CpNnGkPz.mjs.map} +1 -1
- package/dist/page/index.d.mts +11 -2
- package/dist/page/index.d.mts.map +1 -1
- package/dist/page/index.mjs +23 -1
- package/dist/page/index.mjs.map +1 -1
- package/dist/{placeholder-DntBEQo7.mjs → placeholder-C-fk5hYI.mjs} +1 -1
- package/dist/{placeholder-DntBEQo7.mjs.map → placeholder-C-fk5hYI.mjs.map} +1 -1
- package/dist/{placeholder-BBCtpTES.d.mts → placeholder-tzpqGWII.d.mts} +1 -1
- package/dist/{placeholder-BBCtpTES.d.mts.map → placeholder-tzpqGWII.d.mts.map} +1 -1
- package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
- package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
- package/dist/{query-B6Vu0d2i.mjs → query-Bk_3vKvU.mjs} +78 -11
- package/dist/query-Bk_3vKvU.mjs.map +1 -0
- package/dist/{registry-BgnP3ysR.mjs → registry-Ci3WxVAr.mjs} +133 -97
- package/dist/registry-Ci3WxVAr.mjs.map +1 -0
- package/dist/request-cache-DiR961CV.mjs +79 -0
- package/dist/request-cache-DiR961CV.mjs.map +1 -0
- package/dist/request-context.d.mts +19 -16
- package/dist/request-context.d.mts.map +1 -1
- package/dist/request-context.mjs.map +1 -1
- package/dist/{runner-DYv3rX8P.d.mts → runner-Fl2NcUUz.d.mts} +2 -2
- package/dist/{runner-DYv3rX8P.d.mts.map → runner-Fl2NcUUz.d.mts.map} +1 -1
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +1 -1
- package/dist/{search-B5p9D36n.mjs → search-DI4bM2w9.mjs} +110 -209
- package/dist/search-DI4bM2w9.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +8 -7
- package/dist/seo/index.d.mts +1 -1
- package/dist/storage/local.d.mts +1 -1
- package/dist/storage/local.mjs +1 -1
- package/dist/storage/s3.d.mts +1 -1
- package/dist/storage/s3.mjs +1 -1
- package/dist/taxonomies-DbrKzDju.mjs +308 -0
- package/dist/taxonomies-DbrKzDju.mjs.map +1 -0
- package/dist/{tokens-DKHiCYCB.mjs → tokens-BFPFx3CA.mjs} +1 -1
- package/dist/{tokens-DKHiCYCB.mjs.map → tokens-BFPFx3CA.mjs.map} +1 -1
- package/dist/{transport-BtcQ-Z7T.mjs → transport-BykRfpyy.mjs} +1 -1
- package/dist/{transport-BtcQ-Z7T.mjs.map → transport-BykRfpyy.mjs.map} +1 -1
- package/dist/{transport-CKQA_G44.d.mts → transport-H4Iwx7tC.d.mts} +1 -1
- package/dist/{transport-CKQA_G44.d.mts.map → transport-H4Iwx7tC.d.mts.map} +1 -1
- package/dist/{types-BmkQR1En.d.mts → types-6CUZRrZP.d.mts} +1 -1
- package/dist/{types-BmkQR1En.d.mts.map → types-6CUZRrZP.d.mts.map} +1 -1
- package/dist/{types-B6BzlZxx.d.mts → types-8xrvl_68.d.mts} +1 -1
- package/dist/{types-B6BzlZxx.d.mts.map → types-8xrvl_68.d.mts.map} +1 -1
- package/dist/{types-Dz9_WMS6.mjs → types-BH2L167P.mjs} +1 -1
- package/dist/{types-Dz9_WMS6.mjs.map → types-BH2L167P.mjs.map} +1 -1
- package/dist/{types-DNZpaCBk.d.mts → types-CFWjXmus.d.mts} +1 -1
- package/dist/{types-DNZpaCBk.d.mts.map → types-CFWjXmus.d.mts.map} +1 -1
- package/dist/{types-gLYVCXCQ.d.mts → types-CnZYHyLW.d.mts} +55 -5
- package/dist/types-CnZYHyLW.d.mts.map +1 -0
- package/dist/{types-xxCWI3j0.mjs → types-DDS4MxsT.mjs} +11 -3
- package/dist/types-DDS4MxsT.mjs.map +1 -0
- package/dist/{types-BYWYxLcp.d.mts → types-DgrIP0tF.d.mts} +9 -2
- package/dist/types-DgrIP0tF.d.mts.map +1 -0
- package/dist/{validate-CcNRWH6I.d.mts → validate-CaLH1Ia2.d.mts} +5 -52
- package/dist/validate-CaLH1Ia2.d.mts.map +1 -0
- package/dist/{validate-DuZDIxfy.mjs → validate-CqsNItbt.mjs} +2 -2
- package/dist/{validate-DuZDIxfy.mjs.map → validate-CqsNItbt.mjs.map} +1 -1
- package/dist/version-Uaf2ynPX.mjs +7 -0
- package/dist/{version-DlTDRdpv.mjs.map → version-Uaf2ynPX.mjs.map} +1 -1
- package/package.json +10 -5
- package/src/after.ts +62 -0
- package/src/api/handlers/oauth-authorization.ts +2 -32
- package/src/api/handlers/oauth-clients.ts +40 -4
- package/src/api/handlers/taxonomies.ts +13 -0
- package/src/api/oauth/redirect-uri.ts +34 -0
- package/src/api/openapi/document.ts +126 -118
- package/src/api/schemas/auth.ts +7 -0
- package/src/api/schemas/media.ts +26 -15
- package/src/api/schemas/schema.ts +1 -0
- package/src/astro/integration/font-provider.ts +176 -0
- package/src/astro/integration/index.ts +42 -0
- package/src/astro/integration/routes.ts +17 -1
- package/src/astro/integration/runtime.ts +63 -0
- package/src/astro/integration/virtual-modules.ts +41 -39
- package/src/astro/integration/vite-config.ts +16 -5
- package/src/astro/middleware/auth.ts +39 -6
- package/src/astro/middleware/request-context.ts +15 -3
- package/src/astro/middleware.ts +340 -263
- package/src/astro/routes/admin.astro +10 -5
- package/src/astro/routes/api/auth/invite/register-options.ts +78 -0
- package/src/astro/routes/api/auth/passkey/verify.ts +5 -1
- package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +5 -0
- package/src/astro/routes/api/import/wordpress/execute.ts +1 -1
- package/src/astro/routes/api/import/wordpress-plugin/execute.ts +1 -1
- package/src/astro/routes/api/media/upload-url.ts +10 -2
- package/src/astro/routes/api/media.ts +10 -7
- package/src/astro/routes/api/oauth/register.ts +178 -0
- package/src/astro/routes/api/oauth/token.ts +15 -0
- package/src/astro/routes/api/openapi.json.ts +15 -5
- package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +2 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +1 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +1 -0
- package/src/astro/routes/api/search/index.ts +5 -0
- package/src/astro/routes/api/search/suggest.ts +3 -0
- package/src/astro/routes/api/taxonomies/index.ts +1 -0
- package/src/astro/routes/api/well-known/oauth-authorization-server.ts +6 -4
- package/src/bylines/index.ts +22 -45
- package/src/components/EmDashHead.astro +23 -7
- package/src/components/Table.astro +73 -41
- package/src/components/index.ts +2 -12
- package/src/components/marks.ts +20 -0
- package/src/database/connection.ts +23 -1
- package/src/database/instrumentation.ts +98 -0
- package/src/db/adapters.ts +15 -0
- package/src/emdash-runtime.ts +309 -91
- package/src/index.ts +6 -0
- package/src/loader.ts +19 -24
- package/src/menus/index.ts +6 -3
- package/src/page/index.ts +1 -1
- package/src/page/seo-contributions.ts +36 -0
- package/src/plugins/context.ts +1 -0
- package/src/plugins/email-console.ts +9 -2
- package/src/plugins/types.ts +8 -0
- package/src/query.ts +104 -7
- package/src/request-cache.ts +106 -0
- package/src/request-context.ts +19 -0
- package/src/schema/query.ts +5 -2
- package/src/schema/registry.ts +243 -166
- package/src/schema/types.ts +13 -2
- package/src/schema/zod-generator.ts +4 -0
- package/src/search/fts-manager.ts +19 -5
- package/src/search/query.ts +4 -3
- package/src/seed/apply.ts +15 -1
- package/src/settings/index.ts +24 -5
- package/src/taxonomies/index.ts +324 -124
- package/src/utils/db-errors.ts +46 -0
- package/src/virtual-modules.d.ts +31 -10
- package/src/widgets/index.ts +54 -25
- package/dist/adapters-C2BzVy0p.d.mts.map +0 -1
- package/dist/apply-Cma_PiF6.mjs.map +0 -1
- package/dist/byline-WuOq9MFJ.mjs.map +0 -1
- package/dist/bylines-C_Wsnz4L.mjs.map +0 -1
- package/dist/connection-B4zVnQIa.mjs.map +0 -1
- package/dist/index-CRg3PWfZ.d.mts.map +0 -1
- package/dist/loader-BYzwzORf.mjs.map +0 -1
- package/dist/query-B6Vu0d2i.mjs.map +0 -1
- package/dist/registry-BgnP3ysR.mjs.map +0 -1
- package/dist/search-B5p9D36n.mjs.map +0 -1
- package/dist/types-BYWYxLcp.d.mts.map +0 -1
- package/dist/types-gLYVCXCQ.d.mts.map +0 -1
- package/dist/types-xxCWI3j0.mjs.map +0 -1
- package/dist/validate-CcNRWH6I.d.mts.map +0 -1
- package/dist/version-DlTDRdpv.mjs +0 -7
package/src/menus/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { sql } from "kysely";
|
|
|
10
10
|
import type { Database } from "../database/types.js";
|
|
11
11
|
import { validateIdentifier } from "../database/validate.js";
|
|
12
12
|
import { getDb } from "../loader.js";
|
|
13
|
+
import { requestCached } from "../request-cache.js";
|
|
13
14
|
import { sanitizeHref } from "../utils/url.js";
|
|
14
15
|
import type { Menu, MenuItem, MenuItemRow } from "./types.js";
|
|
15
16
|
|
|
@@ -26,9 +27,11 @@ import type { Menu, MenuItem, MenuItemRow } from "./types.js";
|
|
|
26
27
|
* }
|
|
27
28
|
* ```
|
|
28
29
|
*/
|
|
29
|
-
export
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
export function getMenu(name: string): Promise<Menu | null> {
|
|
31
|
+
return requestCached(`menu:${name}`, async () => {
|
|
32
|
+
const db = await getDb();
|
|
33
|
+
return getMenuWithDb(name, db);
|
|
34
|
+
});
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/**
|
package/src/page/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ export type { ResolvedPageMetadata } from "./metadata.js";
|
|
|
24
24
|
|
|
25
25
|
export { resolveFragments, renderFragments } from "./fragments.js";
|
|
26
26
|
|
|
27
|
-
export { generateBaseSeoContributions } from "./seo-contributions.js";
|
|
27
|
+
export { generateBaseSeoContributions, generateSiteSeoContributions } from "./seo-contributions.js";
|
|
28
28
|
export { cleanJsonLd, buildBlogPostingJsonLd, buildWebSiteJsonLd } from "./jsonld.js";
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { PageMetadataContribution, PublicPageContext } from "../plugins/types.js";
|
|
13
|
+
import type { SeoSettings } from "../settings/types.js";
|
|
13
14
|
import { buildBlogPostingJsonLd, buildWebSiteJsonLd } from "./jsonld.js";
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -134,3 +135,38 @@ export function generateBaseSeoContributions(page: PublicPageContext): PageMetad
|
|
|
134
135
|
|
|
135
136
|
return contributions;
|
|
136
137
|
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Generate site-level SEO metadata contributions from SiteSettings.seo.
|
|
141
|
+
*
|
|
142
|
+
* These tags apply to every page (search engine ownership verification),
|
|
143
|
+
* so they're sourced from site settings rather than per-page context.
|
|
144
|
+
* Returns an empty array when no relevant settings are configured.
|
|
145
|
+
*/
|
|
146
|
+
export function generateSiteSeoContributions(
|
|
147
|
+
seoSettings: SeoSettings | undefined,
|
|
148
|
+
): PageMetadataContribution[] {
|
|
149
|
+
const contributions: PageMetadataContribution[] = [];
|
|
150
|
+
|
|
151
|
+
if (!seoSettings) {
|
|
152
|
+
return contributions;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (seoSettings.googleVerification) {
|
|
156
|
+
contributions.push({
|
|
157
|
+
kind: "meta",
|
|
158
|
+
name: "google-site-verification",
|
|
159
|
+
content: seoSettings.googleVerification,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (seoSettings.bingVerification) {
|
|
164
|
+
contributions.push({
|
|
165
|
+
kind: "meta",
|
|
166
|
+
name: "msvalidate.01",
|
|
167
|
+
content: seoSettings.bingVerification,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return contributions;
|
|
172
|
+
}
|
package/src/plugins/context.ts
CHANGED
|
@@ -24,8 +24,15 @@ export interface StoredEmail {
|
|
|
24
24
|
sentAt: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* In-memory store for dev emails.
|
|
29
|
+
* Uses globalThis so the same array is shared across Vite SSR module
|
|
30
|
+
* instances (the runtime and the route handler may load separate copies
|
|
31
|
+
* of this module, but globalThis is always the same object).
|
|
32
|
+
*/
|
|
33
|
+
const GLOBAL_KEY = "__emdash_dev_emails__" as const;
|
|
34
|
+
const storedEmails: StoredEmail[] = ((globalThis as Record<string, unknown>)[GLOBAL_KEY] ??=
|
|
35
|
+
[]) as StoredEmail[];
|
|
29
36
|
|
|
30
37
|
/**
|
|
31
38
|
* Get all stored dev emails (most recent first).
|
package/src/plugins/types.ts
CHANGED
|
@@ -209,6 +209,13 @@ export interface ContentItem {
|
|
|
209
209
|
publishedAt: string | null;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
export interface ContentListWhere {
|
|
213
|
+
/** Exact match on `status` (e.g. `"published"`, `"draft"`). */
|
|
214
|
+
status?: string;
|
|
215
|
+
/** Exact match on `locale` (e.g. `"en"`, `"fr-CA"`). */
|
|
216
|
+
locale?: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
212
219
|
/**
|
|
213
220
|
* Content list options
|
|
214
221
|
*/
|
|
@@ -216,6 +223,7 @@ export interface ContentListOptions {
|
|
|
216
223
|
limit?: number;
|
|
217
224
|
cursor?: string;
|
|
218
225
|
orderBy?: Record<string, "asc" | "desc">;
|
|
226
|
+
where?: ContentListWhere;
|
|
219
227
|
}
|
|
220
228
|
|
|
221
229
|
/**
|
package/src/query.ts
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { getFallbackChain, getI18nConfig, isI18nEnabled } from "./i18n/config.js";
|
|
16
|
+
import { requestCached } from "./request-cache.js";
|
|
16
17
|
import { getRequestContext } from "./request-context.js";
|
|
18
|
+
import { isMissingTableError } from "./utils/db-errors.js";
|
|
17
19
|
import {
|
|
18
20
|
createEditable,
|
|
19
21
|
createNoop,
|
|
@@ -269,6 +271,51 @@ function entryEditOptions(entry: { data?: unknown }): EditableOptions {
|
|
|
269
271
|
export async function getEmDashCollection<T extends string, D = InferCollectionData<T>>(
|
|
270
272
|
type: T,
|
|
271
273
|
filter?: CollectionFilter,
|
|
274
|
+
): Promise<CollectionResult<D>> {
|
|
275
|
+
// Cache per (type, filter) within a single request. Edit mode and
|
|
276
|
+
// preview are request-scoped and stable, so they don't need to be
|
|
277
|
+
// part of the key. Widgets and layouts frequently request the same
|
|
278
|
+
// collection shape as the page itself (e.g. a "recent posts" list
|
|
279
|
+
// appears on the home page AND in the sidebar) — caching collapses
|
|
280
|
+
// those duplicate queries, along with the bylines and taxonomy-term
|
|
281
|
+
// hydration each call would otherwise re-do.
|
|
282
|
+
return requestCached(collectionCacheKey(type, filter), () =>
|
|
283
|
+
getEmDashCollectionUncached<T, D>(type, filter),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Build a canonical cache key for `getEmDashCollection`.
|
|
289
|
+
*
|
|
290
|
+
* `JSON.stringify` is insertion-order-sensitive, so two callers passing
|
|
291
|
+
* semantically identical filters with different key orders would miss
|
|
292
|
+
* the cache. We fix the top-level field order and sort `where` keys
|
|
293
|
+
* (order there is irrelevant), while preserving `orderBy` key order
|
|
294
|
+
* because that's the sort priority.
|
|
295
|
+
*/
|
|
296
|
+
function collectionCacheKey(type: string, filter?: CollectionFilter): string {
|
|
297
|
+
if (!filter) return `collection:${type}:`;
|
|
298
|
+
const parts = [
|
|
299
|
+
filter.status ?? "",
|
|
300
|
+
filter.limit ?? "",
|
|
301
|
+
filter.cursor ?? "",
|
|
302
|
+
filter.where ? stableStringify(filter.where) : "",
|
|
303
|
+
filter.orderBy ? JSON.stringify(filter.orderBy) : "",
|
|
304
|
+
filter.locale ?? "",
|
|
305
|
+
];
|
|
306
|
+
return `collection:${type}:${parts.join("|")}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function stableStringify(value: Record<string, unknown>): string {
|
|
310
|
+
const keys = Object.keys(value).toSorted();
|
|
311
|
+
const ordered: Record<string, unknown> = {};
|
|
312
|
+
for (const k of keys) ordered[k] = value[k];
|
|
313
|
+
return JSON.stringify(ordered);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function getEmDashCollectionUncached<T extends string, D = InferCollectionData<T>>(
|
|
317
|
+
type: T,
|
|
318
|
+
filter?: CollectionFilter,
|
|
272
319
|
): Promise<CollectionResult<D>> {
|
|
273
320
|
// Dynamic import to avoid build-time issues
|
|
274
321
|
const { getLiveCollection } = await import("astro:content");
|
|
@@ -313,8 +360,13 @@ export async function getEmDashCollection<T extends string, D = InferCollectionD
|
|
|
313
360
|
};
|
|
314
361
|
});
|
|
315
362
|
|
|
316
|
-
// Eagerly hydrate bylines for all entries
|
|
317
|
-
|
|
363
|
+
// Eagerly hydrate bylines and taxonomy terms for all entries in parallel.
|
|
364
|
+
// Both are independent queries, so running them concurrently halves the
|
|
365
|
+
// round-trip cost on remote databases (D1 replicas, etc.).
|
|
366
|
+
await Promise.all([
|
|
367
|
+
hydrateEntryBylines(type, entriesWithEdit),
|
|
368
|
+
hydrateEntryTerms(type, entriesWithEdit),
|
|
369
|
+
]);
|
|
318
370
|
|
|
319
371
|
return { entries: entriesWithEdit, nextCursor, cacheHint: cacheHint ?? {} };
|
|
320
372
|
}
|
|
@@ -386,12 +438,12 @@ export async function getEmDashEntry<T extends string, D = InferCollectionData<T
|
|
|
386
438
|
const localeChain =
|
|
387
439
|
requestedLocale && isI18nEnabled() ? getFallbackChain(requestedLocale) : [requestedLocale];
|
|
388
440
|
|
|
389
|
-
/** Return a successful EntryResult with bylines hydrated */
|
|
441
|
+
/** Return a successful EntryResult with bylines and taxonomy terms hydrated */
|
|
390
442
|
async function successResult(
|
|
391
443
|
wrapped: ContentEntry<D>,
|
|
392
444
|
opts: { isPreview: boolean; fallbackLocale?: string; cacheHint: CacheHint },
|
|
393
445
|
): Promise<EntryResult<D>> {
|
|
394
|
-
await hydrateEntryBylines(type, [wrapped]);
|
|
446
|
+
await Promise.all([hydrateEntryBylines(type, [wrapped]), hydrateEntryTerms(type, [wrapped])]);
|
|
395
447
|
return {
|
|
396
448
|
entry: wrapped,
|
|
397
449
|
isPreview: opts.isPreview,
|
|
@@ -525,14 +577,59 @@ async function hydrateEntryBylines<D>(type: string, entries: ContentEntry<D>[]):
|
|
|
525
577
|
data.byline = credits[0]?.byline ?? null;
|
|
526
578
|
}
|
|
527
579
|
} catch (err) {
|
|
528
|
-
// Only swallow "table not found" errors from pre-migration databases
|
|
529
|
-
|
|
530
|
-
|
|
580
|
+
// Only swallow "table not found" errors from pre-migration databases.
|
|
581
|
+
// Matches SQLite/D1 ("no such table") and PostgreSQL ("relation/table
|
|
582
|
+
// ... does not exist") via the shared helper.
|
|
583
|
+
if (!isMissingTableError(err)) {
|
|
584
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
531
585
|
console.warn("[emdash] Failed to hydrate bylines:", msg);
|
|
532
586
|
}
|
|
533
587
|
}
|
|
534
588
|
}
|
|
535
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Eagerly hydrate taxonomy term data onto entry.data for one or more entries.
|
|
592
|
+
*
|
|
593
|
+
* Attaches `terms` (Record keyed by taxonomy name with an array of TaxonomyTerm
|
|
594
|
+
* values) to each entry's data object. Uses a single batched JOIN query across
|
|
595
|
+
* all taxonomies so the cost is O(1) regardless of the number of entries or
|
|
596
|
+
* taxonomies on the site.
|
|
597
|
+
*
|
|
598
|
+
* This eliminates the common N+1 pattern where templates loop over list
|
|
599
|
+
* results and call getEntryTerms() per entry. With hydration, the list page
|
|
600
|
+
* stays at a single round-trip for term data.
|
|
601
|
+
*
|
|
602
|
+
* Fails silently if the taxonomy tables don't exist yet (pre-migration).
|
|
603
|
+
*/
|
|
604
|
+
async function hydrateEntryTerms<D>(type: string, entries: ContentEntry<D>[]): Promise<void> {
|
|
605
|
+
if (entries.length === 0) return;
|
|
606
|
+
|
|
607
|
+
try {
|
|
608
|
+
const { getAllTermsForEntries } = await import("./taxonomies/index.js");
|
|
609
|
+
|
|
610
|
+
const ids = entries.map((e) => dataStr(entryData(e), "id")).filter(Boolean);
|
|
611
|
+
if (ids.length === 0) return;
|
|
612
|
+
|
|
613
|
+
const termsMap = await getAllTermsForEntries(type, ids);
|
|
614
|
+
|
|
615
|
+
for (const entry of entries) {
|
|
616
|
+
const data = entryData(entry);
|
|
617
|
+
const dbId = dataStr(data, "id");
|
|
618
|
+
if (!dbId) continue;
|
|
619
|
+
|
|
620
|
+
data.terms = termsMap.get(dbId) ?? {};
|
|
621
|
+
}
|
|
622
|
+
} catch (err) {
|
|
623
|
+
// Only swallow "table not found" errors from pre-migration databases.
|
|
624
|
+
// Matches SQLite/D1 ("no such table") and PostgreSQL ("relation/table
|
|
625
|
+
// ... does not exist") via the shared helper.
|
|
626
|
+
if (!isMissingTableError(err)) {
|
|
627
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
628
|
+
console.warn("[emdash] Failed to hydrate terms:", msg);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
536
633
|
/**
|
|
537
634
|
* Translation summary for a single locale variant
|
|
538
635
|
*/
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request query cache
|
|
3
|
+
*
|
|
4
|
+
* Deduplicates identical database queries within a single page render.
|
|
5
|
+
* Uses the ALS request context as a WeakMap key so the cache is
|
|
6
|
+
* automatically GC'd when the request completes.
|
|
7
|
+
*
|
|
8
|
+
* When no request context is available (e.g. local dev without D1
|
|
9
|
+
* replicas), queries bypass the cache — local SQLite is fast enough
|
|
10
|
+
* that deduplication doesn't matter.
|
|
11
|
+
*
|
|
12
|
+
* The WeakMap is stored on globalThis with a Symbol key to guarantee
|
|
13
|
+
* a singleton even when bundlers duplicate this module across chunks
|
|
14
|
+
* (same pattern as request-context.ts).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { EmDashRequestContext } from "./request-context.js";
|
|
18
|
+
import { getRequestContext } from "./request-context.js";
|
|
19
|
+
|
|
20
|
+
type CacheStore = WeakMap<EmDashRequestContext, Map<string, Promise<unknown>>>;
|
|
21
|
+
|
|
22
|
+
const STORE_KEY = Symbol.for("emdash:request-cache");
|
|
23
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
24
|
+
const store: CacheStore =
|
|
25
|
+
(g[STORE_KEY] as CacheStore | undefined) ??
|
|
26
|
+
(() => {
|
|
27
|
+
const wm: CacheStore = new WeakMap();
|
|
28
|
+
g[STORE_KEY] = wm;
|
|
29
|
+
return wm;
|
|
30
|
+
})();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Return a cached result for `key` if one exists in the current
|
|
34
|
+
* request scope, otherwise call `fn`, cache its promise, and return it.
|
|
35
|
+
*
|
|
36
|
+
* Caches the *promise*, not the resolved value, so concurrent calls
|
|
37
|
+
* with the same key share a single in-flight query.
|
|
38
|
+
*/
|
|
39
|
+
export function requestCached<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
40
|
+
const ctx = getRequestContext();
|
|
41
|
+
if (!ctx) return fn();
|
|
42
|
+
|
|
43
|
+
let cache = store.get(ctx);
|
|
44
|
+
if (!cache) {
|
|
45
|
+
cache = new Map();
|
|
46
|
+
store.set(ctx, cache);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const existing = cache.get(key);
|
|
50
|
+
if (existing) return existing as Promise<T>;
|
|
51
|
+
|
|
52
|
+
const promise = Promise.resolve()
|
|
53
|
+
.then(fn)
|
|
54
|
+
.catch((error) => {
|
|
55
|
+
cache.delete(key);
|
|
56
|
+
throw error;
|
|
57
|
+
});
|
|
58
|
+
cache.set(key, promise);
|
|
59
|
+
return promise;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Look up an entry in the request-scoped cache without inserting one.
|
|
64
|
+
*
|
|
65
|
+
* Returns the in-flight or resolved promise if the key exists in the
|
|
66
|
+
* current request, otherwise `undefined`. Callers can use this to
|
|
67
|
+
* opportunistically satisfy a narrower query (e.g. `getSiteSetting("seo")`)
|
|
68
|
+
* from a broader one (`getSiteSettings()`) that's already been loaded
|
|
69
|
+
* by a parent template — avoiding a redundant round-trip.
|
|
70
|
+
*
|
|
71
|
+
* No-ops outside a request context.
|
|
72
|
+
*/
|
|
73
|
+
export function peekRequestCache<T>(key: string): Promise<T> | undefined {
|
|
74
|
+
const ctx = getRequestContext();
|
|
75
|
+
if (!ctx) return undefined;
|
|
76
|
+
const cache = store.get(ctx);
|
|
77
|
+
return cache?.get(key) as Promise<T> | undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pre-populate the request-scoped cache with a resolved value.
|
|
82
|
+
*
|
|
83
|
+
* Internal helper shared between hydration paths (taxonomy terms,
|
|
84
|
+
* bylines, etc.) that already have the data in hand and want downstream
|
|
85
|
+
* callers using `requestCached(key, ...)` to skip the database entirely.
|
|
86
|
+
* Not exported from the package entrypoint — keep it internal until we
|
|
87
|
+
* have a documented plugin/extension surface for hydration.
|
|
88
|
+
*
|
|
89
|
+
* No-ops outside a request context (local dev without ALS).
|
|
90
|
+
*
|
|
91
|
+
* Does not overwrite an existing entry — if a query for this key is already
|
|
92
|
+
* in flight, its promise wins.
|
|
93
|
+
*/
|
|
94
|
+
export function setRequestCacheEntry<T>(key: string, value: T): void {
|
|
95
|
+
const ctx = getRequestContext();
|
|
96
|
+
if (!ctx) return;
|
|
97
|
+
|
|
98
|
+
let cache = store.get(ctx);
|
|
99
|
+
if (!cache) {
|
|
100
|
+
cache = new Map();
|
|
101
|
+
store.set(ctx, cache);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (cache.has(key)) return;
|
|
105
|
+
cache.set(key, Promise.resolve(value));
|
|
106
|
+
}
|
package/src/request-context.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
|
|
18
18
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
19
19
|
|
|
20
|
+
import type { QueryRecorder } from "./database/instrumentation.js";
|
|
21
|
+
|
|
20
22
|
export interface EmDashRequestContext {
|
|
21
23
|
/** Whether the current request is in visual editing mode */
|
|
22
24
|
editMode: boolean;
|
|
@@ -35,6 +37,23 @@ export interface EmDashRequestContext {
|
|
|
35
37
|
* the singleton instance. Also used by the DO preview pattern.
|
|
36
38
|
*/
|
|
37
39
|
db?: unknown;
|
|
40
|
+
/**
|
|
41
|
+
* Indicates the per-request `db` points at an isolated database
|
|
42
|
+
* instance whose schema may diverge from the configured one
|
|
43
|
+
* (playground, DO preview sessions). When true, schema-derived caches
|
|
44
|
+
* (manifest, taxonomy defs, etc.) must not be reused across requests.
|
|
45
|
+
*
|
|
46
|
+
* Plain D1 Sessions API routing does NOT set this — sessions are just
|
|
47
|
+
* a routing hint over the same schema, so the module-scoped manifest
|
|
48
|
+
* cache remains valid.
|
|
49
|
+
*/
|
|
50
|
+
dbIsIsolated?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Query recorder attached by middleware when EMDASH_QUERY_LOG_FILE is set.
|
|
53
|
+
* The Kysely `log` hook appends an event per query; middleware flushes
|
|
54
|
+
* to NDJSON after the response.
|
|
55
|
+
*/
|
|
56
|
+
queryRecorder?: QueryRecorder;
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
const ALS_KEY = Symbol.for("emdash:request-context");
|
package/src/schema/query.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { Kysely } from "kysely";
|
|
|
8
8
|
|
|
9
9
|
import type { Database } from "../database/types.js";
|
|
10
10
|
import { getDb } from "../loader.js";
|
|
11
|
+
import { requestCached } from "../request-cache.js";
|
|
11
12
|
import { SchemaRegistry } from "./registry.js";
|
|
12
13
|
import type { Collection } from "./types.js";
|
|
13
14
|
|
|
@@ -25,8 +26,10 @@ import type { Collection } from "./types.js";
|
|
|
25
26
|
* ```
|
|
26
27
|
*/
|
|
27
28
|
export async function getCollectionInfo(slug: string): Promise<Collection | null> {
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
return requestCached(`collection-info:${slug}`, async () => {
|
|
30
|
+
const db = await getDb();
|
|
31
|
+
return getCollectionInfoWithDb(db, slug);
|
|
32
|
+
});
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
/**
|