@simmalugnt-se/payload-content-health 0.1.0 → 0.2.1

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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ - The work list at `/admin/content-health` requires Admin access. Payload leaves custom Admin views
6
+ public, so a visitor who was not signed in saw the list, filled with what anonymous access could
7
+ read. Now they are sent to the login and back, and a signed-in user without Admin access to
8
+ Payload's unauthorized page.
9
+
10
+ ## 0.2.0
11
+
12
+ - Work list at `/admin/content-health`, linked from the nav: every finding, filterable by check,
13
+ collection and locale, with the filters in the address. The widget's "N more" opens it filtered on
14
+ that check.
15
+ - Links from the list and the widget open the document in the finding's locale with the field
16
+ brought into view: tabs and collapsed rows opened, the field scrolled to, focused and highlighted
17
+ (`?field=<path>`, read by a bridge in each checked collection's edit view).
18
+ - "Fix with the assistant" on rows in collections `@simmalugnt-se/payload-editor-assistant` works
19
+ in: the same link with a ready prompt (`?ask=`), in Admin's language. The assistant takes the field
20
+ as its selection from a `content-health:select` event.
21
+ - Widget rows put the document on its own line and each locale below it: one per line when it
22
+ carries a detail (length, word count, heading levels), otherwise side by side.
23
+ - New peer dependencies: `@payloadcms/next`, `@payloadcms/ui`, `next` and `react-dom`.
24
+
3
25
  ## 0.1.0
4
26
 
5
27
  - First release: `contentHealthPlugin` with checks for missing alt text on images and for meta
package/README.md CHANGED
@@ -1,10 +1,11 @@
1
1
  # @simmalugnt-se/payload-content-health
2
2
 
3
- Deterministic content checks for Payload, shown as a dashboard widget: images without alt text,
4
- missing or badly sized meta titles and descriptions, and pages without a share image. No AI; every
5
- finding points at one field of one document, in one locale when the field is localized.
3
+ Deterministic content checks for Payload, shown as a dashboard widget and a work list: images
4
+ without alt text, missing or badly sized meta titles and descriptions, pages without a share image,
5
+ heading structure and thin content. No AI; every finding points at one field of one document, in one
6
+ locale when the field is localized, and opens that field in Admin.
6
7
 
7
- Requires Payload `>=3.90.2 <4` and React 19. The widget uses Payload's modular dashboard
8
+ Requires Payload `>=3.90.2 <4`, Next 16 and React 19. The widget uses Payload's modular dashboard
8
9
  (`admin.dashboard.widgets`), which Payload still marks as experimental.
9
10
 
10
11
  ## Setup
@@ -97,7 +98,36 @@ The plugin registers the widget `content-health`. When the project sets no
97
98
  widget. Payload keeps each editor's own layout as a preference once they edit the dashboard; they add
98
99
  the widget from the dashboard's edit mode, and can limit it to some checks in its settings.
99
100
 
100
- Pass `addToDefaultLayout: false` to only register the widget.
101
+ Pass `addToDefaultLayout: false` to only register the widget. When a check has more documents than
102
+ fit in its tile, "N more" opens the work list filtered on that check.
103
+
104
+ ## Work list
105
+
106
+ `/admin/content-health` (linked at the top of the nav) lists every finding, filterable by check,
107
+ collection and locale. The filters are kept in the address, e.g.
108
+ `/admin/content-health?check=seo.title-missing&locale=sv`. The view scans once per visit.
109
+
110
+ Each row opens the document in the finding's locale with the field brought into view: tabs and
111
+ collapsed rows are opened, and the field is scrolled to, focused and highlighted. A finding in a row
112
+ or block the unsaved form no longer has opens the nearest enclosing field instead.
113
+
114
+ ### With the editor assistant
115
+
116
+ With `@simmalugnt-se/payload-editor-assistant` (0.5.0 or later), rows in the collections the
117
+ assistant works in also get "Fix with the assistant": the same link with a ready prompt that the
118
+ assistant puts in its message box. The editor sends it. To write a missing alt text, the assistant
119
+ needs to see the image: allow `media.view` on the media collection (0.6.0 or later). The packages
120
+ do not import each other; they share these names:
121
+
122
+ | Name | Direction | Meaning |
123
+ | -------------------------------------------- | ----------------------- | ---------------------------------------- |
124
+ | `?field=<path>` | link → this plugin | Form path to bring into view |
125
+ | `?ask=<prompt>` | link → assistant | Prompt to put in the message box |
126
+ | `content-health:select` (`detail: { path }`) | this plugin → assistant | The field brought into view is selected |
127
+ | `config.admin.custom.editorAssistant` | assistant → this plugin | `{ collections }` the assistant works in |
128
+
129
+ Both parameters are removed from the address once read. `editURL(adminRoute, finding, prompt?)` and
130
+ `listURL(adminRoute, filters)` build these links.
101
131
 
102
132
  ## Running the checks yourself
103
133
 
@@ -107,5 +137,6 @@ import { scanContentHealth } from "@simmalugnt-se/payload-content-health";
107
137
  const { findings, problems } = await scanContentHealth({ payload, user: req.user });
108
138
  ```
109
139
 
110
- Each finding is `{ check, collection, id, title, path, locale?, length?, status? }`. `problems`
140
+ Each finding is `{ check, collection, id, title, path, locale?, length?, heading?, words?, status? }`,
141
+ where `path` is the form path with row indexes (`layout.1.items.0.title`). `problems`
111
142
  lists configured checks that could not run, such as a field path that does not exist.
@@ -0,0 +1,8 @@
1
+ import "../styles/admin.css";
2
+ /**
3
+ * Mounted in the edit view of each checked collection. When the address carries `?field=<path>`
4
+ * (a link from the work list or the widget), it opens the tabs and rows around that field, scrolls
5
+ * to it, focuses and highlights it, then announces it with `content-health:select` so the editor
6
+ * assistant takes it as the selection. The parameter is removed from the address once read.
7
+ */
8
+ export declare function ContentHealthFieldBridge(): import("react").JSX.Element;
@@ -0,0 +1,291 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useConfig, useDocumentForm, useDocumentInfo } from "@payloadcms/ui";
4
+ import { useEffect, useRef } from "react";
5
+ import "../styles/admin.css";
6
+ import { FIELD_PARAM, SELECT_EVENT } from "../links.js";
7
+ import { planReveal } from "./reveal-plan.js";
8
+ import { revealTarget } from "./target.js";
9
+ /*
10
+ * The DOM steps below follow VisualEditingAdminBridge in @simmalugnt-se/payload-visual-editing,
11
+ * which reveals fields clicked in the preview; keep fixes in step with it.
12
+ */
13
+ const HIGHLIGHT_CLASS = "content-health-highlight";
14
+ const HIGHLIGHT_MS = 1600;
15
+ /** Tabs and rows render within a few frames. */
16
+ const WAIT_MS = 1000;
17
+ /** Rich text editors are lazy loaded and can take noticeably longer to mount. */
18
+ const FIELD_WAIT_MS = 4000;
19
+ /** Frames an element must keep its position before the layout counts as settled. */
20
+ const STABLE_FRAMES = 3;
21
+ /** Roughly how long a smooth scroll takes. */
22
+ const SCROLL_MS = 600;
23
+ /**
24
+ * Mounted in the edit view of each checked collection. When the address carries `?field=<path>`
25
+ * (a link from the work list or the widget), it opens the tabs and rows around that field, scrolls
26
+ * to it, focuses and highlights it, then announces it with `content-health:select` so the editor
27
+ * assistant takes it as the selection. The parameter is removed from the address once read.
28
+ */
29
+ export function ContentHealthFieldBridge() {
30
+ const form = useDocumentForm();
31
+ const { config, getEntityConfig } = useConfig();
32
+ const { collectionSlug } = useDocumentInfo();
33
+ const anchorRef = useRef(null);
34
+ const latest = useRef({ form, config, getEntityConfig, collectionSlug });
35
+ // Kept across effect re-runs: the parameter is gone from the address after the first read.
36
+ const requested = useRef(undefined);
37
+ useEffect(() => {
38
+ latest.current = { form, config, getEntityConfig, collectionSlug };
39
+ });
40
+ useEffect(() => {
41
+ if (requested.current === undefined) {
42
+ requested.current = takeFieldParam();
43
+ }
44
+ const path = requested.current;
45
+ if (!path) {
46
+ return;
47
+ }
48
+ let cancelled = false;
49
+ const current = () => !cancelled;
50
+ void (async () => {
51
+ const { form, config, getEntityConfig, collectionSlug } = latest.current;
52
+ const fields = form.getFields();
53
+ const rootFields = collectionSlug
54
+ ? (getEntityConfig({ collectionSlug })?.fields ??
55
+ [])
56
+ : [];
57
+ const lookup = {
58
+ blockTypeAt: (rowPath) => rowAt(fields, rowPath)?.blockType,
59
+ blocksBySlug: config.blocksMap,
60
+ };
61
+ const target = revealTarget(path, (candidate) => planReveal(rootFields, candidate, lookup));
62
+ if (!target) {
63
+ return;
64
+ }
65
+ expandRows(target.steps, fields, form.dispatchFields);
66
+ const scope = await openSteps(target.steps, formRoot(anchorRef.current), current);
67
+ if (!scope || !current()) {
68
+ return;
69
+ }
70
+ const element = await waitForRendered(() => findTarget(target.path), scope, FIELD_WAIT_MS);
71
+ if (!element || !current()) {
72
+ return;
73
+ }
74
+ await reveal(element, current);
75
+ if (current()) {
76
+ requested.current = null;
77
+ window.dispatchEvent(new CustomEvent(SELECT_EVENT, { detail: { path: target.path } }));
78
+ }
79
+ })();
80
+ return () => {
81
+ cancelled = true;
82
+ };
83
+ }, []);
84
+ return _jsx("span", { hidden: true, ref: anchorRef });
85
+ }
86
+ /** Reads `?field=` and removes it, so a reload or a save does not jump back to the field. */
87
+ function takeFieldParam() {
88
+ const url = new URL(window.location.href);
89
+ const path = url.searchParams.get(FIELD_PARAM);
90
+ if (path === null) {
91
+ return null;
92
+ }
93
+ url.searchParams.delete(FIELD_PARAM);
94
+ window.history.replaceState(window.history.state, "", url);
95
+ return path || null;
96
+ }
97
+ function expandRows(steps, fields, dispatchFields) {
98
+ for (const step of steps) {
99
+ if (step.kind !== "row") {
100
+ continue;
101
+ }
102
+ const rows = fields[step.parentPath]?.rows;
103
+ if (!rows?.[step.index]?.collapsed) {
104
+ continue;
105
+ }
106
+ dispatchFields({
107
+ type: "SET_ROW_COLLAPSED",
108
+ path: step.parentPath,
109
+ updatedRows: rows.map((row, index) => index === step.index ? { ...row, collapsed: false } : row),
110
+ });
111
+ }
112
+ }
113
+ function rowAt(fields, rowPath) {
114
+ const cut = rowPath.lastIndexOf(".");
115
+ return fields[rowPath.slice(0, cut)]?.rows?.[Number(rowPath.slice(cut + 1))];
116
+ }
117
+ /** The edit form this bridge belongs to, not one in a document drawer. */
118
+ function formRoot(anchor) {
119
+ const view = anchor?.closest(".collection-edit");
120
+ return view?.querySelector(".collection-edit__form") ?? document;
121
+ }
122
+ /**
123
+ * Open the rows and tabs the plan needs, one level at a time, waiting for each to render.
124
+ * Returns the innermost scope (row or tab content), where the target renders.
125
+ */
126
+ async function openSteps(steps, root, current) {
127
+ let scope = root;
128
+ for (const step of steps) {
129
+ if (!current()) {
130
+ return null;
131
+ }
132
+ if (step.kind === "row") {
133
+ const row = await waitForRendered(() => document.getElementById(rowDomId(step)), scope);
134
+ if (!row) {
135
+ return null;
136
+ }
137
+ scope = row;
138
+ continue;
139
+ }
140
+ const tabsScope = scope;
141
+ const tabs = await waitForRendered(() => tabsFieldsIn(tabsScope)[step.ordinal] ?? null, tabsScope);
142
+ const button = tabs?.querySelectorAll(":scope > .tabs-field__tabs-wrap > .tabs-field__tabs > .tabs-field__tab-button")[step.index];
143
+ if (!tabs || !button) {
144
+ return null;
145
+ }
146
+ // Click even the active tab: Payload restores the editor's last tab from preferences once they
147
+ // load, unless a tab was clicked first. Links open on page load, when that restore is pending.
148
+ button.click();
149
+ const content = await waitFor(() => button.classList.contains("tabs-field__tab-button--active")
150
+ ? tabs.querySelector(":scope > .tabs-field__content-wrap")
151
+ : null);
152
+ if (!content) {
153
+ return null;
154
+ }
155
+ scope = content;
156
+ }
157
+ return scope;
158
+ }
159
+ /**
160
+ * Payload renders a group of fields only once it is within 1000px of the viewport
161
+ * (`RenderIfInViewport`); until then the group is an empty div. So when the next level is not in
162
+ * the DOM yet, bring the enclosing scope into view to make Payload render it, then wait.
163
+ */
164
+ async function waitForRendered(find, scope, timeoutMs = WAIT_MS) {
165
+ const found = find();
166
+ if (found) {
167
+ return found;
168
+ }
169
+ if (scope instanceof HTMLElement) {
170
+ scope.scrollIntoView({ block: "start" });
171
+ }
172
+ return waitFor(find, timeoutMs);
173
+ }
174
+ /** Tabs fields at this level of the scope, in DOM order; nested tabs and rows are other scopes. */
175
+ function tabsFieldsIn(scope) {
176
+ return Array.from(scope.querySelectorAll(".tabs-field")).filter((element) => {
177
+ const outer = element.parentElement?.closest(".tabs-field, .blocks-field, .array-field");
178
+ return !outer || !scope.contains(outer);
179
+ });
180
+ }
181
+ function waitFor(find, timeoutMs = WAIT_MS) {
182
+ const deadline = performance.now() + timeoutMs;
183
+ return new Promise((resolve) => {
184
+ const tick = () => {
185
+ const found = find();
186
+ if (found || performance.now() >= deadline) {
187
+ resolve(found);
188
+ return;
189
+ }
190
+ requestAnimationFrame(tick);
191
+ };
192
+ tick();
193
+ });
194
+ }
195
+ /**
196
+ * A field or a row. Inputs carry `field-…` ids, rich text and some other fields only
197
+ * `data-field-path`, block and array rows `…-row-N` ids.
198
+ */
199
+ function findTarget(path) {
200
+ const field = document.getElementById(`field-${path.replace(/\./g, "__")}`) ??
201
+ document.querySelector(`[data-field-path="${CSS.escape(path)}"]`);
202
+ if (field) {
203
+ return field;
204
+ }
205
+ const segments = path.split(".");
206
+ const index = segments.pop();
207
+ return index !== undefined && /^\d+$/.test(index)
208
+ ? document.getElementById(rowDomId({ parentPath: segments.join("."), index: Number(index) }))
209
+ : null;
210
+ }
211
+ /** DOM id Payload gives block and array rows: ("layout", 0) → "layout-row-0". */
212
+ function rowDomId(step) {
213
+ return `${step.parentPath.split(".").join("-")}-row-${step.index}`;
214
+ }
215
+ /**
216
+ * Scroll to and highlight the target. A field with an input gets focus; a row or a blocks field is
217
+ * only highlighted, since focusing it would land in whatever input comes first.
218
+ */
219
+ async function reveal(element, current) {
220
+ const isRow = /-row-\d+$/.test(element.id);
221
+ const highlighted = isRow ? element : (element.closest(".field-type") ?? element);
222
+ const focusable = isRow ? null : focusTarget(element);
223
+ await scrollWhenSettled(highlighted, current, () => {
224
+ focusable?.focus({ preventScroll: true });
225
+ highlighted.classList.remove(HIGHLIGHT_CLASS);
226
+ // Restart the animation when the same element is revealed twice.
227
+ void highlighted.offsetWidth;
228
+ highlighted.classList.add(HIGHLIGHT_CLASS);
229
+ window.setTimeout(() => highlighted.classList.remove(HIGHLIGHT_CLASS), HIGHLIGHT_MS);
230
+ });
231
+ }
232
+ function focusTarget(element) {
233
+ if (isFocusable(element)) {
234
+ return element;
235
+ }
236
+ // A blocks or array field holds other fields' inputs; none of them is the target.
237
+ if (element.matches(".blocks-field, .array-field") || element.querySelector("[id*='-row-']")) {
238
+ return null;
239
+ }
240
+ return (element.querySelector("input:not([type='hidden']):not([type='file']), textarea, select, [contenteditable='true']") ?? element.querySelector("button:not([disabled])"));
241
+ }
242
+ /**
243
+ * Scroll `element` to the middle of the form, robust to the form changing under the scroll:
244
+ * Payload renders field groups as they come near the viewport and textareas grow to fit their
245
+ * text, which pushes the target down. Start once its position is stable, and scroll again if it
246
+ * has moved out of view by the time the scroll is done. `onScroll` runs as the scroll starts.
247
+ */
248
+ async function scrollWhenSettled(element, current, onScroll) {
249
+ await layoutSettled(element);
250
+ if (!current()) {
251
+ return;
252
+ }
253
+ element.scrollIntoView({ behavior: "smooth", block: "center" });
254
+ onScroll?.();
255
+ await new Promise((resolve) => window.setTimeout(resolve, SCROLL_MS));
256
+ await layoutSettled(element);
257
+ if (current() && !inView(element)) {
258
+ element.scrollIntoView({ behavior: "smooth", block: "center" });
259
+ }
260
+ }
261
+ /** Resolves once the element has kept its position for a few frames (or after a timeout). */
262
+ function layoutSettled(element, timeoutMs = WAIT_MS) {
263
+ const deadline = performance.now() + timeoutMs;
264
+ let last = Number.NaN;
265
+ let stable = 0;
266
+ return new Promise((resolve) => {
267
+ const tick = () => {
268
+ const top = element.getBoundingClientRect().top;
269
+ stable = top === last ? stable + 1 : 0;
270
+ last = top;
271
+ if (stable >= STABLE_FRAMES || performance.now() >= deadline) {
272
+ resolve();
273
+ return;
274
+ }
275
+ requestAnimationFrame(tick);
276
+ };
277
+ tick();
278
+ });
279
+ }
280
+ /** Mostly visible: its middle, or for a tall element any part, is within the middle of the viewport. */
281
+ function inView(element) {
282
+ const rect = element.getBoundingClientRect();
283
+ const margin = window.innerHeight * 0.15;
284
+ return rect.bottom > margin && rect.top < window.innerHeight - margin;
285
+ }
286
+ function isFocusable(element) {
287
+ return (element instanceof HTMLInputElement ||
288
+ element instanceof HTMLTextAreaElement ||
289
+ element instanceof HTMLSelectElement ||
290
+ element.isContentEditable);
291
+ }
@@ -0,0 +1,3 @@
1
+ import "../styles/admin.css";
2
+ /** The work list in Admin's nav, marked up like Payload's own collection links. */
3
+ export declare function ContentHealthNavLink(): import("react").JSX.Element;
@@ -0,0 +1,19 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Link, useConfig, useTranslation } from "@payloadcms/ui";
4
+ import "../styles/admin.css";
5
+ import { usePathname } from "next/navigation";
6
+ import { formatAdminURL } from "payload/shared";
7
+ import { LIST_COPY, uiLanguage } from "../copy.js";
8
+ import { VIEW_PATH } from "../links.js";
9
+ /** The work list in Admin's nav, marked up like Payload's own collection links. */
10
+ export function ContentHealthNavLink() {
11
+ const pathname = usePathname();
12
+ const { config } = useConfig();
13
+ const { i18n } = useTranslation();
14
+ const href = formatAdminURL({ adminRoute: config.routes.admin, path: VIEW_PATH });
15
+ const label = LIST_COPY[uiLanguage(i18n.language)].nav;
16
+ const active = pathname === href;
17
+ const content = (_jsxs(_Fragment, { children: [active ? _jsx("div", { className: "nav__link-indicator" }) : null, _jsx("span", { className: "nav__link-label", children: label })] }));
18
+ return active ? (_jsx("div", { className: "nav__link content-health-nav", id: "nav-content-health", children: content })) : (_jsx(Link, { className: "nav__link content-health-nav", href: href, id: "nav-content-health", prefetch: false, children: content }));
19
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Work out which tabs must be opened to show a form path. Payload only renders the active tab,
3
+ * so a field in another tab has no DOM element until its tab button is clicked.
4
+ * Pure: takes client field config, returns steps for the Admin bridge to perform.
5
+ */
6
+ export type FieldLike = {
7
+ type: string;
8
+ name?: string;
9
+ label?: unknown;
10
+ /** Array fields: Payload fills `singular` from the name when the config leaves it out. */
11
+ labels?: {
12
+ singular?: unknown;
13
+ };
14
+ fields?: FieldLike[];
15
+ tabs?: Array<{
16
+ name?: string;
17
+ fields?: FieldLike[];
18
+ }>;
19
+ blocks?: BlockLike[];
20
+ blockReferences?: Array<BlockLike | string>;
21
+ };
22
+ export type BlockLike = {
23
+ slug: string;
24
+ labels?: {
25
+ singular?: unknown;
26
+ };
27
+ fields?: FieldLike[];
28
+ };
29
+ export type RevealStep =
30
+ /** Click tab `index` of the `ordinal`-th tabs field in the current scope. The tab content becomes the scope. */
31
+ {
32
+ kind: "tab";
33
+ ordinal: number;
34
+ index: number;
35
+ }
36
+ /** Enter a block or array row. The row element becomes the scope. */
37
+ | {
38
+ kind: "row";
39
+ parentPath: string;
40
+ index: number;
41
+ };
42
+ export type Lookup = {
43
+ /** Block slug of the row at a form path, e.g. "layout.0" → "hero". */
44
+ blockTypeAt: (rowPath: string) => string | undefined;
45
+ /** Blocks declared once in config and referenced by slug. */
46
+ blocksBySlug?: Record<string, BlockLike | undefined>;
47
+ };
48
+ export declare function planReveal(rootFields: FieldLike[], path: string, lookup: Lookup): RevealStep[] | null;
49
+ /** The field config a form path ends at, e.g. "layout.2.items" → the `items` array field. */
50
+ export declare function fieldAtPath(rootFields: FieldLike[], path: string, lookup: Lookup): FieldLike | null;
@@ -0,0 +1,135 @@
1
+ /*
2
+ * Copied from @simmalugnt-se/payload-visual-editing (src/reveal-plan.ts), which reveals fields
3
+ * clicked in the preview. The packages do not import each other; keep fixes in step with that copy.
4
+ */
5
+ /**
6
+ * Work out which tabs must be opened to show a form path. Payload only renders the active tab,
7
+ * so a field in another tab has no DOM element until its tab button is clicked.
8
+ * Pure: takes client field config, returns steps for the Admin bridge to perform.
9
+ */
10
+ export function planReveal(rootFields, path, lookup) {
11
+ return walkPath(rootFields, path, lookup)?.steps ?? null;
12
+ }
13
+ /** The field config a form path ends at, e.g. "layout.2.items" → the `items` array field. */
14
+ export function fieldAtPath(rootFields, path, lookup) {
15
+ return walkPath(rootFields, path, lookup)?.field ?? null;
16
+ }
17
+ function walkPath(rootFields, path, lookup) {
18
+ const segments = path.split(".");
19
+ const steps = [];
20
+ let fields = rootFields;
21
+ let counter = { value: 0 };
22
+ let walked = [];
23
+ let last = null;
24
+ for (let i = 0; i < segments.length;) {
25
+ const segment = segments[i] ?? "";
26
+ const found = findIn(fields, segment, counter);
27
+ if (!found) {
28
+ return null;
29
+ }
30
+ steps.push(...found.tabs);
31
+ counter = found.counter;
32
+ walked = [...walked, segment];
33
+ i += 1;
34
+ if (found.kind === "namedTab") {
35
+ fields = found.fields;
36
+ counter = { value: 0 };
37
+ continue;
38
+ }
39
+ const field = found.field;
40
+ last = field;
41
+ const next = segments[i];
42
+ if ((field.type === "blocks" || field.type === "array") &&
43
+ next !== undefined &&
44
+ /^\d+$/.test(next)) {
45
+ const parentPath = walked.join(".");
46
+ steps.push({ kind: "row", parentPath, index: Number(next) });
47
+ walked = [...walked, next];
48
+ i += 1;
49
+ counter = { value: 0 };
50
+ if (field.type === "array") {
51
+ fields = field.fields ?? [];
52
+ continue;
53
+ }
54
+ const block = findBlock(field, lookup.blockTypeAt(walked.join(".")), lookup.blocksBySlug);
55
+ if (!block) {
56
+ return null;
57
+ }
58
+ fields = block.fields ?? [];
59
+ continue;
60
+ }
61
+ if (field.fields) {
62
+ // Named group: same DOM scope, keep counting tabs.
63
+ fields = field.fields;
64
+ continue;
65
+ }
66
+ // A leaf; anything after it (e.g. rich text internals) is not a form field.
67
+ return i === segments.length ? { steps, field } : null;
68
+ }
69
+ return { steps, field: last };
70
+ }
71
+ function findIn(fields, name, counter) {
72
+ for (const field of fields) {
73
+ if (field.type === "tabs") {
74
+ const ordinal = counter.value;
75
+ counter.value += 1;
76
+ for (const [index, tab] of (field.tabs ?? []).entries()) {
77
+ const step = { kind: "tab", ordinal, index };
78
+ if (tab.name === name) {
79
+ return {
80
+ kind: "namedTab",
81
+ fields: tab.fields ?? [],
82
+ tabs: [step],
83
+ counter: { value: 0 },
84
+ };
85
+ }
86
+ if (!tab.name) {
87
+ const inner = findIn(tab.fields ?? [], name, { value: 0 });
88
+ if (inner) {
89
+ return { ...inner, tabs: [step, ...inner.tabs] };
90
+ }
91
+ }
92
+ }
93
+ continue;
94
+ }
95
+ if (field.name === name) {
96
+ return { kind: "field", field, tabs: [], counter };
97
+ }
98
+ if (!field.name && field.fields) {
99
+ // Row, collapsible or unnamed group: transparent for paths.
100
+ const inner = findIn(field.fields, name, counter);
101
+ if (inner) {
102
+ return inner;
103
+ }
104
+ continue;
105
+ }
106
+ counter.value += countTabs(field);
107
+ }
108
+ return null;
109
+ }
110
+ /** Tabs fields rendered inside a field that is skipped, so later ordinals stay aligned with the DOM. */
111
+ function countTabs(field) {
112
+ if (field.type === "tabs") {
113
+ return 1;
114
+ }
115
+ if (field.type === "blocks" || field.type === "array" || !field.fields) {
116
+ return 0;
117
+ }
118
+ return field.fields.reduce((sum, child) => sum + countTabs(child), 0);
119
+ }
120
+ function findBlock(field, slug, blocksBySlug) {
121
+ if (!slug) {
122
+ return undefined;
123
+ }
124
+ const inline = field.blocks?.find((block) => block.slug === slug);
125
+ if (inline) {
126
+ return inline;
127
+ }
128
+ for (const reference of field.blockReferences ?? []) {
129
+ const block = typeof reference === "string" ? blocksBySlug?.[reference] : reference;
130
+ if (block?.slug === slug) {
131
+ return block;
132
+ }
133
+ }
134
+ return undefined;
135
+ }
@@ -0,0 +1,9 @@
1
+ import type { RevealStep } from "./reveal-plan.ts";
2
+ /**
3
+ * The path itself when the form has it, otherwise the nearest enclosing field or row that it has:
4
+ * the finding was made on the saved version, and the unsaved form may have fewer rows.
5
+ */
6
+ export declare function revealTarget(path: string, plan: (path: string) => RevealStep[] | null): {
7
+ path: string;
8
+ steps: RevealStep[];
9
+ } | null;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The path itself when the form has it, otherwise the nearest enclosing field or row that it has:
3
+ * the finding was made on the saved version, and the unsaved form may have fewer rows.
4
+ */
5
+ export function revealTarget(path, plan) {
6
+ const segments = path.split(".");
7
+ for (let length = segments.length; length > 0; length -= 1) {
8
+ const candidate = segments.slice(0, length).join(".");
9
+ const steps = plan(candidate);
10
+ if (steps) {
11
+ return { path: candidate, steps };
12
+ }
13
+ }
14
+ return null;
15
+ }