@jini-ai/cms 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/consent/express/index.d.ts +53 -0
  3. package/dist/consent/express/index.js +159 -0
  4. package/dist/consent/express/response.d.ts +21 -0
  5. package/dist/consent/express/response.js +89 -0
  6. package/dist/consent/html/client.d.ts +6 -0
  7. package/dist/consent/html/client.js +135 -0
  8. package/dist/consent/html/gate.d.ts +29 -0
  9. package/dist/consent/html/gate.js +104 -0
  10. package/dist/consent/html/index.d.ts +4 -0
  11. package/dist/consent/html/index.js +3 -0
  12. package/dist/consent/html/render.d.ts +27 -0
  13. package/dist/consent/html/render.js +62 -0
  14. package/dist/consent/html/static.d.ts +7 -0
  15. package/dist/consent/html/static.js +25 -0
  16. package/dist/consent/html/styles.d.ts +3 -0
  17. package/dist/consent/html/styles.js +22 -0
  18. package/dist/consent/index.d.ts +3 -0
  19. package/dist/consent/index.js +3 -0
  20. package/dist/consent/node/index.d.ts +47 -0
  21. package/dist/consent/node/index.js +169 -0
  22. package/dist/consent/policy.d.ts +47 -0
  23. package/dist/consent/policy.js +62 -0
  24. package/dist/consent/types.d.ts +40 -0
  25. package/dist/consent/types.js +5 -0
  26. package/dist/content-types/agent-tools.js +1 -1
  27. package/dist/content-types/tool-registrations.d.ts +2 -0
  28. package/dist/content-types/tool-registrations.js +1 -0
  29. package/dist/content-types/write-service.d.ts +2 -0
  30. package/dist/content-types/write-service.js +3 -0
  31. package/dist/forms/descriptor-rules.d.ts +9 -0
  32. package/dist/forms/descriptor-rules.js +112 -0
  33. package/dist/forms/email-format.d.ts +7 -0
  34. package/dist/forms/email-format.js +11 -0
  35. package/dist/forms/express/handler.js +4 -8
  36. package/dist/forms/field-registry.d.ts +193 -0
  37. package/dist/forms/field-registry.js +231 -0
  38. package/dist/forms/forms.d.ts +8 -5
  39. package/dist/forms/forms.js +29 -46
  40. package/dist/forms/html/html-authoring.js +63 -13
  41. package/dist/forms/html/html-submission-adapter.d.ts +5 -5
  42. package/dist/forms/html/html-submission-adapter.js +6 -13
  43. package/dist/forms/html-render.d.ts +10 -3
  44. package/dist/forms/html-render.js +116 -14
  45. package/dist/forms/index.d.ts +1 -0
  46. package/dist/forms/index.js +1 -0
  47. package/dist/forms/repo.memory.js +4 -4
  48. package/dist/forms/submission-attempts.d.ts +1 -1
  49. package/dist/forms/submission-attempts.js +13 -2
  50. package/dist/forms/submission-email-validation.js +2 -4
  51. package/dist/forms/submit-service.d.ts +3 -0
  52. package/dist/forms/submit-service.js +38 -5
  53. package/dist/forms/types.d.ts +50 -2
  54. package/dist/listing/batch.d.ts +53 -0
  55. package/dist/listing/batch.js +57 -0
  56. package/dist/listing/html.d.ts +72 -0
  57. package/dist/listing/html.js +109 -0
  58. package/dist/listing/index.d.ts +5 -0
  59. package/dist/listing/index.js +5 -0
  60. package/dist/listing/paths.d.ts +32 -0
  61. package/dist/listing/paths.js +37 -0
  62. package/dist/listing/see-more-script.d.ts +16 -0
  63. package/dist/listing/see-more-script.js +62 -0
  64. package/dist/media/content-type-sniffer.d.ts +1 -1
  65. package/dist/media/content-type-sniffer.js +15 -0
  66. package/dist/media/image-transformer.sharp.js +4 -1
  67. package/dist/media/media-service.d.ts +5 -3
  68. package/dist/media/media-service.js +10 -26
  69. package/dist/media/node.d.ts +1 -1
  70. package/dist/media/node.js +1 -1
  71. package/dist/media/slug.d.ts +8 -0
  72. package/dist/media/slug.js +32 -0
  73. package/dist/media/transform-registry.js +1 -1
  74. package/dist/media/transform-types.d.ts +2 -0
  75. package/dist/media/transform-types.js +3 -0
  76. package/dist/media/video-frames.ffmpeg.d.ts +15 -2
  77. package/dist/media/video-frames.ffmpeg.js +28 -13
  78. package/dist/navigation/dropdown.d.ts +41 -0
  79. package/dist/navigation/dropdown.js +85 -0
  80. package/dist/navigation/index.d.ts +2 -0
  81. package/dist/navigation/index.js +2 -0
  82. package/dist/redirects/capture.d.ts +2 -0
  83. package/dist/redirects/capture.js +4 -0
  84. package/dist/redirects/redirects.d.ts +8 -0
  85. package/dist/redirects/redirects.js +14 -0
  86. package/dist/routing/index.d.ts +5 -0
  87. package/dist/routing/index.js +2 -0
  88. package/dist/routing/ports.d.ts +51 -0
  89. package/dist/routing/ports.js +2 -0
  90. package/dist/routing/routing.d.ts +138 -0
  91. package/dist/routing/routing.js +443 -0
  92. package/dist/routing/types.d.ts +164 -0
  93. package/dist/routing/types.js +2 -0
  94. package/dist/seo/media.d.ts +23 -0
  95. package/dist/seo/media.js +30 -5
  96. package/dist/seo/page-head.js +8 -1
  97. package/dist/seo/seo.d.ts +2 -0
  98. package/dist/seo/seo.js +11 -6
  99. package/dist/seo/types.d.ts +8 -0
  100. package/dist/seo/xml-escape.d.ts +1 -3
  101. package/dist/seo/xml-escape.js +1 -6
  102. package/dist/widgets/concurrency.d.ts +27 -1
  103. package/dist/widgets/concurrency.js +12 -23
  104. package/dist/widgets/config-validation.js +6 -0
  105. package/dist/widgets/gallery-resolver.d.ts +23 -0
  106. package/dist/widgets/gallery-resolver.js +80 -0
  107. package/dist/widgets/html/gallery-render.d.ts +24 -0
  108. package/dist/widgets/html/gallery-render.js +65 -0
  109. package/dist/widgets/html/html-embeds.d.ts +4 -0
  110. package/dist/widgets/html/html-embeds.js +7 -1
  111. package/dist/widgets/html/index.d.ts +3 -0
  112. package/dist/widgets/html/index.js +3 -0
  113. package/dist/widgets/html/server-fragment.d.ts +9 -0
  114. package/dist/widgets/html/server-fragment.js +38 -0
  115. package/dist/widgets/markers/gallery.d.ts +50 -0
  116. package/dist/widgets/markers/gallery.js +164 -0
  117. package/dist/widgets/markers/html-attributes.d.ts +6 -1
  118. package/dist/widgets/markers/html-attributes.js +24 -9
  119. package/dist/widgets/markers/index.d.ts +2 -0
  120. package/dist/widgets/markers/index.js +2 -0
  121. package/dist/widgets/markers/marker.d.ts +4 -0
  122. package/dist/widgets/markers/marker.js +6 -0
  123. package/dist/widgets/markers/media-reference.d.ts +11 -0
  124. package/dist/widgets/markers/media-reference.js +11 -0
  125. package/dist/widgets/page-embed-types.d.ts +1 -1
  126. package/dist/widgets/page-embed-types.js +1 -1
  127. package/dist/widgets/registry.d.ts +144 -10
  128. package/dist/widgets/registry.js +40 -0
  129. package/dist/widgets/resolver-service.d.ts +10 -0
  130. package/dist/widgets/resolver-service.js +73 -29
  131. package/dist/widgets/resolvers/create-core-resolvers.d.ts +3 -0
  132. package/dist/widgets/resolvers/create-core-resolvers.js +2 -0
  133. package/dist/widgets/resolvers/gallery.d.ts +12 -0
  134. package/dist/widgets/resolvers/gallery.js +33 -0
  135. package/dist/widgets/resolvers/index.d.ts +1 -0
  136. package/dist/widgets/resolvers/index.js +1 -0
  137. package/dist/widgets/types.d.ts +3 -1
  138. package/package.json +100 -7
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @file Core type definitions for the Tovu `routing` Tier-2 library (ADR-039 v0).
3
+ *
4
+ * Purpose:
5
+ * Declares the `RouteTarget` vocabulary, the workspace-scoped resolve context,
6
+ * the inverse-resolver result shape, the forward resolution pipeline's phase
7
+ * vocabulary, and the `SlugChangeCapture` slot's payload shape. This is the one
8
+ * place `entryRef | termRef | url | route` is defined — promoted from
9
+ * Menus-local (ADR-029 §3) to routing-owned per ADR-039 §3, so Menus/SEO/
10
+ * Redirects share one vocabulary instead of inventing three.
11
+ *
12
+ * How it relates to the project:
13
+ * - `core/ports.ts` supplies the shared primitives (`UUID`, `ISODateTime`, …).
14
+ * - `routing/ports.ts` declares the dependency shape `urlFor`/`isActive` need
15
+ * (the existing `PostRepoPort`, reused rather than duplicated).
16
+ * - `routing/routing.ts` implements the resolve functions against these types.
17
+ *
18
+ * Architectural role:
19
+ * INTERFACES + TYPES ONLY (no feature logic). ADR-039 v0 is deliberately the
20
+ * minimal shape three other sections depend on; full permalink-structure
21
+ * design is explicitly deferred (ADR-039 "Open").
22
+ */
23
+ import type { JsonObject, UUID } from "@jini-ai/core/primitives";
24
+ /** Discriminant values for {@link RouteTarget}. */
25
+ export type RouteTargetKind = "entryRef" | "termRef" | "url" | "route";
26
+ /**
27
+ * A link to a content entry by id. Only `post` has a real resolver in this
28
+ * repo today (ADR-039 v0); `contentType` is accepted for forward-compat with
29
+ * future content types but is not required to resolve a post reference.
30
+ */
31
+ export interface EntryRefTarget {
32
+ readonly kind: "entryRef";
33
+ readonly entryId: UUID;
34
+ readonly contentType?: string;
35
+ }
36
+ /**
37
+ * A link to a taxonomy term (category/tag archive).
38
+ *
39
+ * NOT resolvable today: taxonomy isn't implemented in this repo, and ADR-029's
40
+ * Round-3 audit fold promoted "term-link schema" from an Open item to an
41
+ * explicit Wave-1 acceptance blocker — ADR-022 §5's `entry_refs` covers
42
+ * entry-to-entry references only, with no term-target schema yet (content-lib
43
+ * sign-off owed). `urlFor`/`isActive` resolve every `termRef` target to `null`
44
+ * until that schema lands; see the stub in `routing.ts`.
45
+ */
46
+ export interface TermRefTarget {
47
+ readonly kind: "termRef";
48
+ readonly termId: UUID;
49
+ readonly taxonomy: string;
50
+ }
51
+ /**
52
+ * An external or site-relative URL, passed through as given (scheme-validated:
53
+ * `javascript:`/`data:` are rejected and resolve to `null`, mirroring the
54
+ * "unavailable → null" contract rather than throwing).
55
+ */
56
+ export interface UrlTarget {
57
+ readonly kind: "url";
58
+ readonly href: string;
59
+ }
60
+ /** A named core route (e.g. `home`, `search`), resolved via the route registry. */
61
+ export interface RouteRefTarget {
62
+ readonly kind: "route";
63
+ readonly route: string;
64
+ /** Optional static params; unused by the v0 registry, reserved for v0.1 param interpolation. */
65
+ readonly params?: JsonObject;
66
+ }
67
+ /** v0 discriminated union of route targets (ADR-039 §3). */
68
+ export type RouteTarget = EntryRefTarget | TermRefTarget | UrlTarget | RouteRefTarget;
69
+ /**
70
+ * Workspace-scoped context threaded through every resolve call. `siteId`/
71
+ * `locale`/`originKey` are reserved for multi-site/i18n v0.1 work (ADR-039
72
+ * "Open"); only `workspaceId` is load-bearing today.
73
+ *
74
+ * `originOverride` is a stand-in for `core/origin` (ADR-040), which is being
75
+ * built in parallel and does not exist yet. Once it lands, `canonicalUrl`
76
+ * composition should read the verified origin from
77
+ * `OriginRegistryPort.canonicalOrigin(ctx)` instead of this field.
78
+ */
79
+ export interface RouteResolveContext {
80
+ readonly workspaceId: UUID;
81
+ readonly siteId?: string;
82
+ readonly locale?: string;
83
+ readonly originKey?: string;
84
+ /**
85
+ * TODO(ADR-040): compose canonicalUrl from OriginRegistryPort.canonicalOrigin(ctx)
86
+ * once src/origin exists. Until then, a caller that knows the verified origin
87
+ * may pass it here; absent, canonicalUrl falls back to the relative path.
88
+ */
89
+ readonly originOverride?: string;
90
+ }
91
+ /** Result of a successful inverse resolve — the shape Menus/SEO consume. */
92
+ export interface RouteUrl {
93
+ readonly path: string;
94
+ readonly canonicalUrl: string;
95
+ }
96
+ /**
97
+ * The two registerable phases of the forward pipeline
98
+ * (`normalize → pre_content → content-resolve → post_content → 404`).
99
+ * `content-resolve` is fixed core content lookup and is not a registerable
100
+ * phase of this library; it is out of scope for ADR-039 v0's registration
101
+ * mechanism.
102
+ */
103
+ export type RouteResolvePhaseName = "pre_content" | "post_content";
104
+ /** Terminal decision a phase resolver may return; anything else falls through. */
105
+ export type RouteResolvePhaseOutcome = {
106
+ readonly kind: "redirect";
107
+ readonly location: string;
108
+ readonly statusCode: number;
109
+ } | {
110
+ readonly kind: "resolved";
111
+ readonly path: string;
112
+ } | {
113
+ readonly kind: "not_found";
114
+ };
115
+ /**
116
+ * A single phase resolver. Returns `null` to fall through to the next
117
+ * registered resolver in the same phase (and eventually the next phase);
118
+ * returns an outcome to short-circuit the pipeline (ADR-039 §1 invariant:
119
+ * `pre_content` may only redirect/gone, never turn a resolvable live path
120
+ * into a 404 — Round-2 amendment 5).
121
+ */
122
+ export type RouteResolvePhaseHandler = (path: string, ctx: RouteResolveContext) => Promise<RouteResolvePhaseOutcome | null>;
123
+ /** Final outcome of {@link resolve} after all phases have run. */
124
+ export type RouteResolveResult = {
125
+ readonly matched: false;
126
+ } | {
127
+ readonly matched: true;
128
+ readonly phase: RouteResolvePhaseName;
129
+ readonly outcome: RouteResolvePhaseOutcome;
130
+ };
131
+ /** Payload handed to the bound {@link SlugChangeCapture} inside the rename transaction. */
132
+ export interface SlugChangeCaptureInput {
133
+ readonly workspaceId: UUID;
134
+ readonly entryId: UUID;
135
+ readonly oldPath: string;
136
+ readonly newPath: string;
137
+ readonly actor: UUID;
138
+ readonly changeSetId?: UUID;
139
+ }
140
+ /**
141
+ * The single named slot content's write chokepoint calls inside the rename
142
+ * transaction (ADR-039 §4). Declared here by routing (content knows nothing of
143
+ * redirects); implemented by the redirects library; wired by the composition
144
+ * root. **No slot implementation may be plugin-supplied** (Round-2 amendment 1
145
+ * — the slot is core-resident by definition).
146
+ *
147
+ * Constraints on any bound implementation (Round-2 amendment 2): idempotent by
148
+ * `changeSetId`, no external I/O, no in-tx publish (an outbox row may be
149
+ * written in-tx per the transactional-outbox pattern, but dispatch happens
150
+ * post-commit), and a strict lock order of content row before redirect rows.
151
+ *
152
+ * Fail-closed nuance (Round-2 amendment 3, not enforced by this library):
153
+ * absent binding is a no-op only before Redirects is installed, or while
154
+ * slug-preservation is disabled for the workspace. Once link-preservation is
155
+ * enabled, a slug-changing rename with no bound capture MUST fail
156
+ * `LINK_PRESERVATION_UNAVAILABLE` rather than silently skip the redirect. That
157
+ * enforcement belongs to the content write chokepoint (which knows whether
158
+ * preservation is enabled) and to Redirects (which doesn't exist yet in this
159
+ * repo) — this library only exposes the registration slot.
160
+ */
161
+ export interface SlugChangeCapture {
162
+ onSlugChange(input: SlugChangeCaptureInput): Promise<void>;
163
+ }
164
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -29,6 +29,29 @@ export declare function resolveSeoImageRef(required: {
29
29
  deps: ResolveSeoImageRefDeps;
30
30
  input: ResolveSeoImageRefInput;
31
31
  }, _optional?: Record<string, never>): Promise<string | undefined>;
