@jini-ai/cms 0.5.2 → 0.5.3

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 (205) hide show
  1. package/CHANGELOG.md +11 -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/media/index.d.ts +0 -3
  46. package/dist/media/index.js +1 -4
  47. package/dist/media/node.d.ts +5 -0
  48. package/dist/media/node.js +6 -0
  49. package/dist/redirects/capture.d.ts +80 -0
  50. package/dist/redirects/capture.js +66 -0
  51. package/dist/redirects/express/index.d.ts +27 -0
  52. package/dist/redirects/express/index.js +40 -0
  53. package/dist/redirects/hit-sink.d.ts +56 -0
  54. package/dist/redirects/hit-sink.js +44 -0
  55. package/dist/redirects/host-config/index.d.ts +40 -0
  56. package/dist/redirects/host-config/index.js +145 -0
  57. package/dist/redirects/index.d.ts +14 -0
  58. package/dist/redirects/index.js +14 -0
  59. package/dist/redirects/matcher.d.ts +58 -0
  60. package/dist/redirects/matcher.js +144 -0
  61. package/dist/redirects/order.d.ts +30 -0
  62. package/dist/redirects/order.js +26 -0
  63. package/dist/redirects/ports.d.ts +242 -0
  64. package/dist/redirects/ports.internal.d.ts +57 -0
  65. package/dist/redirects/ports.internal.js +14 -0
  66. package/dist/redirects/ports.js +2 -0
  67. package/dist/redirects/redirects.d.ts +226 -0
  68. package/dist/redirects/redirects.js +590 -0
  69. package/dist/redirects/referrer-alias.d.ts +31 -0
  70. package/dist/redirects/referrer-alias.js +33 -0
  71. package/dist/redirects/repo.memory.d.ts +51 -0
  72. package/dist/redirects/repo.memory.js +98 -0
  73. package/dist/redirects/reserved-destination.d.ts +58 -0
  74. package/dist/redirects/reserved-destination.js +59 -0
  75. package/dist/redirects/resolver.d.ts +100 -0
  76. package/dist/redirects/resolver.js +87 -0
  77. package/dist/redirects/spa/index.d.ts +30 -0
  78. package/dist/redirects/spa/index.js +27 -0
  79. package/dist/redirects/sql/index.d.ts +4 -0
  80. package/dist/redirects/sql/index.js +4 -0
  81. package/dist/redirects/sql/repo.d.ts +82 -0
  82. package/dist/redirects/sql/repo.js +143 -0
  83. package/dist/redirects/sql/repo.rows.d.ts +49 -0
  84. package/dist/redirects/sql/repo.rows.js +79 -0
  85. package/dist/redirects/sql/row-types.d.ts +34 -0
  86. package/dist/redirects/sql/row-types.js +2 -0
  87. package/dist/redirects/target-gate.d.ts +30 -0
  88. package/dist/redirects/target-gate.js +85 -0
  89. package/dist/redirects/types.d.ts +213 -0
  90. package/dist/redirects/types.js +42 -0
  91. package/dist/seo/absolute-url.d.ts +58 -0
  92. package/dist/seo/absolute-url.js +117 -0
  93. package/dist/seo/dom/index.d.ts +14 -0
  94. package/dist/seo/dom/index.js +52 -0
  95. package/dist/seo/errors.d.ts +35 -0
  96. package/dist/seo/errors.js +35 -0
  97. package/dist/seo/feed.d.ts +49 -0
  98. package/dist/seo/feed.js +61 -0
  99. package/dist/seo/index.d.ts +13 -0
  100. package/dist/seo/index.js +13 -0
  101. package/dist/seo/media.d.ts +57 -0
  102. package/dist/seo/media.js +134 -0
  103. package/dist/seo/page-head.d.ts +18 -0
  104. package/dist/seo/page-head.js +114 -0
  105. package/dist/seo/ports.d.ts +221 -0
  106. package/dist/seo/ports.js +27 -0
  107. package/dist/seo/seo.d.ts +73 -0
  108. package/dist/seo/seo.js +230 -0
  109. package/dist/seo/sitemap.d.ts +139 -0
  110. package/dist/seo/sitemap.js +217 -0
  111. package/dist/seo/types.d.ts +278 -0
  112. package/dist/seo/types.js +2 -0
  113. package/dist/seo/write-service.d.ts +79 -0
  114. package/dist/seo/write-service.js +273 -0
  115. package/dist/seo/xml-escape.d.ts +16 -0
  116. package/dist/seo/xml-escape.js +19 -0
  117. package/dist/settings/express/cms-adapter.d.ts +20 -0
  118. package/dist/settings/express/cms-adapter.js +124 -0
  119. package/dist/settings/express/contracts.d.ts +135 -0
  120. package/dist/settings/express/contracts.js +2 -0
  121. package/dist/settings/express/http.d.ts +10 -0
  122. package/dist/settings/express/http.js +211 -0
  123. package/dist/settings/express/index.d.ts +5 -0
  124. package/dist/settings/express/index.js +5 -0
  125. package/dist/settings/express/shared.d.ts +68 -0
  126. package/dist/settings/express/shared.js +54 -0
  127. package/dist/settings/express/stream.d.ts +24 -0
  128. package/dist/settings/express/stream.js +147 -0
  129. package/dist/settings/index.d.ts +3 -38
  130. package/dist/settings/index.js +3 -35
  131. package/dist/taxonomy/write-service.d.ts +2 -30
  132. package/dist/widgets/authorize-helper.d.ts +42 -0
  133. package/dist/widgets/authorize-helper.js +16 -0
  134. package/dist/widgets/concurrency.d.ts +12 -0
  135. package/dist/widgets/concurrency.js +44 -0
  136. package/dist/widgets/config-validation.d.ts +34 -0
  137. package/dist/widgets/config-validation.js +125 -0
  138. package/dist/widgets/embed-service.d.ts +133 -0
  139. package/dist/widgets/embed-service.js +460 -0
  140. package/dist/widgets/embed-validation.d.ts +23 -0
  141. package/dist/widgets/embed-validation.js +38 -0
  142. package/dist/widgets/entry-payload.d.ts +103 -0
  143. package/dist/widgets/entry-payload.js +160 -0
  144. package/dist/widgets/errors.d.ts +136 -0
  145. package/dist/widgets/errors.js +136 -0
  146. package/dist/widgets/host-ports.d.ts +149 -0
  147. package/dist/widgets/host-ports.js +2 -0
  148. package/dist/widgets/html/html-embeds.d.ts +247 -0
  149. package/dist/widgets/html/html-embeds.js +408 -0
  150. package/dist/widgets/html/index.d.ts +5 -0
  151. package/dist/widgets/html/index.js +4 -0
  152. package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
  153. package/dist/widgets/html/legacy-widget-markers.js +45 -0
  154. package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
  155. package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
  156. package/dist/widgets/index.d.ts +20 -0
  157. package/dist/widgets/index.js +19 -0
  158. package/dist/widgets/markers/html-attributes.d.ts +98 -0
  159. package/dist/widgets/markers/html-attributes.js +216 -0
  160. package/dist/widgets/markers/index.d.ts +3 -0
  161. package/dist/widgets/markers/index.js +3 -0
  162. package/dist/widgets/markers/marker.d.ts +496 -0
  163. package/dist/widgets/markers/marker.js +780 -0
  164. package/dist/widgets/page-embed-types.d.ts +42 -0
  165. package/dist/widgets/page-embed-types.js +42 -0
  166. package/dist/widgets/ports.d.ts +142 -0
  167. package/dist/widgets/ports.js +2 -0
  168. package/dist/widgets/read-service.d.ts +81 -0
  169. package/dist/widgets/read-service.js +79 -0
  170. package/dist/widgets/region-area-service.d.ts +110 -0
  171. package/dist/widgets/region-area-service.js +180 -0
  172. package/dist/widgets/registry.d.ts +217 -0
  173. package/dist/widgets/registry.js +172 -0
  174. package/dist/widgets/repo.memory.d.ts +41 -0
  175. package/dist/widgets/repo.memory.js +48 -0
  176. package/dist/widgets/resolver-service.d.ts +209 -0
  177. package/dist/widgets/resolver-service.js +909 -0
  178. package/dist/widgets/resolvers/contact-form.d.ts +26 -0
  179. package/dist/widgets/resolvers/contact-form.js +54 -0
  180. package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
  181. package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
  182. package/dist/widgets/resolvers/index.d.ts +65 -0
  183. package/dist/widgets/resolvers/index.js +155 -0
  184. package/dist/widgets/resolvers/menu.d.ts +42 -0
  185. package/dist/widgets/resolvers/menu.js +43 -0
  186. package/dist/widgets/resolvers/ports.d.ts +82 -0
  187. package/dist/widgets/resolvers/ports.js +2 -0
  188. package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
  189. package/dist/widgets/resolvers/recent-entries.js +165 -0
  190. package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
  191. package/dist/widgets/resolvers/taxonomy.js +34 -0
  192. package/dist/widgets/sql/index.d.ts +3 -0
  193. package/dist/widgets/sql/index.js +3 -0
  194. package/dist/widgets/sql/repo.d.ts +41 -0
  195. package/dist/widgets/sql/repo.js +82 -0
  196. package/dist/widgets/sql/tables.d.ts +13 -0
  197. package/dist/widgets/sql/tables.js +2 -0
  198. package/dist/widgets/types.d.ts +213 -0
  199. package/dist/widgets/types.js +17 -0
  200. package/dist/widgets/where-used.d.ts +41 -0
  201. package/dist/widgets/where-used.js +11 -0
  202. package/dist/widgets/write-service.d.ts +204 -0
  203. package/dist/widgets/write-service.js +332 -0
  204. package/dist/workspace/delete.d.ts +1 -1
  205. package/package.json +220 -11
