@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.
- package/dist/__tests__/api/api-key-auth.test.js +46 -9
- package/dist/__tests__/api/api-key-auth.test.js.map +1 -1
- package/dist/__tests__/api/public-seo.test.d.ts +2 -0
- package/dist/__tests__/api/public-seo.test.d.ts.map +1 -0
- package/dist/__tests__/api/public-seo.test.js +341 -0
- package/dist/__tests__/api/public-seo.test.js.map +1 -0
- package/dist/__tests__/collections/presets.test.d.ts +2 -0
- package/dist/__tests__/collections/presets.test.d.ts.map +1 -0
- package/dist/__tests__/collections/presets.test.js +141 -0
- package/dist/__tests__/collections/presets.test.js.map +1 -0
- package/dist/__tests__/fields/presets.test.d.ts +2 -0
- package/dist/__tests__/fields/presets.test.d.ts.map +1 -0
- package/dist/__tests__/fields/presets.test.js +99 -0
- package/dist/__tests__/fields/presets.test.js.map +1 -0
- package/dist/__tests__/security/api-key-enhanced.test.js.map +1 -1
- package/dist/__tests__/seo/page-meta.test.d.ts +2 -0
- package/dist/__tests__/seo/page-meta.test.d.ts.map +1 -0
- package/dist/__tests__/seo/page-meta.test.js +204 -0
- package/dist/__tests__/seo/page-meta.test.js.map +1 -0
- package/dist/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +2 -1
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +370 -2
- package/dist/api/handlers.js.map +1 -1
- package/dist/collections/index.d.ts +2 -0
- package/dist/collections/index.d.ts.map +1 -1
- package/dist/collections/index.js +1 -0
- package/dist/collections/index.js.map +1 -1
- package/dist/collections/presets.d.ts +71 -0
- package/dist/collections/presets.d.ts.map +1 -0
- package/dist/collections/presets.js +504 -0
- package/dist/collections/presets.js.map +1 -0
- package/dist/config/types.d.ts +75 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/fields/index.d.ts +1 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +1 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/presets.d.ts +90 -0
- package/dist/fields/presets.d.ts.map +1 -0
- package/dist/fields/presets.js +253 -0
- package/dist/fields/presets.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/page-builder/schema.d.ts +8 -8
- package/dist/page-builder/templates.d.ts.map +1 -1
- package/dist/page-builder/templates.js +312 -0
- package/dist/page-builder/templates.js.map +1 -1
- package/dist/seo/index.d.ts +2 -0
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +1 -0
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/page-meta.d.ts +79 -0
- package/dist/seo/page-meta.d.ts.map +1 -0
- package/dist/seo/page-meta.js +209 -0
- package/dist/seo/page-meta.js.map +1 -0
- package/package.json +1 -1
package/dist/api/handlers.js
CHANGED
|
@@ -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, '<')
|
|
189
|
+
.replace(/>/g, '>')
|
|
190
|
+
.replace(/"/g, '"')
|
|
191
|
+
.replace(/'/g, ''');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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) {
|