@pramen/cms 0.0.23 → 0.0.24
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/index.d.ts +5 -0
- package/dist/index.js +23 -5
- package/package.json +2 -2
- package/src/index.ts +26 -5
package/dist/index.d.ts
CHANGED
|
@@ -532,6 +532,9 @@ export interface AssembledPage {
|
|
|
532
532
|
slug: string;
|
|
533
533
|
status: string;
|
|
534
534
|
locale: string;
|
|
535
|
+
/** The page's content-type slug (e.g. "article", "page") — lets a frontend route/render
|
|
536
|
+
* by type. `null` if the type row is missing. */
|
|
537
|
+
contentType: string | null;
|
|
535
538
|
translationGroupId: string | null;
|
|
536
539
|
/** Published sibling locales of this page (for hreflang alternates). */
|
|
537
540
|
translations: PageTranslation[];
|
|
@@ -665,6 +668,7 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
|
|
|
665
668
|
listPublishedPages: import("@pramen/server").Handler<unknown, {
|
|
666
669
|
slug: string;
|
|
667
670
|
locale: string;
|
|
671
|
+
contentType: string | null;
|
|
668
672
|
updatedAt: string;
|
|
669
673
|
}[]>;
|
|
670
674
|
/** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
|
|
@@ -918,6 +922,7 @@ export declare const cmsHandlers: {
|
|
|
918
922
|
listPublishedPages: import("@pramen/server").Handler<unknown, {
|
|
919
923
|
slug: string;
|
|
920
924
|
locale: string;
|
|
925
|
+
contentType: string | null;
|
|
921
926
|
updatedAt: string;
|
|
922
927
|
}[]>;
|
|
923
928
|
/** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
|
package/dist/index.js
CHANGED
|
@@ -465,6 +465,13 @@ const isEditor = (ctx, roles) => {
|
|
|
465
465
|
};
|
|
466
466
|
/** Assemble a page LIVE from its placements/blocks/types, grouped by region and ordered
|
|
467
467
|
* by position, merging each shared placement's `overrides` over its block's fields. */
|
|
468
|
+
/** Resolve a page's content-type slug from its `typeId` (null when the type row is gone). */
|
|
469
|
+
async function contentTypeSlug(db, typeId) {
|
|
470
|
+
if (typeof typeId !== "string" || !typeId)
|
|
471
|
+
return null;
|
|
472
|
+
const rows = await db.find({ from: "cms_content_types", where: { id: typeId }, limit: 1 });
|
|
473
|
+
return rows[0] ? String(rows[0].slug) : null;
|
|
474
|
+
}
|
|
468
475
|
async function assembleLive(db, page) {
|
|
469
476
|
const placements = await db.find({
|
|
470
477
|
from: "cms_page_blocks",
|
|
@@ -512,8 +519,8 @@ async function assembleLive(db, page) {
|
|
|
512
519
|
is_shared: Boolean(m.p.isShared),
|
|
513
520
|
});
|
|
514
521
|
}
|
|
515
|
-
const [translations, ogImage] = await Promise.all([siblingTranslations(db, page), resolveMediaId(db, page.ogImage)]);
|
|
516
|
-
return { page: pageMeta(page, translations, ogImage), regions };
|
|
522
|
+
const [translations, ogImage, contentType] = await Promise.all([siblingTranslations(db, page), resolveMediaId(db, page.ogImage), contentTypeSlug(db, page.typeId)]);
|
|
523
|
+
return { page: pageMeta(page, translations, ogImage, contentType), regions };
|
|
517
524
|
}
|
|
518
525
|
/** Resolve a single media id to a ResolvedMedia (for og:image etc.), or null. */
|
|
519
526
|
async function resolveMediaId(db, id) {
|
|
@@ -559,7 +566,7 @@ async function siblingTranslations(db, page) {
|
|
|
559
566
|
.map((r) => ({ locale: String(r.locale ?? "en"), slug: String(r.slug) }));
|
|
560
567
|
}
|
|
561
568
|
/** Project a page row to the public AssembledPage.page shape. */
|
|
562
|
-
function pageMeta(page, translations = [], ogImage = null) {
|
|
569
|
+
function pageMeta(page, translations = [], ogImage = null, contentType = null) {
|
|
563
570
|
const metaTitle = page.metaTitle ?? null;
|
|
564
571
|
const metaDescription = page.metaDescription ?? null;
|
|
565
572
|
return {
|
|
@@ -568,6 +575,7 @@ function pageMeta(page, translations = [], ogImage = null) {
|
|
|
568
575
|
slug: String(page.slug),
|
|
569
576
|
status: String(page.status),
|
|
570
577
|
locale: String(page.locale ?? "en"),
|
|
578
|
+
contentType,
|
|
571
579
|
translationGroupId: page.translationGroupId ?? null,
|
|
572
580
|
translations,
|
|
573
581
|
fields: page.fields ?? null,
|
|
@@ -840,8 +848,14 @@ export function createCmsHandlers(opts = {}) {
|
|
|
840
848
|
/** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
|
|
841
849
|
* anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
|
|
842
850
|
listPublishedPages: query(async (ctx) => {
|
|
843
|
-
const
|
|
844
|
-
|
|
851
|
+
const db = cdb(ctx);
|
|
852
|
+
const rows = await db.find({ from: "cms_pages", where: { status: "published" }, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
|
|
853
|
+
// Join typeId → content-type slug so a frontend can route/filter by type (e.g. articles
|
|
854
|
+
// vs pages) without a second round-trip.
|
|
855
|
+
const typeIds = [...new Set(rows.map((r) => r.typeId).filter((v) => typeof v === "string"))];
|
|
856
|
+
const types = typeIds.length ? await db.find({ from: "cms_content_types", where: { id: { in: typeIds } } }) : [];
|
|
857
|
+
const slugById = new Map(types.map((t) => [String(t.id), String(t.slug)]));
|
|
858
|
+
return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), contentType: slugById.get(String(r.typeId)) ?? null, updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
|
|
845
859
|
}),
|
|
846
860
|
/** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
|
|
847
861
|
updatePageSeo: mutation(async (ctx, input) => {
|
|
@@ -1409,6 +1423,10 @@ export function cmsPolicies(opts = {}) {
|
|
|
1409
1423
|
}
|
|
1410
1424
|
return {
|
|
1411
1425
|
public: [
|
|
1426
|
+
// Content-type metadata (slug/name) is public: the content API returns each published
|
|
1427
|
+
// page's content-type slug (via listPublishedPages' live typeId→slug join) so a frontend
|
|
1428
|
+
// can route/render by type. Slugs/names are structural, not sensitive.
|
|
1429
|
+
policy(`${p}:public:content-types:read`, "cms_content_types", "read", allow()),
|
|
1412
1430
|
// Only published pages are readable; the snapshot carries the content.
|
|
1413
1431
|
policy(`${p}:public:pages:read`, "cms_pages", "read", { where: { status: "published" } }),
|
|
1414
1432
|
// getPage reads the latest revision snapshot. Scope the grant by the revision's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.24",
|
|
4
4
|
"description": "Optional block/page builder for pramen — Drupal-Paragraphs-style typed blocks in named regions, reusable blocks, scheduled publishing, built entirely from pramen primitives.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@pramen/server": "0.0.
|
|
44
|
+
"@pramen/server": "0.0.24",
|
|
45
45
|
"xss": "^1.0.15"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -605,6 +605,9 @@ export interface AssembledPage {
|
|
|
605
605
|
slug: string;
|
|
606
606
|
status: string;
|
|
607
607
|
locale: string;
|
|
608
|
+
/** The page's content-type slug (e.g. "article", "page") — lets a frontend route/render
|
|
609
|
+
* by type. `null` if the type row is missing. */
|
|
610
|
+
contentType: string | null;
|
|
608
611
|
translationGroupId: string | null;
|
|
609
612
|
/** Published sibling locales of this page (for hreflang alternates). */
|
|
610
613
|
translations: PageTranslation[];
|
|
@@ -731,6 +734,13 @@ const isEditor = (ctx: HandlerContext, roles: readonly string[]): boolean => {
|
|
|
731
734
|
|
|
732
735
|
/** Assemble a page LIVE from its placements/blocks/types, grouped by region and ordered
|
|
733
736
|
* by position, merging each shared placement's `overrides` over its block's fields. */
|
|
737
|
+
/** Resolve a page's content-type slug from its `typeId` (null when the type row is gone). */
|
|
738
|
+
async function contentTypeSlug(db: CmsDb, typeId: unknown): Promise<string | null> {
|
|
739
|
+
if (typeof typeId !== "string" || !typeId) return null;
|
|
740
|
+
const rows = await db.find({ from: "cms_content_types", where: { id: typeId }, limit: 1 });
|
|
741
|
+
return rows[0] ? String(rows[0].slug) : null;
|
|
742
|
+
}
|
|
743
|
+
|
|
734
744
|
async function assembleLive(db: CmsDb, page: Record<string, unknown>): Promise<AssembledPage> {
|
|
735
745
|
const placements = await db.find({
|
|
736
746
|
from: "cms_page_blocks",
|
|
@@ -779,8 +789,8 @@ async function assembleLive(db: CmsDb, page: Record<string, unknown>): Promise<A
|
|
|
779
789
|
is_shared: Boolean(m.p.isShared),
|
|
780
790
|
});
|
|
781
791
|
}
|
|
782
|
-
const [translations, ogImage] = await Promise.all([siblingTranslations(db, page), resolveMediaId(db, page.ogImage)]);
|
|
783
|
-
return { page: pageMeta(page, translations, ogImage), regions };
|
|
792
|
+
const [translations, ogImage, contentType] = await Promise.all([siblingTranslations(db, page), resolveMediaId(db, page.ogImage), contentTypeSlug(db, page.typeId)]);
|
|
793
|
+
return { page: pageMeta(page, translations, ogImage, contentType), regions };
|
|
784
794
|
}
|
|
785
795
|
|
|
786
796
|
/** Resolve a single media id to a ResolvedMedia (for og:image etc.), or null. */
|
|
@@ -826,7 +836,7 @@ async function siblingTranslations(db: CmsDb, page: Record<string, unknown>): Pr
|
|
|
826
836
|
}
|
|
827
837
|
|
|
828
838
|
/** Project a page row to the public AssembledPage.page shape. */
|
|
829
|
-
function pageMeta(page: Record<string, unknown>, translations: PageTranslation[] = [], ogImage: ResolvedMedia | null = null): AssembledPage["page"] {
|
|
839
|
+
function pageMeta(page: Record<string, unknown>, translations: PageTranslation[] = [], ogImage: ResolvedMedia | null = null, contentType: string | null = null): AssembledPage["page"] {
|
|
830
840
|
const metaTitle = (page.metaTitle as string | null) ?? null;
|
|
831
841
|
const metaDescription = (page.metaDescription as string | null) ?? null;
|
|
832
842
|
return {
|
|
@@ -835,6 +845,7 @@ function pageMeta(page: Record<string, unknown>, translations: PageTranslation[]
|
|
|
835
845
|
slug: String(page.slug),
|
|
836
846
|
status: String(page.status),
|
|
837
847
|
locale: String(page.locale ?? "en"),
|
|
848
|
+
contentType,
|
|
838
849
|
translationGroupId: (page.translationGroupId as string | null) ?? null,
|
|
839
850
|
translations,
|
|
840
851
|
fields: (page.fields as Record<string, unknown> | null) ?? null,
|
|
@@ -1130,8 +1141,14 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1130
1141
|
/** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
|
|
1131
1142
|
* anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
|
|
1132
1143
|
listPublishedPages: query(async (ctx) => {
|
|
1133
|
-
const
|
|
1134
|
-
|
|
1144
|
+
const db = cdb(ctx);
|
|
1145
|
+
const rows = await db.find({ from: "cms_pages", where: { status: "published" }, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
|
|
1146
|
+
// Join typeId → content-type slug so a frontend can route/filter by type (e.g. articles
|
|
1147
|
+
// vs pages) without a second round-trip.
|
|
1148
|
+
const typeIds = [...new Set(rows.map((r) => r.typeId).filter((v): v is string => typeof v === "string"))];
|
|
1149
|
+
const types = typeIds.length ? await db.find({ from: "cms_content_types", where: { id: { in: typeIds } } }) : [];
|
|
1150
|
+
const slugById = new Map(types.map((t) => [String(t.id), String(t.slug)]));
|
|
1151
|
+
return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), contentType: slugById.get(String(r.typeId)) ?? null, updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
|
|
1135
1152
|
}),
|
|
1136
1153
|
|
|
1137
1154
|
/** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
|
|
@@ -1681,6 +1698,10 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
|
|
|
1681
1698
|
}
|
|
1682
1699
|
return {
|
|
1683
1700
|
public: [
|
|
1701
|
+
// Content-type metadata (slug/name) is public: the content API returns each published
|
|
1702
|
+
// page's content-type slug (via listPublishedPages' live typeId→slug join) so a frontend
|
|
1703
|
+
// can route/render by type. Slugs/names are structural, not sensitive.
|
|
1704
|
+
policy(`${p}:public:content-types:read`, "cms_content_types", "read", allow()),
|
|
1684
1705
|
// Only published pages are readable; the snapshot carries the content.
|
|
1685
1706
|
policy(`${p}:public:pages:read`, "cms_pages", "read", { where: { status: "published" } }),
|
|
1686
1707
|
// getPage reads the latest revision snapshot. Scope the grant by the revision's
|