emdash 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +9 -0
  2. package/dist/{apply-Bjfq_b4-.mjs → apply-kC39ev1Z.mjs} +4 -4
  3. package/dist/{apply-Bjfq_b4-.mjs.map → apply-kC39ev1Z.mjs.map} +1 -1
  4. package/dist/astro/index.d.mts +3 -3
  5. package/dist/astro/index.mjs +16 -1
  6. package/dist/astro/index.mjs.map +1 -1
  7. package/dist/astro/middleware/auth.d.mts +3 -3
  8. package/dist/astro/middleware/request-context.mjs +84 -22
  9. package/dist/astro/middleware/request-context.mjs.map +1 -1
  10. package/dist/astro/middleware.mjs +41 -12
  11. package/dist/astro/middleware.mjs.map +1 -1
  12. package/dist/astro/types.d.mts +5 -4
  13. package/dist/astro/types.d.mts.map +1 -1
  14. package/dist/cli/index.mjs +65 -6
  15. package/dist/cli/index.mjs.map +1 -1
  16. package/dist/db/index.mjs +1 -1
  17. package/dist/{index-C1xF3OGh.d.mts → index-CLBc4gw-.d.mts} +42 -11
  18. package/dist/{index-C1xF3OGh.d.mts.map → index-CLBc4gw-.d.mts.map} +1 -1
  19. package/dist/index.d.mts +5 -5
  20. package/dist/index.mjs +9 -9
  21. package/dist/{manifest-schema-Dcl0R6nM.mjs → manifest-schema-CL8DWO9b.mjs} +5 -2
  22. package/dist/manifest-schema-CL8DWO9b.mjs.map +1 -0
  23. package/dist/media/index.d.mts +1 -1
  24. package/dist/media/index.mjs +1 -1
  25. package/dist/media/local-runtime.d.mts +4 -4
  26. package/dist/page/index.d.mts +1 -1
  27. package/dist/{placeholder-CmGAmqeO.d.mts → placeholder-SvFCKbz_.d.mts} +10 -2
  28. package/dist/{placeholder-CmGAmqeO.d.mts.map → placeholder-SvFCKbz_.d.mts.map} +1 -1
  29. package/dist/{placeholder-SmpOx-_v.mjs → placeholder-aiCD8aSZ.mjs} +27 -2
  30. package/dist/placeholder-aiCD8aSZ.mjs.map +1 -0
  31. package/dist/plugins/adapt-sandbox-entry.d.mts +3 -3
  32. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  33. package/dist/{query-CS_iSj34.mjs → query-BVYN0PJ6.mjs} +2 -2
  34. package/dist/{query-CS_iSj34.mjs.map → query-BVYN0PJ6.mjs.map} +1 -1
  35. package/dist/{registry-D_w5HW4G.mjs → registry-BNYQKX_d.mjs} +23 -38
  36. package/dist/registry-BNYQKX_d.mjs.map +1 -0
  37. package/dist/{runner-C0hCbYnD.mjs → runner-BraqvGYk.mjs} +251 -158
  38. package/dist/runner-BraqvGYk.mjs.map +1 -0
  39. package/dist/runner-EAtf0ZIe.d.mts.map +1 -1
  40. package/dist/runtime.d.mts +4 -4
  41. package/dist/{search-DG603UrT.mjs → search-C1gg67nN.mjs} +125 -18
  42. package/dist/search-C1gg67nN.mjs.map +1 -0
  43. package/dist/seed/index.d.mts +1 -1
  44. package/dist/seed/index.mjs +3 -3
  45. package/dist/{types-DvhsUmSJ.d.mts → types-BQo5JS0J.d.mts} +15 -2
  46. package/dist/{types-DvhsUmSJ.d.mts.map → types-BQo5JS0J.d.mts.map} +1 -1
  47. package/dist/{types-DY5zk5HN.mjs → types-CiA5Gac0.mjs} +5 -3
  48. package/dist/types-CiA5Gac0.mjs.map +1 -0
  49. package/dist/{types-C4-fAxN3.d.mts → types-DPfzHnjW.d.mts} +13 -2
  50. package/dist/types-DPfzHnjW.d.mts.map +1 -0
  51. package/dist/{validate-CpBtVMsD.d.mts → validate-HtxZeaBi.d.mts} +2 -2
  52. package/dist/{validate-CpBtVMsD.d.mts.map → validate-HtxZeaBi.d.mts.map} +1 -1
  53. package/dist/{validate-O7PWmlnq.mjs → validate-_rsF-Dx_.mjs} +2 -2
  54. package/dist/{validate-O7PWmlnq.mjs.map → validate-_rsF-Dx_.mjs.map} +1 -1
  55. package/package.json +6 -4
  56. package/src/api/handlers/marketplace.ts +7 -4
  57. package/src/api/schemas/schema.ts +12 -0
  58. package/src/astro/integration/index.ts +17 -0
  59. package/src/astro/integration/runtime.ts +13 -0
  60. package/src/astro/integration/virtual-modules.ts +13 -1
  61. package/src/astro/routes/admin.astro +1 -1
  62. package/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts +3 -1
  63. package/src/astro/routes/api/auth/invite/complete.ts +2 -1
  64. package/src/astro/routes/api/auth/passkey/options.ts +2 -1
  65. package/src/astro/routes/api/auth/passkey/register/options.ts +2 -1
  66. package/src/astro/routes/api/auth/passkey/register/verify.ts +2 -1
  67. package/src/astro/routes/api/auth/passkey/verify.ts +2 -1
  68. package/src/astro/routes/api/auth/signup/complete.ts +2 -1
  69. package/src/astro/routes/api/import/wordpress/analyze.ts +24 -3
  70. package/src/astro/routes/api/import/wordpress/execute.ts +5 -1
  71. package/src/astro/routes/api/import/wordpress/prepare.ts +2 -2
  72. package/src/astro/routes/api/media.ts +16 -4
  73. package/src/astro/routes/api/search/index.ts +1 -5
  74. package/src/astro/routes/api/search/suggest.ts +1 -5
  75. package/src/astro/routes/api/setup/admin-verify.ts +2 -1
  76. package/src/astro/routes/api/setup/admin.ts +2 -1
  77. package/src/astro/types.ts +1 -0
  78. package/src/auth/passkey-config.ts +24 -3
  79. package/src/cli/commands/bundle-utils.ts +26 -0
  80. package/src/cli/commands/bundle.ts +15 -0
  81. package/src/cli/commands/content.ts +11 -1
  82. package/src/cli/commands/login.ts +2 -0
  83. package/src/cli/commands/media.ts +5 -1
  84. package/src/cli/commands/menu.ts +3 -1
  85. package/src/cli/commands/schema.ts +7 -1
  86. package/src/cli/commands/search-cmd.ts +2 -1
  87. package/src/cli/commands/taxonomy.ts +4 -1
  88. package/src/cli/output.ts +14 -0
  89. package/src/components/InlinePortableTextEditor.tsx +33 -3
  90. package/src/database/migrations/033_optimize_content_indexes.ts +113 -0
  91. package/src/database/migrations/runner.ts +40 -33
  92. package/src/database/repositories/comment.ts +32 -20
  93. package/src/emdash-runtime.ts +64 -2
  94. package/src/media/placeholder.ts +31 -0
  95. package/src/media/thumbnail.ts +32 -0
  96. package/src/plugins/hooks.ts +91 -0
  97. package/src/plugins/manager.ts +22 -0
  98. package/src/plugins/manifest-schema.ts +3 -0
  99. package/src/plugins/marketplace.ts +25 -12
  100. package/src/plugins/types.ts +24 -0
  101. package/src/schema/registry.ts +23 -27
  102. package/src/schema/types.ts +27 -1
  103. package/src/search/fts-manager.ts +1 -18
  104. package/src/visual-editing/toolbar.ts +84 -22
  105. package/dist/manifest-schema-Dcl0R6nM.mjs.map +0 -1
  106. package/dist/placeholder-SmpOx-_v.mjs.map +0 -1
  107. package/dist/registry-D_w5HW4G.mjs.map +0 -1
  108. package/dist/runner-C0hCbYnD.mjs.map +0 -1
  109. package/dist/search-DG603UrT.mjs.map +0 -1
  110. package/dist/types-C4-fAxN3.d.mts.map +0 -1
  111. package/dist/types-DY5zk5HN.mjs.map +0 -1
