@jini-ai/cms 0.5.2 → 0.5.4

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 (261) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +14 -8
  3. package/dist/comments/errors.d.ts +23 -0
  4. package/dist/comments/errors.js +25 -0
  5. package/dist/comments/express/index.d.ts +2 -0
  6. package/dist/comments/express/index.js +2 -0
  7. package/dist/comments/express/submit-handler.d.ts +30 -0
  8. package/dist/comments/express/submit-handler.js +85 -0
  9. package/dist/comments/hooks.d.ts +39 -0
  10. package/dist/comments/hooks.js +41 -0
  11. package/dist/comments/index.d.ts +11 -0
  12. package/dist/comments/index.js +11 -0
  13. package/dist/comments/ingress.d.ts +57 -0
  14. package/dist/comments/ingress.js +173 -0
  15. package/dist/comments/module.d.ts +94 -0
  16. package/dist/comments/module.js +73 -0
  17. package/dist/comments/ports.d.ts +184 -0
  18. package/dist/comments/ports.js +2 -0
  19. package/dist/comments/repo.memory.d.ts +70 -0
  20. package/dist/comments/repo.memory.js +120 -0
  21. package/dist/comments/sanitize.d.ts +15 -0
  22. package/dist/comments/sanitize.js +31 -0
  23. package/dist/comments/spam.heuristic.d.ts +8 -0
  24. package/dist/comments/spam.heuristic.js +54 -0
  25. package/dist/comments/sql/index.d.ts +3 -0
  26. package/dist/comments/sql/index.js +3 -0
  27. package/dist/comments/sql/repo.d.ts +77 -0
  28. package/dist/comments/sql/repo.js +190 -0
  29. package/dist/comments/sql/repo.rows.d.ts +49 -0
  30. package/dist/comments/sql/repo.rows.js +71 -0
  31. package/dist/comments/types.d.ts +187 -0
  32. package/dist/comments/types.js +39 -0
  33. package/dist/comments/write-service.d.ts +121 -0
  34. package/dist/comments/write-service.js +144 -0
  35. package/dist/core/commands/command.d.ts +2 -9
  36. package/dist/core/commands/command.js +2 -9
  37. package/dist/core/index.d.ts +2 -1
  38. package/dist/core/index.js +1 -0
  39. package/dist/core/keyset-cursor.d.ts +25 -0
  40. package/dist/core/keyset-cursor.js +20 -0
  41. package/dist/core/ports.d.ts +30 -0
  42. package/dist/core/ports.js +0 -1
  43. package/dist/core/tools/args.d.ts +1 -46
  44. package/dist/core/tools/args.js +1 -44
  45. package/dist/forms/delete-submission.d.ts +38 -0
  46. package/dist/forms/delete-submission.js +25 -0
  47. package/dist/forms/duplicate-slug.d.ts +81 -0
  48. package/dist/forms/duplicate-slug.js +115 -0
  49. package/dist/forms/errors.d.ts +53 -0
  50. package/dist/forms/errors.js +25 -0
  51. package/dist/forms/express/handler.d.ts +59 -0
  52. package/dist/forms/express/handler.js +217 -0
  53. package/dist/forms/express/index.d.ts +2 -0
  54. package/dist/forms/express/index.js +2 -0
  55. package/dist/forms/fields-json.d.ts +14 -0
  56. package/dist/forms/fields-json.js +11 -0
  57. package/dist/forms/forms.d.ts +59 -0
  58. package/dist/forms/forms.js +213 -0
  59. package/dist/forms/html/authoring-adapter.d.ts +34 -0
  60. package/dist/forms/html/authoring-adapter.js +48 -0
  61. package/dist/forms/html/html-authoring.d.ts +12 -0
  62. package/dist/forms/html/html-authoring.js +133 -0
  63. package/dist/forms/html/html-submission-adapter.d.ts +10 -0
  64. package/dist/forms/html/html-submission-adapter.js +15 -0
  65. package/dist/forms/html/index.d.ts +4 -0
  66. package/dist/forms/html/index.js +4 -0
  67. package/dist/forms/html-render.d.ts +33 -0
  68. package/dist/forms/html-render.js +31 -0
  69. package/dist/forms/index.d.ts +17 -0
  70. package/dist/forms/index.js +17 -0
  71. package/dist/forms/notify-subscriber.d.ts +32 -0
  72. package/dist/forms/notify-subscriber.js +60 -0
  73. package/dist/forms/ports.d.ts +168 -0
  74. package/dist/forms/ports.js +2 -0
  75. package/dist/forms/rate-limit-key.d.ts +8 -0
  76. package/dist/forms/rate-limit-key.js +7 -0
  77. package/dist/forms/repo.memory.d.ts +120 -0
  78. package/dist/forms/repo.memory.js +188 -0
  79. package/dist/forms/sql/index.d.ts +5 -0
  80. package/dist/forms/sql/index.js +5 -0
  81. package/dist/forms/sql/repo.d.ts +94 -0
  82. package/dist/forms/sql/repo.js +175 -0
  83. package/dist/forms/sql/repo.rows.d.ts +33 -0
  84. package/dist/forms/sql/repo.rows.js +64 -0
  85. package/dist/forms/sql/submission-ip-retention-repo.d.ts +9 -0
  86. package/dist/forms/sql/submission-ip-retention-repo.js +40 -0
  87. package/dist/forms/sql/tables.d.ts +32 -0
  88. package/dist/forms/sql/tables.js +2 -0
  89. package/dist/forms/submission-attempts.d.ts +70 -0
  90. package/dist/forms/submission-attempts.js +70 -0
  91. package/dist/forms/submission-email-validation.d.ts +8 -0
  92. package/dist/forms/submission-email-validation.js +34 -0
  93. package/dist/forms/submission-ip-retention.d.ts +30 -0
  94. package/dist/forms/submission-ip-retention.js +27 -0
  95. package/dist/forms/submit-service.d.ts +78 -0
  96. package/dist/forms/submit-service.js +109 -0
  97. package/dist/forms/types.d.ts +49 -0
  98. package/dist/forms/types.js +2 -0
  99. package/dist/forms/write-service.d.ts +83 -0
  100. package/dist/forms/write-service.js +271 -0
  101. package/dist/media/index.d.ts +0 -3
  102. package/dist/media/index.js +1 -4
  103. package/dist/media/node.d.ts +5 -0
  104. package/dist/media/node.js +6 -0
  105. package/dist/redirects/capture.d.ts +80 -0
  106. package/dist/redirects/capture.js +66 -0
  107. package/dist/redirects/express/index.d.ts +27 -0
  108. package/dist/redirects/express/index.js +40 -0
  109. package/dist/redirects/hit-sink.d.ts +56 -0
  110. package/dist/redirects/hit-sink.js +44 -0
  111. package/dist/redirects/host-config/index.d.ts +40 -0
  112. package/dist/redirects/host-config/index.js +145 -0
  113. package/dist/redirects/index.d.ts +14 -0
  114. package/dist/redirects/index.js +14 -0
  115. package/dist/redirects/matcher.d.ts +58 -0
  116. package/dist/redirects/matcher.js +144 -0
  117. package/dist/redirects/order.d.ts +30 -0
  118. package/dist/redirects/order.js +26 -0
  119. package/dist/redirects/ports.d.ts +242 -0
  120. package/dist/redirects/ports.internal.d.ts +57 -0
  121. package/dist/redirects/ports.internal.js +14 -0
  122. package/dist/redirects/ports.js +2 -0
  123. package/dist/redirects/redirects.d.ts +226 -0
  124. package/dist/redirects/redirects.js +590 -0
  125. package/dist/redirects/referrer-alias.d.ts +31 -0
  126. package/dist/redirects/referrer-alias.js +33 -0
  127. package/dist/redirects/repo.memory.d.ts +51 -0
  128. package/dist/redirects/repo.memory.js +98 -0
  129. package/dist/redirects/reserved-destination.d.ts +58 -0
  130. package/dist/redirects/reserved-destination.js +59 -0
  131. package/dist/redirects/resolver.d.ts +100 -0
  132. package/dist/redirects/resolver.js +87 -0
  133. package/dist/redirects/spa/index.d.ts +30 -0
  134. package/dist/redirects/spa/index.js +27 -0
  135. package/dist/redirects/sql/index.d.ts +4 -0
  136. package/dist/redirects/sql/index.js +4 -0
  137. package/dist/redirects/sql/repo.d.ts +82 -0
  138. package/dist/redirects/sql/repo.js +143 -0
  139. package/dist/redirects/sql/repo.rows.d.ts +49 -0
  140. package/dist/redirects/sql/repo.rows.js +79 -0
  141. package/dist/redirects/sql/row-types.d.ts +34 -0
  142. package/dist/redirects/sql/row-types.js +2 -0
  143. package/dist/redirects/target-gate.d.ts +30 -0
  144. package/dist/redirects/target-gate.js +85 -0
  145. package/dist/redirects/types.d.ts +213 -0
  146. package/dist/redirects/types.js +42 -0
  147. package/dist/seo/absolute-url.d.ts +58 -0
  148. package/dist/seo/absolute-url.js +117 -0
  149. package/dist/seo/dom/index.d.ts +14 -0
  150. package/dist/seo/dom/index.js +52 -0
  151. package/dist/seo/errors.d.ts +35 -0
  152. package/dist/seo/errors.js +35 -0
  153. package/dist/seo/feed.d.ts +49 -0
  154. package/dist/seo/feed.js +61 -0
  155. package/dist/seo/index.d.ts +13 -0
  156. package/dist/seo/index.js +13 -0
  157. package/dist/seo/media.d.ts +57 -0
  158. package/dist/seo/media.js +134 -0
  159. package/dist/seo/page-head.d.ts +18 -0
  160. package/dist/seo/page-head.js +114 -0
  161. package/dist/seo/ports.d.ts +221 -0
  162. package/dist/seo/ports.js +27 -0
  163. package/dist/seo/seo.d.ts +73 -0
  164. package/dist/seo/seo.js +230 -0
  165. package/dist/seo/sitemap.d.ts +139 -0
  166. package/dist/seo/sitemap.js +217 -0
  167. package/dist/seo/types.d.ts +278 -0
  168. package/dist/seo/types.js +2 -0
  169. package/dist/seo/write-service.d.ts +79 -0
  170. package/dist/seo/write-service.js +273 -0
  171. package/dist/seo/xml-escape.d.ts +16 -0
  172. package/dist/seo/xml-escape.js +19 -0
  173. package/dist/settings/express/cms-adapter.d.ts +20 -0
  174. package/dist/settings/express/cms-adapter.js +124 -0
  175. package/dist/settings/express/contracts.d.ts +135 -0
  176. package/dist/settings/express/contracts.js +2 -0
  177. package/dist/settings/express/http.d.ts +10 -0
  178. package/dist/settings/express/http.js +211 -0
  179. package/dist/settings/express/index.d.ts +5 -0
  180. package/dist/settings/express/index.js +5 -0
  181. package/dist/settings/express/shared.d.ts +68 -0
  182. package/dist/settings/express/shared.js +54 -0
  183. package/dist/settings/express/stream.d.ts +24 -0
  184. package/dist/settings/express/stream.js +147 -0
  185. package/dist/settings/index.d.ts +3 -38
  186. package/dist/settings/index.js +3 -35
  187. package/dist/taxonomy/write-service.d.ts +2 -30
  188. package/dist/widgets/authorize-helper.d.ts +42 -0
  189. package/dist/widgets/authorize-helper.js +16 -0
  190. package/dist/widgets/concurrency.d.ts +12 -0
  191. package/dist/widgets/concurrency.js +44 -0
  192. package/dist/widgets/config-validation.d.ts +34 -0
  193. package/dist/widgets/config-validation.js +125 -0
  194. package/dist/widgets/embed-service.d.ts +133 -0
  195. package/dist/widgets/embed-service.js +460 -0
  196. package/dist/widgets/embed-validation.d.ts +23 -0
  197. package/dist/widgets/embed-validation.js +38 -0
  198. package/dist/widgets/entry-payload.d.ts +103 -0
  199. package/dist/widgets/entry-payload.js +160 -0
  200. package/dist/widgets/errors.d.ts +136 -0
  201. package/dist/widgets/errors.js +136 -0
  202. package/dist/widgets/host-ports.d.ts +149 -0
  203. package/dist/widgets/host-ports.js +2 -0
  204. package/dist/widgets/html/html-embeds.d.ts +247 -0
  205. package/dist/widgets/html/html-embeds.js +408 -0
  206. package/dist/widgets/html/index.d.ts +5 -0
  207. package/dist/widgets/html/index.js +4 -0
  208. package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
  209. package/dist/widgets/html/legacy-widget-markers.js +45 -0
  210. package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
  211. package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
  212. package/dist/widgets/index.d.ts +20 -0
  213. package/dist/widgets/index.js +19 -0
  214. package/dist/widgets/markers/html-attributes.d.ts +98 -0
  215. package/dist/widgets/markers/html-attributes.js +216 -0
  216. package/dist/widgets/markers/index.d.ts +3 -0
  217. package/dist/widgets/markers/index.js +3 -0
  218. package/dist/widgets/markers/marker.d.ts +496 -0
  219. package/dist/widgets/markers/marker.js +780 -0
  220. package/dist/widgets/page-embed-types.d.ts +42 -0
  221. package/dist/widgets/page-embed-types.js +42 -0
  222. package/dist/widgets/ports.d.ts +142 -0
  223. package/dist/widgets/ports.js +2 -0
  224. package/dist/widgets/read-service.d.ts +81 -0
  225. package/dist/widgets/read-service.js +79 -0
  226. package/dist/widgets/region-area-service.d.ts +110 -0
  227. package/dist/widgets/region-area-service.js +180 -0
  228. package/dist/widgets/registry.d.ts +217 -0
  229. package/dist/widgets/registry.js +172 -0
  230. package/dist/widgets/repo.memory.d.ts +41 -0
  231. package/dist/widgets/repo.memory.js +48 -0
  232. package/dist/widgets/resolver-service.d.ts +209 -0
  233. package/dist/widgets/resolver-service.js +909 -0
  234. package/dist/widgets/resolvers/contact-form.d.ts +26 -0
  235. package/dist/widgets/resolvers/contact-form.js +54 -0
  236. package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
  237. package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
  238. package/dist/widgets/resolvers/index.d.ts +65 -0
  239. package/dist/widgets/resolvers/index.js +155 -0
  240. package/dist/widgets/resolvers/menu.d.ts +42 -0
  241. package/dist/widgets/resolvers/menu.js +43 -0
  242. package/dist/widgets/resolvers/ports.d.ts +82 -0
  243. package/dist/widgets/resolvers/ports.js +2 -0
  244. package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
  245. package/dist/widgets/resolvers/recent-entries.js +165 -0
  246. package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
  247. package/dist/widgets/resolvers/taxonomy.js +34 -0
  248. package/dist/widgets/sql/index.d.ts +3 -0
  249. package/dist/widgets/sql/index.js +3 -0
  250. package/dist/widgets/sql/repo.d.ts +41 -0
  251. package/dist/widgets/sql/repo.js +82 -0
  252. package/dist/widgets/sql/tables.d.ts +13 -0
  253. package/dist/widgets/sql/tables.js +2 -0
  254. package/dist/widgets/types.d.ts +213 -0
  255. package/dist/widgets/types.js +17 -0
  256. package/dist/widgets/where-used.d.ts +41 -0
  257. package/dist/widgets/where-used.js +11 -0
  258. package/dist/widgets/write-service.d.ts +204 -0
  259. package/dist/widgets/write-service.js +332 -0
  260. package/dist/workspace/delete.d.ts +1 -1
  261. package/package.json +220 -12