32
+ /** Metadata belongs to the selected transform, never the asset's editable display-size fields. */
33
+ export interface SeoImageDetails {
34
+ url: string;
35
+ /** Original source choice for admin diagnostics; a resized rendition can conceal a tiny source. */
36
+ ref?: string | undefined;
37
+ width?: number | undefined;
38
+ height?: number | undefined;
39
+ type?: string | undefined;
40
+ }
41
+ /** Resolve through the existing media owners, optionally selecting a host's share transform.
42
+ * Absolute external URLs remain unchanged with unknown metadata; no remote fetch occurs.
43
+ * The latest definition keeps the anonymous lazy-generation guarantee documented above.
44
+ * @param required Media ports, workspace and persisted reference.
45
+ * @param optional Host-selected transform; omitted or unregistered retains the reference's named transform.
46
+ * @returns URL and known output metadata, or undefined on a lookup miss.
47
+ * @complexity O(v) in registered transform versions; up to three bounded repository reads.
48
+ */
49
+ export declare function resolveSeoImageDetails(required: {
50
+ deps: ResolveSeoImageRefDeps;
51
+ input: ResolveSeoImageRefInput;
52
+ }, optional?: {
53
+ transformName?: string | undefined;
54
+ }): Promise<SeoImageDetails | undefined>;
32
55
  /**
33
56
  * The write-path refusal for an `ogImage`/`twitterImage`/`defaultOgImage` value (2026-10-05): the
34
57
  * message to refuse it with, or `null` to accept it. Both SEO write chokepoints call it
package/dist/seo/media.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { mimeForTransformFormat } from "../media/transform-types.js";
1
2
  /**
2
3
  * @file `resolveSeoImageRef` (ADR-PIPE-008 Decision §6, C-013, EC-07;
3
4
  * EC-07's "never generates" clause AMENDED 2026-09-05 — see the ADR's
@@ -83,23 +84,47 @@ function buildSeoImageUrl(deps, asset, transformName, latest) {
83
84
  * version listing).
84
85
  */
