@jini-ai/admin 0.4.10 → 0.4.12

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 (38) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/comments/rules.d.ts +3 -3
  3. package/dist/comments/rules.js +3 -3
  4. package/dist/core/ports/seo.d.ts +20 -0
  5. package/dist/forms/field-settings.d.ts +1 -0
  6. package/dist/forms/field-settings.js +4 -4
  7. package/dist/forms/react/components/FieldAttributesDialog.js +1 -1
  8. package/dist/forms/react/hooks/use-forms-list.hooks.d.ts +1 -1
  9. package/dist/forms/rules.d.ts +2 -2
  10. package/dist/forms/rules.js +2 -2
  11. package/dist/redirects/react/hooks/RedirectsPage.hooks.js +1 -1
  12. package/dist/redirects/react/pages/RedirectsPage.js +2 -1
  13. package/dist/redirects/rules.d.ts +2 -2
  14. package/dist/redirects/rules.js +2 -2
  15. package/dist/seo/adapters/http.js +5 -0
  16. package/dist/seo/adapters/memory.js +1 -1
  17. package/dist/seo/messages.en.d.ts +15 -10
  18. package/dist/seo/messages.en.js +15 -10
  19. package/dist/seo/models.d.ts +11 -0
  20. package/dist/seo/react/components/MediaRefField.d.ts +17 -5
  21. package/dist/seo/react/components/MediaRefField.js +14 -4
  22. package/dist/seo/react/components/SitemapModal.js +1 -1
  23. package/dist/seo/react/hooks/MediaRefField.hooks.js +10 -2
  24. package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -0
  25. package/dist/seo/react/hooks/SeoPage.hooks.js +2 -2
  26. package/dist/seo/react/hooks/SeoQuery.hooks.d.ts +1 -0
  27. package/dist/seo/react/hooks/SeoQuery.hooks.js +1 -0
  28. package/dist/seo/react/hooks/use-entry-picker.hooks.js +7 -2
  29. package/dist/seo/react/hooks/use-seo-entry-panel.hooks.js +13 -2
  30. package/dist/seo/react/hooks/use-share-image-warning.hooks.d.ts +4 -1
  31. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +1 -0
  32. package/dist/seo/react/index.d.ts +45 -30
  33. package/dist/seo/react/pages/SeoPage.d.ts +3 -1
  34. package/dist/seo/react/pages/SeoPage.js +26 -24
  35. package/dist/seo/rules.d.ts +95 -4
  36. package/dist/seo/rules.js +134 -8
  37. package/dist/seo/seo.module.d.ts +15 -10
  38. package/package.json +14 -8