@@ -14,6 +14,8 @@ import type { MediaProviderDescriptor } from "../../media/types.js";
14
14
  import { defaultSeed } from "../../seed/default.js";
15
15
  import type { PluginDescriptor } from "./runtime.js";
16
16
 
17
+ const TS_SOURCE_EXT_RE = /^\.(ts|tsx|mts|cts|jsx)$/;
18
+
17
19
  /** Pattern to remove scoped package prefix from plugin ID */
18
20
  const SCOPED_PREFIX_PATTERN = /^@[^/]+\/plugin-/;
19
21
 
@@ -435,7 +437,17 @@ export const sandboxedPlugins = [];
435
437
 
436
438
  // Resolve the bundle to a file path using project's require context
437
439
  const filePath = resolveModulePathFromProject(bundleSpecifier, projectRoot);
438
- // Read the source code
440
+
441
+ const ext = filePath.slice(filePath.lastIndexOf("."));
442
+ if (TS_SOURCE_EXT_RE.test(ext)) {
443
+ throw new Error(
444
+ `Sandboxed plugin "${descriptor.id}" entrypoint "${bundleSpecifier}" resolves to ` +
445
+ `unbuilt source (${filePath}). Sandbox entries must be pre-built JavaScript. ` +
446
+ `Ensure the plugin's package.json exports point to built files (e.g. dist/*.mjs) ` +
447
+ `and run the plugin's build step before building the site.`,
448
+ );
449
+ }
450
+
439
451
  const code = readFileSync(filePath, "utf-8");
