@byline/core 4.3.0 → 4.4.0

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.
@@ -0,0 +1,318 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { validateAdminConfigs } from '../config/validate-admin-configs.js';
10
+ import { formatDeclarationPath, formatInstancePath, parseDeclarationPath, parseInstancePath, resolveDeclarationPath, toDeclarationSegments, walkFieldDeclarations, } from './index.js';
11
+ // The same structural shape the characterization suite uses: array→group
12
+ // nesting, a blocks field, an array inside a block, and two block types that
13
+ // each declare `alt` — the collision an unqualified path cannot resolve.
14
+ const fields = [
15
+ { name: 'title', label: 'Title', type: 'text' },
16
+ {
17
+ name: 'files',
18
+ label: 'Files',
19
+ type: 'array',
20
+ fields: [
21
+ {
22
+ name: 'filesGroup',
23
+ type: 'group',
24
+ fields: [{ name: 'caption', label: 'Caption', type: 'text' }],
25
+ },
26
+ ],
27
+ },
28
+ {
29
+ name: 'content',
30
+ label: 'Content',
31
+ type: 'blocks',
32
+ blocks: [
33
+ {
34
+ blockType: 'photoBlock',
35
+ fields: [
36
+ {
37
+ name: 'gallery',
38
+ label: 'Gallery',
39
+ type: 'array',
40
+ fields: [{ name: 'alt', label: 'Alt', type: 'text' }],
41
+ },
42
+ ],
43
+ },
44
+ { blockType: 'videoBlock', fields: [{ name: 'alt', label: 'Alt', type: 'text' }] },
45
+ ],
46
+ },
47
+ ];
48
+ describe('parseDeclarationPath', () => {
49
+ it('splits a dotted path into field segments', () => {
50
+ expect(parseDeclarationPath('files.filesGroup.caption')).toEqual({
51
+ ok: true,
52
+ segments: [
53
+ { kind: 'field', name: 'files' },
54
+ { kind: 'field', name: 'filesGroup' },
55
+ { kind: 'field', name: 'caption' },
56
+ ],
57
+ });
58
+ });
59
+ it('cannot classify a block type on its own — that needs the schema', () => {
60
+ // `photoBlock` comes back as a field segment. Only resolution against a
61
+ // field set can tell it apart from a field of the same name.
62
+ const parsed = parseDeclarationPath('content.photoBlock.gallery.alt');
63
+ expect(parsed.ok && parsed.segments.every((s) => s.kind === 'field')).toBe(true);
64
+ });
65
+ it('rejects item indices rather than silently dropping them', () => {
66
+ expect(parseDeclarationPath('files[0].filesGroup.caption')).toEqual({
67
+ ok: false,
68
+ reason: 'index',
69
+ });
70
+ });
71
+ it('rejects empty paths and empty segments', () => {
72
+ expect(parseDeclarationPath('')).toEqual({ ok: false, reason: 'empty' });
73
+ expect(parseDeclarationPath(' ')).toEqual({ ok: false, reason: 'empty' });
74
+ expect(parseDeclarationPath('a..b')).toEqual({ ok: false, reason: 'emptySegment' });
75
+ expect(parseDeclarationPath('.a')).toEqual({ ok: false, reason: 'emptySegment' });
76
+ expect(parseDeclarationPath('a.')).toEqual({ ok: false, reason: 'emptySegment' });
77
+ });
78
+ });
79
+ describe('parseInstancePath', () => {
80
+ it('reads positional item selectors', () => {
81
+ expect(parseInstancePath('content[0].gallery[1].alt')).toEqual({
82
+ ok: true,
83
+ segments: [
84
+ { kind: 'field', name: 'content' },
85
+ { kind: 'index', index: 0 },
86
+ { kind: 'field', name: 'gallery' },
87
+ { kind: 'index', index: 1 },
88
+ { kind: 'field', name: 'alt' },
89
+ ],
90
+ });
91
+ });
92
+ it('reads stable-id item selectors', () => {
93
+ expect(parseInstancePath('content[id=abc].alt')).toEqual({
94
+ ok: true,
95
+ segments: [
96
+ { kind: 'field', name: 'content' },
97
+ { kind: 'id', id: 'abc' },
98
+ { kind: 'field', name: 'alt' },
99
+ ],
100
+ });
101
+ });
102
+ it('rejects malformed bracket syntax instead of truncating', () => {
103
+ for (const bad of ['a[', 'a[]', 'a[x]', 'a]b']) {
104
+ expect(parseInstancePath(bad).ok).toBe(false);
105
+ }
106
+ });
107
+ });
108
+ describe('formatting', () => {
109
+ it('round-trips a declaration path', () => {
110
+ const parsed = parseDeclarationPath('files.filesGroup.caption');
111
+ expect(parsed.ok && formatDeclarationPath(parsed.segments)).toBe('files.filesGroup.caption');
112
+ });
113
+ it('round-trips an instance path', () => {
114
+ const parsed = parseInstancePath('content[0].gallery[1].alt');
115
+ expect(parsed.ok && formatInstancePath(parsed.segments)).toBe('content[0].gallery[1].alt');
116
+ });
117
+ it('renders a block type in declaration form and omits it in instance form', () => {
118
+ const segments = [
119
+ { kind: 'field', name: 'content' },
120
+ { kind: 'blockType', blockType: 'photoBlock' },
121
+ { kind: 'field', name: 'alt' },
122
+ ];
123
+ expect(formatDeclarationPath(segments)).toBe('content.photoBlock.alt');
124
+ expect(formatInstancePath(segments)).toBe('content.alt');
125
+ });
126
+ });
127
+ describe('toDeclarationSegments', () => {
128
+ it('drops item selectors and keeps the block type', () => {
129
+ const parsed = parseInstancePath('content[0].gallery[1].alt');
130
+ expect(parsed.ok && formatDeclarationPath(toDeclarationSegments(parsed.segments))).toBe('content.gallery.alt');
131
+ });
132
+ it('preserves a field whose name is numeric', () => {
133
+ // The regex-over-text approach each call site hand-rolls today deletes
134
+ // this segment. Working over typed segments cannot: `0` here is a field.
135
+ const parsed = parseInstancePath('weird.0.value');
136
+ expect(parsed.ok && formatDeclarationPath(toDeclarationSegments(parsed.segments))).toBe('weird.0.value');
137
+ });
138
+ });
139
+ describe('walkFieldDeclarations', () => {
140
+ const collect = () => {
141
+ const out = [];
142
+ walkFieldDeclarations(fields, (_field, segments) => {
143
+ out.push(formatDeclarationPath(segments));
144
+ });
145
+ return out;
146
+ };
147
+ it('qualifies paths through a blocks field with the block type', () => {
148
+ expect(collect()).toEqual(expect.arrayContaining([
149
+ 'title',
150
+ 'files.filesGroup.caption',
151
+ 'content.photoBlock.gallery.alt',
152
+ 'content.videoBlock.alt',
153
+ ]));
154
+ });
155
+ it('keeps same-named fields in different blocks distinct', () => {
156
+ // The defect this walk exists to prevent: without the block type both of
157
+ // these collapse to `content.alt` and become unidentifiable.
158
+ const alts = collect().filter((p) => p.endsWith('.alt'));
159
+ expect(new Set(alts).size).toBe(alts.length);
160
+ });
161
+ it('visits structure fields themselves, not only leaves', () => {
162
+ expect(collect()).toEqual(expect.arrayContaining(['files', 'files.filesGroup', 'content']));
163
+ });
164
+ it('reports every block through onBlock, addressed by its own path', () => {
165
+ const blocks = [];
166
+ walkFieldDeclarations(fields, () => { }, {
167
+ onBlock: (block, segments) => {
168
+ blocks.push(`${formatDeclarationPath(segments)} (${block.blockType})`);
169
+ },
170
+ });
171
+ expect(blocks).toEqual(['content.photoBlock (photoBlock)', 'content.videoBlock (videoBlock)']);
172
+ });
173
+ it('reports blocks nested inside other blocks', () => {
174
+ // `validateBlockAdminConfigs` collects every declaration site of a block
175
+ // type, and a block may be declared inside another block. The walk has to
176
+ // recurse through block fields, not just collection fields.
177
+ const nested = [
178
+ {
179
+ name: 'content',
180
+ label: 'C',
181
+ type: 'blocks',
182
+ blocks: [
183
+ {
184
+ blockType: 'outerBlock',
185
+ fields: [
186
+ {
187
+ name: 'inner',
188
+ label: 'I',
189
+ type: 'blocks',
190
+ blocks: [{ blockType: 'deepBlock', fields: [{ name: 'deep', type: 'text' }] }],
191
+ },
192
+ ],
193
+ },
194
+ ],
195
+ },
196
+ ];
197
+ const seen = [];
198
+ walkFieldDeclarations(nested, () => { }, {
199
+ onBlock: (_block, segments) => seen.push(formatDeclarationPath(segments)),
200
+ });
201
+ expect(seen).toEqual(['content.outerBlock', 'content.outerBlock.inner.deepBlock']);
202
+ });
203
+ it('reports a block declaring no fields, which the field visitor cannot see', () => {
204
+ // Validation that must inspect every block — the dot-free check on block
205
+ // types, for one — would silently stop covering empty blocks if blocks
206
+ // were inferred from the segments of the fields inside them.
207
+ const withEmpty = [
208
+ {
209
+ name: 'content',
210
+ label: 'C',
211
+ type: 'blocks',
212
+ blocks: [{ blockType: 'emptyBlock', fields: [] }],
213
+ },
214
+ ];
215
+ const visited = [];
216
+ const blocks = [];
217
+ walkFieldDeclarations(withEmpty, (_field, segments) => visited.push(formatDeclarationPath(segments)), { onBlock: (block) => blocks.push(block.blockType) });
218
+ expect(visited).toEqual(['content']);
219
+ expect(blocks).toEqual(['emptyBlock']);
220
+ });
221
+ it('reports each block before the fields inside it', () => {
222
+ const events = [];
223
+ walkFieldDeclarations(fields, (_field, segments) => events.push(`field:${formatDeclarationPath(segments)}`), {
224
+ onBlock: (_block, segments) => events.push(`block:${formatDeclarationPath(segments)}`),
225
+ });
226
+ expect(events.indexOf('block:content.photoBlock')).toBeLessThan(events.indexOf('field:content.photoBlock.gallery'));
227
+ });
228
+ });
229
+ describe('resolveDeclarationPath', () => {
230
+ it('resolves a path through array and group structure', () => {
231
+ const result = resolveDeclarationPath(fields, 'files.filesGroup.caption');
232
+ expect(result.status).toBe('ok');
233
+ expect(result.status === 'ok' && result.field.name).toBe('caption');
234
+ });
235
+ it('reclassifies a block-type segment the parser could not identify', () => {
236
+ const result = resolveDeclarationPath(fields, 'content.photoBlock.gallery.alt');
237
+ expect(result.status).toBe('ok');
238
+ expect(result.status === 'ok' && result.segments[1]).toEqual({
239
+ kind: 'blockType',
240
+ blockType: 'photoBlock',
241
+ });
242
+ });
243
+ it('distinguishes same-named fields in different blocks', () => {
244
+ const photo = resolveDeclarationPath(fields, 'content.photoBlock.gallery.alt');
245
+ const video = resolveDeclarationPath(fields, 'content.videoBlock.alt');
246
+ expect(photo.status).toBe('ok');
247
+ expect(video.status).toBe('ok');
248
+ });
249
+ it('reports `blocks` rather than `unresolved` when traversal is barred', () => {
250
+ const result = resolveDeclarationPath(fields, 'content.photoBlock.gallery.alt', {
251
+ blocks: 'forbidden',
252
+ });
253
+ expect(result).toEqual({ status: 'blocks', at: 0 });
254
+ });
255
+ it('reports `blocks` even for an unqualified path into a block', () => {
256
+ expect(resolveDeclarationPath(fields, 'content.alt', { blocks: 'forbidden' }).status).toBe('blocks');
257
+ });
258
+ it('rejects an unknown block type', () => {
259
+ expect(resolveDeclarationPath(fields, 'content.audioBlock.alt').status).toBe('unresolved');
260
+ });
261
+ it('rejects a path that stops on the block type', () => {
262
+ // Addresses a block, not a field declaration.
263
+ expect(resolveDeclarationPath(fields, 'content.photoBlock').status).toBe('unresolved');
264
+ });
265
+ it('rejects a path walking through a value field', () => {
266
+ expect(resolveDeclarationPath(fields, 'title.anything').status).toBe('unresolved');
267
+ });
268
+ it('rejects instance segments passed where a declaration is required', () => {
269
+ expect(resolveDeclarationPath(fields, 'files[0].filesGroup.caption').status).toBe('unresolved');
270
+ });
271
+ it('resolves a structure field addressed on its own', () => {
272
+ expect(resolveDeclarationPath(fields, 'files').status).toBe('ok');
273
+ });
274
+ });
275
+ // ---------------------------------------------------------------------------
276
+ // Equivalence with the validator this module will replace.
277
+ //
278
+ // Phase 2c swaps `validate-admin-configs`' private `resolveSchemaPath` for
279
+ // `resolveDeclarationPath({ blocks: 'forbidden' })`. That is only safe if the
280
+ // two agree on every key today, so assert it directly rather than assuming.
281
+ // ---------------------------------------------------------------------------
282
+ describe('resolveDeclarationPath — agreement with the admin fields{} validator', () => {
283
+ const collection = {
284
+ path: 'pages',
285
+ labels: { singular: 'Page', plural: 'Pages' },
286
+ useAsTitle: 'title',
287
+ fields,
288
+ };
289
+ const validatorAccepts = (key) => {
290
+ try {
291
+ validateAdminConfigs([{ slug: 'pages', fields: { [key]: {} } }], [collection]);
292
+ return true;
293
+ }
294
+ catch {
295
+ return false;
296
+ }
297
+ };
298
+ const resolverAccepts = (key) => resolveDeclarationPath(fields, key, { blocks: 'forbidden' }).status === 'ok';
299
+ const keys = [
300
+ 'title',
301
+ 'files',
302
+ 'files.filesGroup',
303
+ 'files.filesGroup.caption',
304
+ 'files.filesGroup.missing',
305
+ 'files[0].filesGroup.caption',
306
+ 'title.anything',
307
+ 'content',
308
+ 'content.alt',
309
+ 'content.photoBlock.gallery.alt',
310
+ 'missing',
311
+ '',
312
+ ];
313
+ for (const key of keys) {
314
+ it(`agrees on ${key === '' ? '<empty>' : key}`, () => {
315
+ expect(resolverAccepts(key)).toBe(validatorAccepts(key));
316
+ });
317
+ }
318
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { Block, Field } from '../@types/index.js';
9
+ import type { PathResolution, PathSegment, ResolveOptions } from './path-types.js';
10
+ export interface WalkOptions {
11
+ /**
12
+ * Called for every block declaration encountered, immediately before its
13
+ * fields are visited, with the segments addressing the block itself
14
+ * (`content.photoBlock`).
15
+ *
16
+ * Blocks are declaration sites in their own right, and a block with no
17
+ * fields is invisible to `visit` — so validation that must see every block
18
+ * regardless of its contents needs this rather than inferring blocks from
19
+ * the segments of the fields inside them.
20
+ */
21
+ readonly onBlock?: (block: Block, segments: readonly PathSegment[]) => void;
22
+ }
23
+ /**
24
+ * Visit every field declaration in a field set, depth first, handing each one
25
+ * the segments that address it.
26
+ *
27
+ * Structure fields are visited themselves and then descended into. A `blocks`
28
+ * field contributes two segments per hop — its own name and the block type —
29
+ * which is what keeps two blocks declaring the same field name apart.
30
+ *
31
+ * This is the single canonical walk. Producers of declaration paths (the
32
+ * upload hook registry, boot-validation error messages) should use it rather
33
+ * than re-implementing the descent, which is how they drifted apart.
34
+ */
35
+ export declare function walkFieldDeclarations(fields: readonly Field[], visit: (field: Field, segments: readonly PathSegment[]) => void, options?: WalkOptions): void;
36
+ /**
37
+ * Resolve a declaration path against a field set.
38
+ *
39
+ * Accepts either a path string or pre-parsed segments. Segments arriving as
40
+ * `kind: 'field'` where the schema expects a block type are reclassified in
41
+ * the returned `segments`, so callers get a correctly typed path back even
42
+ * though the parser could not have known.
43
+ *
44
+ * Item selectors are rejected: a declaration path addresses a declaration, so
45
+ * an index is a category error rather than something to ignore.
46
+ */
47
+ export declare function resolveDeclarationPath(fields: readonly Field[], path: string | readonly PathSegment[], options?: ResolveOptions): PathResolution;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { parseDeclarationPath } from './parse-path.js';
9
+ /**
10
+ * Visit every field declaration in a field set, depth first, handing each one
11
+ * the segments that address it.
12
+ *
13
+ * Structure fields are visited themselves and then descended into. A `blocks`
14
+ * field contributes two segments per hop — its own name and the block type —
15
+ * which is what keeps two blocks declaring the same field name apart.
16
+ *
17
+ * This is the single canonical walk. Producers of declaration paths (the
18
+ * upload hook registry, boot-validation error messages) should use it rather
19
+ * than re-implementing the descent, which is how they drifted apart.
20
+ */
21
+ export function walkFieldDeclarations(fields, visit, options = {}) {
22
+ const walk = (current, prefix) => {
23
+ for (const field of current) {
24
+ const segments = [...prefix, { kind: 'field', name: field.name }];
25
+ visit(field, segments);
26
+ if (field.type === 'group' || field.type === 'array') {
27
+ walk(field.fields, segments);
28
+ }
29
+ else if (field.type === 'blocks') {
30
+ for (const block of field.blocks) {
31
+ const blockSegments = [
32
+ ...segments,
33
+ { kind: 'blockType', blockType: block.blockType },
34
+ ];
35
+ options.onBlock?.(block, blockSegments);
36
+ walk(block.fields, blockSegments);
37
+ }
38
+ }
39
+ }
40
+ };
41
+ walk(fields, []);
42
+ }
43
+ /**
44
+ * Resolve a declaration path against a field set.
45
+ *
46
+ * Accepts either a path string or pre-parsed segments. Segments arriving as
47
+ * `kind: 'field'` where the schema expects a block type are reclassified in
48
+ * the returned `segments`, so callers get a correctly typed path back even
49
+ * though the parser could not have known.
50
+ *
51
+ * Item selectors are rejected: a declaration path addresses a declaration, so
52
+ * an index is a category error rather than something to ignore.
53
+ */
54
+ export function resolveDeclarationPath(fields, path, options = {}) {
55
+ const allowBlocks = (options.blocks ?? 'qualified') === 'qualified';
56
+ let input;
57
+ if (typeof path === 'string') {
58
+ const parsed = parseDeclarationPath(path);
59
+ if (!parsed.ok)
60
+ return { status: 'unresolved', at: 0 };
61
+ input = parsed.segments;
62
+ }
63
+ else {
64
+ input = path;
65
+ }
66
+ if (input.length === 0)
67
+ return { status: 'unresolved', at: 0 };
68
+ const resolved = [];
69
+ let current = fields;
70
+ let i = 0;
71
+ while (i < input.length) {
72
+ const segment = input[i];
73
+ // Declaration paths carry names only. Anything else is a caller passing
74
+ // instance segments where a declaration was required.
75
+ if (segment == null || (segment.kind !== 'field' && segment.kind !== 'blockType')) {
76
+ return { status: 'unresolved', at: i };
77
+ }
78
+ const name = segment.kind === 'field' ? segment.name : segment.blockType;
79
+ const field = current.find((candidate) => candidate.name === name);
80
+ if (field == null)
81
+ return { status: 'unresolved', at: i };
82
+ resolved.push({ kind: 'field', name: field.name });
83
+ const isLast = i === input.length - 1;
84
+ if (isLast)
85
+ return { status: 'ok', field, segments: resolved };
86
+ if (field.type === 'group' || field.type === 'array') {
87
+ current = field.fields;
88
+ i += 1;
89
+ continue;
90
+ }
91
+ if (field.type === 'blocks') {
92
+ if (!allowBlocks)
93
+ return { status: 'blocks', at: i };
94
+ // The next segment names the block type — the discriminator that makes
95
+ // the rest of the path unambiguous.
96
+ const next = input[i + 1];
97
+ if (next == null || (next.kind !== 'field' && next.kind !== 'blockType')) {
98
+ return { status: 'unresolved', at: i + 1 };
99
+ }
100
+ const blockType = next.kind === 'field' ? next.name : next.blockType;
101
+ const block = field.blocks.find((candidate) => candidate.blockType === blockType);
102
+ if (block == null)
103
+ return { status: 'unresolved', at: i + 1 };
104
+ resolved.push({ kind: 'blockType', blockType: block.blockType });
105
+ // A path ending on the block type addresses the block, not a field.
106
+ if (i + 1 === input.length - 1)
107
+ return { status: 'unresolved', at: i + 1 };
108
+ current = block.fields;
109
+ i += 2;
110
+ continue;
111
+ }
112
+ // A value field with path left to walk.
113
+ return { status: 'unresolved', at: i };
114
+ }
115
+ return { status: 'unresolved', at: input.length - 1 };
116
+ }
@@ -46,9 +46,15 @@ function* walkCounterSites(fields, data, pathPrefix = '') {
46
46
  }
47
47
  }
48
48
  /**
49
- * Resolve a counter value already present in `previousData` at the
50
- * same dotted path as the site. Returns `undefined` if the value is
51
- * missing or not a finite number.
49
+ * Resolve a counter value already present in `previousData` at the same
50
+ * dotted path as the site. Returns `undefined` if the value is missing or
51
+ * not a finite number.
52
+ *
53
+ * `path` is a plain object accessor, **not** a field path in the sense of
54
+ * `@byline/core` `paths/` — it walks data, never the schema, and stops at
55
+ * any array because counters may not be declared inside `array` / `blocks`
56
+ * (see `walkCounterSites`). It therefore needs no item selectors and no
57
+ * block-type segments, which is why it does not use the shared grammar.
52
58
  */
53
59
  function readPreviousValue(previousData, path) {
54
60
  const segments = path.split('.');
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "4.3.0",
5
+ "version": "4.4.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -81,7 +81,7 @@
81
81
  "pino": "^10.3.1",
82
82
  "sharp": "^0.35.3",
83
83
  "zod": "^4.4.3",
84
- "@byline/auth": "4.3.0"
84
+ "@byline/auth": "4.4.0"
85
85
  },
86
86
  "devDependencies": {
87
87
  "@biomejs/biome": "2.5.4",