@actuate-media/cms-core 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/__tests__/api/api-key-auth.test.js +46 -9
  2. package/dist/__tests__/api/api-key-auth.test.js.map +1 -1
  3. package/dist/__tests__/api/public-seo.test.d.ts +2 -0
  4. package/dist/__tests__/api/public-seo.test.d.ts.map +1 -0
  5. package/dist/__tests__/api/public-seo.test.js +341 -0
  6. package/dist/__tests__/api/public-seo.test.js.map +1 -0
  7. package/dist/__tests__/collections/presets.test.d.ts +2 -0
  8. package/dist/__tests__/collections/presets.test.d.ts.map +1 -0
  9. package/dist/__tests__/collections/presets.test.js +141 -0
  10. package/dist/__tests__/collections/presets.test.js.map +1 -0
  11. package/dist/__tests__/fields/presets.test.d.ts +2 -0
  12. package/dist/__tests__/fields/presets.test.d.ts.map +1 -0
  13. package/dist/__tests__/fields/presets.test.js +99 -0
  14. package/dist/__tests__/fields/presets.test.js.map +1 -0
  15. package/dist/__tests__/security/api-key-enhanced.test.js.map +1 -1
  16. package/dist/__tests__/seo/page-meta.test.d.ts +2 -0
  17. package/dist/__tests__/seo/page-meta.test.d.ts.map +1 -0
  18. package/dist/__tests__/seo/page-meta.test.js +204 -0
  19. package/dist/__tests__/seo/page-meta.test.js.map +1 -0
  20. package/dist/api/handler-factory.d.ts.map +1 -1
  21. package/dist/api/handler-factory.js +2 -1
  22. package/dist/api/handler-factory.js.map +1 -1
  23. package/dist/api/handlers.d.ts.map +1 -1
  24. package/dist/api/handlers.js +370 -2
  25. package/dist/api/handlers.js.map +1 -1
  26. package/dist/collections/index.d.ts +2 -0
  27. package/dist/collections/index.d.ts.map +1 -1
  28. package/dist/collections/index.js +1 -0
  29. package/dist/collections/index.js.map +1 -1
  30. package/dist/collections/presets.d.ts +71 -0
  31. package/dist/collections/presets.d.ts.map +1 -0
  32. package/dist/collections/presets.js +504 -0
  33. package/dist/collections/presets.js.map +1 -0
  34. package/dist/config/types.d.ts +75 -0
  35. package/dist/config/types.d.ts.map +1 -1
  36. package/dist/fields/index.d.ts +1 -0
  37. package/dist/fields/index.d.ts.map +1 -1
  38. package/dist/fields/index.js +1 -0
  39. package/dist/fields/index.js.map +1 -1
  40. package/dist/fields/presets.d.ts +90 -0
  41. package/dist/fields/presets.d.ts.map +1 -0
  42. package/dist/fields/presets.js +253 -0
  43. package/dist/fields/presets.js.map +1 -0
  44. package/dist/index.d.ts +3 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/page-builder/schema.d.ts +8 -8
  49. package/dist/page-builder/templates.d.ts.map +1 -1
  50. package/dist/page-builder/templates.js +312 -0
  51. package/dist/page-builder/templates.js.map +1 -1
  52. package/dist/seo/index.d.ts +2 -0
  53. package/dist/seo/index.d.ts.map +1 -1
  54. package/dist/seo/index.js +1 -0
  55. package/dist/seo/index.js.map +1 -1
  56. package/dist/seo/page-meta.d.ts +79 -0
  57. package/dist/seo/page-meta.d.ts.map +1 -0
  58. package/dist/seo/page-meta.js +209 -0
  59. package/dist/seo/page-meta.js.map +1 -0
  60. package/package.json +1 -1
@@ -177,6 +177,79 @@ function modelNotAvailable(name) {
177
177
  'Run `actuate db:init` for new schemas, or carefully update the existing Actuate block, create/apply a Prisma migration, then regenerate Prisma Client. ' +
178
178
  'See https://actuatecms.dev/docs/database-setup for required models.', 501);
179
179
  }