440
452
 
441
453
  // Create the plugin entry with embedded code and sandbox config
@@ -20,7 +20,7 @@ export const prerender = false;
20
20
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
21
21
  <link
22
22
  rel="icon"
23
- href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%92%AB%3C/text%3E%3C/svg%3E"
23
+ href="data:image/svg+xml,<svg width='75' height='75' viewBox='0 0 75 75' fill='none' xmlns='http://www.w3.org/2000/svg'> <g clip-path='url(%23clip0_50_99)'> <rect x='3' y='3' width='69' height='69' rx='10.518' stroke='url(%23paint0_linear_50_99)' stroke-width='6'/> <rect x='18' y='34' width='39.3661' height='6.56101' fill='url(%23paint1_linear_50_99)'/> </g> <defs> <linearGradient id='paint0_linear_50_99' x1='-42.9996' y1='124' x2='92.4233' y2='-41.7456' gradientUnits='userSpaceOnUse'> <stop stop-color='%230F006B'/> <stop offset='0.0833333' stop-color='%23281A81'/> <stop offset='0.166667' stop-color='%235D0C83'/> <stop offset='0.25' stop-color='%23911475'/> <stop offset='0.333333' stop-color='%23CE2F55'/> <stop offset='0.416667' stop-color='%23FF6633'/> <stop offset='0.5' stop-color='%23F6821F'/> <stop offset='0.583333' stop-color='%23FBAD41'/> <stop offset='0.666667' stop-color='%23FFCD89'/> <stop offset='0.75' stop-color='%23FFE9CB'/> <stop offset='0.833333' stop-color='%23FFF7EC'/> <stop offset='0.916667' stop-color='%23FFF8EE'/> <stop offset='1' stop-color='white'/> </linearGradient> <linearGradient id='paint1_linear_50_99' x1='91.4992' y1='27.4982' x2='28.1217' y2='54.1775' gradientUnits='userSpaceOnUse'> <stop stop-color='white'/> <stop offset='0.129253' stop-color='%23FFF8EE'/> <stop offset='0.617058' stop-color='%23FBAD41'/> <stop offset='0.848019' stop-color='%23F6821F'/> <stop offset='1' stop-color='%23FF6633'/> </linearGradient> <clipPath id='clip0_50_99'> <rect width='75' height='75' fill='white'/> </clipPath> </defs> </svg>"
24
24
  />
25
25
  <title>EmDash Admin</title>
26
26
  </head>
@@ -41,13 +41,15 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
41
41
  emdash.configuredPlugins.map((p: { id: string }) => p.id),
42
42
  );
43
43
 
44
+ const siteOrigin = new URL(request.url).origin;
45
+
44
46
  const result = await handleMarketplaceInstall(
45
47
  emdash.db,
46
48
  emdash.storage,
47
49
  emdash.getSandboxRunner(),
48
50
  emdash.config.marketplace,
49
51
  id,
50
- { version: body.version, configuredPluginIds },
52
+ { version: body.version, configuredPluginIds, siteOrigin },
51
53
  );
52
54
 
53
55
  if (!result.success) return unwrapResult(result);
@@ -22,6 +22,7 @@ import { OptionsRepository } from "#db/repositories/options.js";
22
22
 