package/dist/seo/rules.js CHANGED
@@ -23,7 +23,7 @@ export function sortIssuesBySeverity({ issues }, _optional = {}) {
23
23
  /**
24
24
  * One edited per-entry override field's outgoing value. An emptied text/URL box means "remove this
25
25
  * override so the entry falls back to the site default", which on the wire is `null` — the clear
26
- * sentinel `SeoExtFieldsPatch` documents (`apps/website/src/features/seo/types.ts`) and
26
+ * sentinel `SeoExtFieldsPatch` documents (`features/seo/types.ts`) and
27
27
  * `setEntrySeoOverrides` implements by `delete`-ing the key.
28
28
  *
29
29
  * It is NOT `""`. `write-service.ts` stores `""` as a genuine override and `seo.ts` resolves
@@ -46,6 +46,43 @@ export function overrideOrClear({ value }, _optional = {}) {
46
46
  export function orEmpty({ value }, _optional = {}) {
47
47
  return value ?? "";
48
48
  }
49
+ /** One entry's effective X value next to its effective social preview value, per field. Images
50
+ * compare by source ref (the resolved URL is resized), falling back to the URL for pasted ones. */
51
+ function xAndSocial({ meta }) {
52
+ return {
53
+ twitterTitle: [meta.twitter.title, meta.openGraph.title],
54
+ twitterDescription: [meta.twitter.description, meta.openGraph.description],
55
+ twitterImage: [meta.twitter.imageRef ?? meta.twitter.image, meta.openGraph.imageRef ?? meta.openGraph.image],
56
+ };
57
+ }
58
+ /** Whether this entry's X card differs from its social preview in any field — i.e. an X override
59
+ * is in effect, so the "Customize for X/Twitter" disclosure opens on load. (2026-10-15, owner: one
60
+ * generic social preview, X only as an optional customization.)
61
+ * @complexity O(1). */
62
+ export function xIsCustomized({ meta }, _optional = {}) {
63
+ return Object.values(xAndSocial({ meta })).some(([x, social]) => x !== social);
64
+ }
65
+ /** An X field's untouched box value, handed to `fieldValue` as its resolved value (so a pending
66
+ * edit or clear still wins there). An X value equal to the social preview's is inherited, so the
67
+ * box stays empty and its placeholder shows the live social value — typing there is what creates
68
+ * an X override.
69
+ * @complexity O(1). */
70
+ export function xResolvedValue({ key, meta }, _optional = {}) {
71
+ const [x, social] = xAndSocial({ meta })[key];
72
+ return x === social ? '' : x ?? '';
73
+ }
74
+ /** The X field an edit to a social preview field should carry along: when X still shows the
75
+ * preview's value and has no pending edit of its own, the edit also clears X's stored override so
76
+ * X keeps following the preview after save. Without it, an X override saved equal to the preview
77
+ * (indistinguishable here, the screen sees only effective values) would silently pin X to the old
78
+ * text. Clearing an override that does not exist is a no-op on the server.
79
+ * @complexity O(1). */
80
+ export function inheritedXFor({ key, meta, touched }, _optional = {}) {
81
+ const xKey = { ogTitle: 'twitterTitle', ogDescription: 'twitterDescription', ogImage: 'twitterImage' }[key];
82
+ if (!xKey || xKey in touched)
83
+ return null;
84
+ return xResolvedValue({ key: xKey, meta }) === '' ? xKey : null;
85
+ }
49
86
  /** A pending-action button's label — `react/pages/SeoPage.tsx` uses this for both the save-settings button
50
87
  * ("Saving…"/"Save settings") and the regenerate-sitemap button ("Working…"/"Regenerate
51
88
  * sitemap"), same `pending ? … : …` shape, different copy. Also reused by `SitemapModal.tsx`'s
@@ -89,10 +126,10 @@ export function filterSitemapEntries({ entries, query }, _optional = {}) {
89
126
  * `MediaRefField`'s ref-building/preview logic (`ogImage`/`twitterImage`/`defaultOgImage` all
90
127
  * share this shape — SPEC intent: "make the OG image selectable, with a preview"). The reference
91
128
  * format itself is NOT reinvented here — it is verified, not guessed, against the one function that
92
- * actually parses it server-side: `resolveSeoImageRef` (`apps/website/src/features/seo/media.ts`)
129
+ * actually parses it server-side: `resolveSeoImageRef` (`features/seo/media.ts`)
93
130
  * accepts either an already-absolute URL, passed through unchanged, or a bare `{assetId}:{transformName}`
94
131
  * pair split on the FIRST `:`. `isAbsoluteMediaRef`/`parseMediaRefAssetId` below duplicate that
95
- * parsing rather than import across the app boundary (apps/admin has no dependency on apps/website)
132
+ * parsing rather than import across the app boundary (the admin UI has no dependency on the host server)
96
133
  * — kept in lockstep by this doc comment naming the source of truth; a change to that split MUST
97
134
  * update both.
98
135
  */
@@ -101,7 +138,7 @@ export function filterSitemapEntries({ entries, query }, _optional = {}) {
101
138
  * `transformName: "public"` for the identical `{assetId}:{transformName}` shape it writes into a
102
139
  * post body's image node. */
103
140
  const SEO_IMAGE_TRANSFORM = "public";
104
- /** Mirrors `resolveSeoImageRef`'s own `isAbsoluteUrl` (`apps/website/src/features/seo/media.ts`) —
141
+ /** Mirrors `resolveSeoImageRef`'s own `isAbsoluteUrl` (`features/seo/media.ts`) —
105
142
  * see this section's file-header doc for why this is a duplicate, not an import. */
106
143
  const ABSOLUTE_URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
107
144
  function isAbsoluteMediaRef(value) {
@@ -126,10 +163,68 @@ export function isStoredMediaRef({ value }, _optional = {}) {
126
163
  // A URL typed one character at a time reaches "https:/" before it reaches "https://".
127
164
  return !trimmed.includes(":/") && parseMediaRefAssetId(trimmed) !== null;
128
165
  }
166
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
167
+ /** What an image field shows for its value — the file's name and pixel size — in place of the raw
168
+ * `{slug}:public` reference or URL (owner, 2026-10-15: a read-only "Selected image" box "looks
169
+ * useless"). The library row wins when the host can list media; otherwise a readable slug or the
170
+ * URL's file name stands in, and an opaque id falls back to the translated "Image".
171
+ * Size prefers the stored dimensions, then the decoded preview's natural size.
172
+ * @example describeMediaRef({ value: 'sunset:public', media: [{ id: 'a1', slug: 'sunset', title: 'Sunset', width: 1200, height: 630 }] }) // { name: 'Sunset', size: '1200 × 630' }
173
+ * @complexity O(m) over the media rows; no I/O.
174
+ */
175
+ export function describeMediaRef({ value, media }, { width, height, t = key => key, extensionFor } = {}) {
176
+ const trimmed = typeof value === 'string' ? value.trim() : '';
177
+ if (!trimmed)
178
+ return null;
179
+ const key = parseMediaRefAssetId(trimmed);
180
+ const row = key ? media?.find(item => item.id === key || item.slug === key) : undefined;
181
+ const name = row?.title ? withExtension({ name: row.title, extension: row.contentType && extensionFor ? extensionFor(row.contentType) : '' }) : fallbackMediaName({ key, value: trimmed, t });
182
+ return { name, size: pixelSize(row?.width ?? width, row?.height ?? height) };
183
+ }
184
+ /** A library title reads as a file name (owner, 2026-10-15: "it should be testing-page.png or
185
+ * whatever the extension is"). No original upload name is stored, so the extension comes from the
186
+ * stored bytes' type; a title that already ends in it is left alone. */
187
+ function withExtension({ name, extension }) {
188
+ return extension && !name.toLowerCase().endsWith(extension) ? `${name}${extension}` : name;
189
+ }
190
+ /** Whether an entry's social preview image comes from content or the site default, with no override
191
+ * of its own, so the field shows its source muted. "Using site default" is the original copy (owner,
192
+ * 2026-10-15). A pending edit is the entry's own, and so is a stored override the host flags
193
+ * (`imageOverridden`) even when it equals the default. A host without that flag can't tell such an
194
+ * override apart, so it reads as inherited (same image either way). Returns 'content' for an image
195
+ * the host identifies as content, 'default' for the site default, and false for an own/absent image.
196
+ * @complexity O(1). */
197
+ export function ogImageInherited({ meta, defaultOgImage, touched }, _optional = {}) {
198
+ if ('ogImage' in touched || meta.openGraph.imageOverridden)
199
+ return false;
200
+ const image = meta.openGraph.imageRef ?? meta.openGraph.image;
201
+ if (meta.openGraph.imageSource === 'content' && image)
202
+ return 'content';
203
+ return defaultOgImage && image === defaultOgImage ? 'default' : false;
204
+ }
205
+ function fallbackMediaName({ key, value, t }) {
206
+ if (!key)
207
+ return urlFileName(value);
208
+ return UUID_PATTERN.test(key) ? t('Image') : key;
209
+ }
210
+ function pixelSize(width, height) {
211
+ return width && height ? `${width} × ${height}` : null;
212
+ }
213
+ /** The last path segment of an image URL ("https://cdn.example/a/pic.png" → "pic.png"). */
214
+ function urlFileName(value) {
215
+ try {
216
+ const url = new URL(value, 'https://placeholder.invalid');
217
+ const last = url.pathname.split('/').filter(Boolean).pop();
218
+ return last ? decodeURIComponent(last) : url.hostname || value;
219
+ }
220
+ catch {
221
+ return value;
222
+ }
223
+ }
129
224
  /** Builds the `{slug}:public` reference `MediaRefField` writes into the field on selection —
130
225
  * prefers the asset's readable slug over its id (readable-slugs S5b, 2026-09-23), matching
131
226
  * `resolveSeoImageRef`'s own id-or-slug lookup server-side (`findMediaByIdOrSlug`,
132
- * `apps/website/src/features/seo/media.ts`, S4) so a saved override reads as a slug, not an opaque
227
+ * `features/seo/media.ts`, S4) so a saved override reads as a slug, not an opaque
133
228
  * uuid. Falls back to `id` when `slug` is empty — defensive only; a real upload always derives one
134
229
  * (`deriveUniqueMediaSlug`). Same `transformName: "public"` `EmbedInsertControl.tsx` already writes
135
230
  * for an inserted image node. An old stored `{assetId}:public` value keeps resolving regardless
@@ -149,7 +244,7 @@ export function buildMediaRef({ item }, _optional = {}) {
149
244
  * "string"` also resolves to `null` rather than throwing — `MediaRefField`'s `value` prop is a
150
245
  * `fieldValue(key, resolved) ?? ""` fallback chain (`react/pages/SeoPage.tsx`), where `??` does not replace a
151
246
  * non-nullish-but-wrong-typed result from a misbehaving caller; matches `resolveSeoImageRef`'s own
152
- * "never throws over one bad reference" contract (`apps/website/src/features/seo/media.ts`).
247
+ * "never throws over one bad reference" contract (`features/seo/media.ts`).
153
248
  *
154
249
  * @complexity O(1) — string parsing only, no I/O (the returned URL is a template; the browser
155
250
  * performs the actual fetch only once it is used as an `<img src>`). */
@@ -205,7 +300,7 @@ export function shareImageWarning({ value, previewUrl, width, height, byteSize,
205
300
  /** One `FormData` field as a string, with the caller's fallback for an absent field. Its own named
206
301
  * function purely so {@link buildSeoSettingsPatch} below stays readable at a glance: written
207
302
  * inline, the seven fields' `?? `/`||` chains scored the patch builder at the ESLint ceiling
208
- * (`apps/admin` enforces 9/9 as a hard error) for no gain in clarity. */
303
+ * (the admin lint policy enforces 9/9 as a hard error) for no gain in clarity. */
209
304
  function formString(form, key, fallback = "") {
210
305
  return String(form.get(key) ?? fallback);
211
306
  }
@@ -214,7 +309,7 @@ function formString(form, key, fallback = "") {
214
309
  *
215
310
  * `null` is what CLEARS a site default: the server registers these three (`default_description`,
216
311
  * `default_og_image`, `twitter_site`) as `nullable: true` and `buildScalarWrites`
217
- * (`apps/website/src/features/seo/settings.ts`) normalizes `null` to the `""` absent-sentinel that
312
+ * (`features/seo/settings.ts`) normalizes `null` to the `""` absent-sentinel that
218
313
  * `getSeoSettings` reads back as `undefined`.
219
314
  *
220
315
  * It used to return `undefined`, under a comment claiming "an empty box means 'no site default'".
@@ -259,4 +354,35 @@ export const SEO_TAB_IDS = ['defaults', 'sitemap', 'entries'];
259
354
  export function resolveSeoTabId({ tabId }, _optional = {}) {
260
355
  return SEO_TAB_IDS.find(id => id === tabId) ?? 'defaults';
261
356
  }
357
+ /** Colour band for the analysis score pill: the same success/warning/failure tones the issue pills
358
+ * use, so a 100 reads green and a failing page reads red at a glance.
359
+ * @example scoreTone({ score: 100 }) // 'success'
360
+ */
361
+ export function scoreTone({ score }, _optional = {}) {
362
+ if (score >= 80)
363
+ return 'success';
364
+ return score >= 50 ? 'warning' : 'failure';
365
+ }
366
+ /**
367
+ * Picker rows for the Pages & posts tab. Pages lead because they are fewer and are the usual SEO
368
+ * targets (home, about, pricing); a published entry carries no pill, so only the exceptions
369
+ * (draft, scheduled, …) draw the eye. An untitled entry stays pickable by its id.
370
+ * @example entryPickerOptions({ posts, pages }, { t }) // [{ value: 'p1', label: 'About', group: 'Pages' }, …]
371
+ */
372
+ export function entryPickerOptions({ posts, pages }, { t = key => key } = {}) {
373
+ // Two entries can share a title ("Landing Page" twice, owner 2026-10-15); both stay listed and
374
+ // each duplicate shows its slug as muted secondary text so the operator can tell them apart.
375
+ const rows = (entries, group) => {
376
+ const labelOf = (entry) => entry.title.trim() || entry.id;
377
+ const counts = new Map();
378
+ for (const entry of entries)
379
+ counts.set(labelOf(entry), (counts.get(labelOf(entry)) ?? 0) + 1);
380
+ return entries.map((entry) => ({
381
+ value: entry.id, label: labelOf(entry), group,
382
+ ...(entry.status && entry.status !== 'published' ? { badge: t(entry.status) } : {}),
383
+ ...((counts.get(labelOf(entry)) ?? 0) > 1 ? { detail: entry.slug || entry.id } : {}),
384
+ }));
385
+ };
386
+ return [...rows(pages, t('Pages')), ...rows(posts, t('Posts'))];
387
+ }
262
388
  //# sourceMappingURL=rules.js.map
@@ -29,21 +29,24 @@ export declare const seoModule: {
29
29
  readonly "Canonical URL": "Canonical URL";
30
30
  readonly Noindex: "Noindex";
31
31
  readonly Nofollow: "Nofollow";
32
- readonly "OG title": "OG title";
33
- readonly "OG description": "OG description";
34
- readonly "OG image": "OG image";
35
- readonly "Twitter title": "Twitter title";
36
- readonly "Twitter description": "Twitter description";
37
- readonly "Twitter image": "Twitter image";
32
+ readonly "Preview title": "Preview title";
33
+ readonly "Preview description": "Preview description";
34
+ readonly "Preview image": "Preview image";
35
+ readonly "X/Twitter title": "X/Twitter title";
36
+ readonly "X/Twitter description": "X/Twitter description";
37
+ readonly "X/Twitter image": "X/Twitter image";
38
+ readonly "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)";
39
+ readonly "Same as the preview image": "Same as the preview image";
40
+ readonly "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter";
38
41
  readonly "Saving\u2026": "Saving…";
39
42
  readonly "Save overrides": "Save overrides";
40
43
  readonly "Per-entry SEO": "Per-entry SEO";
41
44
  readonly "Search appearance": "Search appearance";
42
45
  readonly "Title template (must contain %s)": "Title template (must contain %s)";
43
46
  readonly "Default meta description": "Default meta description";
44
- readonly "Social sharing": "Social sharing";
45
- readonly "Default Open Graph / Twitter image": "Default Open Graph / Twitter image";
46
- readonly "Twitter @site handle": "Twitter @site handle";
47
+ readonly "Social preview": "Social preview";
48
+ readonly "Default preview image": "Default preview image";
49
+ readonly "X/Twitter @handle": "X/Twitter @handle";
47
50
  readonly Crawling: "Crawling";
48
51
  readonly "Default noindex": "Default noindex";
49
52
  readonly "Default nofollow": "Default nofollow";
@@ -56,7 +59,7 @@ export declare const seoModule: {
56
59
  readonly "View sitemap": "View sitemap";
57
60
  readonly "Loading SEO settings\u2026": "Loading SEO settings…";
58
61
  readonly Marketing: "Marketing";
59
- readonly "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.": "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.";
62
+ readonly "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.": "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.";
60
63
  readonly "Choose image": "Choose image";
61
64
  readonly Remove: "Remove";
62
65
  readonly "Sitemap \u00B7 {count} URLs": "Sitemap · {count} URLs";
@@ -84,6 +87,8 @@ export declare const seoModule: {
84
87
  readonly "failed to load the sitemap": "failed to load the sitemap";
85
88
  readonly "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).";
86
89
  readonly "Selected image": "Selected image";
90
+ readonly "Using site default: {name}": "Using site default: {name}";
91
+ readonly "Using image from page content: {name}": "Using image from page content: {name}";
87
92
  };
88
93
  readonly pages: {
89
94
  readonly settings: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/admin",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "description": "Composable admin surface for Jini-hosted products: framework-free contracts, panel registry, and route model in `/core`; React shell and panels in `/react`. Import only the layers you need.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -79,6 +79,12 @@
79
79
  "./consent/react": "browser"
80
80
  },
81
81
  "isolation": {
82
+ "shared": [
83
+ "src/react/components/DataTable.tsx",
84
+ "src/react/components/ConfirmDialog",
85
+ "src/react/components/RowMenu",
86
+ "src/react/types.ts"
87
+ ],
82
88
  "forbidden": {
83
89
  "universal": [
84
90
  "node:",
@@ -434,10 +440,10 @@
434
440
  "react": "^18.3.0 || ^19.0.0",
435
441
  "react-dom": "^18.3.0 || ^19.0.0",
436
442
  "@jini-ai/agentic": "^0.4.4",
437
- "@jini-ai/ui": "^0.5.1",
443
+ "@jini-ai/ui": "^0.5.5",
438
444
  "@jini-ai/ui-kit": "^0.1.3",
439
445
  "@jini-ai/agent-plugins": "^0.4.3",
440
- "@jini-ai/cms": "^0.5.11",
446
+ "@jini-ai/cms": "^0.5.13",
441
447
  "@jini-ai/core": "^0.4.3"
442
448
  },
443
449
  "peerDependenciesMeta": {
@@ -476,12 +482,12 @@
476
482
  "react": "^19.2.0",
477
483
  "react-dom": "^19.2.0",
478
484
  "@testing-library/user-event": "^14.6.1",
479
- "@jini-ai/cms": "^0.5.11",
480
- "@jini-ai/ui-kit": "^0.1.4",
481
- "@jini-ai/core": "^0.4.5",
482
- "@jini-ai/ui": "^0.5.1",
483
485
  "@jini-ai/agentic": "^0.4.4",
484
- "@jini-ai/agent-plugins": "^0.4.6"
486
+ "@jini-ai/agent-plugins": "^0.4.7",
487
+ "@jini-ai/ui-kit": "^0.1.4",
488
+ "@jini-ai/cms": "^0.5.13",
489
+ "@jini-ai/core": "^0.4.7",
490
+ "@jini-ai/ui": "^0.5.5"
485
491
  },
486
492
  "typesVersions": {
487
493
  "*": {