180
+ /**
181
+ * XML-escape a value for safe inclusion in sitemap content. Sitemaps reject
182
+ * unescaped ampersands and angle brackets; URLs frequently contain `&` query
183
+ * separators, so this is non-optional.
184
+ */
185
+ function escapeXml(value) {
186
+ return value
187
+ .replace(/&/g, '&')
188
+ .replace(/</g, '&lt;')
189
+ .replace(/>/g, '&gt;')
190
+ .replace(/"/g, '&quot;')
191
+ .replace(/'/g, '&apos;');
192
+ }
193
+ /**
194
+ * Renders a 1200x630 SVG suitable for og:image. We don't pull in Satori/resvg
195
+ * because most integrators don't need PNG — major crawlers (Facebook, Twitter,
196
+ * LinkedIn, Slack, Discord) handle image/svg+xml correctly. Sites that
197
+ * specifically need PNG can override with their own /og endpoint via
198
+ * @vercel/og and point `seo.defaultOgImage` at it.
199
+ */
200
+ function renderOgSvg(opts) {
201
+ const { title, description, siteName, bg, fg, muted } = opts;
202
+ // Naive line wrapping at ~22 chars (large font). Good enough for an OG card;
203
+ // anything longer than ~3 lines gets truncated with an ellipsis.
204
+ const wrap = (text, maxChars, maxLines) => {
205
+ const words = text.split(/\s+/);
206
+ const lines = [];
207
+ let current = '';
208
+ for (const w of words) {
209
+ if (lines.length >= maxLines)
210
+ break;
211
+ const next = current ? `${current} ${w}` : w;
212
+ if (next.length > maxChars) {
213
+ if (current)
214
+ lines.push(current);
215
+ current = w;
216
+ if (lines.length === maxLines - 1 && words.indexOf(w) < words.length - 1) {
217
+ lines.push(current.length > maxChars ? current.slice(0, maxChars - 1) + '…' : current + '…');
218
+ current = '';
219
+ break;
220
+ }
221
+ }
222
+ else {
223
+ current = next;
224
+ }
225
+ }
226
+ if (current && lines.length < maxLines)
227
+ lines.push(current);
228
+ return lines;
229
+ };
230
+ const escapeSvg = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
231
+ const titleLines = wrap(title, 28, 3);
232
+ const descLines = description ? wrap(description, 60, 2) : [];
233
+ const titleEls = titleLines
234
+ .map((line, i) => `<tspan x="60" dy="${i === 0 ? 0 : 78}">${escapeSvg(line)}</tspan>`)
235
+ .join('');
236
+ const descEls = descLines
237
+ .map((line, i) => `<tspan x="60" dy="${i === 0 ? 0 : 36}">${escapeSvg(line)}</tspan>`)
238
+ .join('');
239
+ // Layout: site name top-left, title bottom-left, description below title.
240
+ // Coordinates are roughly aligned to the 1200x630 spec used by every major
241
+ // social platform.
242
+ const titleY = descLines.length > 0 ? 360 : 420;
243
+ const descY = titleY + 80 * titleLines.length;
244
+ return `<?xml version="1.0" encoding="UTF-8"?>
245
+ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
246
+ <rect width="1200" height="630" fill="${bg}"/>
247
+ ${siteName ? `<text x="60" y="100" font-family="system-ui, -apple-system, Segoe UI, Arial, sans-serif" font-size="28" font-weight="500" fill="${muted}">${escapeSvg(siteName)}</text>` : ''}
248
+ <text x="60" y="${titleY}" font-family="system-ui, -apple-system, Segoe UI, Arial, sans-serif" font-size="68" font-weight="700" fill="${fg}">${titleEls}</text>
249
+ ${descLines.length > 0 ? `<text x="60" y="${descY}" font-family="system-ui, -apple-system, Segoe UI, Arial, sans-serif" font-size="30" fill="${muted}">${descEls}</text>` : ''}
250
+ <rect x="60" y="560" width="60" height="6" fill="${fg}" rx="3"/>
251
+ </svg>`;
252
+ }
180
253
  async function safeCount(model, where) {
181
254
  try {
182
255
  if (!model || typeof model !== 'object')
@@ -313,7 +386,7 @@ async function extractSession(request) {
313
386
  const ipRestrictions = Array.isArray(apiKey.ipRestrictions)
314
387
  ? apiKey.ipRestrictions
315
388
  : apiKey.ipRestrictions
316
- ? apiKey.ipRestrictions.allow ?? null
389
+ ? (apiKey.ipRestrictions.allow ?? null)
317
390
  : null;
318
391
  if (ipRestrictions && ipRestrictions.length > 0) {
319
392
  const ip = getClientIp(request);
@@ -3118,6 +3191,237 @@ export function registerCMSRoutes(router) {
3118
3191
  return internalError(err, 'llms.txt');
3119
3192
  }
3120
3193
  });
3194
+ // ---------------------------------------------------------------------------
3195
+ // Public SEO surfaces: sitemap.xml, per-collection sitemaps, robots.txt,
3196
+ // and the dynamic /og.png OG-image endpoint. These are unauthenticated by
3197
+ // design — search engines and social crawlers will fetch them.
3198
+ // ---------------------------------------------------------------------------
3199
+ function siteUrlFromRequest(request) {
3200
+ const cfg = getActuateConfig()?.seo;
3201
+ if (cfg?.siteUrl)
3202
+ return cfg.siteUrl.replace(/\/+$/, '');
3203
+ // Fall back to the request origin so the routes work on preview deploys
3204
+ // before the integrator has configured siteUrl.
3205
+ try {
3206
+ const u = new URL(request.url);
3207
+ return `${u.protocol}//${u.host}`;
3208
+ }
3209
+ catch {
3210
+ return '';
3211
+ }
3212
+ }
3213
+ function sitemapEligibleCollections() {
3214
+ const cfg = getActuateConfig();
3215
+ if (!cfg)
3216
+ return [];
3217
+ const excluded = new Set(cfg.seo?.sitemap?.excludeCollections ?? []);
3218
+ const out = [];
3219
+ for (const [slug, col] of Object.entries(cfg.collections ?? {})) {
3220
+ if (excluded.has(slug))
3221
+ continue;
3222
+ if (col.seo?.excludeFromSitemap)
3223
+ continue;
3224
+ out.push({ slug, urlPrefix: col.urlPrefix, type: col.type, seo: col.seo });
3225
+ }
3226
+ return out;
3227
+ }
3228
+ router.get('/sitemap.xml', async (request) => {
3229
+ try {
3230
+ const cfg = getActuateConfig();
3231
+ if (cfg?.seo?.sitemap?.disabled)
3232
+ return errorResponse('Sitemap disabled', 404);
3233
+ const base = siteUrlFromRequest(request);
3234
+ const cols = sitemapEligibleCollections();
3235
+ // Sitemap index points at /sitemaps/:slug.xml for each collection.
3236
+ const sitemaps = cols
3237
+ .map((c) => [
3238
+ ' <sitemap>',
3239
+ ` <loc>${base}/api/cms/sitemaps/${c.slug}.xml</loc>`,
3240
+ ` <lastmod>${new Date().toISOString()}</lastmod>`,
3241
+ ' </sitemap>',
3242
+ ].join('\n'))
3243
+ .join('\n');
3244
+ const xml = [
3245
+ '<?xml version="1.0" encoding="UTF-8"?>',
3246
+ '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
3247
+ sitemaps,
3248
+ '</sitemapindex>',
3249
+ ].join('\n');
3250
+ return new Response(xml, {
3251
+ status: 200,
3252
+ headers: {
3253
+ 'Content-Type': 'application/xml; charset=utf-8',
3254
+ 'Cache-Control': 'public, max-age=300, s-maxage=600',
3255
+ },
3256
+ });
3257
+ }
3258
+ catch (err) {
3259
+ return internalError(err, 'sitemap.xml');
3260
+ }
3261
+ });
3262
+ // The router's path-param syntax (`:name`) greedily matches everything up
3263
+ // to the next `/`, so registering as `/sitemaps/:slug.xml` would capture
3264
+ // `slug.xml` into a single param. We register `/sitemaps/:slug` instead and
3265
+ // require the `.xml` suffix in the handler.
3266
+ router.get('/sitemaps/:slug', async (request, params) => {
3267
+ try {
3268
+ const cfg = getActuateConfig();
3269
+ if (cfg?.seo?.sitemap?.disabled)
3270
+ return errorResponse('Sitemap disabled', 404);
3271
+ const slugParam = params.slug ?? '';
3272
+ if (!/\.xml$/i.test(slugParam))
3273
+ return errorResponse('Sitemap not found', 404);
3274
+ const rawSlug = slugParam.replace(/\.xml$/i, '');
3275
+ const collection = cfg?.collections?.[rawSlug];
3276
+ if (!collection)
3277
+ return errorResponse('Collection not found', 404);
3278
+ if (collection.seo?.excludeFromSitemap)
3279
+ return errorResponse('Collection excluded from sitemap', 404);
3280
+ const base = siteUrlFromRequest(request);
3281
+ const docs = await db().document.findMany({
3282
+ where: { collection: rawSlug, deletedAt: null, status: 'PUBLISHED' },
3283
+ select: { slug: true, updatedAt: true, data: true },
3284
+ orderBy: { updatedAt: 'desc' },
3285
+ take: 5000,
3286
+ });
3287
+ const prefix = (collection.urlPrefix ?? '').replace(/^\/|\/$/g, '');
3288
+ const defaultPriority = collection.seo?.sitemapPriority ??
3289
+ cfg?.seo?.sitemap?.defaultPriority ??
3290
+ (collection.type === 'page' ? 0.8 : 0.6);
3291
+ const changefreq = collection.seo?.sitemapChangeFreq ?? cfg?.seo?.sitemap?.defaultChangeFreq ?? 'weekly';
3292
+ const urls = [];
3293
+ // Archive page first (e.g. /blog) for post-type collections.
3294
+ if (collection.seo?.archivePath && collection.type === 'post') {
3295
+ const archive = collection.seo.archivePath.startsWith('http')
3296
+ ? collection.seo.archivePath
3297
+ : `${base}${collection.seo.archivePath}`;
3298
+ urls.push([
3299
+ ' <url>',
3300
+ ` <loc>${escapeXml(archive)}</loc>`,
3301
+ ` <lastmod>${new Date().toISOString()}</lastmod>`,
3302
+ ` <changefreq>${changefreq}</changefreq>`,
3303
+ ' <priority>0.6</priority>',
3304
+ ' </url>',
3305
+ ].join('\n'));
3306
+ }
3307
+ for (const d of docs) {
3308
+ const data = d.data || {};
3309
+ const slug = d.slug ?? data.slug;
3310
+ if (!slug)
3311
+ continue;
3312
+ const loc = prefix ? `${base}/${prefix}/${slug}` : `${base}/${slug}`;
3313
+ urls.push([
3314
+ ' <url>',
3315
+ ` <loc>${escapeXml(loc)}</loc>`,
3316
+ ` <lastmod>${d.updatedAt?.toISOString() ?? new Date().toISOString()}</lastmod>`,
3317
+ ` <changefreq>${changefreq}</changefreq>`,
3318
+ ` <priority>${defaultPriority.toFixed(1)}</priority>`,
3319
+ ' </url>',
3320
+ ].join('\n'));
3321
+ }
3322
+ const xml = [
3323
+ '<?xml version="1.0" encoding="UTF-8"?>',
3324
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
3325
+ urls.join('\n'),
3326
+ '</urlset>',
3327
+ ].join('\n');
3328
+ return new Response(xml, {
3329
+ status: 200,
3330
+ headers: {
3331
+ 'Content-Type': 'application/xml; charset=utf-8',
3332
+ 'Cache-Control': 'public, max-age=300, s-maxage=600',
3333
+ },
3334
+ });
3335
+ }
3336
+ catch (err) {
3337
+ return internalError(err, 'sitemaps/:slug.xml');
3338
+ }
3339
+ });
3340
+ router.get('/robots.txt', async (request) => {
3341
+ try {
3342
+ const cfg = getActuateConfig();
3343
+ const seo = cfg?.seo;
3344
+ if (seo?.robots?.disabled)
3345
+ return errorResponse('robots.txt disabled', 404);
3346
+ const base = siteUrlFromRequest(request);
3347
+ const sitemapUrl = seo?.sitemap?.disabled ? undefined : `${base}/api/cms/sitemap.xml`;
3348
+ const lines = [
3349
+ 'User-agent: *',
3350
+ 'Allow: /',
3351
+ 'Disallow: /admin',
3352
+ 'Disallow: /api/',
3353
+ '',
3354
+ ];
3355
+ for (const rule of seo?.robots?.additionalRules ?? []) {
3356
+ lines.push(`User-agent: ${rule.userAgent}`);
3357
+ for (const allow of rule.allow ?? [])
3358
+ lines.push(`Allow: ${allow}`);
3359
+ for (const dis of rule.disallow ?? [])
3360
+ lines.push(`Disallow: ${dis}`);
3361
+ lines.push('');
3362
+ }
3363
+ if (seo?.robots?.blockAIBots) {
3364
+ const bots = [
3365
+ 'GPTBot',
3366
+ 'ChatGPT-User',
3367
+ 'ClaudeBot',
3368
+ 'Claude-Web',
3369
+ 'anthropic-ai',
3370
+ 'Bytespider',
3371
+ 'CCBot',
3372
+ 'Google-Extended',
3373
+ ];
3374
+ for (const bot of bots) {
3375
+ lines.push(`User-agent: ${bot}`);
3376
+ lines.push('Disallow: /');
3377
+ lines.push('');
3378
+ }
3379
+ }
3380
+ if (sitemapUrl)
3381
+ lines.push(`Sitemap: ${sitemapUrl}`, '');
3382
+ return new Response(lines.join('\n'), {
3383
+ status: 200,
3384
+ headers: {
3385
+ 'Content-Type': 'text/plain; charset=utf-8',
3386
+ 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
3387
+ },
3388
+ });
3389
+ }
3390
+ catch (err) {
3391
+ return internalError(err, 'robots.txt');
3392
+ }
3393
+ });
3394
+ router.get('/og.png', async (request) => {
3395
+ try {
3396
+ const cfg = getActuateConfig();
3397
+ if (cfg?.seo?.ogImage?.disabled)
3398
+ return errorResponse('og.png disabled', 404);
3399
+ const url = new URL(request.url);
3400
+ const title = url.searchParams.get('title') ?? cfg?.seo?.siteName ?? 'Untitled';
3401
+ const description = url.searchParams.get('description') ?? undefined;
3402
+ const siteName = url.searchParams.get('siteName') ?? cfg?.seo?.siteName;
3403
+ const theme = url.searchParams.get('theme') ?? cfg?.seo?.ogImage?.theme ?? 'light';
3404
+ const bg = theme === 'dark' ? '#0a0a0a' : '#ffffff';
3405
+ const fg = theme === 'dark' ? '#fafafa' : '#0a0a0a';
3406
+ const muted = theme === 'dark' ? '#a1a1aa' : '#71717a';
3407
+ // SVG OG image. Crawlers (Facebook, Twitter/X, LinkedIn, Slack, Discord)
3408
+ // all accept image/svg+xml when served with the right Content-Type. We
3409
+ // chose SVG over PNG to avoid forcing a Satori/resvg dependency on
3410
+ // every integrator; sites that need PNG can override with `og:image`
3411
+ // pointing at their own /og endpoint built with @vercel/og.
3412
+ const svg = renderOgSvg({ title, description, siteName, theme, bg, fg, muted });
3413
+ return new Response(svg, {
3414
+ status: 200,
3415
+ headers: {
3416
+ 'Content-Type': 'image/svg+xml; charset=utf-8',
3417
+ 'Cache-Control': 'public, max-age=86400, s-maxage=86400',
3418
+ },
3419
+ });
3420
+ }
3421
+ catch (err) {
3422
+ return internalError(err, 'og.png');
3423
+ }
3424
+ });
3121
3425
  router.get('/seo/schema/:documentId', async (request, params) => {
3122
3426
  try {
3123
3427
  const auth = await requireAuth(request);
@@ -3454,6 +3758,28 @@ export function registerCMSRoutes(router) {
3454
3758
  const docData = doc.data && typeof doc.data === 'object' ? doc.data : {};
3455
3759
  const layout = await resolveLayout(pathParam, docData, matchedCollection);
3456
3760
  const { _layout: _omit, ...cleanData } = docData;
3761
+ // Compose page meta + JSON-LD up front so client renderers (Next.js
3762
+ // generateMetadata, plain SSR, MCP agents) don't have to re-derive
3763
+ // schema, OG tags, and canonical URLs from raw doc data. Reads the
3764
+ // collection's SEO config and the site-wide SEO defaults.
3765
+ const cfg = getActuateConfig();
3766
+ const collectionDef = cfg?.collections?.[matchedCollection] ?? null;
3767
+ const { composePageMeta } = await import('../seo/page-meta.js');
3768
+ const composed = composePageMeta({
3769
+ doc: {
3770
+ id: doc.id,
3771
+ collection: doc.collection,
3772
+ slug: doc.slug ?? cleanData.slug ?? null,
3773
+ data: cleanData,
3774
+ publishedAt: doc.publishedAt,
3775
+ updatedAt: doc.updatedAt,
3776
+ structuredData: doc.structuredData ?? null,
3777
+ pageSettings: cleanData.pageSettings ?? null,
3778
+ },
3779
+ collection: collectionDef,
3780
+ config: cfg ?? null,
3781
+ siteUrl: siteUrlFromRequest(request),
3782
+ });
3457
3783
  return json({
3458
3784
  data: {
3459
3785
  id: doc.id,
@@ -3461,8 +3787,18 @@ export function registerCMSRoutes(router) {
3461
3787
  data: cleanData,
3462
3788
  status: doc.status,
3463
3789
  publishedAt: doc.publishedAt,
3464
- structuredData: doc.structuredData,
3790
+ structuredData: composed.jsonLd ?? doc.structuredData,
3791
+ },
3792
+ meta: {
3793
+ title: composed.title,
3794
+ description: composed.description,
3795
+ canonical: composed.canonical,
3796
+ url: composed.url,
3797
+ tags: composed.meta,
3798
+ html: composed.metaHtml,
3465
3799
  },
3800
+ jsonLd: composed.jsonLd,
3801
+ jsonLdHtml: composed.jsonLdHtml,
3466
3802
  ...(Object.keys(layout).length > 0 ? { layout } : {}),
3467
3803
  });
3468
3804
  }
@@ -4374,6 +4710,7 @@ export function registerCMSRoutes(router) {
4374
4710
  return modelNotAvailable('PageTemplate');
4375
4711
  const url = new URL(request.url, 'http://localhost');
4376
4712
  const category = url.searchParams.get('category');
4713
+ const collection = url.searchParams.get('collection');
4377
4714
  const builtInCount = await safeCount(d.pageTemplate, { builtIn: true });
4378
4715
  if (builtInCount === 0) {
4379
4716
  await seedBuiltInTemplates(d);
@@ -4385,6 +4722,37 @@ export function registerCMSRoutes(router) {
4385
4722
  where,
4386
4723
  orderBy: [{ builtIn: 'desc' }, { updatedAt: 'desc' }],
4387
4724
  });
4725
+ // When a `collection` filter is supplied, reorder the response so the
4726
+ // suggested templates for that collection bubble to the top. The admin
4727
+ // "Create new" picker uses this so blog posts default to `blog-post`,
4728
+ // case studies to `case-study`, etc., without forcing the user to scan
4729
+ // every template alphabetically.
4730
+ if (collection) {
4731
+ const { getTemplatesForCollection } = await import('../collections/presets.js');
4732
+ const { primary, alternates } = getTemplatesForCollection(collection);
4733
+ const suggestionOrder = new Map();
4734
+ if (primary)
4735
+ suggestionOrder.set(`builtin-${primary}`, 0);
4736
+ alternates.forEach((alt, i) => suggestionOrder.set(`builtin-${alt}`, i + 1));
4737
+ templates.sort((a, b) => {
4738
+ const ai = suggestionOrder.has(a.id) ? suggestionOrder.get(a.id) : 100;
4739
+ const bi = suggestionOrder.has(b.id) ? suggestionOrder.get(b.id) : 100;
4740
+ return ai - bi;
4741
+ });
4742
+ const annotated = templates.map((t) => ({
4743
+ ...t,
4744
+ suggested: suggestionOrder.has(t.id),
4745
+ suggestionRank: suggestionOrder.get(t.id) ?? null,
4746
+ }));
4747
+ return json({
4748
+ data: annotated,
4749
+ suggestion: {
4750
+ collection,
4751
+ primary: primary ? `builtin-${primary}` : null,
4752
+ alternates: alternates.map((a) => `builtin-${a}`),
4753
+ },
4754
+ });
4755
+ }
4388
4756
  return json({ data: templates });
4389
4757
  }
4390
4758
  catch (err) {