@@ -0,0 +1,278 @@
1
+ /**
2
+ * @file SEO core type definitions (SPEC-008, ADR-PIPE-008).
3
+ *
4
+ * Types ONLY — no feature logic. Fills in the ADR-032 design-only stub with
5
+ * the shapes ADR-PIPE-008 actually commits to. Deviations from the original
6
+ * stub (state.spec.md §5 + ADR-PIPE-008 Context, all disclosed there):
7
+ * - No `baseUrl`/`seo.base_url` anywhere (INV-07 — single-origin-authority;
8
+ * every absolute URL comes from `routing.urlFor`'s `canonicalUrl`).
9
+ * - `RobotsPolicy` stays computed-only (`sitemapUrls` is never persisted);
10
+ * only `robotsRules: RobotsRule[]` is stored.
11
+ * - `SeoPermission`'s 5-string vocabulary is replaced by the single string
12
+ * `"admin.seo.manage"`, used directly at call sites (no type needed).
13
+ * - `SeoFieldDecl`/`SeoExpressionIndexDecl` (ADR-022 generic-entries-model
14
+ * registration vocabulary) are dropped — this feature stores overrides on
15
+ * the bespoke `posts.seo_ext_json` column (state.spec.md §0), not a
16
+ * generic ext-bag with a content-type field registry.
17
+ * - `HeadElement`/`HeadElementKey`/`PageHeadContext`/`PageHeadEntryRef` live
18
+ * canonically in `src/server/http/site/page-head.ts` (ADR-PIPE-008
19
+ * Decision §2 — the render seam is core-owned, not SEO-owned); as of
20
+ * 2026-08-20 (RouteDeps-narrowing pass 3) this file holds a structural
21
+ * DUPLICATE of them, not a re-export — see the block comment just above
22
+ * their declarations below for why.
23
+ */
24
+ import type { ISODateTime, JsonObject, UUID } from "@jini-ai/core/primitives";
25
+ /**
26
+ * Structural duplicate of `server/http/site/page-head.ts`'s `HeadElement`/`HeadElementKey`/
27
+ * `JsonLd`/`PageHeadContext`/`PageHeadEntryRef`/`PageHeadHook` — NOT a re-export, as of the
28
+ * 2026-08-20 RouteDeps-narrowing pass 3 (`check:architecture`'s `backEdgesIntoServer` metric counts
29
+ * a type-only `export type {...} from ".../server/..."` re-export as a real graph edge, same as any
30
+ * other import — the `--ts-pre-compilation-deps` resolution `route-manifest.ts`'s own header already
31
+ * documents for this exact shape of edge).
32
+ *
33
+ * OWNERSHIP IS UNCHANGED: `page-head.ts` still owns these types canonically. That is a settled,
34
+ * deliberate decision — ADR-PIPE-008 Decision §2 explicitly considered and REJECTED keeping this
35
+ * seam inside `src/seo/` ("Wrong ownership — a future non-SEO contributor [the feeds plugin, ADR-032
36
+ * §4] would have to import the SEO module... couples the render layer to a feature module. Not
37
+ * selected — ADR-032 itself frames this as the theme layer's seam, not SEO's."). Do not "fix" this
38
+ * duplicate by turning it back into an import, and do not move these types into this file's own
39
+ * canonical ownership — both would reopen that rejected alternative.
40
+ *
41
+ * `seo/page-head-contributor.ts` never calls `registerPageHeadContributor` itself (only
42
+ * `server/app.ts` does, at boot) — it only builds a `PageHeadHook`-shaped VALUE, so nothing here
43
+ * needs the nominal identity of the real types, only their structure.
44
+ *
45
+ * Drift safety net — verified empirically with two DIFFERENT perturbations, not asserted from one
46
+ * (a claim this shape has been wrong before in this codebase). Both were applied to this file, typo
47
+ * checked with `npx tsc -p tsconfig.json --noEmit`, and reverted:
48
+ *
49
+ * 1. Dropped `canonicalUrl` from this file's `PageHeadContext` (a field `page-head-contributor.ts`'s
50
+ * `handle` body actually reads). Failed immediately, INSIDE `seo/`, before ever reaching the
51
+ * wiring call: `page-head-contributor.ts(34,55): error TS2339: Property 'canonicalUrl' does not
52
+ * exist on type 'PageHeadContext'.` — an even earlier tripwire than the wiring call, for any
53
+ * drift that touches a field this file's own contributor actually uses.
54
+ * 2. Changed `PageHeadEntryRef.ext`'s type here from `Record<string, unknown>` to `string` — a field
55
+ * NOTHING in `page-head-contributor.ts` reads, chosen specifically to test whether the wiring call
56
+ * is a genuinely independent backstop rather than redundant with case 1. It fired exactly there:
57
+ * `server/app.ts(796,5): error TS2345: Argument of type '...PageHeadHook' [seo/types.ts] is not
58
+ * assignable to parameter of type '...PageHeadHook' [server/http/site/page-head.ts]... Types of
59
+ * property 'ext' are incompatible. Type 'Record<string, unknown>' is not assignable to type
60
+ * 'string'.` — at `registerPageHeadContributor(createSeoPageHeadHook(...))`, line 796.
61
+ *
62
+ * So the real guarantee is TWO-LAYERED: a drift in a field this contributor reads fails fast, inside
63
+ * `seo/`, at the point of use; a drift in a field it does NOT read still fails, loudly, at the
64
+ * `server/app.ts` wiring call — there is no incompatible-drift shape that both layers miss silently.
65
+ */
66
+ /** JSON-LD is a plain JSON object graph (schema.org) — duplicate of `page-head.ts`'s `JsonLd`. */
67
+ export type JsonLd = JsonObject;
68
+ interface HeadElementBase {
69
+ /** behavior.spec.md §2.1's fixed priority bands and dedup comparator — see `page-head.ts`'s own
70
+ * `HeadElementBase` doc for the full rule; unchanged here. */
71
+ readonly priority: number;
72
+ }
73
+ /** Duplicate of `page-head.ts`'s `HeadElement` — see this file's own header for why. */
74
+ export type HeadElement = (HeadElementBase & {
75
+ readonly kind: "title";
76
+ readonly text: string;
77
+ }) | (HeadElementBase & {
78
+ readonly kind: "meta";
79
+ readonly name: string;
80
+ readonly content: string;
81
+ }) | (HeadElementBase & {
82
+ readonly kind: "og";
83
+ readonly property: string;
84
+ readonly content: string;
85
+ }) | (HeadElementBase & {
86
+ readonly kind: "link";
87
+ readonly rel: string;
88
+ readonly href: string;
89
+ readonly hreflang?: string | undefined;
90
+ /** MIME type, e.g. `application/rss+xml` on a feed's `rel="alternate"`. Part of the dedup key. */
91
+ readonly type?: string | undefined;
92
+ readonly title?: string | undefined;
93
+ }) | (HeadElementBase & {
94
+ readonly kind: "jsonld";
95
+ readonly data: JsonLd;
96
+ });
97
+ /** Stable dedup key for a HeadElement — duplicate of `page-head.ts`'s `HeadElementKey`. */
98
+ export type HeadElementKey = string;
99
+ /** Duplicate of `page-head.ts`'s `PageHeadEntryRef` — see this file's own header for why. */
100
+ export interface PageHeadEntryRef {
101
+ id: UUID;
102
+ type: string;
103
+ slug: string;
104
+ title: string;
105
+ status: string;
106
+ publishedAt?: ISODateTime | undefined;
107
+ updatedAt: ISODateTime;
108
+ ext: Record<string, unknown>;
109
+ excerpt?: string | undefined;
110
+ ancestors?: ReadonlyArray<{
111
+ title: string | undefined;
112
+ url: string;
113
+ }>;
114
+ }
115
+ /** Duplicate of `page-head.ts`'s `PageHeadContext` — see this file's own header for why. */
116
+ export interface PageHeadContext {
117
+ workspaceId: UUID;
118
+ route: string;
119
+ contentType?: string | undefined;
120
+ entry?: PageHeadEntryRef | undefined;
121
+ canonicalUrl: string;
122
+ siteTitle: string;
123
+ locale?: string | undefined;
124
+ themeTier?: string | undefined;
125
+ }
126
+ /** Duplicate of `page-head.ts`'s `PageHeadHook` — see this file's own header for why. */
127
+ export interface PageHeadHook {
128
+ readonly priority: number;
129
+ handle(ctx: PageHeadContext): Promise<HeadElement[]>;
130
+ }
131
+ /** Twitter card kind (Twitter/X card meta). */
132
+ export type TwitterCardKind = "summary" | "summary_large_image";
133
+ /** OpenGraph object type (v1 subset). */
134
+ export type OpenGraphType = "website" | "article" | "profile";
135
+ /**
136
+ * The author-authored override bag persisted at `posts.seo_ext_json`. Every
137
+ * field is OPTIONAL — absence means "derive from the entry + site defaults".
138
+ * Unregistered keys are rejected at the write chokepoint (`write-service.ts`);
139
+ * this interface is the registered, validated shape.
140
+ *
141
+ * `ogImage`/`twitterImage` hold a media *ref* (`"{assetId}:{transformName}"`)
142
+ * or an absolute URL — never a frozen `/m/` URL directly (ADR-027 §4: internal
143
+ * content stores refs, not URLs) — resolved to a URL at read time by `media.ts`.
144
+ */
145
+ export interface SeoExtFields {
146
+ /** Meta title override; falls back to `entry.title` run through the template. */
147
+ title?: string | undefined;
148
+ /** Meta description override; falls back to site default / excerpt. */
149
+ description?: string | undefined;
150
+ /** Canonical URL override (absolute); falls back to the routing-resolved URL. */
151
+ canonical?: string | undefined;
152
+ /** Exclude from indexing (emits `robots: noindex` + drops from the sitemap). */
153
+ noindex?: boolean | undefined;
154
+ /** Emit `robots: nofollow`. */
155
+ nofollow?: boolean | undefined;
156
+ /** JSON-LD schema.org @type override (else derived from content type). */
157
+ schemaType?: string | undefined;
158
+ /** OpenGraph overrides. */
159
+ ogTitle?: string | undefined;
160
+ ogDescription?: string | undefined;
161
+ ogImage?: string | undefined;
162
+ ogType?: OpenGraphType | undefined;
163
+ /** Twitter/X card overrides. */
164
+ twitterCard?: TwitterCardKind | undefined;
165
+ twitterTitle?: string | undefined;
166
+ twitterDescription?: string | undefined;
167
+ twitterImage?: string | undefined;
168
+ }
169
+ /**
170
+ * The shape `setEntrySeoOverrides` (`write-service.ts`) accepts as its `patch`. Widens every
171
+ * `SeoExtFields` field to also allow `null`, meaning "remove this key" — the override is cleared
172
+ * back to absent so resolution (`seo.ts`) falls through to the site default / derived value, the
173
+ * same "pass null to clear" vocabulary `SeoSettings`' own nullable fields
174
+ * (`defaultDescription`/`defaultOgImage`/`twitterSite`, see `settings.ts`) already use. `undefined`
175
+ * (an omitted key) still means "leave unchanged" — only an explicit `null` clears.
176
+ *
177
+ * Clearing EVERY currently-set key in one patch (e.g. `{ description: null, title: null, ... }`)
178
+ * is how the entry's `seoExtJson` is expressible back to its true original `NULL` state, not just
179
+ * an empty bag — `setEntrySeoOverrides` collapses a merge result with zero remaining keys to `null`
180
+ * rather than persisting `"{}"` (see that function's own doc comment).
181
+ */
182
+ export type SeoExtFieldsPatch = {
183
+ [K in keyof SeoExtFields]?: SeoExtFields[K] | null;
184
+ };
185
+ export interface OpenGraph {
186
+ title: string;
187
+ description?: string | undefined;
188
+ type: OpenGraphType;
189
+ url: string;
190
+ image?: string | undefined;
191
+ siteName?: string | undefined;
192
+ }
193
+ export interface TwitterCard {
194
+ card: TwitterCardKind;
195
+ title: string;
196
+ description?: string | undefined;
197
+ image?: string | undefined;
198
+ site?: string | undefined;
199
+ }
200
+ export interface RobotsDirective {
201
+ noindex: boolean;
202
+ nofollow: boolean;
203
+ }
204
+ /** JSON-LD is a plain JSON object graph (schema.org). */
205
+ export type JsonLdObject = JsonObject;
206
+ /**
207
+ * The fully-resolved effective meta for one page: author overrides layered
208
+ * over site defaults layered over derived-from-entry (behavior.spec.md §1.1).
209
+ * This is the single source `getEntryMeta`/`analyzeEntry`/the head contributor
210
+ * all read (one evaluator, no back door — INV-09).
211
+ */
212
+ export interface SeoMeta {
213
+ title: string;
214
+ description?: string | undefined;
215
+ canonical: string;
216
+ robots: RobotsDirective;
217
+ openGraph: OpenGraph;
218
+ twitter: TwitterCard;
219
+ jsonLd: JsonLdObject[];
220
+ }
221
+ export type ChangeFreq = "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
222
+ export interface SitemapEntry {
223
+ loc: string;
224
+ lastmod?: string | undefined;
225
+ changefreq?: ChangeFreq | undefined;
226
+ /** 0.0–1.0. */
227
+ priority?: number | undefined;
228
+ }
229
+ /** Context handed to the (v1 empty) `seo.sitemap.collect` hook. */
230
+ export interface SitemapCollectContext {
231
+ workspaceId: string;
232
+ baseUrl: string;
233
+ }
234
+ export interface RobotsRule {
235
+ userAgent: string;
236
+ allow?: string[] | undefined;
237
+ disallow?: string[] | undefined;
238
+ }
239
+ /** Computed at `buildRobots()` read time — never persisted as one shape (state.spec.md §5 item 4). */
240
+ export interface RobotsPolicy {
241
+ rules: RobotsRule[];
242
+ /** Absolute sitemap URLs advertised in robots.txt (`[]` when `sitemapEnabled` is false). */
243
+ sitemapUrls: string[];
244
+ }
245
+ /**
246
+ * Registered `site.seo.*` ledger keys (ADR-PIPE-008 Decision §3's mapping
247
+ * table — 8 registered keys backing 7 `SeoSettings` fields, since
248
+ * `defaultRobots` decomposes into 2 booleans; see `settings.ts`'s file header
249
+ * for the disclosed "7 vs 8" tasks.md wording note).
250
+ */
251
+ export type SeoSettingKey = "title_template" | "default_description" | "default_og_image" | "twitter_site" | "default_robots_noindex" | "default_robots_nofollow" | "sitemap_enabled" | "robots_rules";
252
+ /** The resolved, workspace-scoped SEO settings. No `baseUrl` field exists (INV-07). */
253
+ export interface SeoSettings {
254
+ /** e.g. `"%s — My Site"`; `%s` is the per-page title. */
255
+ titleTemplate: string;
256
+ defaultDescription?: string | undefined;
257
+ defaultOgImage?: string | undefined;
258
+ twitterSite?: string | undefined;
259
+ defaultRobots: RobotsDirective;
260
+ sitemapEnabled: boolean;
261
+ robotsRules: RobotsRule[];
262
+ }
263
+ export type SeoIssueSeverity = "error" | "warning" | "info";
264
+ export interface SeoIssue {
265
+ code: string;
266
+ severity: SeoIssueSeverity;
267
+ message: string;
268
+ /** Which resolved field the issue is about (e.g. `title`, `description`). */
269
+ field?: keyof SeoMeta & string | undefined;
270
+ }
271
+ export interface SeoAnalysis {
272
+ entryId: string;
273
+ score: number;
274
+ issues: SeoIssue[];
275
+ resolved: SeoMeta;
276
+ }
277
+ export {};
278
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,79 @@
1
+ import { type Clock as ClockPort } from "@jini-ai/core/primitives";
2
+ import { type AuthorizeFn } from "../core/index.js";
3
+ import type { SeoPostWritePort as PostRepoPort, SeoFeaturedImagePort } from "./ports.js";
4
+ type FeaturedImageDeps = {
5
+ featuredImage: SeoFeaturedImagePort;
6
+ };
7
+ import type { SeoExtFields, SeoExtFieldsPatch } from "./types.js";
8
+ /**
9
+ * @file `setEntrySeoOverrides` — THE per-entry SEO write chokepoint
10
+ * (ADR-PIPE-008 Decision §4, C-004; REQ-01/02/03). `authorize("admin.seo.manage")`
11
+ * -> validate (registered-key-only, length/URL-scheme) -> merge into the
12
+ * existing (or empty) `posts.seo_ext_json` -> `postRepo.save` -> conditional
13
+ * direct sitemap-cache invalidation on a noindex/canonical-affecting write.
14
+ *
15
+ * No new port: reuses the existing `PostRepoPort` (`findById`/`save`).
16
+ * `invalidateSitemapCache` is injected (not imported from `sitemap.ts`
17
+ * directly) so this file stays independently testable in Phase 2, ahead of
18
+ * Phase 6's `sitemap.ts` existing — the real composition root (Phase 7, T047)
19
+ * wires the real function in.
20
+ *
21
+ * Clearing an override (2026-09-06 fix): a patch value of `null` REMOVES that key from the merged
22
+ * bag (see `SeoExtFieldsPatch`'s doc comment in `types.ts` for the "pass null to clear" convention
23
+ * this mirrors from `SeoSettings`). Before this, there was no way to un-set a key once written — the
24
+ * only options were overwriting it with another value or `""`, and `seo.ts`'s `?? ` precedence chain
25
+ * does not filter `""`, so an empty-string override silently suppressed the site default/derived
26
+ * fallback rather than falling through to it. A merge result left with zero keys is persisted as
27
+ * `seoExtJson: null` (not `"{}"`), so clearing every currently-set key returns the row to its true
28
+ * original state, not a leftover empty bag.
29
+ */
30
+ export declare const STRING_FIELD_MAX_LENGTH = 500;
31
+ export declare const URL_FIELD_MAX_LENGTH = 2048;
32
+ /** Fields whose value is a plain string, length-bounded at 500 chars (behavior.spec.md §4). */
33
+ export declare const STRING_FIELDS: ReadonlyArray<keyof SeoExtFields>;
34
+ /** Fields whose value is a URL/media-ref, length-bounded at 2048 chars (behavior.spec.md §4). */
35
+ export declare const URL_FIELDS: ReadonlyArray<keyof SeoExtFields>;
36
+ export declare const BOOLEAN_FIELDS: ReadonlyArray<keyof SeoExtFields>;
37
+ export declare const OG_TYPE_VALUES: readonly ["website", "article", "profile"];
38
+ export declare const TWITTER_CARD_VALUES: readonly ["summary", "summary_large_image"];
39
+ /** Every registered `SeoExtFields` key — an unregistered patch key is rejected outright (INV-01). */
40
+ export declare const REGISTERED_KEYS: ReadonlyArray<string>;
41
+ export interface SetEntrySeoOverridesDeps {
42
+ postRepo: PostRepoPort;
43
+ authorize: AuthorizeFn;
44
+ /** Injected so this chokepoint stays testable ahead of Phase 6's `sitemap.ts` (see file header). */
45
+ invalidateSitemapCache: (input: {
46
+ workspaceId: string;
47
+ }) => void | Promise<void>;
48
+ /**
49
+ * `post_revisions` timestamping (2026-09-18, round 4) — see {@link mergeOverridesOntoCurrentRow}'s
50
+ * updated doc. Required, not optional-with-a-fallback like `createPost`/`updatePost`'s own
51
+ * `beforeSaveHook`: every real caller (`seo/tool-registrations.ts`, `routes/seo/put-entry.ts`)
52
+ * already has a `ClockPort` on its own deps bag, so there is no "existing caller with no reachable
53
+ * clock" case to preserve compiling here the way `CreatePostDeps.beforeSaveHook`'s doc describes.
54
+ */
55
+ clock: ClockPort;
56
+ /** Media lookups for the share-image check (2026-10-05): an `ogImage`/`twitterImage` ref naming a
57
+ * video is refused. Required so no write path can skip it by not wiring it. */
58
+ media: FeaturedImageDeps;
59
+ }
60
+ export interface SetEntrySeoOverridesInput {
61
+ workspaceId: string;
62
+ entryId: string;
63
+ patch: SeoExtFieldsPatch;
64
+ callerPrincipalId: string;
65
+ }
66
+ export interface SetEntrySeoOverridesRequired {
67
+ deps: SetEntrySeoOverridesDeps;
68
+ input: SetEntrySeoOverridesInput;
69
+ }
70
+ /**
71
+ * REQ-01/02/03 chokepoint write: authorize -> validate -> merge -> save -> conditional cache invalidation.
72
+ * @throws EntityNotLiveError (via {@link mergeOverridesOntoCurrentRow}, S6 web-high fix plan
73
+ * 2026-09-24) when the entry is in the Trash — no merge, no version bump, no revision.
74
+ */
75
+ export declare function setEntrySeoOverrides(required: SetEntrySeoOverridesRequired, _optional?: Record<string, never>): Promise<{
76
+ overrides: SeoExtFields;
77
+ }>;
78
+ export {};
79
+ //# sourceMappingURL=write-service.d.ts.map
@@ -0,0 +1,273 @@
1
+ import { nowIso as clockNowIso } from "@jini-ai/core/primitives";
2
+ import {} from "@jini-ai/core/primitives";
3
+ import { assertEntityLive, ForbiddenError } from "../core/index.js";
4
+ import { SeoConcurrentWriteError, SeoEntryNotFoundError, SeoFieldValidationError, SeoInvalidCanonicalUrlError, } from "./errors.js";
5
+ import { seoImageRefRefusal } from "./media.js";
6
+ /**
7
+ * @file `setEntrySeoOverrides` — THE per-entry SEO write chokepoint
8
+ * (ADR-PIPE-008 Decision §4, C-004; REQ-01/02/03). `authorize("admin.seo.manage")`
9
+ * -> validate (registered-key-only, length/URL-scheme) -> merge into the
10
+ * existing (or empty) `posts.seo_ext_json` -> `postRepo.save` -> conditional
11
+ * direct sitemap-cache invalidation on a noindex/canonical-affecting write.
12
+ *
13
+ * No new port: reuses the existing `PostRepoPort` (`findById`/`save`).
14
+ * `invalidateSitemapCache` is injected (not imported from `sitemap.ts`
15
+ * directly) so this file stays independently testable in Phase 2, ahead of
16
+ * Phase 6's `sitemap.ts` existing — the real composition root (Phase 7, T047)
17
+ * wires the real function in.
18
+ *
19
+ * Clearing an override (2026-09-06 fix): a patch value of `null` REMOVES that key from the merged
20
+ * bag (see `SeoExtFieldsPatch`'s doc comment in `types.ts` for the "pass null to clear" convention
21
+ * this mirrors from `SeoSettings`). Before this, there was no way to un-set a key once written — the
22
+ * only options were overwriting it with another value or `""`, and `seo.ts`'s `?? ` precedence chain
23
+ * does not filter `""`, so an empty-string override silently suppressed the site default/derived
24
+ * fallback rather than falling through to it. A merge result left with zero keys is persisted as
25
+ * `seoExtJson: null` (not `"{}"`), so clearing every currently-set key returns the row to its true
26
+ * original state, not a leftover empty bag.
27
+ */
28
+ // Exported (unchanged values) so `agent-tools.ts`'s published JSON Schema can reuse the exact
29
+ // same bounds/vocabulary this chokepoint validates against, rather than restating them — the same
30
+ // discipline `newsletter/agent-tools.ts` uses for `campaign-write-service.ts`'s `SUBJECT_MAX`/etc.
31
+ export const STRING_FIELD_MAX_LENGTH = 500;
32
+ export const URL_FIELD_MAX_LENGTH = 2048;
33
+ /** Fields whose value is a plain string, length-bounded at 500 chars (behavior.spec.md §4). */
34
+ export const STRING_FIELDS = [
35
+ "title",
36
+ "description",
37
+ "schemaType",
38
+ "ogTitle",
39
+ "ogDescription",
40
+ "twitterTitle",
41
+ "twitterDescription",
42
+ ];
43
+ /** Fields whose value is a URL/media-ref, length-bounded at 2048 chars (behavior.spec.md §4). */
44
+ export const URL_FIELDS = ["canonical", "ogImage", "twitterImage"];
45
+ export const BOOLEAN_FIELDS = ["noindex", "nofollow"];
46
+ export const OG_TYPE_VALUES = ["website", "article", "profile"];
47
+ export const TWITTER_CARD_VALUES = ["summary", "summary_large_image"];
48
+ /** Every registered `SeoExtFields` key — an unregistered patch key is rejected outright (INV-01). */
49
+ export const REGISTERED_KEYS = [
50
+ ...STRING_FIELDS,
51
+ ...URL_FIELDS,
52
+ ...BOOLEAN_FIELDS,
53
+ "ogType",
54
+ "twitterCard",
55
+ ];
56
+ /** `javascript:`/`data:` (and other script-capable schemes) are never a safe canonical override. */
57
+ const UNSAFE_URL_SCHEME_PATTERN = /^\s*(javascript|data|vbscript|file):/i;
58
+ /**
59
+ * Pure validation over a raw patch object. Never mutates; throws the first
60
+ * violation found. Kept separate from the chokepoint so Code Review/tests can
61
+ * exercise it directly if needed, though `setEntrySeoOverrides` is the only
62
+ * caller.
63
+ */
64
+ /** Every patch key must be a known `SeoExtFields` key (INV-01) — checked before any per-field rule. */
65
+ function validateRegisteredKeys(patch) {
66
+ for (const key of Object.keys(patch)) {
67
+ if (!REGISTERED_KEYS.includes(key)) {
68
+ throw new SeoFieldValidationError(`'${key}' is not a registered SEO field`);
69
+ }
70
+ }
71
+ }
72
+ /** Shared shape for both STRING_FIELDS and URL_FIELDS: optional string, bounded by `maxLength`.
73
+ * `null` is the clear sentinel (see file header) — skipped here, same as `undefined`, since it
74
+ * never becomes a stored value for this key. */
75
+ function validateStringLikeFields(patch, fields, maxLength) {
76
+ for (const key of fields) {
77
+ const value = patch[key];
78
+ if (value === undefined || value === null)
79
+ continue;
80
+ if (typeof value !== "string") {
81
+ throw new SeoFieldValidationError(`'${key}' must be a string`);
82
+ }
83
+ if (value.length > maxLength) {
84
+ throw new SeoFieldValidationError(`'${key}' must be at most ${maxLength} characters`);
85
+ }
86
+ }
87
+ }
88
+ // Canonical's unsafe-scheme check is its own typed error (SEO_INVALID_CANONICAL_URL),
89
+ // distinct from the generic length/type SEO_FIELD_VALIDATION_ERROR the other field checks throw.
90
+ function validateCanonicalScheme(patch) {
91
+ const canonical = patch.canonical;
92
+ if (typeof canonical === "string" && UNSAFE_URL_SCHEME_PATTERN.test(canonical)) {
93
+ throw new SeoInvalidCanonicalUrlError(`'canonical' uses an unsafe URL scheme`);
94
+ }
95
+ }
96
+ /** `null` is the clear sentinel (see file header) — skipped, same as `undefined`. */
97
+ function validateBooleanFields(patch) {
98
+ for (const key of BOOLEAN_FIELDS) {
99
+ const value = patch[key];
100
+ if (value !== undefined && value !== null && typeof value !== "boolean") {
101
+ throw new SeoFieldValidationError(`'${key}' must be a boolean`);
102
+ }
103
+ }
104
+ }
105
+ /** `null` is the clear sentinel (see file header) — skipped, same as `undefined`. */
106
+ function validateEnumFields(patch) {
107
+ if (patch.ogType !== undefined &&
108
+ patch.ogType !== null &&
109
+ !OG_TYPE_VALUES.includes(patch.ogType)) {
110
+ throw new SeoFieldValidationError(`'ogType' must be one of ${OG_TYPE_VALUES.join(", ")}`);
111
+ }
112
+ if (patch.twitterCard !== undefined &&
113
+ patch.twitterCard !== null &&
114
+ !TWITTER_CARD_VALUES.includes(patch.twitterCard)) {
115
+ throw new SeoFieldValidationError(`'twitterCard' must be one of ${TWITTER_CARD_VALUES.join(", ")}`);
116
+ }
117
+ }
118
+ /** Share-image fields whose media ref must be an image (2026-10-05, see `seoImageRefRefusal`). */
119
+ const SHARE_IMAGE_FIELDS = ["ogImage", "twitterImage"];
120
+ /** Refuses a share-image field that names a known non-image asset. Runs after `authorize`, so a
121
+ * caller without `admin.seo.manage` cannot probe the media library through its message. */
122
+ async function validateShareImageRefs(media, input) {
123
+ for (const field of SHARE_IMAGE_FIELDS) {
124
+ const refusal = await seoImageRefRefusal({ deps: media, input: { workspaceId: input.workspaceId, field, ref: input.patch[field] } });
125
+ if (refusal)
126
+ throw new SeoFieldValidationError(refusal);
127
+ }
128
+ }
129
+ function validateSeoExtFieldsPatch(patch) {
130
+ validateRegisteredKeys(patch);
131
+ validateStringLikeFields(patch, STRING_FIELDS, STRING_FIELD_MAX_LENGTH);
132
+ validateStringLikeFields(patch, URL_FIELDS, URL_FIELD_MAX_LENGTH);
133
+ validateCanonicalScheme(patch);
134
+ validateBooleanFields(patch);
135
+ validateEnumFields(patch);
136
+ }
137
+ /** Fields whose change can flip sitemap eligibility (INV-04/INV-05) — a direct, non-outbox cache-invalidation trigger. */
138
+ const SITEMAP_ELIGIBILITY_FIELDS = ["noindex", "canonical"];
139
+ function touchesSitemapEligibility(patch) {
140
+ return Object.keys(patch).some((key) => SITEMAP_ELIGIBILITY_FIELDS.includes(key));
141
+ }
142
+ /** Applies `patch` onto `currentOverrides`: a `null` value REMOVES that key (clears the override
143
+ * back to absent, see file header); any other value sets/replaces it; `undefined` (an omitted
144
+ * key) is left untouched. Pure — never mutates `currentOverrides`. */
145
+ function applyOverridesPatch(currentOverrides, patch) {
146
+ const merged = { ...currentOverrides };
147
+ for (const [key, value] of Object.entries(patch)) {
148
+ if (value === null) {
149
+ delete merged[key];
150
+ }
151
+ else if (value !== undefined) {
152
+ merged[key] = value;
153
+ }
154
+ }
155
+ return merged;
156
+ }
157
+ /**
158
+ * How many times the read-merge-write below will re-read a row another writer won underneath it.
159
+ *
160
+ * Three, not one, because a single retry is indistinguishable from luck, and not "until it lands",
161
+ * because an unbounded loop against a hot row is a request that never returns. A row rewritten
162
+ * three times inside the microtask gaps of one SEO write is not contention, it is something wrong.
163
+ */
164
+ const MAX_MERGE_ATTEMPTS = 3;
165
+ /**
166
+ * The SEO chokepoint's whole write: read the row, merge the patch onto whatever it currently holds,
167
+ * and persist it ONLY while it is still the row that was read.
168
+ *
169
+ * Why the predicate (2026-09-07, fable bugs audit SEO-01): this writes the WHOLE row — it is a
170
+ * `PostRecord` spread, not a one-column UPDATE — so an unconditional `save()` here rewrites
171
+ * `bodyJson`, `title` and `status` with whatever they were when this function read them. A content
172
+ * save that landed in the gap was silently reverted by an operator editing a meta description, and
173
+ * the SEO write reported success. `saveIfVersion` refuses that write instead.
174
+ *
175
+ * Why a retry rather than a conflict error: unlike `updatePost`, no SEO caller states a version
176
+ * basis, so there is nobody to hand a 409 to and nothing for them to reconcile — the patch is a
177
+ * merge, and re-reading simply merges it onto newer content, which is what the caller asked for.
178
+ * The caller-visible outcome on a contended row is therefore unchanged (success); what changed is
179
+ * that it no longer takes the other writer's content down with it.
180
+ *
181
+ * Note this DOES still bump `version` on an SEO-only change, which makes an open editor's autosave
182
+ * basis stale (`PostRepoPort.writeAutosave`'s predicate) and can 409 its next explicit Save. That
183
+ * is pre-existing behaviour and deliberately unchanged here — it is a contract question about what
184
+ * `posts.version` means, not a concurrency bug.
185
+ *
186
+ * Revision ledger (2026-09-18, round 4): this was the one production write path that bumped
187
+ * `posts.version` without appending a `post_revisions` row — a real, disclosed bypass
188
+ * (`2026-09-18-impl-post-revisions-3.md` finding #1). Fixed by reusing the SAME
189
+ * `PostRepoPort.transaction`/`appendRevision` primitive `createPost`/`updatePost`/`deletePost`
190
+ * already use for exactly this purpose (`post.ts`), rather than calling `updatePost()` itself:
191
+ * `updatePost` throws `PostVersionConflictError` on a stale basis, which would turn this function's
192
+ * own retry-on-conflict loop (the whole point of SEO-01, see above) into a single-shot rejection —
193
+ * a real behavior change to an already-tested concurrency contract. Reusing the lower primitive
194
+ * keeps `saveIfVersion`'s retry loop byte-for-byte unchanged while still writing exactly one
195
+ * revision per successful merge, atomically with the row write it describes.
196
+ *
197
+ * @throws SeoEntryNotFoundError when the entry does not exist (or is deleted mid-retry).
198
+ * @throws EntityNotLiveError (`@jini-ai/cms/core`, S6 web-high fix plan 2026-09-24) when the entry
199
+ * is in the Trash. Checked every attempt, inside the retry loop, so a row trashed between two
200
+ * attempts is refused on the next read rather than merged onto and resurrected.
201
+ * @throws SeoConcurrentWriteError when {@link MAX_MERGE_ATTEMPTS} reads all lost the row.
202
+ * @complexity O(attempts) queries, one read + one conditional write (+ one revision append on the
203
+ * attempt that lands) each; one of each in the uncontended case.
204
+ */
205
+ async function mergeOverridesOntoCurrentRow(postRepo, clock, input) {
206
+ for (let attempt = 0; attempt < MAX_MERGE_ATTEMPTS; attempt += 1) {
207
+ const existing = await postRepo.findById({ workspaceId: input.workspaceId, id: input.entryId });
208
+ if (!existing) {
209
+ throw new SeoEntryNotFoundError(`entry '${input.entryId}' was not found`);
210
+ }
211
+ assertEntityLive({ entityType: existing.kind, entityId: input.entryId, state: postRepo.isTrashed({ post: existing }) ? "trashed" : "live" });
212
+ const currentOverrides = existing.seoExtJson ? JSON.parse(existing.seoExtJson) : {};
213
+ const mergedOverrides = applyOverridesPatch(currentOverrides, input.patch);
214
+ const nextVersion = existing.version + 1;
215
+ const updatedRecord = {
216
+ ...existing,
217
+ // Zero remaining keys returns the row to its true original state (`NULL`), not a leftover
218
+ // `"{}"` — see file header.
219
+ seoExtJson: Object.keys(mergedOverrides).length === 0 ? null : JSON.stringify(mergedOverrides),
220
+ version: nextVersion,
221
+ };
222
+ // The write and its revision-ledger append are one atomic unit, same discipline as
223
+ // `createPost`/`updatePost`/`deletePost` (`post.ts`'s own doc on `PostRepoPort.transaction`) —
224
+ // a revision must never be recorded for a save that didn't apply, and an applied save must
225
+ // never land unaccompanied by its revision. A `saveIfVersion` miss (lost the race) returns
226
+ // normally with `applied: false` rather than throwing, so the transaction still commits (a
227
+ // harmless no-op) and the outer loop retries with a fresh read, exactly as before this change.
228
+ const applied = await postRepo.transaction({ fn: async () => {
229
+ const { applied: didApply } = await postRepo.saveIfVersion({ record: updatedRecord, ifVersion: existing.version });
230
+ if (!didApply)
231
+ return false;
232
+ await postRepo.appendRevision({
233
+ postId: updatedRecord.id,
234
+ workspaceId: updatedRecord.workspaceId,
235
+ seq: nextVersion,
236
+ op: "update",
237
+ stateJson: updatedRecord,
238
+ actorId: input.callerPrincipalId,
239
+ recordedAt: clockNowIso({ clock: clock }),
240
+ });
241
+ return true;
242
+ } });
243
+ if (applied)
244
+ return mergedOverrides;
245
+ }
246
+ throw new SeoConcurrentWriteError(`entry '${input.entryId}' is being written by another save; the SEO overrides were not applied`);
247
+ }
248
+ /**
249
+ * REQ-01/02/03 chokepoint write: authorize -> validate -> merge -> save -> conditional cache invalidation.
250
+ * @throws EntityNotLiveError (via {@link mergeOverridesOntoCurrentRow}, S6 web-high fix plan
251
+ * 2026-09-24) when the entry is in the Trash — no merge, no version bump, no revision.
252
+ */
253
+ export async function setEntrySeoOverrides(required, _optional = {}) {
254
+ const { deps, input } = required;
255
+ const authResult = await deps.authorize({
256
+ principalId: input.callerPrincipalId,
257
+ permission: "admin.seo.manage",
258
+ workspaceId: input.workspaceId,
259
+ entityType: "seo-entry",
260
+ entityId: input.entryId,
261
+ });
262
+ if (!authResult.allowed) {
263
+ throw new ForbiddenError({ message: `principal '${input.callerPrincipalId}' is not authorized for 'admin.seo.manage' (${authResult.reason})`, permission: "admin.seo.manage", reason: authResult.reason });
264
+ }
265
+ validateSeoExtFieldsPatch(input.patch);
266
+ await validateShareImageRefs(deps.media, input);
267
+ const mergedOverrides = await mergeOverridesOntoCurrentRow(deps.postRepo, deps.clock, input);
268
+ if (touchesSitemapEligibility(input.patch)) {
269
+ await deps.invalidateSitemapCache({ workspaceId: input.workspaceId });
270
+ }
271
+ return { overrides: mergedOverrides };
272
+ }
273
+ //# sourceMappingURL=write-service.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @file The one HTML/XML text escaper. A leaf module with NO imports, on purpose: the old copies
3
+ * lived inside `render.ts`, `form-render.ts` and `static-render.ts` and were duplicated rather than
4
+ * shared because importing them would have closed a runtime import cycle. A module that imports
5
+ * nothing can be imported from anywhere.
6
+ *
7
+ * `&` is replaced first: it is the escape character for every entity after it, so replacing any
8
+ * other character first would double-escape the `&` that replacement introduced. The apostrophe
9
+ * becomes the numeric `&#39;` in HTML, because `&apos;` is undefined in HTML4/XHTML1.
10
+ */
11
+ /** {@link escapeHtml} with the XML named entity `&apos;` for the apostrophe (sitemap and feed XML).
12
+ * @complexity O(text length). */
13
+ export declare function escapeXml(required: {
14
+ text: string;
15
+ }, _optional?: Record<string, never>): string;
16
+ //# sourceMappingURL=xml-escape.d.ts.map