85
86
  export async function resolveSeoImageRef(required, _optional = {}) {
87
+ return (await resolveSeoImageDetails(required))?.url;
88
+ }
89
+ /** Resolve through the existing media owners, optionally selecting a host's share transform.
90
+ * Absolute external URLs remain unchanged with unknown metadata; no remote fetch occurs.
91
+ * The latest definition keeps the anonymous lazy-generation guarantee documented above.
92
+ * @param required Media ports, workspace and persisted reference.
93
+ * @param optional Host-selected transform; omitted or unregistered retains the reference's named transform.
94
+ * @returns URL and known output metadata, or undefined on a lookup miss.
95
+ * @complexity O(v) in registered transform versions; up to three bounded repository reads.
96
+ */
97
+ export async function resolveSeoImageDetails(required, optional = {}) {
86
98
  const { deps, input } = required;
87
99
  const ref = input.ref?.trim();
88
100
  if (!ref)
89
101
  return undefined;
90
102
  if (isAbsoluteUrl(ref))
91
- return ref;
103
+ return { url: ref, ...(optional.transformName === undefined ? {} : { ref }) };
92
104
  const parts = parseMediaRefParts(ref);
93
105
  if (!parts)
94
106
  return undefined;
95
- const { idOrSlug, transformName } = parts;
96
- const asset = await resolveVisibleAsset(deps, input.workspaceId, idOrSlug);
107
+ const asset = await resolveVisibleAsset(deps, input.workspaceId, parts.idOrSlug);
97
108
  if (!asset)
98
109
  return undefined;
99
- const latest = await resolveLatestTransformVersion(deps, input.workspaceId, transformName);
110
+ let transformName = optional.transformName ?? parts.transformName;
111
+ let latest = await resolveLatestTransformVersion(deps, input.workspaceId, transformName);
112
+ // Share-transform registration may still be in flight at boot. Keep the persisted reference
113
+ // servable through its own transform rather than dropping the image from both social cards.
114
+ if (!latest && transformName !== parts.transformName) {
115
+ transformName = parts.transformName;
116
+ latest = await resolveLatestTransformVersion(deps, input.workspaceId, transformName);
117
+ }
100
118
  if (!latest)
101
119
  return undefined;
102
- return buildSeoImageUrl(deps, asset, transformName, latest);
120
+ // inside/outside fit does not produce the exact declared box, so don't advertise that box.
121
+ const exactBox = latest.params.width !== undefined && latest.params.height !== undefined && latest.params.fit !== "inside" && latest.params.fit !== "outside";
122
+ return {
123
+ url: buildSeoImageUrl(deps, asset, transformName, latest),
124
+ ...(optional.transformName === undefined ? {} : { ref }),
125
+ ...(exactBox ? { width: latest.params.width, height: latest.params.height } : {}),
126
+ type: mimeForTransformFormat({ format: latest.params.format }),
127
+ };
103
128
  }