@@ -0,0 +1,230 @@
1
+ import { resolveWorkspaceOrigin, toAbsoluteUrl } from "./absolute-url.js";
2
+ import { SeoEntryNotFoundError } from "./errors.js";
3
+ import { resolveSeoImageRef } from "./media.js";
4
+ /** Featured image (2026-10-05) as a share-image ref: the core "public" transform every `/m/` URL
5
+ * already serves (`deps.ts`'s ADR-027 §4 registration). */
6
+ function featuredImageRef(post) {
7
+ return post.featuredMediaId ? `${post.featuredMediaId}:public` : undefined;
8
+ }
9
+ /**
10
+ * @file `getEntryMeta`/`analyzeEntry` (ADR-PIPE-008 Decision, C-001/C-002) —
11
+ * the pure effective-meta evaluator, per-field precedence (behavior.spec.md
12
+ * §1.1): override ▸ site default ▸ derived. Consumed by the
13
+ * admin preview, the public render (via `page-head-contributor.ts`), and
14
+ * `analyzeEntry` — the one evaluator, no back door (INV-09). Never writes
15
+ * anything — reads only, via injected deps.
16
+ * The public home render supplies its configured site title as a fallback below explicit SEO
17
+ * overrides; consumers without that render context retain the entry-title template.
18
+ */
19
+ export const SEO_NAMESPACE = "site.seo";
20
+ /** Content-type -> JSON-LD `@type` map (behavior.spec.md §1.1/§3; REQ-07). */
21
+ const CONTENT_TYPE_SCHEMA_MAP = {
22
+ post: "Article",
23
+ page: "WebPage",
24
+ };
25
+ function isJsonObjectLike(value) {
26
+ return typeof value === "object" && value !== null;
27
+ }
28
+ /** Plain-text extraction over a TipTap/ProseMirror-style doc (for the derived-excerpt fallback). */
29
+ function extractPlainText(node) {
30
+ if (!isJsonObjectLike(node))
31
+ return "";
32
+ const parts = [];
33
+ if (typeof node.text === "string")
34
+ parts.push(node.text);
35
+ if (Array.isArray(node.content)) {
36
+ for (const child of node.content)
37
+ parts.push(extractPlainText(child));
38
+ }
39
+ return parts.join(" ");
40
+ }
41
+ const EXCERPT_MAX_LENGTH = 160;
42
+ /** override > site default > derived excerpt (behavior.spec.md §1.1). Derivation reads whichever
43
+ * body column `bodyFormat` says is live — `bodyJson` (TipTap doc) or `bodyHtml` (bespoke-HTML
44
+ * Page, SPEC-047/ADR-056 Decision 3) — so an html-format Page gets a real excerpt instead of
45
+ * silently resolving to `undefined`. Exported for the RSS feed (`feed.ts`), whose item description
46
+ * is the post's own excerpt, never the site-wide default. */
47
+ export function deriveExcerpt(required, _optional = {}) {
48
+ const { post, postRepo } = required;
49
+ const rawText = post.bodyFormat === "html" ? postRepo.extractPlainText({ html: post.bodyHtml ?? "" }) : extractPlainText(post.bodyJson);
50
+ const text = rawText.replace(/\s+/g, " ").trim();
51
+ if (!text)
52
+ return undefined;
53
+ return text.length > EXCERPT_MAX_LENGTH ? `${text.slice(0, EXCERPT_MAX_LENGTH).trim()}…` : text;
54
+ }
55
+ function parseOverrides(post) {
56
+ if (!post.seoExtJson)
57
+ return {};
58
+ try {
59
+ return JSON.parse(post.seoExtJson);
60
+ }
61
+ catch {
62
+ return {};
63
+ }
64
+ }
65
+ /** canonical: override (accepted cross-domain as-is, EC-10) > routing-resolved > bare path — then
66
+ * joined onto the workspace's verified origin ({@link toAbsoluteUrl}) so the result is always an
67
+ * absolute URL. A relative override (an author who typed `/pricing` rather than a full URL) is
68
+ * absolutized the same way; an already-absolute override (the intended EC-10 case) passes through
69
+ * {@link toAbsoluteUrl} unchanged. No local origin is ever invented (INV-07's real intent — the
70
+ * origin comes only from `OriginRegistryPort`, never a guess): with no verified origin registered
71
+ * yet, `origin` is `undefined` and this degrades to the bare relative path, same as before this
72
+ * fix existed. */
73
+ async function resolveCanonical(deps, post, overrides, workspaceId, origin) {
74
+ const routed = await deps.urls.urlFor({
75
+ target: { kind: "entryRef", entryId: post.id, contentType: post.kind },
76
+ ctx: { workspaceId },
77
+ });
78
+ return toAbsoluteUrl({ origin, path: overrides.canonical ?? routed?.canonicalUrl ?? deps.urls.postPublicPath({ slug: post.slug }) });
79
+ }
80
+ /** robots: override > site default > derived (draft-safety fallback for noindex, EC-11). */
81
+ async function resolveRobots(deps, post, overrides, settings, workspaceId) {
82
+ let noindex;
83
+ if (overrides.noindex !== undefined) {
84
+ noindex = overrides.noindex;
85
+ }
86
+ else if (await deps.settings.isDefaultRobotsNoindexExplicitlySet({ workspaceId })) {
87
+ noindex = settings.defaultRobots.noindex;
88
+ }
89
+ else {
90
+ // 2026-09-05 fix: `softDelete` (post.ts) stamps only `deletedAt`/`updatedAt`/`version` — it
91
+ // never clears `status`, so a post that was `published` when trashed stays `status:
92
+ // "published"` forever and the status check alone can't catch it. `postRepo.findById` (this
93
+ // function's own read, above) is documented trash-BLIND, so this derived fallback must check
94
+ // `isTrashed` itself, same as `computeIndexableEntries` (sitemap.ts) and
95
+ // `scanEntriesForAsset` (media-rendition.ts) already do at their own read
96
+ // boundaries. `getEntryMeta` is "the one evaluator, no back door" for admin preview, public
97
+ // render, and `analyzeEntry` alike (this file's header) — a trashed entry must resolve
98
+ // noindex:true through every one of those consumers, not just the ones that happen to gate
99
+ // trash upstream before calling in.
100
+ // Scheduled publishing (2026-10-05): a not-yet-live scheduled row is noindex like a draft.
101
+ noindex = post.status !== "published" || deps.postRepo.isTrashed({ post }) || deps.liveness.isScheduledAt({ post, nowIso: deps.clock.nowIso() });
102
+ }
103
+ const nofollow = overrides.nofollow ?? settings.defaultRobots.nofollow ?? false;
104
+ return { noindex, nofollow };
105
+ }
106
+ /** The first of `refs` that resolves through media, or `undefined`. @complexity O(r) resolves. */
107
+ async function firstResolvedImage(deps, workspaceId, refs) {
108
+ for (const ref of refs) {
109
+ const resolved = ref ? await resolveSeoImageRef({ deps: deps.media, input: { workspaceId, ref } }) : undefined;
110
+ if (resolved)
111
+ return resolved;
112
+ }
113
+ return undefined;
114
+ }
115
+ /** openGraph/twitter image refs resolve through media (fail-soft, EC-07), then joined onto the
116
+ * workspace's verified origin ({@link toAbsoluteUrl}) — `resolveSeoImageRef` returns either an
117
+ * already-absolute ref (passed through unchanged) or the site-relative `/m/{assetId}/...` URL
118
+ * contract (ADR-027 §4), which needs the same absolutizing every other SEO URL does; crawlers
119
+ * require `og:image` to be absolute, same as `og:url`. */
120
+ async function resolveShareImages(deps, post, overrides, settings, workspaceId, origin) {
121
+ // Precedence: explicit per-entry override > the entry's featured image > the site default. An
122
+ // override is final (unresolved means no image, as before); a featured image that no longer
123
+ // resolves (trashed, deleted) falls through to the site default rather than leaving none.
124
+ const fallbacks = [featuredImageRef(post), settings.defaultOgImage];
125
+ const ogImageResolved = await firstResolvedImage(deps, workspaceId, overrides.ogImage ? [overrides.ogImage] : fallbacks);
126
+ const twitterImageResolved = await firstResolvedImage(deps, workspaceId, overrides.twitterImage ? [overrides.twitterImage] : fallbacks);
127
+ return {
128
+ ogImage: ogImageResolved ? toAbsoluteUrl({ origin, path: ogImageResolved }) : undefined,
129
+ twitterImage: twitterImageResolved ? toAbsoluteUrl({ origin, path: twitterImageResolved }) : undefined,
130
+ };
131
+ }
132
+ /** title: override, else titleTemplate applied to entry.title. description: override > site default >
133
+ * derived excerpt > omitted. */
134
+ function resolveTitleAndDescription(post, overrides, settings, postRepo) {
135
+ // title is never empty — entry.title is itself validated non-empty at the post write
136
+ // chokepoint, and %s substitution never drops it.
137
+ const title = overrides.title ?? settings.titleTemplate.replaceAll("%s", post.title);
138
+ const description = overrides.description ?? settings.defaultDescription ?? deriveExcerpt({ post, postRepo });
139
+ return { title, description };
140
+ }
141
+ /** ogType/schemaType (REQ-07): override, else derived from the entry's content kind. */
142
+ function resolveContentTypeFields(post, overrides) {
143
+ const ogType = overrides.ogType ?? (post.kind === "page" ? "website" : "article");
144
+ const schemaType = overrides.schemaType ?? CONTENT_TYPE_SCHEMA_MAP[post.kind];
145
+ return { ogType, schemaType };
146
+ }
147
+ function buildOpenGraph(overrides, title, description, ogType, canonical, ogImage) {
148
+ return {
149
+ title: overrides.ogTitle ?? title,
150
+ description: overrides.ogDescription ?? description,
151
+ type: ogType,
152
+ url: canonical,
153
+ image: ogImage,
154
+ siteName: undefined,
155
+ };
156
+ }
157
+ function buildTwitter(overrides, title, description, twitterImage, settings) {
158
+ return {
159
+ card: overrides.twitterCard ?? "summary_large_image",
160
+ title: overrides.twitterTitle ?? title,
161
+ description: overrides.twitterDescription ?? description,
162
+ image: twitterImage,
163
+ site: settings.twitterSite,
164
+ };
165
+ }
166
+ /** JSON-LD `@type` (REQ-07) plus the headline/name field, which differs by content kind. */
167
+ function buildJsonLdEntry(post, schemaType, title, description) {
168
+ const jsonLdEntry = {
169
+ "@context": "https://schema.org",
170
+ "@type": schemaType,
171
+ ...(post.kind === "post" ? { headline: title } : { name: title }),
172
+ };
173
+ if (description)
174
+ jsonLdEntry.description = description;
175
+ return jsonLdEntry;
176
+ }
177
+ /**
178
+ * Resolves the effective `SeoMeta` for one entry (override ▸ site default ▸
179
+ * derived, per field — behavior.spec.md §1.1). Never partial.
180
+ * The public head contributor can supply `homeTitle` for the root page; callers that omit it
181
+ * retain the entry-title template. Explicit SEO/share-title overrides still win.
182
+ *
183
+ * @complexity O(1) — one post read, one settings resolution (8 bounded
184
+ * `getEffective` reads), up to 2 media lookups, one origin lookup (2026-09-03,
185
+ * absolute-URL fix — {@link resolveWorkspaceOrigin}).
186
+ */
187
+ export async function getEntryMeta(required, _optional = {}) {
188
+ const { deps, input } = required;
189
+ const post = await deps.postRepo.findById({ workspaceId: input.workspaceId, id: input.entryId });
190
+ if (!post)
191
+ throw new SeoEntryNotFoundError(`entry '${input.entryId}' was not found`);
192
+ const overrides = parseOverrides(post);
193
+ const settings = await deps.settings.getSeoSettings({ workspaceId: input.workspaceId });
194
+ const origin = await resolveWorkspaceOrigin({ originRegistry: deps.originRegistry, workspaceId: input.workspaceId, runtimeMode: deps.runtimeMode });
195
+ const { title: entryTitle, description } = resolveTitleAndDescription(post, overrides, settings, deps.postRepo);
196
+ const title = overrides.title ?? input.homeTitle ?? entryTitle;
197
+ const canonical = await resolveCanonical(deps, post, overrides, input.workspaceId, origin);
198
+ const { noindex, nofollow } = await resolveRobots(deps, post, overrides, settings, input.workspaceId);
199
+ const { ogImage, twitterImage } = await resolveShareImages(deps, post, overrides, settings, input.workspaceId, origin);
200
+ const { ogType, schemaType } = resolveContentTypeFields(post, overrides);
201
+ return {
202
+ title,
203
+ description,
204
+ canonical,
205
+ robots: { noindex, nofollow },
206
+ openGraph: buildOpenGraph(overrides, title, description, ogType, canonical, ogImage),
207
+ twitter: buildTwitter(overrides, title, description, twitterImage, settings),
208
+ jsonLd: [buildJsonLdEntry(post, schemaType, title, description)],
209
+ };
210
+ }
211
+ /** REQ-14 — SEO analysis over `getEntryMeta`'s resolved result (pure decision, no additional I/O). */
212
+ export async function analyzeEntry(required, _optional = {}) {
213
+ const { deps, input } = required;
214
+ const resolved = await getEntryMeta({ deps, input });
215
+ const issues = [];
216
+ if (!resolved.title) {
217
+ issues.push({ code: "missing_title", severity: "error", message: "Title is missing.", field: "title" });
218
+ }
219
+ if (!resolved.description) {
220
+ issues.push({
221
+ code: "missing_description",
222
+ severity: "warning",
223
+ message: "Description is missing.",
224
+ field: "description",
225
+ });
226
+ }
227
+ const score = issues.length === 0 ? 100 : Math.max(0, 100 - issues.length * 20);
228
+ return { entryId: input.entryId, score, issues, resolved };
229
+ }
230
+ //# sourceMappingURL=seo.js.map
@@ -0,0 +1,139 @@
1
+ import type { EventBusPort, OutboxPort } from "../core/index.js";
2
+ import type { UUID } from "@jini-ai/core/primitives";
3
+ import type { SeoPostRecord as PostRecord, SeoPostListPort, SeoSettingsReaderPort } from "./ports.js";
4
+ import type { OriginRegistryPort } from "@jini-ai/http-kit/verified-origin";
5
+ import { type GetEntryMetaDeps } from "./seo.js";
6
+ import type { RobotsPolicy, SeoMeta, SitemapEntry } from "./types.js";
7
+ import type { SeoEventSubscriptions, SitemapCollectHook } from "./ports.js";
8
+ export interface SeoSitemapDeps extends GetEntryMetaDeps {
9
+ postRepo: SeoPostListPort;
10
+ /**
11
+ * Whether an anonymous, unauthenticated crawler could actually read this post — the sitemap's own
12
+ * visibility bar. `sitemap.xml` has no per-visitor concept at all (it is a single cache-backed
13
+ * document served identically to every requester, ADR-PIPE-008 Decision §5/§7 above), so "would
14
+ * THIS caller be let in" collapses to the one case that matters here: would ANY anonymous caller.
15
+ * `resolvePostMemberAccess(json).visibility === "public"` is exactly that: every other visibility
16
+ * (`members`/`paid`/`tiers`, and the fail-closed `unknown_visibility` default for malformed JSON)
17
+ * denies an unauthenticated `MemberContext` in `access-resolver.ts`'s own `decide()` — this is the
18
+ * same decision restated without needing that resolver's session/subscription/tier repo ports,
19
+ * which this cache-backed, session-blind build path has no reason to carry.
20
+ *
21
+ * @complexity O(1) — `resolvePostMemberAccess` is one `JSON.parse` of a small, editorial string.
22
+ */
23
+ isPubliclyAccessible(required: {
24
+ post: PostRecord;
25
+ }): boolean;
26
+ }
27
+ /** One publish-eligible, indexable entry: the raw post row plus its fully-resolved `SeoMeta`
28
+ * (title/description/canonical/robots — everything {@link getEntryMeta} computes). */
29
+ export interface IndexableEntry {
30
+ readonly post: PostRecord;
31
+ readonly meta: SeoMeta;
32
+ }
33
+ /**
34
+ * The single publish/visibility/indexability filter every public SEO document is built from
35
+ * (INV-04/05): never a non-`published` post, never a members/paid/tiers-gated one
36
+ * ({@link isPubliclyVisible}, ADR-030 §4), never an effective-`noindex` one. Returns the full
37
+ * resolved `SeoMeta` per entry (not just `loc`/`lastmod`) so a consumer that needs a title or
38
+ * description — `llms.txt` (`routes/site/llms.ts`), which `SitemapEntry` can't supply one for —
39
+ * reads it from the same evaluator `sitemap.xml` does, instead of re-deriving a second, subtly
40
+ * different notion of "published and indexable."
41
+ *
42
+ * @complexity O(n) in the workspace's post count, each with one bounded `getEntryMeta` resolution
43
+ * (see that function's own complexity note).
44
+ */
45
+ export declare function computeIndexableEntries(required: {
46
+ deps: SeoSitemapDeps;
47
+ input: {
48
+ workspaceId: UUID;
49
+ nowIso?: string;
50
+ };
51
+ }, _optional?: Record<string, never>): Promise<IndexableEntry[]>;
52
+ /**
53
+ * REQ-09 — composes `RobotsPolicy` from `SeoSettings.robotsRules` +
54
+ * computed `sitemapUrls` (never persisted as one shape — computed fresh at
55
+ * read time). `sitemapUrls` is `[]` when `sitemapEnabled` is `false`.
56
+ *
57
+ * 2026-09-04 fix: the advertised sitemap URL is joined onto the workspace's
58
+ * verified origin ({@link toAbsoluteUrl}, via {@link resolveWorkspaceOrigin}),
59
+ * the same wiring `getEntryMeta`'s `canonical`/`og:url` already use (the
60
+ * `ADR-040` origin-resolution source this doc used to say didn't exist yet —
61
+ * `features/origin`'s `OriginRegistryPort` — landed on 2026-09-03). Google
62
+ * ignores a relative `Sitemap:` directive, so a bare `/sitemap.xml` made the
63
+ * sitemap undiscoverable to crawlers even though `<loc>` entries inside it
64
+ * were already absolute. Degrades to the same bare relative path as before
65
+ * when no verified origin is registered yet — SEO still never fabricates a
66
+ * local origin (INV-07), consistent with `getEntryMeta`'s own fallback.
67
+ */
68
+ export declare function buildRobots(required: {
69
+ deps: {
70
+ settings: SeoSettingsReaderPort;
71
+ originRegistry: OriginRegistryPort;
72
+ runtimeMode: string;
73
+ };
74
+ input: {
75
+ workspaceId: UUID;
76
+ };
77
+ }, _optional?: Record<string, never>): Promise<RobotsPolicy>;
78
+ export declare const SITEMAP_INVALIDATED_EVENT = "seo.sitemap_invalidated";
79
+ /** The daemon enqueues through its enqueue-only outbox; the web process owns delivery.
80
+ * Local invalidation also prevents reads in this process from seeing the old snapshot. */
81
+ export interface SitemapInvalidationDeps {
82
+ outbox: OutboxPort;
83
+ bus: EventBusPort;
84
+ clock: {
85
+ nowIso(): string;
86
+ };
87
+ idGen: {
88
+ newId(): string;
89
+ };
90
+ dispatch(required: {
91
+ outbox: OutboxPort;
92
+ bus: EventBusPort;
93
+ clock: {
94
+ nowMs(): number;
95
+ };
96
+ }): Promise<unknown>;
97
+ invalidateSitemapCache(required: {
98
+ workspaceId: UUID;
99
+ }): void | Promise<void>;
100
+ }
101
+ export declare function requestSitemapInvalidation(required: {
102
+ deps: SitemapInvalidationDeps;
103
+ input: {
104
+ workspaceId: UUID;
105
+ };
106
+ }, _optional?: Record<string, never>): Promise<void>;
107
+ /** Each service owns its hooks, build generations, cache and scheduled expiry; hosts never share state accidentally. */
108
+ export declare function createSitemapService(required: {
109
+ deps: SeoSitemapDeps;
110
+ }, _optional?: Record<string, never>): {
111
+ registerSitemapCollectHook: (required: {
112
+ hook: SitemapCollectHook;
113
+ }, _optional?: Record<string, never>) => () => void;
114
+ resetSitemapCollectHooksForTests: (_required: Record<string, never>, _optional?: Record<string, never>) => void;
115
+ buildSitemap: (input: {
116
+ workspaceId: UUID;
117
+ }, _optional?: Record<string, never>) => Promise<SitemapEntry[]>;
118
+ regenerateSitemapCache: (input: {
119
+ workspaceId: UUID;
120
+ }, _optional?: Record<string, never>) => Promise<void>;
121
+ invalidateSitemapCache: (input: {
122
+ workspaceId: UUID;
123
+ }, _optional?: Record<string, never>) => void;
124
+ createSeoEventSubscriptions: (_required: Record<string, never>, _optional?: Record<string, never>) => SeoEventSubscriptions;
125
+ computeIndexableEntries: (input: {
126
+ workspaceId: UUID;
127
+ nowIso?: string;
128
+ }, optional?: Record<string, never>) => Promise<IndexableEntry[]>;
129
+ buildRobots: (input: {
130
+ workspaceId: UUID;
131
+ }, optional?: Record<string, never>) => Promise<RobotsPolicy>;
132
+ requestSitemapInvalidation: (required: {
133
+ deps: Omit<SitemapInvalidationDeps, "invalidateSitemapCache">;
134
+ input: {
135
+ workspaceId: UUID;
136
+ };
137
+ }, optional?: Record<string, never>) => Promise<void>;
138
+ };
139
+ //# sourceMappingURL=sitemap.d.ts.map
@@ -0,0 +1,217 @@
1
+ import { resolveWorkspaceOrigin, toAbsoluteUrl } from "./absolute-url.js";
2
+ import { getEntryMeta } from "./seo.js";
3
+ /**
4
+ * @file `buildSitemap`/`buildRobots`/`regenerateSitemapCache`/
5
+ * `invalidateSitemapCache` (ADR-PIPE-008 Decision §5/§7, C-008..C-011) — a
6
+ * cache-backed sitemap/robots build over an in-module `Map<string,string>`
7
+ * (no new `CachePort` — none exists in this codebase and none is warranted
8
+ * for a single workspace-keyed value, ADR-006). Never leaks non-published or
9
+ * `noindex` entries (INV-04/05). The empty `seo.sitemap.collect` registry
10
+ * (OQ-01) ships live-but-empty — a real seam, zero real registrants in v1.
11
+ */
12
+ /** INV-08 — always this shape; the only file that constructs the cache key. */
13
+ function cacheKey(workspaceId) {
14
+ return `ws:${workspaceId}:seo:sitemap`;
15
+ }
16
+ /**
17
+ * The single publish/visibility/indexability filter every public SEO document is built from
18
+ * (INV-04/05): never a non-`published` post, never a members/paid/tiers-gated one
19
+ * ({@link isPubliclyVisible}, ADR-030 §4), never an effective-`noindex` one. Returns the full
20
+ * resolved `SeoMeta` per entry (not just `loc`/`lastmod`) so a consumer that needs a title or
21
+ * description — `llms.txt` (`routes/site/llms.ts`), which `SitemapEntry` can't supply one for —
22
+ * reads it from the same evaluator `sitemap.xml` does, instead of re-deriving a second, subtly
23
+ * different notion of "published and indexable."
24
+ *
25
+ * @complexity O(n) in the workspace's post count, each with one bounded `getEntryMeta` resolution
26
+ * (see that function's own complexity note).
27
+ */
28
+ export async function computeIndexableEntries(required, _optional = {}) {
29
+ const { deps, input } = required;
30
+ const { workspaceId } = input;
31
+ const nowIso = input.nowIso ?? deps.clock.nowIso();
32
+ const posts = [...(await deps.postRepo.list({ workspaceId }))].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
33
+ const entries = [];
34
+ for (const post of posts) {
35
+ if (post.status !== "published")
36
+ continue;
37
+ // Scheduled publishing (2026-10-05): not in sitemap/feed/llms.txt until its go-live time.
38
+ if (deps.liveness.isScheduledAt({ post, nowIso }))
39
+ continue;
40
+ // 2026-09-04 fix: `softDelete` (post.ts) stamps only `deletedAt`/`updatedAt`/`version` — it
41
+ // never clears `status`, so a post that was `published` when trashed stays `status:
42
+ // "published"` forever and the guard above alone can't catch it. `PostRepoPort.list()` is
43
+ // documented (and deliberately kept) trash-BLIND (see `PostRecord.deletedAt`'s own doc: "every
44
+ // trash-AWARE read filter lives in this file's own domain functions"), the same contract
45
+ // `listAdminPosts`/`listPublishedPosts`/`listAdminPages` (post.ts) already honor with their own
46
+ // `!isTrashed(post)` filter — this is that same filter, applied at this domain function's own
47
+ // trash-aware read boundary rather than widening the shared repo port.
48
+ if (deps.postRepo.isTrashed({ post }))
49
+ continue;
50
+ // ADR-030 §4 (2026-09-03 sweep): a members/paid/tiers-gated post must not advertise its
51
+ // canonical URL or existence to crawlers here, the same way it was already excluded from the
52
+ // ungated home-page listing (`pages.ts`'s `filterVisiblePosts`).
53
+ if (!deps.isPubliclyAccessible({ post }))
54
+ continue;
55
+ const meta = await getEntryMeta({ deps, input: { workspaceId, entryId: post.id } });
56
+ if (meta.robots.noindex)
57
+ continue;
58
+ entries.push({ post, meta });
59
+ }
60
+ return entries;
61
+ }
62
+ /**
63
+ * REQ-09 — composes `RobotsPolicy` from `SeoSettings.robotsRules` +
64
+ * computed `sitemapUrls` (never persisted as one shape — computed fresh at
65
+ * read time). `sitemapUrls` is `[]` when `sitemapEnabled` is `false`.
66
+ *
67
+ * 2026-09-04 fix: the advertised sitemap URL is joined onto the workspace's
68
+ * verified origin ({@link toAbsoluteUrl}, via {@link resolveWorkspaceOrigin}),
69
+ * the same wiring `getEntryMeta`'s `canonical`/`og:url` already use (the
70
+ * `ADR-040` origin-resolution source this doc used to say didn't exist yet —
71
+ * `features/origin`'s `OriginRegistryPort` — landed on 2026-09-03). Google
72
+ * ignores a relative `Sitemap:` directive, so a bare `/sitemap.xml` made the
73
+ * sitemap undiscoverable to crawlers even though `<loc>` entries inside it
74
+ * were already absolute. Degrades to the same bare relative path as before
75
+ * when no verified origin is registered yet — SEO still never fabricates a
76
+ * local origin (INV-07), consistent with `getEntryMeta`'s own fallback.
77
+ */
78
+ export async function buildRobots(required, _optional = {}) {
79
+ const { deps, input } = required;
80
+ const settings = await deps.settings.getSeoSettings({ workspaceId: input.workspaceId });
81
+ const origin = await resolveWorkspaceOrigin({ originRegistry: deps.originRegistry, workspaceId: input.workspaceId, runtimeMode: deps.runtimeMode });
82
+ return {
83
+ rules: settings.robotsRules,
84
+ sitemapUrls: settings.sitemapEnabled ? [toAbsoluteUrl({ origin, path: "/sitemap.xml" })] : [],
85
+ };
86
+ }
87
+ export const SITEMAP_INVALIDATED_EVENT = "seo.sitemap_invalidated";
88
+ export async function requestSitemapInvalidation(required, _optional = {}) {
89
+ const { deps, input } = required;
90
+ await deps.invalidateSitemapCache(input);
91
+ await deps.outbox.enqueue({ id: deps.idGen.newId(), name: SITEMAP_INVALIDATED_EVENT,
92
+ occurredAt: deps.clock.nowIso(), aggregateId: input.workspaceId, workspaceId: input.workspaceId, payload: {} });
93
+ await deps.dispatch({ outbox: deps.outbox, bus: deps.bus, clock: { nowMs: () => Date.parse(deps.clock.nowIso()) } });
94
+ }
95
+ /** Each service owns its hooks, build generations, cache and scheduled expiry; hosts never share state accidentally. */
96
+ export function createSitemapService(required, _optional = {}) {
97
+ const { deps } = required;
98
+ /** Cached value is the `JSON.stringify`d `SitemapEntry[]` for that workspace. */
99
+ const sitemapCache = new Map();
100
+ /** Only the most recently started build may publish into a workspace cache. */
101
+ const sitemapBuilds = new Map();
102
+ /**
103
+ * Scheduled publishing (2026-10-05): the earliest future `publishAt` seen when a workspace's entry
104
+ * was built. Nothing emits an event when a scheduled post goes live (there is no job — see
105
+ * `contracts/core/scheduled-publish.ts`), so the cached sitemap simply stops being trusted at that
106
+ * instant and the next read rebuilds it.
107
+ */
108
+ const sitemapExpiry = new Map();
109
+ // ---------------------------------------------------------------------------
110
+ // `seo.sitemap.collect` (OQ-01) — a real, empty, in-module ordered registry.
111
+ // ---------------------------------------------------------------------------
112
+ const sitemapCollectHooks = [];
113
+ /** Registers a `seo.sitemap.collect` contributor. Live-but-empty in v1 (OQ-01) — no real registrant ships with this feature. */
114
+ function registerSitemapCollectHook(required, _optional = {}) {
115
+ const { hook } = required;
116
+ sitemapCollectHooks.push(hook);
117
+ return () => {
118
+ const index = sitemapCollectHooks.indexOf(hook);
119
+ if (index !== -1)
120
+ sitemapCollectHooks.splice(index, 1);
121
+ };
122
+ }
123
+ /** Test-only reset of this service's registry. */
124
+ function resetSitemapCollectHooksForTests(_required, _optional = {}) {
125
+ sitemapCollectHooks.length = 0;
126
+ }
127
+ /** The earliest go-live instant still ahead of `nowIso`, or `undefined` when nothing is scheduled.
128
+ * @complexity O(n) in the workspace's post count. */
129
+ function nextScheduledGoLive(posts, nowIso) {
130
+ let next;
131
+ for (const post of posts) {
132
+ if (deps.postRepo.isTrashed({ post }) || !deps.liveness.isScheduledAt({ post, nowIso }))
133
+ continue;
134
+ if (next === undefined || post.publishAt < next)
135
+ next = post.publishAt;
136
+ }
137
+ return next;
138
+ }
139
+ /** Builds and (when still the latest build) caches one workspace's sitemap, with its expiry. */
140
+ async function rebuildSitemapCache(workspaceId) {
141
+ const key = cacheKey(workspaceId);
142
+ const build = Symbol();
143
+ sitemapBuilds.set(key, build);
144
+ const nowIso = deps.clock.nowIso();
145
+ const entries = await computeSitemapEntries(workspaceId, nowIso);
146
+ const expiry = nextScheduledGoLive(await deps.postRepo.list({ workspaceId }), nowIso);
147
+ if (sitemapBuilds.get(key) === build) {
148
+ sitemapCache.set(key, JSON.stringify(entries));
149
+ if (expiry === undefined)
150
+ sitemapExpiry.delete(key);
151
+ else
152
+ sitemapExpiry.set(key, expiry);
153
+ }
154
+ return entries;
155
+ }
156
+ async function computeSitemapEntries(workspaceId, nowIso) {
157
+ // `meta.canonical` is absolute when the workspace has a verified origin (2026-09-03 fix,
158
+ // `getEntryMeta`'s own `resolveCanonical`) — sitemap `loc` entries are required to be absolute
159
+ // by the sitemap protocol, same requirement `og:url` has. Falls back to the bare relative path
160
+ // for the same disclosed no-origin degradation `getEntryMeta` documents; unchanged from before
161
+ // this fix for a workspace with no verified origin yet.
162
+ const entries = (await computeIndexableEntries({ deps, input: { workspaceId, nowIso } })).map(({ post, meta }) => ({
163
+ loc: meta.canonical,
164
+ lastmod: post.updatedAt,
165
+ }));
166
+ for (const hook of [...sitemapCollectHooks].sort((a, b) => a.priority - b.priority)) {
167
+ const collected = await hook.handle({ workspaceId, baseUrl: "" });
168
+ entries.push(...collected);
169
+ }
170
+ return entries;
171
+ }
172
+ /**
173
+ * REQ-08/10 — cache-checked build of `SitemapEntry[]`. Never `null`; `[]`
174
+ * when empty (EC-04). Never includes a non-published or effective-`noindex`
175
+ * entry (INV-04/05).
176
+ */
177
+ async function buildSitemap(input, _optional = {}) {
178
+ const key = cacheKey(input.workspaceId);
179
+ const cached = sitemapCache.get(key);
180
+ const expiry = sitemapExpiry.get(key);
181
+ const expired = expiry !== undefined && deps.clock.nowIso() >= expiry;
182
+ if (cached !== undefined && !expired)
183
+ return JSON.parse(cached);
184
+ return rebuildSitemapCache(input.workspaceId);
185
+ }
186
+ /** REQ-13 — force-rebuilds the cache entry now, bypassing the cache-hit path. */
187
+ async function regenerateSitemapCache(input, _optional = {}) {
188
+ await rebuildSitemapCache(input.workspaceId);
189
+ }
190
+ /** REQ-10/INV-08 — clears the workspace's cache entry. Idempotent: a repeat call on an already-clear key is a no-op. */
191
+ function invalidateSitemapCache(input, _optional = {}) {
192
+ sitemapCache.delete(cacheKey(input.workspaceId));
193
+ sitemapBuilds.delete(cacheKey(input.workspaceId));
194
+ sitemapExpiry.delete(cacheKey(input.workspaceId));
195
+ }
196
+ /**
197
+ * REQ-10 — the 3 outbox-event subscription handlers (idempotent per
198
+ * ADR-009): any `entry.published`/`entry.updated`/`entry.unpublished`
199
+ * delivery invalidates that workspace's sitemap cache entry. Wired to
200
+ * `bus.subscribe` at `server/app.ts` boot (T038).
201
+ */
202
+ function createSeoEventSubscriptions(_required, _optional = {}) {
203
+ const handler = async (event, _optional = {}) => {
204
+ invalidateSitemapCache({ workspaceId: event.workspaceId });
205
+ };
206
+ return { onEntryPublished: handler, onEntryUpdated: handler, onEntryUnpublished: handler,
207
+ onSitemapInvalidated: async (event, _optional = {}) => { invalidateSitemapCache({ workspaceId: event.workspaceId }); } };
208
+ }
209
+ return {
210
+ registerSitemapCollectHook, resetSitemapCollectHooksForTests,
211
+ buildSitemap, regenerateSitemapCache, invalidateSitemapCache, createSeoEventSubscriptions,
212
+ computeIndexableEntries: (input, optional = {}) => computeIndexableEntries({ deps, input }, optional),
213
+ buildRobots: (input, optional = {}) => buildRobots({ deps, input }, optional),
214
+ requestSitemapInvalidation: (required, optional = {}) => requestSitemapInvalidation({ deps: { ...required.deps, invalidateSitemapCache }, input: required.input }, optional),
215
+ };
216
+ }
217
+ //# sourceMappingURL=sitemap.js.map