@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 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 rows = await cdb(ctx).find({ from: "cms_pages", where: { status: "published" }, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
844
- return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
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.23",
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.23",
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 rows = await cdb(ctx).find({ from: "cms_pages", where: { status: "published" }, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
1134
- return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
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