@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.
- package/CHANGELOG.md +15 -0
- package/README.md +14 -8
- package/dist/comments/errors.d.ts +23 -0
- package/dist/comments/errors.js +25 -0
- package/dist/comments/express/index.d.ts +2 -0
- package/dist/comments/express/index.js +2 -0
- package/dist/comments/express/submit-handler.d.ts +30 -0
- package/dist/comments/express/submit-handler.js +85 -0
- package/dist/comments/hooks.d.ts +39 -0
- package/dist/comments/hooks.js +41 -0
- package/dist/comments/index.d.ts +11 -0
- package/dist/comments/index.js +11 -0
- package/dist/comments/ingress.d.ts +57 -0
- package/dist/comments/ingress.js +173 -0
- package/dist/comments/module.d.ts +94 -0
- package/dist/comments/module.js +73 -0
- package/dist/comments/ports.d.ts +184 -0
- package/dist/comments/ports.js +2 -0
- package/dist/comments/repo.memory.d.ts +70 -0
- package/dist/comments/repo.memory.js +120 -0
- package/dist/comments/sanitize.d.ts +15 -0
- package/dist/comments/sanitize.js +31 -0
- package/dist/comments/spam.heuristic.d.ts +8 -0
- package/dist/comments/spam.heuristic.js +54 -0
- package/dist/comments/sql/index.d.ts +3 -0
- package/dist/comments/sql/index.js +3 -0
- package/dist/comments/sql/repo.d.ts +77 -0
- package/dist/comments/sql/repo.js +190 -0
- package/dist/comments/sql/repo.rows.d.ts +49 -0
- package/dist/comments/sql/repo.rows.js +71 -0
- package/dist/comments/types.d.ts +187 -0
- package/dist/comments/types.js +39 -0
- package/dist/comments/write-service.d.ts +121 -0
- package/dist/comments/write-service.js +144 -0
- package/dist/core/commands/command.d.ts +2 -9
- package/dist/core/commands/command.js +2 -9
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +1 -0
- package/dist/core/keyset-cursor.d.ts +25 -0
- package/dist/core/keyset-cursor.js +20 -0
- package/dist/core/ports.d.ts +30 -0
- package/dist/core/ports.js +0 -1
- package/dist/core/tools/args.d.ts +1 -46
- package/dist/core/tools/args.js +1 -44
- package/dist/forms/delete-submission.d.ts +38 -0
- package/dist/forms/delete-submission.js +25 -0
- package/dist/forms/duplicate-slug.d.ts +81 -0
- package/dist/forms/duplicate-slug.js +115 -0
- package/dist/forms/errors.d.ts +53 -0
- package/dist/forms/errors.js +25 -0
- package/dist/forms/express/handler.d.ts +59 -0
- package/dist/forms/express/handler.js +217 -0
- package/dist/forms/express/index.d.ts +2 -0
- package/dist/forms/express/index.js +2 -0
- package/dist/forms/fields-json.d.ts +14 -0
- package/dist/forms/fields-json.js +11 -0
- package/dist/forms/forms.d.ts +59 -0
- package/dist/forms/forms.js +213 -0
- package/dist/forms/html/authoring-adapter.d.ts +34 -0
- package/dist/forms/html/authoring-adapter.js +48 -0
- package/dist/forms/html/html-authoring.d.ts +12 -0
- package/dist/forms/html/html-authoring.js +133 -0
- package/dist/forms/html/html-submission-adapter.d.ts +10 -0
- package/dist/forms/html/html-submission-adapter.js +15 -0
- package/dist/forms/html/index.d.ts +4 -0
- package/dist/forms/html/index.js +4 -0
- package/dist/forms/html-render.d.ts +33 -0
- package/dist/forms/html-render.js +31 -0
- package/dist/forms/index.d.ts +17 -0
- package/dist/forms/index.js +17 -0
- package/dist/forms/notify-subscriber.d.ts +32 -0
- package/dist/forms/notify-subscriber.js +60 -0
- package/dist/forms/ports.d.ts +168 -0
- package/dist/forms/ports.js +2 -0
- package/dist/forms/rate-limit-key.d.ts +8 -0
- package/dist/forms/rate-limit-key.js +7 -0
- package/dist/forms/repo.memory.d.ts +120 -0
- package/dist/forms/repo.memory.js +188 -0
- package/dist/forms/sql/index.d.ts +5 -0
- package/dist/forms/sql/index.js +5 -0
- package/dist/forms/sql/repo.d.ts +94 -0
- package/dist/forms/sql/repo.js +175 -0
- package/dist/forms/sql/repo.rows.d.ts +33 -0
- package/dist/forms/sql/repo.rows.js +64 -0
- package/dist/forms/sql/submission-ip-retention-repo.d.ts +9 -0
- package/dist/forms/sql/submission-ip-retention-repo.js +40 -0
- package/dist/forms/sql/tables.d.ts +32 -0
- package/dist/forms/sql/tables.js +2 -0
- package/dist/forms/submission-attempts.d.ts +70 -0
- package/dist/forms/submission-attempts.js +70 -0
- package/dist/forms/submission-email-validation.d.ts +8 -0
- package/dist/forms/submission-email-validation.js +34 -0
- package/dist/forms/submission-ip-retention.d.ts +30 -0
- package/dist/forms/submission-ip-retention.js +27 -0
- package/dist/forms/submit-service.d.ts +78 -0
- package/dist/forms/submit-service.js +109 -0
- package/dist/forms/types.d.ts +49 -0
- package/dist/forms/types.js +2 -0
- package/dist/forms/write-service.d.ts +83 -0
- package/dist/forms/write-service.js +271 -0
- package/dist/media/index.d.ts +0 -3
- package/dist/media/index.js +1 -4
- package/dist/media/node.d.ts +5 -0
- package/dist/media/node.js +6 -0
- package/dist/redirects/capture.d.ts +80 -0
- package/dist/redirects/capture.js +66 -0
- package/dist/redirects/express/index.d.ts +27 -0
- package/dist/redirects/express/index.js +40 -0
- package/dist/redirects/hit-sink.d.ts +56 -0
- package/dist/redirects/hit-sink.js +44 -0
- package/dist/redirects/host-config/index.d.ts +40 -0
- package/dist/redirects/host-config/index.js +145 -0
- package/dist/redirects/index.d.ts +14 -0
- package/dist/redirects/index.js +14 -0
- package/dist/redirects/matcher.d.ts +58 -0
- package/dist/redirects/matcher.js +144 -0
- package/dist/redirects/order.d.ts +30 -0
- package/dist/redirects/order.js +26 -0
- package/dist/redirects/ports.d.ts +242 -0
- package/dist/redirects/ports.internal.d.ts +57 -0
- package/dist/redirects/ports.internal.js +14 -0
- package/dist/redirects/ports.js +2 -0
- package/dist/redirects/redirects.d.ts +226 -0
- package/dist/redirects/redirects.js +590 -0
- package/dist/redirects/referrer-alias.d.ts +31 -0
- package/dist/redirects/referrer-alias.js +33 -0
- package/dist/redirects/repo.memory.d.ts +51 -0
- package/dist/redirects/repo.memory.js +98 -0
- package/dist/redirects/reserved-destination.d.ts +58 -0
- package/dist/redirects/reserved-destination.js +59 -0
- package/dist/redirects/resolver.d.ts +100 -0
- package/dist/redirects/resolver.js +87 -0
- package/dist/redirects/spa/index.d.ts +30 -0
- package/dist/redirects/spa/index.js +27 -0
- package/dist/redirects/sql/index.d.ts +4 -0
- package/dist/redirects/sql/index.js +4 -0
- package/dist/redirects/sql/repo.d.ts +82 -0
- package/dist/redirects/sql/repo.js +143 -0
- package/dist/redirects/sql/repo.rows.d.ts +49 -0
- package/dist/redirects/sql/repo.rows.js +79 -0
- package/dist/redirects/sql/row-types.d.ts +34 -0
- package/dist/redirects/sql/row-types.js +2 -0
- package/dist/redirects/target-gate.d.ts +30 -0
- package/dist/redirects/target-gate.js +85 -0
- package/dist/redirects/types.d.ts +213 -0
- package/dist/redirects/types.js +42 -0
- package/dist/seo/absolute-url.d.ts +58 -0
- package/dist/seo/absolute-url.js +117 -0
- package/dist/seo/dom/index.d.ts +14 -0
- package/dist/seo/dom/index.js +52 -0
- package/dist/seo/errors.d.ts +35 -0
- package/dist/seo/errors.js +35 -0
- package/dist/seo/feed.d.ts +49 -0
- package/dist/seo/feed.js +61 -0
- package/dist/seo/index.d.ts +13 -0
- package/dist/seo/index.js +13 -0
- package/dist/seo/media.d.ts +57 -0
- package/dist/seo/media.js +134 -0
- package/dist/seo/page-head.d.ts +18 -0
- package/dist/seo/page-head.js +114 -0
- package/dist/seo/ports.d.ts +221 -0
- package/dist/seo/ports.js +27 -0
- package/dist/seo/seo.d.ts +73 -0
- package/dist/seo/seo.js +230 -0
- package/dist/seo/sitemap.d.ts +139 -0
- package/dist/seo/sitemap.js +217 -0
- package/dist/seo/types.d.ts +278 -0
- package/dist/seo/types.js +2 -0
- package/dist/seo/write-service.d.ts +79 -0
- package/dist/seo/write-service.js +273 -0
- package/dist/seo/xml-escape.d.ts +16 -0
- package/dist/seo/xml-escape.js +19 -0
- package/dist/settings/express/cms-adapter.d.ts +20 -0
- package/dist/settings/express/cms-adapter.js +124 -0
- package/dist/settings/express/contracts.d.ts +135 -0
- package/dist/settings/express/contracts.js +2 -0
- package/dist/settings/express/http.d.ts +10 -0
- package/dist/settings/express/http.js +211 -0
- package/dist/settings/express/index.d.ts +5 -0
- package/dist/settings/express/index.js +5 -0
- package/dist/settings/express/shared.d.ts +68 -0
- package/dist/settings/express/shared.js +54 -0
- package/dist/settings/express/stream.d.ts +24 -0
- package/dist/settings/express/stream.js +147 -0
- package/dist/settings/index.d.ts +3 -38
- package/dist/settings/index.js +3 -35
- package/dist/taxonomy/write-service.d.ts +2 -30
- package/dist/widgets/authorize-helper.d.ts +42 -0
- package/dist/widgets/authorize-helper.js +16 -0
- package/dist/widgets/concurrency.d.ts +12 -0
- package/dist/widgets/concurrency.js +44 -0
- package/dist/widgets/config-validation.d.ts +34 -0
- package/dist/widgets/config-validation.js +125 -0
- package/dist/widgets/embed-service.d.ts +133 -0
- package/dist/widgets/embed-service.js +460 -0
- package/dist/widgets/embed-validation.d.ts +23 -0
- package/dist/widgets/embed-validation.js +38 -0
- package/dist/widgets/entry-payload.d.ts +103 -0
- package/dist/widgets/entry-payload.js +160 -0
- package/dist/widgets/errors.d.ts +136 -0
- package/dist/widgets/errors.js +136 -0
- package/dist/widgets/host-ports.d.ts +149 -0
- package/dist/widgets/host-ports.js +2 -0
- package/dist/widgets/html/html-embeds.d.ts +247 -0
- package/dist/widgets/html/html-embeds.js +408 -0
- package/dist/widgets/html/index.d.ts +5 -0
- package/dist/widgets/html/index.js +4 -0
- package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
- package/dist/widgets/html/legacy-widget-markers.js +45 -0
- package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
- package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
- package/dist/widgets/index.d.ts +20 -0
- package/dist/widgets/index.js +19 -0
- package/dist/widgets/markers/html-attributes.d.ts +98 -0
- package/dist/widgets/markers/html-attributes.js +216 -0
- package/dist/widgets/markers/index.d.ts +3 -0
- package/dist/widgets/markers/index.js +3 -0
- package/dist/widgets/markers/marker.d.ts +496 -0
- package/dist/widgets/markers/marker.js +780 -0
- package/dist/widgets/page-embed-types.d.ts +42 -0
- package/dist/widgets/page-embed-types.js +42 -0
- package/dist/widgets/ports.d.ts +142 -0
- package/dist/widgets/ports.js +2 -0
- package/dist/widgets/read-service.d.ts +81 -0
- package/dist/widgets/read-service.js +79 -0
- package/dist/widgets/region-area-service.d.ts +110 -0
- package/dist/widgets/region-area-service.js +180 -0
- package/dist/widgets/registry.d.ts +217 -0
- package/dist/widgets/registry.js +172 -0
- package/dist/widgets/repo.memory.d.ts +41 -0
- package/dist/widgets/repo.memory.js +48 -0
- package/dist/widgets/resolver-service.d.ts +209 -0
- package/dist/widgets/resolver-service.js +909 -0
- package/dist/widgets/resolvers/contact-form.d.ts +26 -0
- package/dist/widgets/resolvers/contact-form.js +54 -0
- package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
- package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
- package/dist/widgets/resolvers/index.d.ts +65 -0
- package/dist/widgets/resolvers/index.js +155 -0
- package/dist/widgets/resolvers/menu.d.ts +42 -0
- package/dist/widgets/resolvers/menu.js +43 -0
- package/dist/widgets/resolvers/ports.d.ts +82 -0
- package/dist/widgets/resolvers/ports.js +2 -0
- package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
- package/dist/widgets/resolvers/recent-entries.js +165 -0
- package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
- package/dist/widgets/resolvers/taxonomy.js +34 -0
- package/dist/widgets/sql/index.d.ts +3 -0
- package/dist/widgets/sql/index.js +3 -0
- package/dist/widgets/sql/repo.d.ts +41 -0
- package/dist/widgets/sql/repo.js +82 -0
- package/dist/widgets/sql/tables.d.ts +13 -0
- package/dist/widgets/sql/tables.js +2 -0
- package/dist/widgets/types.d.ts +213 -0
- package/dist/widgets/types.js +17 -0
- package/dist/widgets/where-used.d.ts +41 -0
- package/dist/widgets/where-used.js +11 -0
- package/dist/widgets/write-service.d.ts +204 -0
- package/dist/widgets/write-service.js +332 -0
- package/dist/workspace/delete.d.ts +1 -1
- package/package.json +220 -12
|
@@ -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,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 `'` in HTML, because `'` is undefined in HTML4/XHTML1.
|
|
10
|
+
*/
|
|
11
|
+
/** {@link escapeHtml} with the XML named entity `'` 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
|