104
129
  /**
105
130
  * The write-path refusal for an `ogImage`/`twitterImage`/`defaultOgImage` value (2026-10-05): the
@@ -22,8 +22,15 @@ function buildOpenGraphElements(og) {
22
22
  { kind: "og", property: "og:type", content: og.type, priority: 141 },
23
23
  { kind: "og", property: "og:url", content: og.url, priority: 142 },
24
24
  ];
25
- if (og.image)
25
+ if (og.image) {
26
26
  elements.push({ kind: "og", property: "og:image", content: og.image, priority: 143 });
27
+ if (og.imageWidth !== undefined)
28
+ elements.push({ kind: "og", property: "og:image:width", content: String(og.imageWidth), priority: 143 });
29
+ if (og.imageHeight !== undefined)
30
+ elements.push({ kind: "og", property: "og:image:height", content: String(og.imageHeight), priority: 143 });
31
+ if (og.imageType)
32
+ elements.push({ kind: "og", property: "og:image:type", content: og.imageType, priority: 143 });
33
+ }
27
34
  if (og.description)
28
35
  elements.push({ kind: "og", property: "og:description", content: og.description, priority: 144 });
29
36
  return elements;
package/dist/seo/seo.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface GetEntryMetaDeps {
37
37
  postRepo: SeoPostReadPort;
38
38
  settings: SeoSettingsReaderPort;
39
39
  media: ResolveSeoImageRefDeps;
40
+ /** Host-owned share-card variant; omitted preserves persisted transform choices. */
41
+ shareImageTransform?: string | undefined;
40
42
  originRegistry: OriginRegistryPort;
