@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,133 @@
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
+ // ---------------------------------------------------------------------------
9
+ // Parsing and serialising field paths.
10
+ //
11
+ // Parsing is schema-unaware: it recovers structure from text and nothing
12
+ // more. In particular it cannot tell a block type from a field name, since
13
+ // both are bare identifiers — `resolveDeclarationPath` settles that against
14
+ // a field set. Callers that already hold the schema (path *producers*) should
15
+ // build segments directly and use the formatters here.
16
+ // ---------------------------------------------------------------------------
17
+ const INDEX_TOKEN = /^\[(\d+)\]$/;
18
+ const ID_TOKEN = /^\[id=(.+)\]$/;
19
+ /**
20
+ * Parse a declaration path: dotted, index-free, block types written as plain
21
+ * segments (`content.photoBlock.gallery.alt`).
22
+ *
23
+ * Every segment comes back as `kind: 'field'`. Item indices are a parse
24
+ * error rather than something silently dropped — a caller writing
25
+ * `files[0].caption` where a declaration is expected has made a category
26
+ * mistake, and saying so is more useful than quietly addressing every item.
27
+ */
28
+ export function parseDeclarationPath(path) {
29
+ if (path.trim() === '')
30
+ return { ok: false, reason: 'empty' };
31
+ if (path.includes('[') || path.includes(']'))
32
+ return { ok: false, reason: 'index' };
33
+ const segments = [];
34
+ for (const part of path.split('.')) {
35
+ if (part === '')
36
+ return { ok: false, reason: 'emptySegment' };
37
+ segments.push({ kind: 'field', name: part });
38
+ }
39
+ return { ok: true, segments };
40
+ }
41
+ /**
42
+ * Parse an instance path: dotted field names with bracket item selectors,
43
+ * either positional (`gallery[1]`) or by stable id (`gallery[id=abc]`).
44
+ *
45
+ * Blocks carry no discriminator here — the addressed item resolves its own
46
+ * `_type` — so a block hop is just the blocks field name followed by an
47
+ * item selector.
48
+ */
49
+ export function parseInstancePath(path) {
50
+ if (path.trim() === '')
51
+ return { ok: false, reason: 'empty' };
52
+ const segments = [];
53
+ for (const part of path.split('.')) {
54
+ if (part === '')
55
+ return { ok: false, reason: 'emptySegment' };
56
+ const bracket = part.indexOf('[');
57
+ if (bracket === -1) {
58
+ if (part.includes(']'))
59
+ return { ok: false, reason: 'malformed' };
60
+ segments.push({ kind: 'field', name: part });
61
+ continue;
62
+ }
63
+ const name = part.slice(0, bracket);
64
+ if (name === '')
65
+ return { ok: false, reason: 'emptySegment' };
66
+ segments.push({ kind: 'field', name });
67
+ // One segment may carry several selectors (`items[0][1]` is not produced
68
+ // today, but parsing it costs nothing and beats silently truncating).
69
+ let rest = part.slice(bracket);
70
+ while (rest !== '') {
71
+ const close = rest.indexOf(']');
72
+ if (close === -1)
73
+ return { ok: false, reason: 'malformed' };
74
+ const token = rest.slice(0, close + 1);
75
+ rest = rest.slice(close + 1);
76
+ const indexMatch = INDEX_TOKEN.exec(token);
77
+ if (indexMatch?.[1] != null) {
78
+ segments.push({ kind: 'index', index: Number.parseInt(indexMatch[1], 10) });
79
+ continue;
80
+ }
81
+ const idMatch = ID_TOKEN.exec(token);
82
+ if (idMatch?.[1] != null) {
83
+ segments.push({ kind: 'id', id: idMatch[1] });
84
+ continue;
85
+ }
86
+ return { ok: false, reason: 'malformed' };
87
+ }
88
+ }
89
+ return { ok: true, segments };
90
+ }
91
+ /**
92
+ * Serialise segments as a declaration path. Index and id segments are
93
+ * dropped, so this doubles as the instance → declaration projection when
94
+ * applied to instance segments.
95
+ */
96
+ export function formatDeclarationPath(segments) {
97
+ return segments
98
+ .filter((segment) => segment.kind === 'field' || segment.kind === 'blockType')
99
+ .map((segment) => (segment.kind === 'blockType' ? segment.blockType : segment.name))
100
+ .join('.');
101
+ }
102
+ /** Serialise segments as an instance path, rendering selectors in brackets. */
103
+ export function formatInstancePath(segments) {
104
+ let out = '';
105
+ for (const segment of segments) {
106
+ switch (segment.kind) {
107
+ case 'field':
108
+ out += out === '' ? segment.name : `.${segment.name}`;
109
+ break;
110
+ case 'blockType':
111
+ // Instance paths carry no discriminator; the item knows its own type.
112
+ break;
113
+ case 'index':
114
+ out += `[${segment.index}]`;
115
+ break;
116
+ case 'id':
117
+ out += `[id=${segment.id}]`;
118
+ break;
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+ /**
124
+ * Drop the item selectors from a path, leaving the declaration it addresses.
125
+ *
126
+ * This is the projection several call sites hand-roll with a regex today.
127
+ * Doing it over segments rather than raw text is what makes it safe: a field
128
+ * legitimately named `0` is a `field` segment and survives, where a
129
+ * string-level `/^\d+$/` filter would silently delete it.
130
+ */
131
+ export function toDeclarationSegments(segments) {
132
+ return segments.filter((segment) => segment.kind === 'field' || segment.kind === 'blockType');
133
+ }
@@ -0,0 +1,8 @@
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
+ export {};
@@ -0,0 +1,267 @@
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 { prepareHookAttachment } from '../config/attach-hooks.js';
10
+ import { validateAdminConfigs, validateBlockAdminConfigs, } from '../config/validate-admin-configs.js';
11
+ import { validateCollections } from '../config/validate-collections.js';
12
+ import { parsePatchPath } from '../patches/apply-patches.js';
13
+ // ---------------------------------------------------------------------------
14
+ // Characterization tests for Byline's field-path notations.
15
+ //
16
+ // Several subsystems address fields by dotted path, and they do NOT all use
17
+ // the same grammar. This file pins the CURRENT behaviour of each so that the
18
+ // reconciliation work (see PATH-GRAMMAR-ANALYSIS-AND-RECONCILIATION.md) either
19
+ // preserves it or changes it visibly and on purpose.
20
+ //
21
+ // The organising model — the two categories these dialects fall into:
22
+ //
23
+ // * INSTANCE paths address a value in one item of one document. Indices are
24
+ // required. The block type is redundant: the item carries its own `_type`.
25
+ // * DECLARATION paths address a field declaration in the schema. There are
26
+ // no indices. The block type IS required — without it, two blocks in the
27
+ // same field that declare the same field name are indistinguishable.
28
+ //
29
+ // The dialects do not all agree yet. Where one is deliberately narrower than
30
+ // the grammar (admin `fields{}` barring block traversal) the comment says so;
31
+ // where one is still being brought into line, it cites the phase that moves
32
+ // it. A failure here means a dialect changed — check it was on purpose.
33
+ // ---------------------------------------------------------------------------
34
+ /**
35
+ * One fixture, exercising every structural combination the dialects differ
36
+ * over: array→group nesting, a blocks field, an array inside a block, an
37
+ * upload field inside a block, and — critically — two block types that each
38
+ * declare a field named `alt`, which is what makes an unqualified path
39
+ * ambiguous.
40
+ */
41
+ const fixture = (overrides = {}) => ({
42
+ path: 'pages',
43
+ labels: { singular: 'Page', plural: 'Pages' },
44
+ useAsTitle: 'title',
45
+ fields: [
46
+ { name: 'title', label: 'Title', type: 'text' },
47
+ {
48
+ name: 'files',
49
+ label: 'Files',
50
+ type: 'array',
51
+ fields: [
52
+ {
53
+ name: 'filesGroup',
54
+ type: 'group',
55
+ fields: [
56
+ { name: 'publicationFile', label: 'File', type: 'file', upload: {} },
57
+ { name: 'caption', label: 'Caption', type: 'text' },
58
+ ],
59
+ },
60
+ ],
61
+ },
62
+ {
63
+ name: 'content',
64
+ label: 'Content',
65
+ type: 'blocks',
66
+ blocks: [
67
+ {
68
+ blockType: 'photoBlock',
69
+ fields: [
70
+ { name: 'display', label: 'Display', type: 'text' },
71
+ {
72
+ name: 'gallery',
73
+ label: 'Gallery',
74
+ type: 'array',
75
+ fields: [
76
+ {
77
+ name: 'alt',
78
+ label: 'Alt',
79
+ type: 'text',
80
+ ...(overrides.altVirtual === true ? { virtual: true } : {}),
81
+ },
82
+ { name: 'heroImage', label: 'Hero', type: 'image', upload: {} },
83
+ ],
84
+ },
85
+ ],
86
+ },
87
+ // Same leaf name (`alt`) as photoBlock's gallery child. This is the
88
+ // collision that an unqualified declaration path cannot resolve.
89
+ {
90
+ blockType: 'videoBlock',
91
+ fields: [{ name: 'alt', label: 'Alt', type: 'text' }],
92
+ },
93
+ ],
94
+ },
95
+ ],
96
+ });
97
+ // ---------------------------------------------------------------------------
98
+ // Dialect 1 — upload hook registry keys (DECLARATION paths)
99
+ //
100
+ // Produced by `indexUploadFields` (config/attach-hooks.ts), consumed as the
101
+ // keys of `ServerConfig.hooks.uploads`. Collection-prefixed, index-free, and
102
+ // block types are named explicitly. This is the only dialect that gets
103
+ // declaration paths fully right today.
104
+ // ---------------------------------------------------------------------------
105
+ describe('path dialect — upload hook registry keys', () => {
106
+ const register = (key) => () => prepareHookAttachment({
107
+ collections: [fixture()],
108
+ hooks: { uploads: { [key]: async () => ({}) } },
109
+ });
110
+ // Rejections assert on the message, not merely that something threw — a
111
+ // bare try/catch would let an unrelated failure masquerade as the
112
+ // path-resolution rejection under test.
113
+ const unresolved = /references unknown or non-upload field/;
114
+ it('addresses a nested upload field with a collection-prefixed declaration path', () => {
115
+ expect(register('pages.files.filesGroup.publicationFile')).not.toThrow();
116
+ });
117
+ it('qualifies a path through a blocks field with the block type', () => {
118
+ // The other half of the elision claim. `storage-paths.test.node.ts` in
119
+ // @byline/db-postgres derives this exact declaration path from real
120
+ // flattener output by dropping index segments; here the real registry
121
+ // accepts it. Neither package can import the other's half, so the two
122
+ // tests meet at this literal.
123
+ expect(register('pages.content.photoBlock.gallery.heroImage')).not.toThrow();
124
+ });
125
+ it('rejects the same path with the block type omitted', () => {
126
+ expect(register('pages.content.gallery.heroImage')).toThrow(unresolved);
127
+ });
128
+ it('rejects item indices — these address declarations, not instances', () => {
129
+ expect(register('pages.content.0.photoBlock.gallery.heroImage')).toThrow(unresolved);
130
+ });
131
+ });
132
+ // ---------------------------------------------------------------------------
133
+ // Dialect 2 — admin `fields{}` override keys (DECLARATION paths, blocks barred)
134
+ //
135
+ // Consumed by `resolveSchemaPath` (config/validate-admin-configs.ts). Same
136
+ // declaration-addressing as the registry, plus one deliberate policy: keys
137
+ // may not traverse a `blocks` field at all, because fields inside a block
138
+ // take their overrides from the blockType-keyed `blockAdmin` registry so that
139
+ // one registration applies wherever the block renders.
140
+ // ---------------------------------------------------------------------------
141
+ describe('path dialect — admin fields{} override keys', () => {
142
+ const admin = (key) => ({ slug: 'pages', fields: { [key]: {} } });
143
+ const validate = (key) => () => validateAdminConfigs([admin(key)], [fixture()]);
144
+ it('accepts a top-level field name', () => {
145
+ expect(validate('title')).not.toThrow();
146
+ });
147
+ it('accepts a declaration path through array and group structure', () => {
148
+ expect(validate('files.filesGroup.caption')).not.toThrow();
149
+ });
150
+ it('rejects an item index, naming the instance-path confusion', () => {
151
+ expect(validate('files[0].filesGroup.caption')).toThrow(/index-free schema paths/);
152
+ });
153
+ it('rejects traversal into a block even when correctly block-qualified', () => {
154
+ expect(validate('content.photoBlock.alt')).toThrow(/blockAdmin/);
155
+ });
156
+ it('rejects traversal into a block when unqualified', () => {
157
+ expect(validate('content.alt')).toThrow(/blockAdmin/);
158
+ });
159
+ it('rejects a path that walks through a value field', () => {
160
+ expect(validate('title.anything')).toThrow(/does not resolve to a field declaration/);
161
+ });
162
+ });
163
+ describe('path dialect — block admin fields{} override keys', () => {
164
+ const validate = (blockType, key) => () => validateBlockAdminConfigs([{ blockType, fields: { [key]: {} } }], [fixture()]);
165
+ it('accepts a top-level field name of the block', () => {
166
+ expect(validate('photoBlock', 'display')).not.toThrow();
167
+ });
168
+ it('accepts a declaration path through the block’s array structure', () => {
169
+ expect(validate('photoBlock', 'gallery.alt')).not.toThrow();
170
+ });
171
+ it('rejects a key that does not resolve within the block', () => {
172
+ expect(validate('photoBlock', 'gallery.missing')).toThrow(/does not resolve to a field declaration/);
173
+ });
174
+ it('resolves keys relative to the block root, not the collection', () => {
175
+ // `content.photoBlock.gallery.alt` is the collection-rooted path; from the
176
+ // block root the same field is just `gallery.alt`.
177
+ expect(validate('photoBlock', 'content.photoBlock.gallery.alt')).toThrow(/does not resolve to a field declaration/);
178
+ });
179
+ });
180
+ // ---------------------------------------------------------------------------
181
+ // Dialect 3 — boot-validation error message paths (DECLARATION paths)
182
+ //
183
+ // Produced by `walkFieldsWithPath` (config/validate-collections.ts) and
184
+ // interpolated into user-facing error messages. Now delegates to the shared
185
+ // grammar's `walkFieldDeclarations`, so it agrees with the upload registry.
186
+ // ---------------------------------------------------------------------------
187
+ describe('path dialect — boot-validation error message paths', () => {
188
+ /** The field path quoted in the virtual-field error, or null. */
189
+ const pathInError = () => {
190
+ try {
191
+ validateCollections([fixture({ altVirtual: true })]);
192
+ return null;
193
+ }
194
+ catch (error) {
195
+ const match = error.message.match(/virtual field "([^"]+)"/);
196
+ return match?.[1] ?? null;
197
+ }
198
+ };
199
+ it('qualifies the path with the block type', () => {
200
+ expect(pathInError()).toBe('content.photoBlock.gallery.alt');
201
+ });
202
+ it('identifies which block declaration is at fault', () => {
203
+ // The fixture declares `alt` in both photoBlock and videoBlock. Before the
204
+ // block type was carried through, both rendered as `content.alt` and the
205
+ // message could not say which declaration it meant.
206
+ expect(pathInError()).toContain('photoBlock');
207
+ });
208
+ });
209
+ // ---------------------------------------------------------------------------
210
+ // Dialect 4 — patch paths (INSTANCE paths)
211
+ //
212
+ // Parsed by `parsePatchPath` (patches/apply-patches.ts). Bracket indices, or
213
+ // `[id=…]` for stable item identity. Blocks are transparent: no block-type
214
+ // segment, because the addressed item carries its own `_type`.
215
+ // ---------------------------------------------------------------------------
216
+ describe('path dialect — patch paths', () => {
217
+ it('addresses array items by positional bracket index', () => {
218
+ expect(parsePatchPath('content[0].gallery[1].alt')).toEqual([
219
+ { kind: 'field', key: 'content' },
220
+ { kind: 'index', index: 0 },
221
+ { kind: 'field', key: 'gallery' },
222
+ { kind: 'index', index: 1 },
223
+ { kind: 'field', key: 'alt' },
224
+ ]);
225
+ });
226
+ it('addresses array items by stable id', () => {
227
+ expect(parsePatchPath('content[id=abc].gallery[id=def].alt')).toEqual([
228
+ { kind: 'field', key: 'content' },
229
+ { kind: 'id', id: 'abc' },
230
+ { kind: 'field', key: 'gallery' },
231
+ { kind: 'id', id: 'def' },
232
+ { kind: 'field', key: 'alt' },
233
+ ]);
234
+ });
235
+ it('carries no block-type segment — the item resolves its own type', () => {
236
+ const segments = parsePatchPath('content[0].alt');
237
+ expect(segments).not.toContainEqual({ kind: 'field', key: 'photoBlock' });
238
+ });
239
+ it('cannot consume a storage path — dotted indices become field names', () => {
240
+ // Storage emits `content.0.photoBlock.display`. Fed to the patch parser,
241
+ // the index and block type silently become field keys. Not currently
242
+ // reachable in product code, but nothing prevents it.
243
+ expect(parsePatchPath('content.0.photoBlock.display')).toEqual([
244
+ { kind: 'field', key: 'content' },
245
+ { kind: 'field', key: '0' },
246
+ { kind: 'field', key: 'photoBlock' },
247
+ { kind: 'field', key: 'display' },
248
+ ]);
249
+ });
250
+ });
251
+ // ---------------------------------------------------------------------------
252
+ // Cross-dialect — the relationship the reconciliation is built on
253
+ // ---------------------------------------------------------------------------
254
+ describe('path dialects — cross-dialect relationships', () => {
255
+ it('registry keys and admin keys agree on non-block structure', () => {
256
+ // The same declaration, addressed by two dialects, differs only by the
257
+ // collection prefix — no divergence in how group/array nesting is spelled.
258
+ const registryKey = 'pages.files.filesGroup.publicationFile';
259
+ const adminKey = 'files.filesGroup.publicationFile';
260
+ expect(registryKey).toBe(`pages.${adminKey}`);
261
+ expect(() => prepareHookAttachment({
262
+ collections: [fixture()],
263
+ hooks: { uploads: { [registryKey]: async () => ({}) } },
264
+ })).not.toThrow();
265
+ expect(() => validateAdminConfigs([{ slug: 'pages', fields: { [adminKey]: {} } }], [fixture()])).not.toThrow();
266
+ });
267
+ });
@@ -0,0 +1,86 @@
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 { Field } from '../@types/index.js';
9
+ /**
10
+ * One step along a field path.
11
+ *
12
+ * `field` and `blockType` are indistinguishable in source text — both are
13
+ * bare identifiers. Parsing is schema-unaware and therefore produces only
14
+ * `field` segments; `resolveDeclarationPath` reclassifies them against a
15
+ * field set. Producers walking a schema (see `walkFieldDeclarations`) know
16
+ * the difference and emit the correct kind directly.
17
+ */
18
+ export type PathSegment = {
19
+ readonly kind: 'field';
20
+ readonly name: string;
21
+ } | {
22
+ readonly kind: 'blockType';
23
+ readonly blockType: string;
24
+ } | {
25
+ readonly kind: 'index';
26
+ readonly index: number;
27
+ } | {
28
+ readonly kind: 'id';
29
+ readonly id: string;
30
+ };
31
+ /** Why a path string failed to parse. */
32
+ export type PathParseFailure =
33
+ /** The whole path was empty or whitespace. */
34
+ 'empty'
35
+ /** A segment between dots was empty (`a..b`, `.a`, `a.`). */
36
+ | 'emptySegment'
37
+ /** A declaration path carried an item index (`files[0].caption`). */
38
+ | 'index'
39
+ /** Bracket syntax present but unparseable (`a[`, `a[]`, `a[x]`). */
40
+ | 'malformed';
41
+ export type PathParseResult = {
42
+ readonly ok: true;
43
+ readonly segments: readonly PathSegment[];
44
+ } | {
45
+ readonly ok: false;
46
+ readonly reason: PathParseFailure;
47
+ };
48
+ /**
49
+ * Outcome of resolving a declaration path against a field set.
50
+ *
51
+ * `blocks` is distinct from `unresolved` because the two warrant different
52
+ * guidance: a path that correctly names a block type but is used where block
53
+ * traversal is barred should point the author at the `blockAdmin` registry,
54
+ * not tell them their path is wrong.
55
+ */
56
+ export type PathResolution =
57
+ /** Resolved to a field declaration. `segments` carry block types correctly classified. */
58
+ {
59
+ readonly status: 'ok';
60
+ readonly field: Field;
61
+ readonly segments: readonly PathSegment[];
62
+ }
63
+ /** Traversal reached a `blocks` field while `blocks: 'forbidden'` was in effect. */
64
+ | {
65
+ readonly status: 'blocks';
66
+ readonly at: number;
67
+ }
68
+ /** A segment named nothing, or a value field appeared mid-path. */
69
+ | {
70
+ readonly status: 'unresolved';
71
+ readonly at: number;
72
+ };
73
+ export interface ResolveOptions {
74
+ /**
75
+ * How to treat a `blocks` field encountered mid-path.
76
+ *
77
+ * - `'qualified'` (default) — descend, consuming the next segment as a
78
+ * block type. This is the grammar's normal behaviour, used by the upload
79
+ * hook registry.
80
+ * - `'forbidden'` — stop and report `status: 'blocks'`. Used by the admin
81
+ * `fields{}` maps, where per-field overrides inside a block belong to the
82
+ * blockType-keyed `blockAdmin` registry so that one registration applies
83
+ * wherever the block renders.
84
+ */
85
+ readonly blocks?: 'qualified' | 'forbidden';
86
+ }
@@ -0,0 +1,8 @@
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
+ export {};
@@ -0,0 +1,8 @@
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
+ export {};