@endora-commerce/mod-blog 0.100.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 (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/dist/admin/api/blog-client.d.ts +70 -0
  4. package/dist/admin/api/blog-client.d.ts.map +1 -0
  5. package/dist/admin/api/blog-client.js +137 -0
  6. package/dist/admin/api/blog-client.js.map +1 -0
  7. package/dist/admin/components/CategoryTreeNode.d.ts +25 -0
  8. package/dist/admin/components/CategoryTreeNode.d.ts.map +1 -0
  9. package/dist/admin/components/CategoryTreeNode.js +14 -0
  10. package/dist/admin/components/CategoryTreeNode.js.map +1 -0
  11. package/dist/admin/components/PostStatusBadge.d.ts +7 -0
  12. package/dist/admin/components/PostStatusBadge.d.ts.map +1 -0
  13. package/dist/admin/components/PostStatusBadge.js +16 -0
  14. package/dist/admin/components/PostStatusBadge.js.map +1 -0
  15. package/dist/admin/components/RelatedPostsPicker.d.ts +11 -0
  16. package/dist/admin/components/RelatedPostsPicker.d.ts.map +1 -0
  17. package/dist/admin/components/RelatedPostsPicker.js +77 -0
  18. package/dist/admin/components/RelatedPostsPicker.js.map +1 -0
  19. package/dist/admin/components/RelatedProductsPicker.d.ts +9 -0
  20. package/dist/admin/components/RelatedProductsPicker.d.ts.map +1 -0
  21. package/dist/admin/components/RelatedProductsPicker.js +66 -0
  22. package/dist/admin/components/RelatedProductsPicker.js.map +1 -0
  23. package/dist/admin/components/TagPicker.d.ts +9 -0
  24. package/dist/admin/components/TagPicker.d.ts.map +1 -0
  25. package/dist/admin/components/TagPicker.js +66 -0
  26. package/dist/admin/components/TagPicker.js.map +1 -0
  27. package/dist/admin/index.d.ts +33 -0
  28. package/dist/admin/index.d.ts.map +1 -0
  29. package/dist/admin/index.js +114 -0
  30. package/dist/admin/index.js.map +1 -0
  31. package/dist/admin/pages/BlogCategoryEditor.d.ts +9 -0
  32. package/dist/admin/pages/BlogCategoryEditor.d.ts.map +1 -0
  33. package/dist/admin/pages/BlogCategoryEditor.js +196 -0
  34. package/dist/admin/pages/BlogCategoryEditor.js.map +1 -0
  35. package/dist/admin/pages/BlogCategoryTreePage.d.ts +9 -0
  36. package/dist/admin/pages/BlogCategoryTreePage.d.ts.map +1 -0
  37. package/dist/admin/pages/BlogCategoryTreePage.js +122 -0
  38. package/dist/admin/pages/BlogCategoryTreePage.js.map +1 -0
  39. package/dist/admin/pages/BlogPostEditor.d.ts +9 -0
  40. package/dist/admin/pages/BlogPostEditor.d.ts.map +1 -0
  41. package/dist/admin/pages/BlogPostEditor.js +314 -0
  42. package/dist/admin/pages/BlogPostEditor.js.map +1 -0
  43. package/dist/admin/pages/BlogPostListPage.d.ts +9 -0
  44. package/dist/admin/pages/BlogPostListPage.d.ts.map +1 -0
  45. package/dist/admin/pages/BlogPostListPage.js +75 -0
  46. package/dist/admin/pages/BlogPostListPage.js.map +1 -0
  47. package/dist/admin/pages/BlogTagListPage.d.ts +9 -0
  48. package/dist/admin/pages/BlogTagListPage.d.ts.map +1 -0
  49. package/dist/admin/pages/BlogTagListPage.js +145 -0
  50. package/dist/admin/pages/BlogTagListPage.js.map +1 -0
  51. package/dist/backend/entities/blog-category-language.entity.d.ts +11 -0
  52. package/dist/backend/entities/blog-category-language.entity.d.ts.map +1 -0
  53. package/dist/backend/entities/blog-category-language.entity.js +35 -0
  54. package/dist/backend/entities/blog-category-language.entity.js.map +1 -0
  55. package/dist/backend/entities/blog-category-sales-channel.entity.d.ts +17 -0
  56. package/dist/backend/entities/blog-category-sales-channel.entity.d.ts.map +1 -0
  57. package/dist/backend/entities/blog-category-sales-channel.entity.js +48 -0
  58. package/dist/backend/entities/blog-category-sales-channel.entity.js.map +1 -0
  59. package/dist/backend/entities/blog-category.entity.d.ts +32 -0
  60. package/dist/backend/entities/blog-category.entity.d.ts.map +1 -0
  61. package/dist/backend/entities/blog-category.entity.js +113 -0
  62. package/dist/backend/entities/blog-category.entity.js.map +1 -0
  63. package/dist/backend/entities/blog-post-category.entity.d.ts +10 -0
  64. package/dist/backend/entities/blog-post-category.entity.d.ts.map +1 -0
  65. package/dist/backend/entities/blog-post-category.entity.js +34 -0
  66. package/dist/backend/entities/blog-post-category.entity.js.map +1 -0
  67. package/dist/backend/entities/blog-post-language.entity.d.ts +9 -0
  68. package/dist/backend/entities/blog-post-language.entity.d.ts.map +1 -0
  69. package/dist/backend/entities/blog-post-language.entity.js +33 -0
  70. package/dist/backend/entities/blog-post-language.entity.js.map +1 -0
  71. package/dist/backend/entities/blog-post-related-post.entity.d.ts +15 -0
  72. package/dist/backend/entities/blog-post-related-post.entity.d.ts.map +1 -0
  73. package/dist/backend/entities/blog-post-related-post.entity.js +42 -0
  74. package/dist/backend/entities/blog-post-related-post.entity.js.map +1 -0
  75. package/dist/backend/entities/blog-post-related-product.entity.d.ts +14 -0
  76. package/dist/backend/entities/blog-post-related-product.entity.d.ts.map +1 -0
  77. package/dist/backend/entities/blog-post-related-product.entity.js +41 -0
  78. package/dist/backend/entities/blog-post-related-product.entity.js.map +1 -0
  79. package/dist/backend/entities/blog-post-sales-channel.entity.d.ts +16 -0
  80. package/dist/backend/entities/blog-post-sales-channel.entity.d.ts.map +1 -0
  81. package/dist/backend/entities/blog-post-sales-channel.entity.js +47 -0
  82. package/dist/backend/entities/blog-post-sales-channel.entity.js.map +1 -0
  83. package/dist/backend/entities/blog-post-tag.entity.d.ts +14 -0
  84. package/dist/backend/entities/blog-post-tag.entity.d.ts.map +1 -0
  85. package/dist/backend/entities/blog-post-tag.entity.js +41 -0
  86. package/dist/backend/entities/blog-post-tag.entity.js.map +1 -0
  87. package/dist/backend/entities/blog-post.entity.d.ts +31 -0
  88. package/dist/backend/entities/blog-post.entity.d.ts.map +1 -0
  89. package/dist/backend/entities/blog-post.entity.js +106 -0
  90. package/dist/backend/entities/blog-post.entity.js.map +1 -0
  91. package/dist/backend/entities/blog-tag.entity.d.ts +18 -0
  92. package/dist/backend/entities/blog-tag.entity.d.ts.map +1 -0
  93. package/dist/backend/entities/blog-tag.entity.js +66 -0
  94. package/dist/backend/entities/blog-tag.entity.js.map +1 -0
  95. package/dist/backend/index.d.ts +100 -0
  96. package/dist/backend/index.d.ts.map +1 -0
  97. package/dist/backend/index.js +182 -0
  98. package/dist/backend/index.js.map +1 -0
  99. package/dist/backend/routes.admin.d.ts +12 -0
  100. package/dist/backend/routes.admin.d.ts.map +1 -0
  101. package/dist/backend/routes.admin.js +148 -0
  102. package/dist/backend/routes.admin.js.map +1 -0
  103. package/dist/backend/routes.storefront.d.ts +6 -0
  104. package/dist/backend/routes.storefront.d.ts.map +1 -0
  105. package/dist/backend/routes.storefront.js +72 -0
  106. package/dist/backend/routes.storefront.js.map +1 -0
  107. package/dist/backend/services/blog-asset-references.d.ts +4 -0
  108. package/dist/backend/services/blog-asset-references.d.ts.map +1 -0
  109. package/dist/backend/services/blog-asset-references.js +79 -0
  110. package/dist/backend/services/blog-asset-references.js.map +1 -0
  111. package/dist/backend/services/blog-cache.d.ts +55 -0
  112. package/dist/backend/services/blog-cache.d.ts.map +1 -0
  113. package/dist/backend/services/blog-cache.js +111 -0
  114. package/dist/backend/services/blog-cache.js.map +1 -0
  115. package/dist/backend/services/blog-category-service.d.ts +53 -0
  116. package/dist/backend/services/blog-category-service.d.ts.map +1 -0
  117. package/dist/backend/services/blog-category-service.js +446 -0
  118. package/dist/backend/services/blog-category-service.js.map +1 -0
  119. package/dist/backend/services/blog-language-reference.d.ts +18 -0
  120. package/dist/backend/services/blog-language-reference.d.ts.map +1 -0
  121. package/dist/backend/services/blog-language-reference.js +43 -0
  122. package/dist/backend/services/blog-language-reference.js.map +1 -0
  123. package/dist/backend/services/blog-post-service.d.ts +79 -0
  124. package/dist/backend/services/blog-post-service.d.ts.map +1 -0
  125. package/dist/backend/services/blog-post-service.js +539 -0
  126. package/dist/backend/services/blog-post-service.js.map +1 -0
  127. package/dist/backend/services/blog-settings-resolver.d.ts +36 -0
  128. package/dist/backend/services/blog-settings-resolver.d.ts.map +1 -0
  129. package/dist/backend/services/blog-settings-resolver.js +68 -0
  130. package/dist/backend/services/blog-settings-resolver.js.map +1 -0
  131. package/dist/backend/services/blog-slug-collision.d.ts +35 -0
  132. package/dist/backend/services/blog-slug-collision.d.ts.map +1 -0
  133. package/dist/backend/services/blog-slug-collision.js +84 -0
  134. package/dist/backend/services/blog-slug-collision.js.map +1 -0
  135. package/dist/backend/services/blog-storefront-resolver.d.ts +107 -0
  136. package/dist/backend/services/blog-storefront-resolver.d.ts.map +1 -0
  137. package/dist/backend/services/blog-storefront-resolver.js +492 -0
  138. package/dist/backend/services/blog-storefront-resolver.js.map +1 -0
  139. package/dist/backend/services/blog-tag-service.d.ts +45 -0
  140. package/dist/backend/services/blog-tag-service.d.ts.map +1 -0
  141. package/dist/backend/services/blog-tag-service.js +198 -0
  142. package/dist/backend/services/blog-tag-service.js.map +1 -0
  143. package/dist/backend/services/seed-default-category.d.ts +25 -0
  144. package/dist/backend/services/seed-default-category.d.ts.map +1 -0
  145. package/dist/backend/services/seed-default-category.js +48 -0
  146. package/dist/backend/services/seed-default-category.js.map +1 -0
  147. package/dist/backend/services/seed-roles.d.ts +41 -0
  148. package/dist/backend/services/seed-roles.d.ts.map +1 -0
  149. package/dist/backend/services/seed-roles.js +98 -0
  150. package/dist/backend/services/seed-roles.js.map +1 -0
  151. package/dist/manifest.d.ts +226 -0
  152. package/dist/manifest.d.ts.map +1 -0
  153. package/dist/manifest.js +236 -0
  154. package/dist/manifest.js.map +1 -0
  155. package/dist/migrations/20260506T081055_blog_init.d.ts +33 -0
  156. package/dist/migrations/20260506T081055_blog_init.d.ts.map +1 -0
  157. package/dist/migrations/20260506T081055_blog_init.js +260 -0
  158. package/dist/migrations/20260506T081055_blog_init.js.map +1 -0
  159. package/dist/migrations/20260903T101744_blog_namespace_block_names.d.ts +6 -0
  160. package/dist/migrations/20260903T101744_blog_namespace_block_names.d.ts.map +1 -0
  161. package/dist/migrations/20260903T101744_blog_namespace_block_names.js +58 -0
  162. package/dist/migrations/20260903T101744_blog_namespace_block_names.js.map +1 -0
  163. package/dist/migrations/index.d.ts +34 -0
  164. package/dist/migrations/index.d.ts.map +1 -0
  165. package/dist/migrations/index.js +37 -0
  166. package/dist/migrations/index.js.map +1 -0
  167. package/docs/blog/index.md +199 -0
  168. package/i18n/en.json +166 -0
  169. package/i18n/pl.json +166 -0
  170. package/package.json +108 -0
  171. package/tailwind.css +14 -0
@@ -0,0 +1,68 @@
1
+ import { z } from 'zod';
2
+ import { ERROR_CODES, BLOG_RESERVED_URL_PREFIXES } from '@endora-commerce/contracts';
3
+ import { HttpError } from '@endora-commerce/platform/http';
4
+ import { BLOG_DEFAULT_ENABLED, BLOG_DEFAULT_LATEST_COUNT, BLOG_DEFAULT_POSTS_PER_PAGE, BLOG_DEFAULT_URL_PREFIX, BLOG_SETTING_CODES, } from '../../manifest.js';
5
+ /**
6
+ * BlogSettingsResolver — feature 016 / R7.
7
+ *
8
+ * Wraps `settings.service.getValue('blog.*', salesChannelId)` with:
9
+ * - default fallbacks (when no row exists yet);
10
+ * - type coercion (the Settings module's value-type registry stores
11
+ * primitives as `unknown`; the resolver narrows them);
12
+ * - range clamps for `latestCount` and `postsPerPage` (any stored value
13
+ * ≤ 0 falls back to the documented default with a logged warning —
14
+ * the caller is the Settings admin form, which has already validated
15
+ * the value, so this branch is a defence-in-depth safeguard);
16
+ * - a pure `assertValidPrefix(value)` validator used as the setter
17
+ * callback for `blog.url_prefix` (per the spec FR-024 + the
18
+ * reference-scan contract § R-5).
19
+ */
20
+ const URL_PREFIX_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
21
+ export class BlogSettingsResolver {
22
+ settings;
23
+ constructor(settings) {
24
+ this.settings = settings;
25
+ }
26
+ async getResolved(salesChannelId) {
27
+ const enabled = await this.readWithFallback(BLOG_SETTING_CODES.ENABLED, salesChannelId, z.boolean(), BLOG_DEFAULT_ENABLED);
28
+ const urlPrefixRaw = await this.readWithFallback(BLOG_SETTING_CODES.URL_PREFIX, salesChannelId, z.string(), BLOG_DEFAULT_URL_PREFIX);
29
+ const latestCount = await this.readWithFallback(BLOG_SETTING_CODES.LATEST_COUNT, salesChannelId, z.number(), BLOG_DEFAULT_LATEST_COUNT);
30
+ const postsPerPage = await this.readWithFallback(BLOG_SETTING_CODES.POSTS_PER_PAGE, salesChannelId, z.number(), BLOG_DEFAULT_POSTS_PER_PAGE);
31
+ return {
32
+ enabled,
33
+ urlPrefix: URL_PREFIX_REGEX.test(urlPrefixRaw) ? urlPrefixRaw : BLOG_DEFAULT_URL_PREFIX,
34
+ latestCount: clampPositiveInt(latestCount, BLOG_DEFAULT_LATEST_COUNT),
35
+ postsPerPage: clampPositiveInt(postsPerPage, BLOG_DEFAULT_POSTS_PER_PAGE),
36
+ };
37
+ }
38
+ async readWithFallback(code, salesChannelId, schema, fallback) {
39
+ try {
40
+ return await this.settings.get(code, salesChannelId, schema);
41
+ }
42
+ catch {
43
+ return fallback;
44
+ }
45
+ }
46
+ /**
47
+ * Pure validator wired into the Settings module's setter callback for
48
+ * the `blog.url_prefix` key. Refuses an invalid shape, a reserved
49
+ * Next.js segment, or a value already owned by another storefront route
50
+ * (the cross-module owner check is delegated; v1 only enforces the
51
+ * shape + reserved-segments rule).
52
+ */
53
+ static assertValidPrefix(value) {
54
+ if (typeof value !== 'string' || !URL_PREFIX_REGEX.test(value)) {
55
+ throw new HttpError(400, ERROR_CODES.BLOG_URL_PREFIX_INVALID, 'Blog URL prefix must match ^[a-z0-9-]+$ (1–64 characters).');
56
+ }
57
+ if (BLOG_RESERVED_URL_PREFIXES.has(value)) {
58
+ throw new HttpError(409, ERROR_CODES.BLOG_URL_PREFIX_RESERVED, `URL prefix "${value}" is reserved by the storefront and cannot be assigned to the blog.`);
59
+ }
60
+ }
61
+ }
62
+ function clampPositiveInt(raw, fallback) {
63
+ if (typeof raw !== 'number' || !Number.isFinite(raw))
64
+ return fallback;
65
+ const n = Math.floor(raw);
66
+ return n >= 1 ? n : fallback;
67
+ }
68
+ //# sourceMappingURL=blog-settings-resolver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blog-settings-resolver.js","sourceRoot":"","sources":["../../../src/backend/services/blog-settings-resolver.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,35 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ /**
3
+ * Cross-table slug-uniqueness probe — feature 016 / R3.
4
+ *
5
+ * Postgres cannot enforce UNIQUE across two tables; the per-table partial
6
+ * unique indexes are defensive. The cross-table check (Post slug colliding
7
+ * with a Category slug in the same Sales Channel — they share the
8
+ * `<prefix>/<slug>` URL space) lives here.
9
+ *
10
+ * Each probe runs inside the caller's transaction and acquires a Postgres
11
+ * advisory lock keyed on (channel, slug) so a concurrent save cannot
12
+ * sneak between probe and write. The lock is released automatically when
13
+ * the transaction commits or rolls back.
14
+ *
15
+ * Callers MUST be inside an `em.transactional(...)` block.
16
+ */
17
+ export interface SlugCollisionCheckInput {
18
+ /** Slug being asserted. */
19
+ slug: string;
20
+ /** Sales-channel ids the entity is being attached to. */
21
+ salesChannelIds: string[];
22
+ /** Which side is calling — drives the row-id exclusion below. */
23
+ kind: 'post' | 'category';
24
+ /** When updating an existing row, exclude it from the probe. */
25
+ excludeId?: string | undefined;
26
+ }
27
+ /**
28
+ * Probe `blog_post_sales_channels` ∪ `blog_category_sales_channels` for
29
+ * any non-soft-deleted row in any of the supplied channels with the same
30
+ * slug. Throws BLOG_SLUG_TAKEN with `details.colliding[]` populated.
31
+ *
32
+ * Returns silently when no collision exists.
33
+ */
34
+ export declare function assertSlugAvailable(em: EntityManager, input: SlugCollisionCheckInput): Promise<void>;
35
+ //# sourceMappingURL=blog-slug-collision.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blog-slug-collision.d.ts","sourceRoot":"","sources":["../../../src/backend/services/blog-slug-collision.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAK3D;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,uBAAuB;IACtC,2BAA2B;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,iEAAiE;IACjE,IAAI,EAAE,MAAM,GAAG,UAAU,CAAC;IAC1B,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAChC;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,aAAa,EACjB,KAAK,EAAE,uBAAuB,GAC7B,OAAO,CAAC,IAAI,CAAC,CAyEf"}
@@ -0,0 +1,84 @@
1
+ import { ERROR_CODES } from '@endora-commerce/contracts';
2
+ import { HttpError } from '@endora-commerce/platform/http';
3
+ import { createHash } from 'crypto';
4
+ /**
5
+ * Probe `blog_post_sales_channels` ∪ `blog_category_sales_channels` for
6
+ * any non-soft-deleted row in any of the supplied channels with the same
7
+ * slug. Throws BLOG_SLUG_TAKEN with `details.colliding[]` populated.
8
+ *
9
+ * Returns silently when no collision exists.
10
+ */
11
+ export async function assertSlugAvailable(em, input) {
12
+ if (input.salesChannelIds.length === 0)
13
+ return;
14
+ // 1. Take per-(channel, slug) advisory locks so a concurrent transaction
15
+ // cannot squeeze a colliding write between this probe and the upcoming
16
+ // INSERT. The lock is released when the transaction completes.
17
+ for (const channelId of input.salesChannelIds) {
18
+ const lockKey = advisoryLockKeyFor(channelId, input.slug);
19
+ await em.execute('select pg_advisory_xact_lock(?)', [lockKey]);
20
+ }
21
+ const placeholders = input.salesChannelIds.map(() => '?').join(', ');
22
+ const params = [input.slug, ...input.salesChannelIds];
23
+ // 2. Check the post side. Exclude the calling row if updating.
24
+ let postSql = `select p.id::text as id, psc.sales_channel_id::text as channel_id
25
+ from blog_post_sales_channels psc
26
+ join blog_posts p on p.id = psc.blog_post_id
27
+ where psc.slug = ?
28
+ and psc.sales_channel_id in (${placeholders})
29
+ and psc.deleted_at is null`;
30
+ if (input.kind === 'post' && input.excludeId) {
31
+ postSql += ' and p.id <> ?';
32
+ params.push(input.excludeId);
33
+ }
34
+ postSql += ' limit 5';
35
+ const postRows = (await em.execute(postSql, params));
36
+ // 3. Check the category side.
37
+ const catParams = [input.slug, ...input.salesChannelIds];
38
+ let categorySql = `select c.id::text as id, csc.sales_channel_id::text as channel_id
39
+ from blog_category_sales_channels csc
40
+ join blog_categories c on c.id = csc.blog_category_id
41
+ where csc.slug = ?
42
+ and csc.sales_channel_id in (${placeholders})
43
+ and csc.deleted_at is null`;
44
+ if (input.kind === 'category' && input.excludeId) {
45
+ categorySql += ' and c.id <> ?';
46
+ catParams.push(input.excludeId);
47
+ }
48
+ categorySql += ' limit 5';
49
+ const categoryRows = (await em.execute(categorySql, catParams));
50
+ if (postRows.length === 0 && categoryRows.length === 0)
51
+ return;
52
+ // Surface each colliding owner as a separate detail entry so the admin
53
+ // UI can render a friendly list. `path: 'slug'` keeps the field
54
+ // attribution consistent with other slug errors; `issue` carries the
55
+ // colliding kind + id + channel.
56
+ const details = [
57
+ ...postRows.map((r) => ({
58
+ path: 'slug',
59
+ issue: `taken by post id=${r.id} in sales-channel id=${r.channel_id}`,
60
+ })),
61
+ ...categoryRows.map((r) => ({
62
+ path: 'slug',
63
+ issue: `taken by category id=${r.id} in sales-channel id=${r.channel_id}`,
64
+ })),
65
+ ];
66
+ throw new HttpError(409, ERROR_CODES.BLOG_SLUG_TAKEN, `Slug "${input.slug}" is already taken in at least one of the assigned Sales Channels.`, details);
67
+ }
68
+ /**
69
+ * Hash `(channelId, slug)` into a `bigint` namespace acceptable to
70
+ * `pg_advisory_xact_lock(bigint)`. We use SHA-256 then take the first
71
+ * 8 bytes interpreted as a signed bigint (Postgres `bigint` range).
72
+ */
73
+ function advisoryLockKeyFor(channelId, slug) {
74
+ const digest = createHash('sha256').update(`blog-slug:${channelId}:${slug}`).digest();
75
+ // Take the first 8 bytes as a 64-bit big-endian integer; force the
76
+ // sign bit so the value fits in Postgres's signed bigint range. The
77
+ // exact mapping doesn't matter — we just need a stable bigint.
78
+ let hi = digest.readUInt32BE(0);
79
+ // Clear the top bit so the resulting bigint is positive.
80
+ hi = hi & 0x7fffffff;
81
+ const lo = digest.readUInt32BE(4);
82
+ return ((BigInt(hi) << 32n) | BigInt(lo)).toString();
83
+ }
84
+ //# sourceMappingURL=blog-slug-collision.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blog-slug-collision.js","sourceRoot":"","sources":["../../../src/backend/services/blog-slug-collision.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzD,OAAO,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAC3D,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AA6BpC;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,EAAiB,EACjB,KAA8B;IAE9B,IAAI,KAAK,CAAC,eAAe,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IAG/C,yEAAyE;IACzE,uEAAuE;IACvE,+DAA+D;IAC/D,KAAK,MAAM,SAAS,IAAI,KAAK,CAAC,eAAe,EAAE,CAAC;QAC9C,MAAM,OAAO,GAAG,kBAAkB,CAAC,SAAS,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;QAC1D,MAAM,EAAE,CAAC,OAAO,CAAC,iCAAiC,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC;IACjE,CAAC;IAED,MAAM,YAAY,GAAG,KAAK,CAAC,eAAe,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACrE,MAAM,MAAM,GAAc,CAAC,KAAK,CAAC,IAAI,EAAE,GAAG,KAAK,CAAC,eAAe,CAAC,CAAC;IAEjE,+DAA+D;IAC/D,IAAI,OAAO,GAAG;;;;mDAImC,YAAY;+CAChB,CAAC;IAC9C,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,IAAI,KAAK,CAAC,SAAS,EAAE,CAAC;QAC7C,OAAO,IAAI,gBAAgB,CAAC;QAC5B,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IAC/B,CAAC;IACD,OAAO,IAAI,UAAU,CAAC;IACtB,MAAM,QAAQ,GAAG,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,MAAM,CAAC,CAGjD,CAAC;IAEH,8BAA8B;IAC9B,MAAM,SAAS,GAAc,CAAC,KAAK,CAAC,IAAI,EAAE,GAAG,KAAK,CAAC,eAAe,CAAC,CAAC;IACpE,IAAI,WAAW,GAAG;;;;uDAImC,YAAY;mDAChB,CAAC;IAClD,IAAI,KAAK,CAAC,IAAI,KAAK,UAAU,IAAI,KAAK,CAAC,SAAS,EAAE,CAAC;QACjD,WAAW,IAAI,gBAAgB,CAAC;QAChC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IAClC,CAAC;IACD,WAAW,IAAI,UAAU,CAAC;IAC1B,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,WAAW,EAAE,SAAS,CAAC,CAG5D,CAAC;IAEH,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,IAAI,YAAY,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IAE/D,uEAAuE;IACvE,gEAAgE;IAChE,qEAAqE;IACrE,iCAAiC;IACjC,MAAM,OAAO,GAAG;QACd,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACtB,IAAI,EAAE,MAAM;YACZ,KAAK,EAAE,oBAAoB,CAAC,CAAC,EAAE,wBAAwB,CAAC,CAAC,UAAU,EAAE;SACtE,CAAC,CAAC;QACH,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YAC1B,IAAI,EAAE,MAAM;YACZ,KAAK,EAAE,wBAAwB,CAAC,CAAC,EAAE,wBAAwB,CAAC,CAAC,UAAU,EAAE;SAC1E,CAAC,CAAC;KACJ,CAAC;IAEF,MAAM,IAAI,SAAS,CACjB,GAAG,EACH,WAAW,CAAC,eAAe,EAC3B,SAAS,KAAK,CAAC,IAAI,oEAAoE,EACvF,OAAO,CACR,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,SAAS,kBAAkB,CAAC,SAAiB,EAAE,IAAY;IACzD,MAAM,MAAM,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,aAAa,SAAS,IAAI,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC;IACtF,mEAAmE;IACnE,oEAAoE;IACpE,+DAA+D;IAE/D,IAAI,EAAE,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IAChC,yDAAyD;IACzD,EAAE,GAAG,EAAE,GAAG,UAAU,CAAC;IACrB,MAAM,EAAE,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IAClC,OAAO,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,GAAG,CAAC,GAAG,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;AACvD,CAAC"}
@@ -0,0 +1,107 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import type { BlogBySlugResponse, BlogIndexResponse, BlogProductCard, BlogTagByCodeResponse } from '@endora-commerce/contracts';
3
+ import { BlogCacheService } from './blog-cache.js';
4
+ import type { BlogSettingsResolver } from './blog-settings-resolver.js';
5
+ /**
6
+ * Cross-module surfaces the storefront resolver delegates to. Each is a
7
+ * narrow port (Constitution I) — the resolver does not import the
8
+ * upstream module's internals.
9
+ */
10
+ export interface BlogStorefrontDeps {
11
+ /**
12
+ * Resolves a Library Asset id to a CDN-ready URL. Returns null when
13
+ * the asset is missing, soft-deleted, or otherwise unavailable.
14
+ */
15
+ resolveAssetUrl?: (assetId: string) => Promise<string | null>;
16
+ /**
17
+ * Resolves a Catalog Product id to a card payload (or null when the
18
+ * product is soft-deleted / out-of-channel — handled at the upstream
19
+ * port, not here). Optional: when omitted, related-products lists
20
+ * always render as empty (R6 storefront-filter contract).
21
+ */
22
+ resolveProductCard?: (productId: string, salesChannelId: string, language: string) => Promise<BlogProductCard | null>;
23
+ }
24
+ /**
25
+ * The already-resolved sales channel handed down from the route layer
26
+ * (feature 053). Mirrors the shape of the sales-channel resolver's
27
+ * `CachedChannel`; the resolver has already refused inactive/unknown
28
+ * channels upstream, so no re-query or active check is needed here.
29
+ */
30
+ export type ResolvedChannel = {
31
+ id: string;
32
+ code: string;
33
+ defaultLanguage: string;
34
+ };
35
+ /**
36
+ * BlogStorefrontResolver — feature 016 / T051.
37
+ *
38
+ * Three read endpoints behind `/api/v1/blog/**`:
39
+ *
40
+ * getIndex → BlogIndexResponse (latest N + first-level categories)
41
+ * getBySlug → BlogBySlugResponse discriminated by `kind`
42
+ * getTagByCode → BlogTagByCodeResponse paginated
43
+ *
44
+ * Every method:
45
+ * - reads the channel-resolved blog settings via BlogSettingsResolver;
46
+ * - returns null (the route maps to 404) when blog.enabled = false;
47
+ * - applies the platform-wide language-fallback rule (feature 005)
48
+ * via the `pickLanguage` helper;
49
+ * - wraps results in BlogCacheService when Redis is wired.
50
+ */
51
+ export declare class BlogStorefrontResolver {
52
+ private readonly emFactory;
53
+ private readonly settingsResolver;
54
+ private readonly deps;
55
+ private readonly cache?;
56
+ constructor(emFactory: () => EntityManager, settingsResolver: BlogSettingsResolver, deps?: BlogStorefrontDeps, cache?: BlogCacheService | undefined);
57
+ getIndex(input: {
58
+ resolvedChannel: ResolvedChannel;
59
+ language?: string | undefined;
60
+ }): Promise<BlogIndexResponse | null>;
61
+ getBySlug(slug: string, input: {
62
+ resolvedChannel: ResolvedChannel;
63
+ language?: string | undefined;
64
+ page?: number;
65
+ }): Promise<BlogBySlugResponse | null>;
66
+ getTagByCode(code: string, input: {
67
+ resolvedChannel: ResolvedChannel;
68
+ language?: string | undefined;
69
+ page?: number;
70
+ }): Promise<BlogTagByCodeResponse | null>;
71
+ /**
72
+ * Language-fallback rule (FR-019 / R12 / per feature 005): pick the
73
+ * requested language; fall back to the channel's default language; if
74
+ * neither exists, return null. The caller decides whether `null`
75
+ * means "skip this field" or "404 the page" depending on context.
76
+ */
77
+ private pickLanguage;
78
+ /**
79
+ * Resolve a human-readable name for a published entity. Applies the
80
+ * standard language fallback (requested → channel default) and, when that
81
+ * is exhausted, falls back to the first language actually authored on the
82
+ * entity rather than giving up.
83
+ *
84
+ * This keeps a *published* post/category viewable even when its content
85
+ * was authored only in a language other than the channel's default — the
86
+ * alternative (returning null) made the post-detail resolver 404 while the
87
+ * index/category resolvers still listed the post (via their `?? slug`
88
+ * fallback), an inconsistency that surfaced as "the post is in the list but
89
+ * its page 404s". A published entity is always reachable; only its display
90
+ * language degrades.
91
+ */
92
+ private pickName;
93
+ private toPostCard;
94
+ private toCategoryTile;
95
+ private toResolvedPost;
96
+ private maybeAssetUrl;
97
+ private normaliseDescription;
98
+ private normaliseContent;
99
+ /**
100
+ * Best-effort excerpt extraction — picks the first text-bearing node
101
+ * in the resolved language's tree. Falls back to the channel default
102
+ * language. Returns null when nothing usable is found.
103
+ */
104
+ private firstParagraph;
105
+ private toIso;
106
+ }
107
+ //# sourceMappingURL=blog-storefront-resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blog-storefront-resolver.d.ts","sourceRoot":"","sources":["../../../src/backend/services/blog-storefront-resolver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EACV,kBAAkB,EAElB,iBAAiB,EAGjB,eAAe,EAIf,qBAAqB,EACtB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AAExE;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC9D;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,CACnB,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,EACtB,QAAQ,EAAE,MAAM,KACb,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAAC;CACtC;AAQD;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,MAAM,CAAA;CAAE,CAAC;AAoCpF;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,sBAAsB;IAE/B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC;gBAHN,SAAS,EAAE,MAAM,aAAa,EAC9B,gBAAgB,EAAE,oBAAoB,EACtC,IAAI,GAAE,kBAAuB,EAC7B,KAAK,CAAC,EAAE,gBAAgB,YAAA;IAGrC,QAAQ,CAAC,KAAK,EAAE;QACpB,eAAe,EAAE,eAAe,CAAC;QACjC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;KAC/B,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;IAyD/B,SAAS,CACb,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE;QAAE,eAAe,EAAE,eAAe,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GACxF,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAuJ/B,YAAY,CAChB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE;QAAE,eAAe,EAAE,eAAe,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GACxF,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC;IAiGxC;;;;;OAKG;IACH,OAAO,CAAC,YAAY;IAWpB;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,QAAQ;YAcF,UAAU;YAmCV,cAAc;YAad,cAAc;YA2Gd,aAAa;IAM3B,OAAO,CAAC,oBAAoB;IAU5B,OAAO,CAAC,gBAAgB;IAUxB;;;;OAIG;IACH,OAAO,CAAC,cAAc;IAWtB,OAAO,CAAC,KAAK;CAId"}