41
43
  runtimeMode: string;
42
44
  urls: SeoUrlsPort;
package/dist/seo/seo.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { resolveWorkspaceOrigin, toAbsoluteUrl } from "./absolute-url.js";
2
2
  import { SeoEntryNotFoundError } from "./errors.js";
3
- import { resolveSeoImageRef } from "./media.js";
3
+ import { resolveSeoImageDetails } from "./media.js";
4
4
  /** Featured image (2026-10-05) as a share-image ref: the core "public" transform every `/m/` URL
5
5
  * already serves (`deps.ts`'s ADR-027 §4 registration). */
6
6
  function featuredImageRef(post) {
@@ -106,7 +106,7 @@ async function resolveRobots(deps, post, overrides, settings, workspaceId) {
106
106
  /** The first of `refs` that resolves through media, or `undefined`. @complexity O(r) resolves. */
107
107
  async function firstResolvedImage(deps, workspaceId, refs) {
108
108
  for (const ref of refs) {
109
- const resolved = ref ? await resolveSeoImageRef({ deps: deps.media, input: { workspaceId, ref } }) : undefined;
109
+ const resolved = ref ? await resolveSeoImageDetails({ deps: deps.media, input: { workspaceId, ref } }, { transformName: deps.shareImageTransform }) : undefined;
110
110
  if (resolved)
111
111
  return resolved;
112
112
  }
@@ -125,8 +125,8 @@ async function resolveShareImages(deps, post, overrides, settings, workspaceId,
125
125
  const ogImageResolved = await firstResolvedImage(deps, workspaceId, overrides.ogImage ? [overrides.ogImage] : fallbacks);
126
126
  const twitterImageResolved = await firstResolvedImage(deps, workspaceId, overrides.twitterImage ? [overrides.twitterImage] : fallbacks);
127
127
  return {
128
- ogImage: ogImageResolved ? toAbsoluteUrl({ origin, path: ogImageResolved }) : undefined,
129
- twitterImage: twitterImageResolved ? toAbsoluteUrl({ origin, path: twitterImageResolved }) : undefined,
128
+ ogImage: ogImageResolved ? { ...ogImageResolved, url: toAbsoluteUrl({ origin, path: ogImageResolved.url }) } : undefined,
129
+ twitterImage: twitterImageResolved ? { ...twitterImageResolved, url: toAbsoluteUrl({ origin, path: twitterImageResolved.url }) } : undefined,
130
130
  };
131
131
  }
132
132
  /** title: override, else titleTemplate applied to entry.title. description: override > site default >
@@ -150,7 +150,11 @@ function buildOpenGraph(overrides, title, description, ogType, canonical, ogImag
150
150
  description: overrides.ogDescription ?? description,
151
151
  type: ogType,
152
152
  url: canonical,
153
- image: ogImage,
153
+ image: ogImage?.url,
154
+ ...(ogImage?.ref === undefined ? {} : { imageRef: ogImage.ref }),
155
+ ...(ogImage?.width === undefined ? {} : { imageWidth: ogImage.width }),
156
+ ...(ogImage?.height === undefined ? {} : { imageHeight: ogImage.height }),
157
+ ...(ogImage?.type === undefined ? {} : { imageType: ogImage.type }),
154
158
  siteName: undefined,
155
159
  };
156
160
  }
@@ -159,7 +163,8 @@ function buildTwitter(overrides, title, description, twitterImage, settings) {
159
163
  card: overrides.twitterCard ?? "summary_large_image",
160
164
  title: overrides.twitterTitle ?? title,
161
165
  description: overrides.twitterDescription ?? description,
162
- image: twitterImage,
166
+ image: twitterImage?.url,
167
+ ...(twitterImage?.ref === undefined ? {} : { imageRef: twitterImage.ref }),
163
168
  site: settings.twitterSite,
164
169
  };
165
170
  }
@@ -188,6 +188,12 @@ export interface OpenGraph {
188
188
  type: OpenGraphType;
189
189
  url: string;
190
190
  image?: string | undefined;
191
+ /** Original selected source for admin diagnostics, before any share-card resize. */
192
+ imageRef?: string | undefined;
193
+ /** Known output metadata from the selected named transform, omitted for external URLs. */
194
+ imageWidth?: number | undefined;
195
+ imageHeight?: number | undefined;
196
+ imageType?: string | undefined;
191
197
  siteName?: string | undefined;
192
198
  }
193
199
  export interface TwitterCard {
@@ -195,6 +201,8 @@ export interface TwitterCard {
195
201
  title: string;
196
202
  description?: string | undefined;
197
203
  image?: string | undefined;
204
+ /** Original selected source for admin diagnostics, before any share-card resize. */
205
+ imageRef?: string | undefined;
198
206
  site?: string | undefined;
199
207
  }
200
208
  export interface RobotsDirective {
@@ -10,7 +10,5 @@
10
10
  */
11
11
  /** {@link escapeHtml} with the XML named entity `&apos;` for the apostrophe (sitemap and feed XML).
12
12
  * @complexity O(text length). */
13
- export declare function escapeXml(required: {
14
- text: string;
15
- }, _optional?: Record<string, never>): string;
13
+ export { escapeXml } from "@jini-ai/core/text";
16
14
  //# sourceMappingURL=xml-escape.d.ts.map
@@ -8,12 +8,7 @@
8
8
  * other character first would double-escape the `&` that replacement introduced. The apostrophe
9
9
  * becomes the numeric `&#39;` in HTML, because `&apos;` is undefined in HTML4/XHTML1.
10
10
  */
11
- const XML_ENTITIES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" };
12
- const SPECIAL = /[&<>"']/g;
13
11
  /** {@link escapeHtml} with the XML named entity `&apos;` for the apostrophe (sitemap and feed XML).
14
12
  * @complexity O(text length). */
15
- // escapeHtml stays with the host renderer. A shared core owner for this leaf is a coordinator follow-up.
16
- export function escapeXml(required, _optional = {}) {
17
- return required.text.replace(SPECIAL, (char) => XML_ENTITIES[char] ?? char);
18
- }
13
+ export { escapeXml } from "@jini-ai/core/text";
19
14
  //# sourceMappingURL=xml-escape.js.map
@@ -1,3 +1,27 @@
1
+ /**
2
+ * @file A tiny per-key async mutex (SPEC-043 REQ-06/15, AC-04/EC-02).
3
+ *
4
+ * Purpose:
5
+ * `features/entries/write-service.ts`'s `updateEntry` reads the current row, checks
6
+ * `expectedVersion`, then writes — but `InMemoryEntryRepo.transaction` provides no real
7
+ * serialization (`async transaction(fn) { return fn(); }`), and the version check itself happens
8
+ * BEFORE that transaction call. Two truly concurrent callers racing the same entry id can both
9
+ * observe the pre-write version and both pass the check under the in-memory adapter (the real
10
+ * `SqliteEntryRepo.transaction`'s `BEGIN IMMEDIATE` already serializes real concurrent writers at
11
+ * the DB layer — this mutex is redundant-but-harmless there, and load-bearing for the in-memory
12
+ * path this test suite's concurrency assertions exercise).
13
+ *
14
+ * This module serializes the whole "read current version -> validate -> call updateEntry" sequence
15
+ * per entry id at the WIDGETS domain layer, restoring correct optimistic-concurrency semantics
16
+ * deterministically regardless of the underlying repo adapter's own concurrency properties.
17
+ *
18
+ * Architectural role:
19
+ * `widgets` domain-internal helper, not a frozen public contract.
20
+ */
21
+ export type EntryLockState = Map<string, Promise<void>>;
22
+ /** A host can own this state; the compatibility adapter still serializes existing callers.
23
+ * Idle keys are removed by withEntryLock. @complexity O(1). */
24
+ export declare function createEntryLockState(_required: Record<string, never>, _optional?: Record<string, never>): EntryLockState;
1
25
  /**
2
26
  * Runs `fn` exclusively with respect to every other `withEntryLock` call sharing the same `key` —
3
27
  * callers queue in call order, each waiting for the previous holder to finish.
@@ -8,5 +32,7 @@
8
32
  export declare function withEntryLock<T>({ key, fn }: {
9
33
  key: string;
10
34
  fn: () => Promise<T>;
11
- }, _optional?: Record<string, never>): Promise<T>;
35
+ }, { locks }?: {
36
+ locks?: EntryLockState;
37
+ }): Promise<T>;
12
38
  //# sourceMappingURL=concurrency.d.ts.map
@@ -1,24 +1,9 @@
1
- /**
2
- * @file A tiny per-key async mutex (SPEC-043 REQ-06/15, AC-04/EC-02).
3
- *
4
- * Purpose:
5
- * `features/entries/write-service.ts`'s `updateEntry` reads the current row, checks
6
- * `expectedVersion`, then writes — but `InMemoryEntryRepo.transaction` provides no real
7
- * serialization (`async transaction(fn) { return fn(); }`), and the version check itself happens
8
- * BEFORE that transaction call. Two truly concurrent callers racing the same entry id can both
9
- * observe the pre-write version and both pass the check under the in-memory adapter (the real
10
- * `SqliteEntryRepo.transaction`'s `BEGIN IMMEDIATE` already serializes real concurrent writers at
11
- * the DB layer — this mutex is redundant-but-harmless there, and load-bearing for the in-memory
12
- * path this test suite's concurrency assertions exercise).
13
- *
14
- * This module serializes the whole "read current version -> validate -> call updateEntry" sequence
15
- * per entry id at the WIDGETS domain layer, restoring correct optimistic-concurrency semantics
16
- * deterministically regardless of the underlying repo adapter's own concurrency properties.
17
- *
18
- * Architectural role:
19
- * `widgets` domain-internal helper, not a frozen public contract.
20
- */
21
- const locks = new Map();
1
+ /** A host can own this state; the compatibility adapter still serializes existing callers.
2
+ * Idle keys are removed by withEntryLock. @complexity O(1). */
3
+ export function createEntryLockState(_required, _optional = {}) {
4
+ return new Map();
5
+ }
6
+ const defaultLocks = createEntryLockState({});
22
7
  /**
23
8
  * Runs `fn` exclusively with respect to every other `withEntryLock` call sharing the same `key` —
24
9
  * callers queue in call order, each waiting for the previous holder to finish.
@@ -26,19 +11,23 @@ const locks = new Map();
26
11
  * @complexity O(1) scheduling overhead beyond `fn`'s own cost.
27
12
  * @overallScore 100
28
13
  */
29
- export async function withEntryLock({ key, fn }, _optional = {}) {
14
+ export async function withEntryLock({ key, fn }, { locks = defaultLocks } = {}) {
30
15
  const previous = locks.get(key) ?? Promise.resolve();
31
16
  let release;
32
17
  const next = new Promise((resolve) => {
33
18
  release = resolve;
34
19
  });
35
- locks.set(key, previous.then(() => next));
20
+ const tail = previous.then(() => next);
21
+ locks.set(key, tail);
36
22
  await previous;
37
23
  try {
38
24
  return await fn();
39
25
  }
40
26
  finally {
41
27
  release();
28
+ // A newer waiter owns the key until it finishes; deleting its tail would let callers race.
29
+ if (locks.get(key) === tail)
30
+ locks.delete(key);
42
31
  }
43
32
  }
44
33
  //# sourceMappingURL=concurrency.js.map
@@ -1,3 +1,4 @@
1
+ import { parseGalleryConfig } from "./markers/gallery.js";
1
2
  import { isNonArrayRecord } from "@jini-ai/core";
2
3
  /** Reports every key in `schema.required` that is missing from `value`. */
3
4
  function validateRequiredKeys(value, schema, path, errors) {
@@ -94,6 +95,11 @@ function walk(value, schema, path, errors) {
94
95
  export function validateWidgetConfig(required, _optional = {}) {
95
96
  const errors = [];
96
97
  walk(required.config, required.schema, "config", errors);
98
+ // The shared parser owns layout, id and CSS-ratio invariants; the schema narrows widgets to library images.
99
+ if (required.schema["x-gallery-config"] === true) {
100
+ for (const reason of parseGalleryConfig({ config: required.config }, { allowEmpty: true }).errors)
101
+ errors.push({ field: "config", reason });
102
+ }
97
103
  return { valid: errors.length === 0, fieldErrors: errors };
98
104
  }
99
105
  /** Pushes each declared free-text string under `schema` (recursion into declared `properties` and
@@ -0,0 +1,23 @@
1
+ import type { PageHtmlEmbedRef } from './html/html-embeds.js';
2
+ import type { WidgetRenderIR } from './types.js';
3
+ export type GalleryMediaResolver = (required: {
4
+ refs: readonly PageHtmlEmbedRef[];
5
+ }, optional?: Record<string, never>) => Promise<ReadonlyMap<string, WidgetRenderIR>>;
6
+ export type GalleryIssueReporter = (required: {
7
+ key: string | null;
8
+ messages: readonly string[];
9
+ }, optional?: Record<string, never>) => void;
10
+ /** Resolve bounded, deduplicated gallery assets through the existing media resolver port.
11
+ * Occurrence keys preserve different layouts/alt/captions for the same assets on one page.
12
+ * At the page media budget, whole later galleries degrade instead of silently losing images.
13
+ * @complexity O(g*i) time/space for g markers and i<=50 images; one media batch of at most maxMedia.
14
+ */
15
+ export declare function resolveGalleryEmbeds({ refs, resolveMedia }: {
16
+ refs: readonly PageHtmlEmbedRef[];
17
+ resolveMedia: GalleryMediaResolver;
18
+ }, { maxMedia, timeoutMs, reportIssue }?: {
19
+ maxMedia?: number;
20
+ timeoutMs?: number;
21
+ reportIssue?: GalleryIssueReporter;
22
+ }): Promise<ReadonlyMap<string, WidgetRenderIR>>;
23
+ //# sourceMappingURL=gallery-resolver.d.ts.map
@@ -0,0 +1,80 @@
1
+ import { galleryMediaReferences, parseGalleryConfig } from './markers/gallery.js';
2
+ /** Bound the media wait without taking ownership of the host repository's cancellation policy.
3
+ * The port has no abort signal; an expired call may finish in the background but is never awaited
4
+ * indefinitely and its timer is always released. No retries or duplicate repository calls.
5
+ */
6
+ async function resolveGalleryMedia({ resolveMedia, refs, timeoutMs }) {
7
+ let timer;
8
+ try {
9
+ return await Promise.race([
10
+ resolveMedia({ refs }),
11
+ new Promise(resolve => { timer = setTimeout(() => resolve(undefined), timeoutMs); }),
12
+ ]);
13
+ }
14
+ finally {
15
+ clearTimeout(timer);
16
+ }
17
+ }
18
+ /** Resolve bounded, deduplicated gallery assets through the existing media resolver port.
19
+ * Occurrence keys preserve different layouts/alt/captions for the same assets on one page.
20
+ * At the page media budget, whole later galleries degrade instead of silently losing images.
21
+ * @complexity O(g*i) time/space for g markers and i<=50 images; one media batch of at most maxMedia.
22
+ */
23
+ export async function resolveGalleryEmbeds({ refs, resolveMedia }, { maxMedia = 50, timeoutMs = 10000, reportIssue } = {}) {
24
+ const accepted = [];
25
+ const legacyIds = new Set();
26
+ const slugKeys = new Set();
27
+ const mediaRefs = new Map();
28
+ for (const ref of refs) {
29
+ const config = ref.config?.content ?? ref.config;
30
+ const parsed = parseGalleryConfig({ config }, { allowEmpty: ref.type === 'media' });
31
+ if (!parsed.config || ref.id === null) {
32
+ reportIssue?.({ key: ref.id, messages: parsed.errors });
33
+ continue;
34
+ }
35
+ const ids = new Set(parsed.config.mode === 'html'
36
+ ? galleryMediaReferences({ config: { html: parsed.config.html } }).map(item => item.mediaId)
37
+ : parsed.config.images.flatMap(item => (item.slug ?? item.mediaId) === undefined ? [] : [(item.slug ?? item.mediaId)]));
38
+ const added = [...ids].filter(id => !mediaRefs.has(id));
39
+ if (mediaRefs.size + added.length > maxMedia) {
40
+ reportIssue?.({ key: ref.id, messages: [`gallery media exceeds the page budget of ${maxMedia} distinct assets`] });
41
+ continue;
42
+ }
43
+ accepted.push({ ...ref, config: config });
44
+ const legacy = parsed.config.mode === undefined;
45
+ for (const key of ids)
46
+ (legacy ? legacyIds : slugKeys).add(key);
47
+ for (const id of added)
48
+ mediaRefs.set(id, { type: 'media', id: legacy ? id : null, slug: legacy ? null : id, name: null, variant: null, header: true });
49
+ }
50
+ // No second repository lifecycle: media owns workspace lookup, visibility, transforms and metadata.
51
+ const media = mediaRefs.size === 0 ? new Map() : await resolveGalleryMedia({ resolveMedia, refs: [...mediaRefs.values()], timeoutMs });
52
+ if (media === undefined) {
53
+ reportIssue?.({ key: null, messages: ['gallery media resolution timed out'] });
54
+ return new Map();
55
+ }
56
+ // Author-supplied ids are map keys, including names such as __proto__; never assign them onto
57
+ // an object with a prototype or inherit a fake media record from Object.prototype.
58
+ const imageProps = Object.create(null);
59
+ for (const [id, ir] of media) {
60
+ // Legacy mediaId is an indexed asset id, not the single-media marker's slug alias. The reused
61
+ // media resolver accepts both; accepting a slug here would render an asset that the pure
62
+ // entry_refs extractor cannot protect, since it cannot resolve a slug to the canonical id.
63
+ // New slug content deliberately indexes authored keys; the host usage adapter reads id and slug.
64
+ if (legacyIds.has(id) && !slugKeys.has(id)) {
65
+ if (ir.props.assetId !== id) {
66
+ reportIssue?.({ key: id, messages: ['gallery mediaId must be the canonical media id, not a slug'] });
67
+ continue;
68
+ }
69
+ // A video resolved by the generic media pipeline is not an image in a legacy gallery.
70
+ if (typeof ir.props.contentType === 'string' && (ir.props.contentType.startsWith('video/') || ir.props.contentType.startsWith('audio/')))
71
+ continue;
72
+ }
73
+ imageProps[id] = ir.props;
74
+ }
75
+ for (const key of slugKeys)
76
+ if (!media.has(key))
77
+ reportIssue?.({ key, messages: [`unknown media slug: ${key}`] });
78
+ return new Map(accepted.map(ref => [ref.id, { componentId: ref.type === 'media' ? 'media' : 'gallery', props: { config: ref.config, media: imageProps } }]));
79
+ }
80
+ //# sourceMappingURL=gallery-resolver.js.map
@@ -0,0 +1,24 @@
1
+ import type { JsonObject } from '@jini-ai/core/primitives';
2
+ import { type GalleryImage } from '../markers/gallery.js';
3
+ /** Layout-only baseline; host themes override jini classes/custom properties without palette coupling. */
4
+ export declare const GALLERY_STYLE = "<style data-jini-gallery-style>\n.jini-gallery{gap:var(--jini-gallery-gap,1rem)}\n.jini-gallery-item{margin:0;min-width:0;break-inside:avoid}\n.jini-gallery audio{display:block;width:100%}\n.jini-gallery img,.jini-gallery video{display:block;width:100%;height:auto;aspect-ratio:var(--jini-gallery-ratio,auto);object-fit:cover}\n.jini-gallery-single{display:grid;grid-template-columns:1fr}\n.jini-gallery-grid{display:grid;grid-template-columns:repeat(var(--jini-gallery-columns,3),minmax(0,1fr))}\n.jini-gallery-masonry{columns:var(--jini-gallery-columns,3);column-gap:var(--jini-gallery-gap,1rem)}\n.jini-gallery-masonry .jini-gallery-item{margin-block-end:var(--jini-gallery-gap,1rem)}\n.jini-gallery-carousel{display:flex;overflow-x:auto;scroll-snap-type:x mandatory}\n.jini-gallery-carousel .jini-gallery-item{flex:0 0 min(85%,36rem);scroll-snap-align:start}\n@media(max-width:48rem){.jini-gallery-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.jini-gallery-masonry{columns:2}}\n@media(max-width:30rem){.jini-gallery-grid{grid-template-columns:1fr}.jini-gallery-masonry{columns:1}}\n</style>";
5
+ export type GalleryImageRenderer = (required: {
6
+ image: GalleryImage & {
7
+ alt: string;
8
+ };
9
+ media: JsonObject | undefined;
10
+ }, optional?: Record<string, never>) => string;
11
+ /** Server-render accessible gallery markup. Image URLs, metadata and srcset belong to the host
12
+ * image pipeline supplied as a port, never templated here. Invalid config produces no output.
13
+ * @complexity O(n) time and output space for at most 50 images, without I/O.
14
+ */
15
+ export declare function renderGallery({ config, media, renderImage, renderRawHtml }: {
16
+ config: unknown;
17
+ media?: unknown;
18
+ renderImage: GalleryImageRenderer;
19
+ renderRawHtml?: (required: {
20
+ html: string;
21
+ media: Record<string, JsonObject>;
22
+ }, optional?: Record<string, never>) => string;
23
+ }, _optional?: Record<string, never>): string;
24
+ //# sourceMappingURL=gallery-render.d.ts.map