@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.
- package/CHANGELOG.md +11 -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/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 -11
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `resolveSeoImageRef` (ADR-PIPE-008 Decision §6, C-013, EC-07;
|
|
3
|
+
* EC-07's "never generates" clause AMENDED 2026-09-05 — see the ADR's
|
|
4
|
+
* Amendments section) — media-ref-to-URL resolution for
|
|
5
|
+
* `ogImage`/`twitterImage`. Read-only, over `media`'s existing repos (no new
|
|
6
|
+
* port). Never throws — resolves `undefined` on any lookup miss (deleted
|
|
7
|
+
* asset, trashed asset, unregistered transform) so the rest of the head
|
|
8
|
+
* render never breaks over one bad image reference.
|
|
9
|
+
*
|
|
10
|
+
* Does NOT require a rendition to already exist. It always composes the URL
|
|
11
|
+
* for the LATEST registered version of `transformName` (`buildSeoImageUrl`
|
|
12
|
+
* below), whether or not that exact rendition row has been generated yet.
|
|
13
|
+
* This is safe because the public serving route
|
|
14
|
+
* (`routes/site/media-rendition.ts` -> `resolveMediaRendition` ->
|
|
15
|
+
* `isLatestTransformVersion`) always allows anonymous lazy generation for a
|
|
16
|
+
* not-yet-generated rendition of the latest registered version — the same
|
|
17
|
+
* "latest" this function selects — so every URL this function emits is
|
|
18
|
+
* guaranteed servable on first fetch. `assetRenditionRepo` stays on
|
|
19
|
+
* {@link ResolveSeoImageRefDeps} unused by this function today (kept for
|
|
20
|
+
* shape stability / potential future consumers, e.g. an admin preview that
|
|
21
|
+
* wants to know whether a rendition already exists) rather than removed as
|
|
22
|
+
* part of this change.
|
|
23
|
+
*/
|
|
24
|
+
// The host featured-image port replaces the historical helper/repo bindings described above.
|
|
25
|
+
// The unused assetRenditionRepo is no longer required: resolution still checks no rendition row.
|
|
26
|
+
const ABSOLUTE_URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
27
|
+
function isAbsoluteUrl(value) {
|
|
28
|
+
return ABSOLUTE_URL_PATTERN.test(value) || value.startsWith("//");
|
|
29
|
+
}
|
|
30
|
+
/** Cosmetic only (ADR-027 §4 — never participates in the rendition lookup itself). */
|
|
31
|
+
const EXT_BY_TRANSFORM_FORMAT = {
|
|
32
|
+
jpeg: "jpg",
|
|
33
|
+
png: "png",
|
|
34
|
+
webp: "webp",
|
|
35
|
+
gif: "gif",
|
|
36
|
+
};
|
|
37
|
+
/** Splits `"{idOrSlug}:{transformName}"` out of a ref string, or `null` when the ref is not that shape. */
|
|
38
|
+
function parseMediaRefParts(ref) {
|
|
39
|
+
const separatorIndex = ref.indexOf(":");
|
|
40
|
+
if (separatorIndex <= 0 || separatorIndex === ref.length - 1)
|
|
41
|
+
return null;
|
|
42
|
+
return { idOrSlug: ref.slice(0, separatorIndex), transformName: ref.slice(separatorIndex + 1) };
|
|
43
|
+
}
|
|
44
|
+
/** The asset, looked up by either its id or its (possibly retired-and-current, see
|
|
45
|
+
* {@link findMediaByIdOrSlug}) slug, or `null` when it does not exist or is trashed (EC-07
|
|
46
|
+
* fail-soft miss). Readable-slugs S4: a stored `<slug>:transform` ref must resolve exactly like a
|
|
47
|
+
* `<uuid>:transform` one always has. */
|
|
48
|
+
async function resolveVisibleAsset(deps, workspaceId, idOrSlug) {
|
|
49
|
+
const asset = await deps.featuredImage.findMediaByIdOrSlug({ workspaceId, idOrSlug });
|
|
50
|
+
if (!asset || asset.status === "trashed")
|
|
51
|
+
return null;
|
|
52
|
+
return asset;
|
|
53
|
+
}
|
|
54
|
+
/** The highest-`version` registered definition for `transformName`, or `null` when none is registered. */
|
|
55
|
+
async function resolveLatestTransformVersion(deps, workspaceId, transformName) {
|
|
56
|
+
const versions = await deps.transformDefinitionRepo.listByName({ workspaceId, name: transformName });
|
|
57
|
+
if (versions.length === 0)
|
|
58
|
+
return null;
|
|
59
|
+
return versions.reduce((a, b) => (b.version > a.version ? b : a));
|
|
60
|
+
}
|
|
61
|
+
/** The `/m/{key}/{transformName}.v{version}/image.{ext}` URL contract (ADR-027 §4), keyed by the
|
|
62
|
+
* asset's current slug when it has a valid one (readable-slugs S4), otherwise its id. */
|
|
63
|
+
function buildSeoImageUrl(deps, asset, transformName, latest) {
|
|
64
|
+
const ext = EXT_BY_TRANSFORM_FORMAT[latest.params.format] ?? latest.params.format;
|
|
65
|
+
return deps.featuredImage.mediaPublicPath({ key: deps.featuredImage.mediaUrlKey({ asset }), variant: { kind: "transform", name: transformName, version: latest.version, ext } });
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolves an `ogImage`/`twitterImage` field to a URL, or `undefined` on any
|
|
69
|
+
* miss. Returns `ref` unchanged when it is already an absolute URL; otherwise
|
|
70
|
+
* returns the site-relative `/m/{key}/...` URL contract (ADR-027 §4), keyed by the asset's current
|
|
71
|
+
* slug when it has a valid one (readable-slugs S4), otherwise its id —
|
|
72
|
+
* NOT necessarily absolute (this function has no origin to join, by design;
|
|
73
|
+
* `seo.ts`'s `resolveShareImages` is the one caller and absolutizes the
|
|
74
|
+
* result via `toAbsoluteUrl`, since `og:image`/`twitter:image` must be
|
|
75
|
+
* absolute for crawlers, same as `og:url`).
|
|
76
|
+
*
|
|
77
|
+
* Does not check whether a rendition row already exists (AMENDED 2026-09-05
|
|
78
|
+
* — see this file's header doc for the safety argument): the emitted URL
|
|
79
|
+
* always targets the latest registered transform version, which the public
|
|
80
|
+
* `/m/` route always lazily generates on first anonymous fetch.
|
|
81
|
+
*
|
|
82
|
+
* @complexity O(1) — bounded repo lookups (one asset read, one transform-
|
|
83
|
+
* version listing).
|
|
84
|
+
*/
|
|
85
|
+
export async function resolveSeoImageRef(required, _optional = {}) {
|
|
86
|
+
const { deps, input } = required;
|
|
87
|
+
const ref = input.ref?.trim();
|
|
88
|
+
if (!ref)
|
|
89
|
+
return undefined;
|
|
90
|
+
if (isAbsoluteUrl(ref))
|
|
91
|
+
return ref;
|
|
92
|
+
const parts = parseMediaRefParts(ref);
|
|
93
|
+
if (!parts)
|
|
94
|
+
return undefined;
|
|
95
|
+
const { idOrSlug, transformName } = parts;
|
|
96
|
+
const asset = await resolveVisibleAsset(deps, input.workspaceId, idOrSlug);
|
|
97
|
+
if (!asset)
|
|
98
|
+
return undefined;
|
|
99
|
+
const latest = await resolveLatestTransformVersion(deps, input.workspaceId, transformName);
|
|
100
|
+
if (!latest)
|
|
101
|
+
return undefined;
|
|
102
|
+
return buildSeoImageUrl(deps, asset, transformName, latest);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The write-path refusal for an `ogImage`/`twitterImage`/`defaultOgImage` value (2026-10-05): the
|
|
106
|
+
* message to refuse it with, or `null` to accept it. Both SEO write chokepoints call it
|
|
107
|
+
* (`setEntrySeoOverrides` in `write-service.ts`, `setSeoSettings` in `settings.ts`), so the chat
|
|
108
|
+
* tools and the admin REST routes share it.
|
|
109
|
+
*
|
|
110
|
+
* The check itself is the post featured image's ({@link resolveFeaturedImageRef}), run on the
|
|
111
|
+
* ref's `{idOrSlug}` half — one content-type rule for every "this must be an image" media field.
|
|
112
|
+
* Only a KNOWN non-image is refused. A missing or trashed asset is accepted, as before: the head
|
|
113
|
+
* render already drops such a ref fail-soft (EC-07, {@link resolveSeoImageRef}), and refusing it
|
|
114
|
+
* here would be a new rule this fix was not asked for. An absolute URL, a non-ref string and
|
|
115
|
+
* `null`/`undefined` (clear/omitted) are accepted untouched.
|
|
116
|
+
*
|
|
117
|
+
* @complexity O(1) — at most two repo reads and one content-type read.
|
|
118
|
+
*/
|
|
119
|
+
export async function seoImageRefRefusal(required, _optional = {}) {
|
|
120
|
+
const { deps, input } = required;
|
|
121
|
+
if (typeof input.ref !== "string")
|
|
122
|
+
return null;
|
|
123
|
+
const ref = input.ref.trim();
|
|
124
|
+
if (!ref || isAbsoluteUrl(ref))
|
|
125
|
+
return null;
|
|
126
|
+
const parts = parseMediaRefParts(ref);
|
|
127
|
+
if (!parts)
|
|
128
|
+
return null;
|
|
129
|
+
const resolution = await deps.featuredImage.resolveFeaturedImageRef({ workspaceId: input.workspaceId, ref: parts.idOrSlug });
|
|
130
|
+
if (resolution.ok || resolution.reason !== "not-image")
|
|
131
|
+
return null;
|
|
132
|
+
return `${input.field}: media asset '${parts.idOrSlug}' is ${resolution.contentType}, not an image. A social share image must be an image.`;
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=media.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { HeadElement, PageHeadContext } from "./types.js";
|
|
2
|
+
import { type GetEntryMetaDeps } from "./seo.js";
|
|
3
|
+
/** Builds SEO's own `PageHeadHook`, closing over the deps `getEntryMeta` needs.
|
|
4
|
+
* The reserved root slug identifies the content-owned home across template, generic, and bare
|
|
5
|
+
* renders, which all arrive through the same entry-bearing seam. Only a workspace title value
|
|
6
|
+
* counts: the setting's default and `ctx.siteTitle`'s display-name fallback are not owner-set titles.
|
|
7
|
+
* @complexity O(1) bounded reads, including one additional settings read for the root entry.
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildHeadElements(required: {
|
|
10
|
+
deps: GetEntryMetaDeps;
|
|
11
|
+
ctx: PageHeadContext;
|
|
12
|
+
rootSlug: string;
|
|
13
|
+
/** Returns only an explicitly configured workspace title, normalized by its owner. */
|
|
14
|
+
siteTitle(required: {
|
|
15
|
+
workspaceId: string;
|
|
16
|
+
}, optional?: Record<string, never>): Promise<string | undefined>;
|
|
17
|
+
}, _optional?: Record<string, never>): Promise<HeadElement[]>;
|
|
18
|
+
//# sourceMappingURL=page-head.d.ts.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { FEED_PATH } from "./feed.js";
|
|
2
|
+
import { getEntryMeta } from "./seo.js";
|
|
3
|
+
/**
|
|
4
|
+
* @file `seoPageHeadHook` (ADR-PIPE-008 Decision, C-003) — SEO's own
|
|
5
|
+
* `PageHeadHook` implementation. Maps `getEntryMeta`'s resolved `SeoMeta`
|
|
6
|
+
* into ordered `HeadElement[]` per the fixed priority bands
|
|
7
|
+
* (behavior.spec.md §2.1): title=100, meta-desc=110, canonical=120,
|
|
8
|
+
* robots=130, og=140-149, twitter=150-159 (as `kind:"meta"` with a
|
|
9
|
+
* `twitter:*` name — no separate "twitter" `HeadElement` kind exists),
|
|
10
|
+
* jsonld=900. Registered once at `server/app.ts` boot into
|
|
11
|
+
* `page-head.ts`'s registry — never imported directly by `render.ts`.
|
|
12
|
+
*/
|
|
13
|
+
/** Default contributor-level priority (irrelevant with a single v1 contributor; kept explicit for future ones). */
|
|
14
|
+
// Contributor registration and its priority remain host-owned; this producer retains element priorities.
|
|
15
|
+
function robotsContent(noindex, nofollow) {
|
|
16
|
+
return `${noindex ? "noindex" : "index"},${nofollow ? "nofollow" : "follow"}`;
|
|
17
|
+
}
|
|
18
|
+
/** og:* elements (priority 140-149) — title/type/url always present, image/description only when resolved. */
|
|
19
|
+
function buildOpenGraphElements(og) {
|
|
20
|
+
const elements = [
|
|
21
|
+
{ kind: "og", property: "og:title", content: og.title, priority: 140 },
|
|
22
|
+
{ kind: "og", property: "og:type", content: og.type, priority: 141 },
|
|
23
|
+
{ kind: "og", property: "og:url", content: og.url, priority: 142 },
|
|
24
|
+
];
|
|
25
|
+
if (og.image)
|
|
26
|
+
elements.push({ kind: "og", property: "og:image", content: og.image, priority: 143 });
|
|
27
|
+
if (og.description)
|
|
28
|
+
elements.push({ kind: "og", property: "og:description", content: og.description, priority: 144 });
|
|
29
|
+
return elements;
|
|
30
|
+
}
|
|
31
|
+
/** twitter:* meta elements (priority 150-159) — card/title always present, the rest only when resolved. */
|
|
32
|
+
function buildTwitterElements(twitter) {
|
|
33
|
+
const elements = [
|
|
34
|
+
{ kind: "meta", name: "twitter:card", content: twitter.card, priority: 150 },
|
|
35
|
+
{ kind: "meta", name: "twitter:title", content: twitter.title, priority: 151 },
|
|
36
|
+
];
|
|
37
|
+
if (twitter.description)
|
|
38
|
+
elements.push({ kind: "meta", name: "twitter:description", content: twitter.description, priority: 152 });
|
|
39
|
+
if (twitter.image)
|
|
40
|
+
elements.push({ kind: "meta", name: "twitter:image", content: twitter.image, priority: 153 });
|
|
41
|
+
if (twitter.site)
|
|
42
|
+
elements.push({ kind: "meta", name: "twitter:site", content: twitter.site, priority: 154 });
|
|
43
|
+
return elements;
|
|
44
|
+
}
|
|
45
|
+
/** The BreadcrumbList JSON-LD element (priority 900), or `null` when the entry has no ancestors. */
|
|
46
|
+
function buildBreadcrumbJsonLd(ancestors) {
|
|
47
|
+
if (!ancestors || ancestors.length === 0)
|
|
48
|
+
return null;
|
|
49
|
+
return {
|
|
50
|
+
kind: "jsonld",
|
|
51
|
+
priority: 900,
|
|
52
|
+
data: {
|
|
53
|
+
"@context": "https://schema.org",
|
|
54
|
+
"@type": "BreadcrumbList",
|
|
55
|
+
itemListElement: ancestors.map((ancestor, index) => ({
|
|
56
|
+
"@type": "ListItem",
|
|
57
|
+
position: index + 1,
|
|
58
|
+
// JSON has no undefined value; omit an absent title just as serialization would.
|
|
59
|
+
...(ancestor.title === undefined ? {} : { name: ancestor.title }),
|
|
60
|
+
item: ancestor.url,
|
|
61
|
+
})),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** RSS auto-discovery (`features/seo/feed.ts`), on every page. Root-relative, so a static export's
|
|
66
|
+
* base-path rewrite prefixes it like any other site link. Priority 125: after canonical, before
|
|
67
|
+
* robots. */
|
|
68
|
+
function feedAlternateLink(siteTitle) {
|
|
69
|
+
return { kind: "link", rel: "alternate", type: "application/rss+xml", title: siteTitle, href: FEED_PATH, priority: 125 };
|
|
70
|
+
}
|
|
71
|
+
/** Builds SEO's own `PageHeadHook`, closing over the deps `getEntryMeta` needs.
|
|
72
|
+
* The reserved root slug identifies the content-owned home across template, generic, and bare
|
|
73
|
+
* renders, which all arrive through the same entry-bearing seam. Only a workspace title value
|
|
74
|
+
* counts: the setting's default and `ctx.siteTitle`'s display-name fallback are not owner-set titles.
|
|
75
|
+
* @complexity O(1) bounded reads, including one additional settings read for the root entry.
|
|
76
|
+
*/
|
|
77
|
+
export async function buildHeadElements(required, _optional = {}) {
|
|
78
|
+
const { deps, ctx, rootSlug, siteTitle } = required;
|
|
79
|
+
if (!ctx.entry) {
|
|
80
|
+
// Home / entry-less route: no per-entry meta to resolve — still emit site-level tags.
|
|
81
|
+
return [
|
|
82
|
+
{ kind: "title", text: ctx.siteTitle, priority: 100 },
|
|
83
|
+
{ kind: "link", rel: "canonical", href: ctx.canonicalUrl, priority: 120 },
|
|
84
|
+
feedAlternateLink(ctx.siteTitle),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
let homeTitle;
|
|
88
|
+
if (ctx.entry.slug === rootSlug) {
|
|
89
|
+
homeTitle = await siteTitle({ workspaceId: ctx.workspaceId });
|
|
90
|
+
}
|
|
91
|
+
const resolved = await getEntryMeta({ deps, input: { workspaceId: ctx.workspaceId, entryId: ctx.entry.id, homeTitle } });
|
|
92
|
+
const elements = [{ kind: "title", text: resolved.title, priority: 100 }];
|
|
93
|
+
if (resolved.description) {
|
|
94
|
+
elements.push({ kind: "meta", name: "description", content: resolved.description, priority: 110 });
|
|
95
|
+
}
|
|
96
|
+
elements.push({ kind: "link", rel: "canonical", href: resolved.canonical, priority: 120 });
|
|
97
|
+
elements.push(feedAlternateLink(ctx.siteTitle));
|
|
98
|
+
elements.push({
|
|
99
|
+
kind: "meta",
|
|
100
|
+
name: "robots",
|
|
101
|
+
content: robotsContent(resolved.robots.noindex, resolved.robots.nofollow),
|
|
102
|
+
priority: 130,
|
|
103
|
+
});
|
|
104
|
+
elements.push(...buildOpenGraphElements(resolved.openGraph));
|
|
105
|
+
elements.push(...buildTwitterElements(resolved.twitter));
|
|
106
|
+
for (const jsonLdEntry of resolved.jsonLd) {
|
|
107
|
+
elements.push({ kind: "jsonld", data: jsonLdEntry, priority: 900 });
|
|
108
|
+
}
|
|
109
|
+
const breadcrumb = buildBreadcrumbJsonLd(ctx.entry.ancestors);
|
|
110
|
+
if (breadcrumb)
|
|
111
|
+
elements.push(breadcrumb);
|
|
112
|
+
return elements;
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=page-head.js.map
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file SEO contract interfaces (SPEC-008, ADR-PIPE-008). Interfaces ONLY.
|
|
3
|
+
*
|
|
4
|
+
* ADR-006 note (rule-of-two): introduces NO new infrastructure port. Every
|
|
5
|
+
* dependency `seo` needs is a typed call to an already-ported existing module
|
|
6
|
+
* (`post` via `PostRepoPort`, `routing` via `urlFor`, `settings` via
|
|
7
|
+
* `getEffective`/write-service, `media` via its existing read repos,
|
|
8
|
+
* `identity` via `authorize()`) — plain dependency-injected function params,
|
|
9
|
+
* not the ADR-032 stub's "capability handle" abstraction
|
|
10
|
+
* (`SeoContentReadCap`/`SeoUrlCap`/`SeoSettingsCap`/`SeoCacheCap`/
|
|
11
|
+
* `SeoPluginCaps`). Those five stub-era interfaces are dropped here (zero
|
|
12
|
+
* call sites existed, ADR-PIPE-008 Context) — every real function in this
|
|
13
|
+
* module (`src/seo/{seo,write-service,settings,sitemap,media}.ts`) takes its
|
|
14
|
+
* dependencies the same direct-DI way every other feature module in this repo
|
|
15
|
+
* does, matching the Contract Map (C-001..C-024), which never mentions a caps
|
|
16
|
+
* object either. Documented here as a disclosed simplification, not a silent
|
|
17
|
+
* one.
|
|
18
|
+
*
|
|
19
|
+
* `SitemapCollectHook` is kept (OQ-01: a real, empty, in-module registry ships
|
|
20
|
+
* in v1 — Decision §7). `SeoQueryPort` is kept as the documented single read
|
|
21
|
+
* surface INV-09 requires every route to go through — Code Review's
|
|
22
|
+
* architecture check (T058) verifies every admin/public route calls into
|
|
23
|
+
* `seo.ts`/`sitemap.ts`'s exported functions, matching this shape, rather than
|
|
24
|
+
* literally constructing one object with these five methods.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* @file SEO contracts (SPEC-008, ADR-PIPE-008). Interfaces only.
|
|
28
|
+
*
|
|
29
|
+
* Dependencies are explicit function ports. The host binds its post, routing, settings and
|
|
30
|
+
* featured-image owners; SEO never constructs a second implementation of those domains.
|
|
31
|
+
* `SitemapCollectHook` remains the ordered contribution seam, scoped to a service instance.
|
|
32
|
+
* `SeoQueryPort` documents the single evaluator every admin/public reader consumes (INV-09).
|
|
33
|
+
*/
|
|
34
|
+
import type { JsonValue } from "@jini-ai/core/primitives";
|
|
35
|
+
import type { MediaRecord } from "../media/types.js";
|
|
36
|
+
import type { TransformDefinitionRepoPort } from "../media/ports.js";
|
|
37
|
+
import type { HeadElement, PageHeadContext, RobotsPolicy, SeoAnalysis, SeoMeta, SeoSettings, SitemapCollectContext, SitemapEntry } from "./types.js";
|
|
38
|
+
/** Only the post fields SEO reads. The host adapter must preserve other fields during row saves. */
|
|
39
|
+
export interface SeoPostRecord {
|
|
40
|
+
id: string;
|
|
41
|
+
workspaceId: string;
|
|
42
|
+
title: string;
|
|
43
|
+
slug: string;
|
|
44
|
+
kind: "post" | "page";
|
|
45
|
+
status: string;
|
|
46
|
+
bodyFormat: "doc" | "html";
|
|
47
|
+
bodyJson: JsonValue;
|
|
48
|
+
bodyHtml: string | null;
|
|
49
|
+
updatedAt: string;
|
|
50
|
+
version: number;
|
|
51
|
+
seoExtJson?: string | null | undefined;
|
|
52
|
+
deletedAt?: string | null | undefined;
|
|
53
|
+
publishAt?: string | null | undefined;
|
|
54
|
+
featuredMediaId?: string | null | undefined;
|
|
55
|
+
memberAccessJson?: string | null | undefined;
|
|
56
|
+
}
|
|
57
|
+
export interface SeoPostReadPort {
|
|
58
|
+
findById(required: {
|
|
59
|
+
workspaceId: string;
|
|
60
|
+
id: string;
|
|
61
|
+
}, optional?: Record<string, never>): Promise<SeoPostRecord | null>;
|
|
62
|
+
extractPlainText(required: {
|
|
63
|
+
html: string;
|
|
64
|
+
}, optional?: Record<string, never>): string;
|
|
65
|
+
isTrashed(required: {
|
|
66
|
+
post: SeoPostRecord;
|
|
67
|
+
}, optional?: Record<string, never>): boolean;
|
|
68
|
+
}
|
|
69
|
+
export interface SeoPostListPort extends SeoPostReadPort {
|
|
70
|
+
list(required: {
|
|
71
|
+
workspaceId: string;
|
|
72
|
+
}, optional?: Record<string, never>): Promise<readonly SeoPostRecord[]>;
|
|
73
|
+
}
|
|
74
|
+
/** Conditional row save and revision append must share one transaction, including rollback. */
|
|
75
|
+
export interface SeoPostWritePort extends SeoPostReadPort {
|
|
76
|
+
transaction<T>(required: {
|
|
77
|
+
fn: () => Promise<T>;
|
|
78
|
+
}, optional?: Record<string, never>): Promise<T>;
|
|
79
|
+
saveIfVersion(required: {
|
|
80
|
+
record: SeoPostRecord;
|
|
81
|
+
ifVersion: number;
|
|
82
|
+
}, optional?: Record<string, never>): Promise<{
|
|
83
|
+
applied: boolean;
|
|
84
|
+
}>;
|
|
85
|
+
appendRevision(required: {
|
|
86
|
+
postId: string;
|
|
87
|
+
workspaceId: string;
|
|
88
|
+
seq: number;
|
|
89
|
+
op: "update";
|
|
90
|
+
stateJson: SeoPostRecord;
|
|
91
|
+
actorId: string;
|
|
92
|
+
recordedAt: string;
|
|
93
|
+
}, optional?: Record<string, never>): Promise<unknown>;
|
|
94
|
+
}
|
|
95
|
+
/** Settings namespace and keys remain `site.seo`; defaults and storage belong to the host. */
|
|
96
|
+
export interface SeoSettingsReaderPort {
|
|
97
|
+
getSeoSettings(required: {
|
|
98
|
+
workspaceId: string;
|
|
99
|
+
}, optional?: Record<string, never>): Promise<SeoSettings>;
|
|
100
|
+
isDefaultRobotsNoindexExplicitlySet(required: {
|
|
101
|
+
workspaceId: string;
|
|
102
|
+
}, optional?: Record<string, never>): Promise<boolean>;
|
|
103
|
+
}
|
|
104
|
+
export interface SeoUrlsPort {
|
|
105
|
+
postPublicPath(required: {
|
|
106
|
+
slug: string;
|
|
107
|
+
}, optional?: Record<string, never>): string;
|
|
108
|
+
urlFor(required: {
|
|
109
|
+
target: {
|
|
110
|
+
kind: "entryRef";
|
|
111
|
+
entryId: string;
|
|
112
|
+
contentType: "post" | "page";
|
|
113
|
+
};
|
|
114
|
+
ctx: {
|
|
115
|
+
workspaceId: string;
|
|
116
|
+
};
|
|
117
|
+
}, optional?: Record<string, never>): Promise<{
|
|
118
|
+
canonicalUrl: string;
|
|
119
|
+
} | null>;
|
|
120
|
+
}
|
|
121
|
+
/** Render-time scheduled publication; no background job or implicit wall clock. */
|
|
122
|
+
export interface SeoLivenessPort {
|
|
123
|
+
isScheduledAt(required: {
|
|
124
|
+
post: SeoPostRecord;
|
|
125
|
+
nowIso: string;
|
|
126
|
+
}, optional?: Record<string, never>): boolean;
|
|
127
|
+
isLiveAt(required: {
|
|
128
|
+
post: SeoPostRecord;
|
|
129
|
+
nowIso: string;
|
|
130
|
+
}, optional?: Record<string, never>): boolean;
|
|
131
|
+
postDisplayDateIso(required: {
|
|
132
|
+
post: SeoPostRecord;
|
|
133
|
+
}, optional?: Record<string, never>): string;
|
|
134
|
+
}
|
|
135
|
+
/** Existing media/post helper behavior, bound once by the composition root. */
|
|
136
|
+
export interface SeoFeaturedImagePort {
|
|
137
|
+
findMediaByIdOrSlug(required: {
|
|
138
|
+
workspaceId: string;
|
|
139
|
+
idOrSlug: string;
|
|
140
|
+
}, optional?: Record<string, never>): Promise<MediaRecord | null>;
|
|
141
|
+
mediaUrlKey(required: {
|
|
142
|
+
asset: MediaRecord;
|
|
143
|
+
}, optional?: Record<string, never>): string;
|
|
144
|
+
mediaPublicPath(required: {
|
|
145
|
+
key: string;
|
|
146
|
+
variant: {
|
|
147
|
+
kind: "transform";
|
|
148
|
+
name: string;
|
|
149
|
+
version: number;
|
|
150
|
+
ext: string;
|
|
151
|
+
};
|
|
152
|
+
}, optional?: Record<string, never>): string;
|
|
153
|
+
resolveFeaturedImageRef(required: {
|
|
154
|
+
workspaceId: string;
|
|
155
|
+
ref: string;
|
|
156
|
+
}, optional?: Record<string, never>): Promise<{
|
|
157
|
+
ok: true;
|
|
158
|
+
} | {
|
|
159
|
+
ok: false;
|
|
160
|
+
reason: string;
|
|
161
|
+
contentType?: string;
|
|
162
|
+
}>;
|
|
163
|
+
}
|
|
164
|
+
export interface SeoMediaPort {
|
|
165
|
+
featuredImage: SeoFeaturedImagePort;
|
|
166
|
+
transformDefinitionRepo: Pick<TransformDefinitionRepoPort, "listByName">;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* `seo.sitemap.collect` lets other plugins/core contribute URL sets without SEO knowing their
|
|
170
|
+
* routes. Ships live-but-empty in v1 (OQ-01), with no real registrant yet.
|
|
171
|
+
*/
|
|
172
|
+
export interface SitemapCollectHook {
|
|
173
|
+
readonly priority: number;
|
|
174
|
+
handle(ctx: SitemapCollectContext, optional?: Record<string, never>): Promise<SitemapEntry[]>;
|
|
175
|
+
}
|
|
176
|
+
/** Single read surface: evaluator, head producer and public documents share these contracts. */
|
|
177
|
+
export interface SeoQueryPort {
|
|
178
|
+
getEntryMeta(input: {
|
|
179
|
+
workspaceId: string;
|
|
180
|
+
entryId: string;
|
|
181
|
+
}, optional?: Record<string, never>): Promise<SeoMeta>;
|
|
182
|
+
renderHead(ctx: PageHeadContext, optional?: Record<string, never>): Promise<HeadElement[]>;
|
|
183
|
+
buildSitemap(input: {
|
|
184
|
+
workspaceId: string;
|
|
185
|
+
}, optional?: Record<string, never>): Promise<SitemapEntry[]>;
|
|
186
|
+
buildRobots(input: {
|
|
187
|
+
workspaceId: string;
|
|
188
|
+
}, optional?: Record<string, never>): Promise<RobotsPolicy>;
|
|
189
|
+
analyzeEntry(input: {
|
|
190
|
+
workspaceId: string;
|
|
191
|
+
entryId: string;
|
|
192
|
+
}, optional?: Record<string, never>): Promise<SeoAnalysis>;
|
|
193
|
+
}
|
|
194
|
+
/** Idempotent outbox subscriptions: content changes invalidate that workspace's sitemap. */
|
|
195
|
+
export interface SeoEventSubscriptions {
|
|
196
|
+
onSitemapInvalidated(event: {
|
|
197
|
+
workspaceId: string;
|
|
198
|
+
}, optional?: Record<string, never>): Promise<void>;
|
|
199
|
+
onEntryPublished(event: {
|
|
200
|
+
payload: {
|
|
201
|
+
entryId: string;
|
|
202
|
+
contentType: string;
|
|
203
|
+
};
|
|
204
|
+
workspaceId: string;
|
|
205
|
+
}, optional?: Record<string, never>): Promise<void>;
|
|
206
|
+
onEntryUpdated(event: {
|
|
207
|
+
payload: {
|
|
208
|
+
entryId: string;
|
|
209
|
+
contentType: string;
|
|
210
|
+
};
|
|
211
|
+
workspaceId: string;
|
|
212
|
+
}, optional?: Record<string, never>): Promise<void>;
|
|
213
|
+
onEntryUnpublished(event: {
|
|
214
|
+
payload: {
|
|
215
|
+
entryId: string;
|
|
216
|
+
contentType: string;
|
|
217
|
+
};
|
|
218
|
+
workspaceId: string;
|
|
219
|
+
}, optional?: Record<string, never>): Promise<void>;
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=ports.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file SEO contract interfaces (SPEC-008, ADR-PIPE-008). Interfaces ONLY.
|
|
3
|
+
*
|
|
4
|
+
* ADR-006 note (rule-of-two): introduces NO new infrastructure port. Every
|
|
5
|
+
* dependency `seo` needs is a typed call to an already-ported existing module
|
|
6
|
+
* (`post` via `PostRepoPort`, `routing` via `urlFor`, `settings` via
|
|
7
|
+
* `getEffective`/write-service, `media` via its existing read repos,
|
|
8
|
+
* `identity` via `authorize()`) — plain dependency-injected function params,
|
|
9
|
+
* not the ADR-032 stub's "capability handle" abstraction
|
|
10
|
+
* (`SeoContentReadCap`/`SeoUrlCap`/`SeoSettingsCap`/`SeoCacheCap`/
|
|
11
|
+
* `SeoPluginCaps`). Those five stub-era interfaces are dropped here (zero
|
|
12
|
+
* call sites existed, ADR-PIPE-008 Context) — every real function in this
|
|
13
|
+
* module (`src/seo/{seo,write-service,settings,sitemap,media}.ts`) takes its
|
|
14
|
+
* dependencies the same direct-DI way every other feature module in this repo
|
|
15
|
+
* does, matching the Contract Map (C-001..C-024), which never mentions a caps
|
|
16
|
+
* object either. Documented here as a disclosed simplification, not a silent
|
|
17
|
+
* one.
|
|
18
|
+
*
|
|
19
|
+
* `SitemapCollectHook` is kept (OQ-01: a real, empty, in-module registry ships
|
|
20
|
+
* in v1 — Decision §7). `SeoQueryPort` is kept as the documented single read
|
|
21
|
+
* surface INV-09 requires every route to go through — Code Review's
|
|
22
|
+
* architecture check (T058) verifies every admin/public route calls into
|
|
23
|
+
* `seo.ts`/`sitemap.ts`'s exported functions, matching this shape, rather than
|
|
24
|
+
* literally constructing one object with these five methods.
|
|
25
|
+
*/
|
|
26
|
+
export {};
|
|
27
|
+
//# sourceMappingURL=ports.js.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { OriginRegistryPort } from "@jini-ai/http-kit/verified-origin";
|
|
2
|
+
import type { SeoPostRecord as PostRecord, SeoPostReadPort, SeoSettingsReaderPort, SeoUrlsPort, SeoLivenessPort } from "./ports.js";
|
|
3
|
+
import { type ResolveSeoImageRefDeps } from "./media.js";
|
|
4
|
+
import type { SeoAnalysis, SeoMeta } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* @file `getEntryMeta`/`analyzeEntry` (ADR-PIPE-008 Decision, C-001/C-002) —
|
|
7
|
+
* the pure effective-meta evaluator, per-field precedence (behavior.spec.md
|
|
8
|
+
* §1.1): override ▸ site default ▸ derived. Consumed by the
|
|
9
|
+
* admin preview, the public render (via `page-head-contributor.ts`), and
|
|
10
|
+
* `analyzeEntry` — the one evaluator, no back door (INV-09). Never writes
|
|
11
|
+
* anything — reads only, via injected deps.
|
|
12
|
+
* The public home render supplies its configured site title as a fallback below explicit SEO
|
|
13
|
+
* overrides; consumers without that render context retain the entry-title template.
|
|
14
|
+
*/
|
|
15
|
+
export declare const SEO_NAMESPACE = "site.seo";
|
|
16
|
+
/** override > site default > derived excerpt (behavior.spec.md §1.1). Derivation reads whichever
|
|
17
|
+
* body column `bodyFormat` says is live — `bodyJson` (TipTap doc) or `bodyHtml` (bespoke-HTML
|
|
18
|
+
* Page, SPEC-047/ADR-056 Decision 3) — so an html-format Page gets a real excerpt instead of
|
|
19
|
+
* silently resolving to `undefined`. Exported for the RSS feed (`feed.ts`), whose item description
|
|
20
|
+
* is the post's own excerpt, never the site-wide default. */
|
|
21
|
+
export declare function deriveExcerpt(required: {
|
|
22
|
+
post: PostRecord;
|
|
23
|
+
postRepo: Pick<SeoPostReadPort, "extractPlainText">;
|
|
24
|
+
}, _optional?: Record<string, never>): string | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* behavior.spec.md §1.1's `robots.noindex` row needs to know whether the
|
|
27
|
+
* workspace's `default_robots_noindex` was EXPLICITLY configured (site
|
|
28
|
+
* default present) vs. still resting on the ledger's own schema default
|
|
29
|
+
* (site default effectively absent) — `getSeoSettings` alone can't
|
|
30
|
+
* distinguish these (it only returns the resolved value, per
|
|
31
|
+
* `settings.ts`'s doc comment), so this reads the raw `getEffective`
|
|
32
|
+
* `sourceLayer` directly. Only `noindex` needs this: `nofollow`'s derived
|
|
33
|
+
* and default outcomes are identical (`false`), so no test/behavior depends
|
|
34
|
+
* on distinguishing them there.
|
|
35
|
+
*/
|
|
36
|
+
export interface GetEntryMetaDeps {
|
|
37
|
+
postRepo: SeoPostReadPort;
|
|
38
|
+
settings: SeoSettingsReaderPort;
|
|
39
|
+
media: ResolveSeoImageRefDeps;
|
|
40
|
+
originRegistry: OriginRegistryPort;
|
|
41
|
+
runtimeMode: string;
|
|
42
|
+
urls: SeoUrlsPort;
|
|
43
|
+
liveness: SeoLivenessPort;
|
|
44
|
+
clock: {
|
|
45
|
+
nowIso(): string;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export interface GetEntryMetaInput {
|
|
49
|
+
workspaceId: string;
|
|
50
|
+
entryId: string;
|
|
51
|
+
/** Public home render only: configured site title, below an explicit SEO title and above the page-title template. */
|
|
52
|
+
homeTitle?: string | undefined;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolves the effective `SeoMeta` for one entry (override ▸ site default ▸
|
|
56
|
+
* derived, per field — behavior.spec.md §1.1). Never partial.
|
|
57
|
+
* The public head contributor can supply `homeTitle` for the root page; callers that omit it
|
|
58
|
+
* retain the entry-title template. Explicit SEO/share-title overrides still win.
|
|
59
|
+
*
|
|
60
|
+
* @complexity O(1) — one post read, one settings resolution (8 bounded
|
|
61
|
+
* `getEffective` reads), up to 2 media lookups, one origin lookup (2026-09-03,
|
|
62
|
+
* absolute-URL fix — {@link resolveWorkspaceOrigin}).
|
|
63
|
+
*/
|
|
64
|
+
export declare function getEntryMeta(required: {
|
|
65
|
+
deps: GetEntryMetaDeps;
|
|
66
|
+
input: GetEntryMetaInput;
|
|
67
|
+
}, _optional?: Record<string, never>): Promise<SeoMeta>;
|
|
68
|
+
/** REQ-14 — SEO analysis over `getEntryMeta`'s resolved result (pure decision, no additional I/O). */
|
|
69
|
+
export declare function analyzeEntry(required: {
|
|
70
|
+
deps: GetEntryMetaDeps;
|
|
71
|
+
input: GetEntryMetaInput;
|
|
72
|
+
}, _optional?: Record<string, never>): Promise<SeoAnalysis>;
|
|
73
|
+
//# sourceMappingURL=seo.d.ts.map
|