23
23
  export const POST: APIRoute = async ({ request, locals, session }) => {
24
24
  const { emdash } = locals;
25
+ const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
25
26
 
26
27
  if (!emdash?.db) {
27
28
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -37,7 +38,7 @@ export const POST: APIRoute = async ({ request, locals, session }) => {
37
38
  const url = new URL(request.url);
38
39
  const options = new OptionsRepository(emdash.db);
39
40
  const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
40
- const passkeyConfig = getPasskeyConfig(url, siteName);
41
+ const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
41
42
 
42
43
  // Verify the passkey registration response
43
44
  const challengeStore = createChallengeStore(emdash.db);
@@ -23,6 +23,7 @@ import { OptionsRepository } from "#db/repositories/options.js";
23
23
 
24
24
  export const POST: APIRoute = async ({ request, locals }) => {
25
25
  const { emdash } = locals;
26
+ const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
26
27
 
27
28
  if (!emdash?.db) {
28
29
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -62,7 +63,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
62
63
  const url = new URL(request.url);
63
64
  const options = new OptionsRepository(emdash.db);
64
65
  const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
65
- const passkeyConfig = getPasskeyConfig(url, siteName);
66
+ const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
66
67
 
67
68
  // Generate authentication options
68
69
  const challengeStore = createChallengeStore(emdash.db);
@@ -22,6 +22,7 @@ const MAX_PASSKEYS = 10;
22
22
 
23
23
  export const POST: APIRoute = async ({ request, locals }) => {
24
24
  const { emdash, user } = locals;
25
+ const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
25
26
 
26
27
  if (!emdash?.db) {
27
28
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -52,7 +53,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
52
53
  const url = new URL(request.url);
53
54
  const optionsRepo = new OptionsRepository(emdash.db);
54
55
  const siteName = (await optionsRepo.get<string>("emdash:site_title")) ?? undefined;
55
- const passkeyConfig = getPasskeyConfig(url, siteName);
56
+ const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
56
57
 
57
58
  // Generate registration options
58
59
  const challengeStore = createChallengeStore(emdash.db);
@@ -31,6 +31,7 @@ interface PasskeyResponse {
31
31
 
32
32
  export const POST: APIRoute = async ({ request, locals }) => {
33
33
  const { emdash, user } = locals;
34
+ const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
34
35
 
35
36
  if (!emdash?.db) {
36
37
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -58,7 +59,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
58
59
  const url = new URL(request.url);
59
60
  const optionsRepo = new OptionsRepository(emdash.db);
60
61
  const siteName = (await optionsRepo.get<string>("emdash:site_title")) ?? undefined;
61
- const passkeyConfig = getPasskeyConfig(url, siteName);
62
+ const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
62
63
 
63
64
  // Verify the registration response
64
65
  const challengeStore = createChallengeStore(emdash.db);
@@ -20,6 +20,7 @@ import { OptionsRepository } from "#db/repositories/options.js";
20
20
 
21
21
  export const POST: APIRoute = async ({ request, locals, session }) => {
22
22
  const { emdash } = locals;
23
+ const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
23
24
 
24
25
  if (!emdash?.db) {
25
26
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -33,7 +34,7 @@ export const POST: APIRoute = async ({ request, locals, session }) => {
33
34
  const url = new URL(request.url);
34
35
  const options = new OptionsRepository(emdash.db);
35
36
  const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
36
- const passkeyConfig = getPasskeyConfig(url, siteName);
37
+ const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
37
38
 
38
39
  // Authenticate with passkey
39
40
  const adapter = createKyselyAdapter(emdash.db);
@@ -22,6 +22,7 @@ import { OptionsRepository } from "#db/repositories/options.js";
22
22
 
23
23
  export const POST: APIRoute = async ({ request, locals, session }) => {
24
24
  const { emdash } = locals;
25
+ const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
25
26
 
26
27
  if (!emdash?.db) {
27
28
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -37,7 +38,7 @@ export const POST: APIRoute = async ({ request, locals, session }) => {
37
38
  const url = new URL(request.url);
38
39
  const options = new OptionsRepository(emdash.db);
39
40
  const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
40
- const passkeyConfig = getPasskeyConfig(url, siteName);
41
+ const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
41
42
 
42
43
  // Verify the passkey registration response
43
44
  const challengeStore = createChallengeStore(emdash.db);
@@ -13,11 +13,14 @@ import mime from "mime/lite";
13
13
 
14
14
  import { requirePerm } from "#api/authorize.js";
15
15
  import { apiError, apiSuccess, handleError } from "#api/error.js";
16
+ import { RESERVED_COLLECTION_SLUGS } from "#schema/types.js";
16
17
  import type { EmDashHandlers } from "#types";
17
18
 
18
19
  export const prerender = false;
19
20
 
20
21
  const NUMERIC_PATTERN = /^-?\d+(\.\d+)?$/;
22
+ const INVALID_SLUG_CHARS = /[^a-z0-9_]/g;
23
+ const LEADING_NON_ALPHA = /^[^a-z]+/;
21
24
 
22
25
  /** Field compatibility status */
23
26
  export type FieldCompatibility =
@@ -252,10 +255,18 @@ function analyzeWxr(
252
255
  .toSorted((a, b) => b.count - a.count);
253
256
 
254
257
  // Build post type analysis with schema compatibility
258
+ const seenSlugs = new Map<string, number>();
255
259
  const postTypes: PostTypeAnalysis[] = [...postTypeCounts.entries()]
256
260
  .filter(([type]) => !isInternalPostType(type))
257
261
  .map(([name, count]) => {
258
- const suggestedCollection = mapPostTypeToCollection(name);
262
+ let suggestedCollection = mapPostTypeToCollection(name);
263
+
264
+ // Deduplicate: if multiple post types produce the same slug, append a suffix
265
+ const seen = seenSlugs.get(suggestedCollection) ?? 0;
266
+ seenSlugs.set(suggestedCollection, seen + 1);
267
+ if (seen > 0) {
268
+ suggestedCollection = `${suggestedCollection}_${seen}`;
269
+ }
259
270
  const existingCollection = existingCollections.get(suggestedCollection);
260
271
 
261
272
  // Build required fields - add featured_image only if posts have thumbnails
@@ -445,6 +456,16 @@ function isInternalMetaKey(key: string): boolean {
445
456
  return false;
446
457
  }
447
458
 
459
+ function sanitizeSlug(slug: string): string {
460
+ const sanitized = slug
461
+ .toLowerCase()
462
+ .replace(INVALID_SLUG_CHARS, "_")
463
+ .replace(LEADING_NON_ALPHA, "");
464
+ if (!sanitized) return "imported";
465
+ if (RESERVED_COLLECTION_SLUGS.includes(sanitized)) return `wp_${sanitized}`;
466
+ return sanitized;
467
+ }
468
+
448
469
  function mapPostTypeToCollection(postType: string): string {
449
470
  const mapping: Record<string, string> = {
450
471
  post: "posts",
@@ -457,7 +478,7 @@ function mapPostTypeToCollection(postType: string): string {
457
478
  event: "events",
458
479
  faq: "faqs",
459
480
  };
460
- return mapping[postType] || postType;
481
+ return mapping[postType] || sanitizeSlug(postType);
461
482
  }
462
483
 
463
484
  function mapMetaKeyToField(key: string): string {
@@ -507,4 +528,4 @@ function singularize(str: string): string {
507
528
  }
508
529
 
509
530
  // Export helpers for use in prepare endpoint
510
- export { capitalize, singularize, mapPostTypeToCollection };
531
+ export { capitalize, sanitizeSlug, singularize, mapPostTypeToCollection };
@@ -22,6 +22,8 @@ import { resolveImportByline } from "#import/utils.js";
22
22
  import type { EmDashHandlers, EmDashManifest } from "#types";
23
23
  import { slugify } from "#utils/slugify.js";
24
24
 
25
+ import { sanitizeSlug } from "./analyze.js";
26
+
25
27
  export const prerender = false;
26
28
 
27
29
  export interface ImportConfig {
@@ -165,7 +167,9 @@ async function importContent(
165
167
  continue;
166
168
  }
167
169
 
168
- const collection = mapping.collection;
170
+ // Defensive: mapping.collection is already sanitized by prepare, but the user
171
+ // could manually edit the import config between prepare and execute.
172
+ const collection = sanitizeSlug(mapping.collection);
169
173
 
170
174
  // Check if collection exists in manifest
171
175
  if (!manifest?.collections[collection]) {
@@ -16,7 +16,7 @@ import { wpPrepareBody } from "#api/schemas.js";
16
16
  import { FIELD_TYPES, type FieldType } from "#schema/types.js";
17
17
  import type { EmDashHandlers } from "#types";
18
18
 
19
- import { capitalize, singularize, type ImportFieldDef } from "./analyze.js";
19
+ import { capitalize, sanitizeSlug, singularize, type ImportFieldDef } from "./analyze.js";
20
20
 
21
21
  /** Validate that a string is a known FieldType, returning undefined if not */
22
22
  function asFieldType(value: string): FieldType | undefined {
@@ -79,7 +79,7 @@ async function prepareImport(
79
79
  };
80
80
 
81
81
  for (const postType of request.postTypes) {
82
- const collectionSlug = postType.collection;
82
+ const collectionSlug = sanitizeSlug(postType.collection);
83
83
 
84
84
  try {
85
85
  // Check if collection exists
@@ -151,10 +151,22 @@ export const POST: APIRoute = async ({ request, locals }) => {
151
151
  const width = widthStr ? parseInt(widthStr, 10) : undefined;
152
152
  const height = heightStr ? parseInt(heightStr, 10) : undefined;
153
153
 
154
- // Generate placeholder data for images
155
- const placeholder = file.type.startsWith("image/")
156
- ? await generatePlaceholder(buffer, file.type)
157
- : null;
154
+ // Generate placeholder data for images.
155
+ // If the client sent a thumbnail (small pre-resized image), use that
156
+ // instead of the full buffer to avoid OOM on memory-constrained runtimes.
157
+ const thumbnailEntry = formData.get("thumbnail");
158
+ const thumbnail = thumbnailEntry instanceof File ? thumbnailEntry : null;
159
+
160
+ let placeholder: Awaited<ReturnType<typeof generatePlaceholder>> = null;
161
+ if (file.type.startsWith("image/")) {
162
+ if (thumbnail) {
163
+ const thumbBuffer = new Uint8Array(await thumbnail.arrayBuffer());
164
+ placeholder = await generatePlaceholder(thumbBuffer, thumbnail.type);
165
+ } else {
166
+ const clientDims = width && height ? { width, height } : undefined;
167
+ placeholder = await generatePlaceholder(buffer, file.type, clientDims);
168
+ }
169
+ }
158
170
 
159
171
  // Create media record
160
172
  const result = await emdash.handleMediaCreate({
@@ -6,7 +6,6 @@
6
6
 
7
7
  import type { APIRoute } from "astro";
8
8
 
9
- import { requirePerm } from "#api/authorize.js";
10
9
  import { apiError, apiSuccess, handleError } from "#api/error.js";
11
10
  import { isParseError, parseQuery } from "#api/parse.js";
12
11
  import { searchQuery } from "#api/schemas.js";
@@ -24,10 +23,7 @@ export const prerender = false;
24
23
  * - limit: Maximum results (optional, defaults to 20)
25
24
  */
26
25
  export const GET: APIRoute = async ({ url, locals }) => {
27
- const { emdash, user } = locals;
28
-
29
- const denied = requirePerm(user, "search:read");
30
- if (denied) return denied;
26
+ const { emdash } = locals;
31
27
 
32
28
  if (!emdash?.db) {
33
29
  return apiError("NOT_CONFIGURED", "EmDash not configured", 500);
@@ -6,7 +6,6 @@
6
6
 
7
7
  import type { APIRoute } from "astro";
8
8
 
9
- import { requirePerm } from "#api/authorize.js";
10
9
  import { apiError, apiSuccess, handleError } from "#api/error.js";
11
10
  import { isParseError, parseQuery } from "#api/parse.js";
12
11
  import { searchSuggestQuery } from "#api/schemas.js";
@@ -23,10 +22,7 @@ export const prerender = false;
23
22
  * - limit: Maximum suggestions (optional, defaults to 5)
24
23
  */
25
24
  export const GET: APIRoute = async ({ url, locals }) => {
26
- const { emdash, user } = locals;
27
-
28
- const denied = requirePerm(user, "search:read");
29
- if (denied) return denied;
25
+ const { emdash } = locals;
30
26
 
31
27
  if (!emdash?.db) {
32
28
  return apiError("NOT_CONFIGURED", "EmDash not configured", 500);
@@ -21,6 +21,7 @@ import { OptionsRepository } from "#db/repositories/options.js";
21
21
 
22
22
  export const POST: APIRoute = async ({ request, locals }) => {
23
23
  const { emdash } = locals;
24
+ const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
24
25
 
25
26
  if (!emdash?.db) {
26
27
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -57,7 +58,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
57
58
  // Get passkey config
58
59
  const url = new URL(request.url);
59
60
  const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
60
- const passkeyConfig = getPasskeyConfig(url, siteName);
61
+ const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
61
62
 
62
63
  // Verify the registration response
63
64
  const challengeStore = createChallengeStore(emdash.db);
@@ -20,6 +20,7 @@ import { OptionsRepository } from "#db/repositories/options.js";
20
20
 
21
21
  export const POST: APIRoute = async ({ request, locals }) => {
22
22
  const { emdash } = locals;
23
+ const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
23
24
 
24
25
  if (!emdash?.db) {
25
26
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -56,7 +57,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
56
57
  // Get passkey config
57
58
  const url = new URL(request.url);
58
59
  const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
59
- const passkeyConfig = getPasskeyConfig(url, siteName);
60
+ const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
60
61
 
61
62
  // Generate registration options
62
63
  const challengeStore = createChallengeStore(emdash.db);
@@ -28,6 +28,7 @@ export interface ManifestCollection {
28
28
  labelSingular: string;
29
29
  supports: string[];
30
30
  hasSeo: boolean;
31
+ urlPattern?: string;
31
32
  fields: Record<
32
33
  string,
33
34
  {
@@ -15,10 +15,31 @@ export interface PasskeyConfig {
15
15
  /**
16
16
  * Get passkey configuration from request URL
17
17
  *
18
- * @param url The request URL
19
- * @param siteName Optional site name for rpName (defaults to hostname)
18
+ * @param url The request URL (typically `new URL(Astro.request.url)` or `new URL(request.url)`)
19
+ * @param siteName Optional site name for rpName (defaults to hostname from `url` or public origin)
20
+ * @param passkeyPublicOrigin Optional browser-facing origin (see `EmDashConfig.passkeyPublicOrigin`).
21
+ * When set, **origin** and **rpId** are taken from this URL so they match WebAuthn `clientData.origin`.
22
+ * @throws If `passkeyPublicOrigin` is non-empty but not parseable by `new URL()`.
20
23
  */
21
- export function getPasskeyConfig(url: URL, siteName?: string): PasskeyConfig {
24
+ export function getPasskeyConfig(
25
+ url: URL,
26
+ siteName?: string,
27
+ passkeyPublicOrigin?: string,
28
+ ): PasskeyConfig {
29
+ if (passkeyPublicOrigin) {
30
+ let publicUrl: URL;
31
+ try {
32
+ publicUrl = new URL(passkeyPublicOrigin);
33
+ } catch (e) {
34
+ throw new Error(`Invalid passkeyPublicOrigin: "${passkeyPublicOrigin}"`, { cause: e });
35
+ }
36
+ return {
37
+ rpName: siteName || publicUrl.hostname,
38
+ rpId: publicUrl.hostname,
39
+ origin: publicUrl.origin,
40
+ };
41
+ }
42
+
22
43
  return {
23
44
  rpName: siteName || url.hostname,
24
45
  rpId: url.hostname,
@@ -213,6 +213,32 @@ export async function resolveSourceEntry(
213
213
  return undefined;
214
214
  }
215
215
 
216
+ // ── Export validation ───────────────────────────────────────────────────────
217
+
218
+ const TS_SOURCE_EXPORT_RE = /\.(?:ts|tsx|mts|cts|jsx)$/;
219
+
220
+ /**
221
+ * Find package.json exports that point to source files instead of built output.
222
+ * Returns an array of `{ exportPath, resolvedPath }` for each offending export.
223
+ */
224
+ export function findSourceExports(
225
+ exports: Record<string, unknown>,
226
+ ): Array<{ exportPath: string; resolvedPath: string }> {
227
+ const issues: Array<{ exportPath: string; resolvedPath: string }> = [];
228
+ for (const [exportPath, exportValue] of Object.entries(exports)) {
229
+ const resolved =
230
+ typeof exportValue === "string"
231
+ ? exportValue
232
+ : exportValue && typeof exportValue === "object" && "import" in exportValue
233
+ ? (exportValue as { import: string }).import
234
+ : null;
235
+ if (resolved && TS_SOURCE_EXPORT_RE.test(resolved)) {
236
+ issues.push({ exportPath, resolvedPath: resolved });
237
+ }
238
+ }
239
+ return issues;
240
+ }
241
+
216
242
  // ── Directory helpers ────────────────────────────────────────────────────────
217
243
 
218
244
  /**
@@ -27,6 +27,7 @@ import {
27
27
  extractManifest,
28
28
  findNodeBuiltinImports,
29
29
  findBuildOutput,
30
+ findSourceExports,
30
31
  resolveSourceEntry,
31
32
  calculateDirectorySize,
32
33
  createTarball,
@@ -495,6 +496,20 @@ export const bundleCommand = defineCommand({
495
496
  consola.start("Validating bundle...");
496
497
  let hasErrors = false;
497
498
 
499
+ // Check that package.json exports point to built files, not source.
500
+ // Plugins published to npm with source exports will break site builds
501
+ // because the sandbox module generator embeds the resolved file as-is.
502
+ if (pkg.exports) {
503
+ for (const issue of findSourceExports(pkg.exports)) {
504
+ consola.error(
505
+ `Export "${issue.exportPath}" points to source (${issue.resolvedPath}). ` +
506
+ `Package exports must point to built files (e.g. dist/*.mjs). ` +
507
+ `Add a build step and update the exports map.`,
508
+ );
509
+ hasErrors = true;
510
+ }
511
+ }
512
+
498
513
  // Check for Node.js builtins in backend.js
499
514
  const backendPath = join(bundleDir, "backend.js");
500
515
  if (await fileExists(backendPath)) {
@@ -10,7 +10,7 @@ import { defineCommand } from "citty";
10
10
  import { consola } from "consola";
11
11
 
12
12
  import { connectionArgs, createClientFromArgs } from "../client-factory.js";
13
- import { output } from "../output.js";
13
+ import { configureOutputMode, output } from "../output.js";
14
14
 
15
15
  // ---------------------------------------------------------------------------
16
16
  // Helpers
@@ -77,6 +77,7 @@ const listCommand = defineCommand({
77
77
  ...connectionArgs,
78
78
  },
79
79
  async run({ args }) {
80
+ configureOutputMode(args);
80
81
  try {
81
82
  const client = createClientFromArgs(args);
82
83
  const result = await client.list(args.collection, {
@@ -130,6 +131,7 @@ const getCommand = defineCommand({
130
131
  ...connectionArgs,
131
132
  },
132
133
  async run({ args }) {
134
+ configureOutputMode(args);
133
135
  try {
134
136
  const client = createClientFromArgs(args);
135
137
  const item = await client.get(args.collection, args.id, {
@@ -177,6 +179,7 @@ const createCommand = defineCommand({
177
179
  ...connectionArgs,
178
180
  },
179
181
  async run({ args }) {
182
+ configureOutputMode(args);
180
183
  try {
181
184
  const data = await readInputData(args);
182
185
  const client = createClientFromArgs(args);
@@ -229,6 +232,7 @@ const updateCommand = defineCommand({
229
232
  ...connectionArgs,
230
233
  },
231
234
  async run({ args }) {
235
+ configureOutputMode(args);
232
236
  try {
233
237
  const data = await readInputData(args);
234
238
  const client = createClientFromArgs(args);
@@ -270,6 +274,7 @@ const deleteCommand = defineCommand({
270
274
  ...connectionArgs,
271
275
  },
272
276
  async run({ args }) {
277
+ configureOutputMode(args);
273
278
  try {
274
279
  const client = createClientFromArgs(args);
275
280
  await client.delete(args.collection, args.id);
@@ -297,6 +302,7 @@ const publishCommand = defineCommand({
297
302
  ...connectionArgs,
298
303
  },
299
304
  async run({ args }) {
305
+ configureOutputMode(args);
300
306
  try {
301
307
  const client = createClientFromArgs(args);
302
308
  await client.publish(args.collection, args.id);
@@ -324,6 +330,7 @@ const unpublishCommand = defineCommand({
324
330
  ...connectionArgs,
325
331
  },
326
332
  async run({ args }) {
333
+ configureOutputMode(args);
327
334
  try {
328
335
  const client = createClientFromArgs(args);
329
336
  await client.unpublish(args.collection, args.id);
@@ -356,6 +363,7 @@ const scheduleCommand = defineCommand({
356
363
  ...connectionArgs,
357
364
  },
358
365
  async run({ args }) {
366
+ configureOutputMode(args);
359
367
  try {
360
368
  const client = createClientFromArgs(args);
361
369
  await client.schedule(args.collection, args.id, { at: args.at });
@@ -383,6 +391,7 @@ const restoreCommand = defineCommand({
383
391
  ...connectionArgs,
384
392
  },
385
393
  async run({ args }) {
394
+ configureOutputMode(args);
386
395
  try {
387
396
  const client = createClientFromArgs(args);
388
397
  await client.restore(args.collection, args.id);
@@ -410,6 +419,7 @@ const translationsCommand = defineCommand({
410
419
  ...connectionArgs,
411
420
  },
412
421
  async run({ args }) {
422
+ configureOutputMode(args);
413
423
  try {
414
424
  const client = createClientFromArgs(args);
415
425
  const translations = await client.translations(args.collection, args.id);
@@ -29,6 +29,7 @@ import {
29
29
  resolveCredentialKey,
30
30
  saveCredentials,
31
31
  } from "../credentials.js";
32
+ import { configureOutputMode } from "../output.js";
32
33
 
33
34
  // ---------------------------------------------------------------------------
34
35
  // Types for discovery + device flow responses
@@ -423,6 +424,7 @@ export const whoamiCommand = defineCommand({
423
424
  },
424
425
  },
425
426
  async run({ args }) {
427
+ configureOutputMode(args);
426
428
  const baseUrl = args.url || "http://localhost:4321";
427
429
 
428
430
  // Resolve token: --token flag > EMDASH_TOKEN env > stored credentials
@@ -11,7 +11,7 @@ import { defineCommand } from "citty";
11
11
  import { consola } from "consola";
12
12
 
13
13
  import { connectionArgs, createClientFromArgs } from "../client-factory.js";
14
- import { output } from "../output.js";
14
+ import { configureOutputMode, output } from "../output.js";
15
15
 
16
16
  const listCommand = defineCommand({
17
17
  meta: {
@@ -34,6 +34,7 @@ const listCommand = defineCommand({
34
34
  },
35
35
  },
36
36
  async run({ args }) {
37
+ configureOutputMode(args);
37
38
  const client = createClientFromArgs(args);
38
39
 
39
40
  try {
@@ -73,6 +74,7 @@ const uploadCommand = defineCommand({
73
74
  },
74
75
  },
75
76
  async run({ args }) {
77
+ configureOutputMode(args);
76
78
  const client = createClientFromArgs(args);
77
79
  const filename = basename(args.file);
78
80
 
@@ -108,6 +110,7 @@ const getCommand = defineCommand({
108
110
  ...connectionArgs,
109
111
  },
110
112
  async run({ args }) {
113
+ configureOutputMode(args);
111
114
  const client = createClientFromArgs(args);
112
115
 
113
116
  try {
@@ -134,6 +137,7 @@ const deleteCommand = defineCommand({
134
137
  ...connectionArgs,
135
138
  },
136
139
  async run({ args }) {
140
+ configureOutputMode(args);
137
141
  const client = createClientFromArgs(args);
138
142
 
139
143
  try {
@@ -8,7 +8,7 @@ import { defineCommand } from "citty";
8
8
  import { consola } from "consola";
9
9
 
10
10
  import { connectionArgs, createClientFromArgs } from "../client-factory.js";
11
- import { output } from "../output.js";
11
+ import { configureOutputMode, output } from "../output.js";
12
12
 
13
13
  const listCommand = defineCommand({
14
14
  meta: {
@@ -19,6 +19,7 @@ const listCommand = defineCommand({
19
19
  ...connectionArgs,
20
20
  },
21
21
  async run({ args }) {
22
+ configureOutputMode(args);
22
23
  try {
23
24
  const client = createClientFromArgs(args);
24
25
  const menus = await client.menus();
@@ -44,6 +45,7 @@ const getCommand = defineCommand({
44
45
  ...connectionArgs,
45
46
  },
46
47
  async run({ args }) {
48
+ configureOutputMode(args);
47
49
  try {
48
50
  const client = createClientFromArgs(args);
49
51
  const menu = await client.menu(args.name);