@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.
- package/dist/@types/collection-types.d.ts +9 -3
- package/dist/config/attach-hooks.js +19 -24
- package/dist/config/validate-admin-configs.js +17 -51
- package/dist/config/validate-collections.js +63 -25
- package/dist/config/validate-collections.test.node.js +63 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/patches/apply-patches.js +20 -29
- package/dist/patches/patch.test.node.js +49 -1
- package/dist/paths/index.d.ts +16 -0
- package/dist/paths/index.js +15 -0
- package/dist/paths/parse-path.d.ts +44 -0
- package/dist/paths/parse-path.js +133 -0
- package/dist/paths/path-dialects.test.node.d.ts +8 -0
- package/dist/paths/path-dialects.test.node.js +267 -0
- package/dist/paths/path-types.d.ts +86 -0
- package/dist/paths/path-types.js +8 -0
- package/dist/paths/paths.test.node.d.ts +8 -0
- package/dist/paths/paths.test.node.js +318 -0
- package/dist/paths/resolve-path.d.ts +47 -0
- package/dist/paths/resolve-path.js +116 -0
- package/dist/services/assign-counter-values.js +9 -3
- package/package.json +2 -2
|
@@ -1152,9 +1152,15 @@ export interface CollectionDefinition {
|
|
|
1152
1152
|
*
|
|
1153
1153
|
* Receives the same minimal document shape as
|
|
1154
1154
|
* `CollectionAdminConfig.preview.url` (`PreviewDocument`-style envelope:
|
|
1155
|
-
* top-level columns plus `fields`) so the two hooks can share an
|
|
1156
|
-
* implementation
|
|
1157
|
-
* `
|
|
1155
|
+
* top-level columns plus `fields`), so the two hooks can share an
|
|
1156
|
+
* implementation. `preview.url` already defaults to this hook when
|
|
1157
|
+
* omitted — see `resolvePreviewUrl` in `@byline/host-tanstack-start` —
|
|
1158
|
+
* so the admin Preview button and the public path agree by construction.
|
|
1159
|
+
*
|
|
1160
|
+
* Note the `null` return is read differently by the two surfaces:
|
|
1161
|
+
* `resolvePreviewUrl` hides the Preview affordance, while the richtext
|
|
1162
|
+
* walker treats it as "no path could be built" and falls through to the
|
|
1163
|
+
* generic `/${collectionPath}/${path}` compose.
|
|
1158
1164
|
*/
|
|
1159
1165
|
buildDocumentPath?: (doc: {
|
|
1160
1166
|
id: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatDeclarationPath, walkFieldDeclarations } from '../paths/index.js';
|
|
1
2
|
const registryCollectionHooks = new WeakMap();
|
|
2
3
|
const registryUploadHooks = new WeakMap();
|
|
3
4
|
/**
|
|
@@ -97,31 +98,25 @@ function assertOwnershipIntact(label, current, owned) {
|
|
|
97
98
|
}
|
|
98
99
|
function indexUploadFields(collection, uploadFields) {
|
|
99
100
|
const leafPaths = new Map();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
throw new Error(`Collection "${collection.path}" has duplicate upload-capable leaf name "${field.name}" at "${previous}" and "${canonical}". Upload leaf names must be unique within a collection.`);
|
|
109
|
-
}
|
|
110
|
-
leafPaths.set(field.name, canonical);
|
|
111
|
-
uploadFields.set(canonical, field);
|
|
112
|
-
}
|
|
113
|
-
if (field.type === 'group' || field.type === 'array') {
|
|
114
|
-
walk(field.fields, path);
|
|
115
|
-
}
|
|
116
|
-
else if (field.type === 'blocks') {
|
|
117
|
-
for (const block of field.blocks) {
|
|
118
|
-
assertDotFreeSegment(block.blockType, `block type "${block.blockType}" in collection "${collection.path}"`);
|
|
119
|
-
walk(block.fields, [...path, block.blockType]);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
101
|
+
walkFieldDeclarations(collection.fields, (field, segments) => {
|
|
102
|
+
assertDotFreeSegment(field.name, `field name "${field.name}" in collection "${collection.path}"`);
|
|
103
|
+
if ((field.type !== 'file' && field.type !== 'image') || field.upload === undefined)
|
|
104
|
+
return;
|
|
105
|
+
const canonical = `${collection.path}.${formatDeclarationPath(segments)}`;
|
|
106
|
+
const previous = leafPaths.get(field.name);
|
|
107
|
+
if (previous !== undefined) {
|
|
108
|
+
throw new Error(`Collection "${collection.path}" has duplicate upload-capable leaf name "${field.name}" at "${previous}" and "${canonical}". Upload leaf names must be unique within a collection.`);
|
|
122
109
|
}
|
|
123
|
-
|
|
124
|
-
|
|
110
|
+
leafPaths.set(field.name, canonical);
|
|
111
|
+
uploadFields.set(canonical, field);
|
|
112
|
+
}, {
|
|
113
|
+
// Every block is checked, including one declaring no fields — such a
|
|
114
|
+
// block never reaches the field visitor, so inferring block types from
|
|
115
|
+
// field segments alone would silently stop validating them.
|
|
116
|
+
onBlock: (block) => {
|
|
117
|
+
assertDotFreeSegment(block.blockType, `block type "${block.blockType}" in collection "${collection.path}"`);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
125
120
|
}
|
|
126
121
|
function assertDotFreeSegment(segment, label) {
|
|
127
122
|
if (segment.length === 0 || segment.includes('.')) {
|
|
@@ -5,40 +5,18 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { resolveDeclarationPath, walkFieldDeclarations, } from '../paths/index.js';
|
|
8
9
|
/**
|
|
9
|
-
* Resolve a
|
|
10
|
-
* against a field set. Schema paths address field *declarations*: every
|
|
11
|
-
* segment names a field, intermediate segments must be `group` / `array`
|
|
12
|
-
* structure fields, and no segment carries an item index. Returns:
|
|
10
|
+
* Resolve a `fields{}` override key against a field set.
|
|
13
11
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* appears mid-path.
|
|
12
|
+
* Admin override keys are declaration paths with one deliberate narrowing:
|
|
13
|
+
* they may not traverse a `blocks` field at all, even correctly qualified by
|
|
14
|
+
* block type. Fields inside a block take their overrides from the
|
|
15
|
+
* blockType-keyed `blockAdmin` registry, so that one registration applies
|
|
16
|
+
* wherever the block renders.
|
|
20
17
|
*/
|
|
21
18
|
function resolveSchemaPath(fields, path) {
|
|
22
|
-
|
|
23
|
-
let current = fields;
|
|
24
|
-
for (let i = 0; i < segments.length; i++) {
|
|
25
|
-
const name = segments[i];
|
|
26
|
-
const field = current.find((f) => 'name' in f && f.name === name);
|
|
27
|
-
if (field == null)
|
|
28
|
-
return 'unresolved';
|
|
29
|
-
if (i === segments.length - 1)
|
|
30
|
-
return 'ok';
|
|
31
|
-
if (field.type === 'group' || field.type === 'array') {
|
|
32
|
-
current = field.fields;
|
|
33
|
-
}
|
|
34
|
-
else if (field.type === 'blocks') {
|
|
35
|
-
return 'blocks';
|
|
36
|
-
}
|
|
37
|
-
else {
|
|
38
|
-
return 'unresolved';
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return 'unresolved';
|
|
19
|
+
return resolveDeclarationPath(fields, path, { blocks: 'forbidden' }).status;
|
|
42
20
|
}
|
|
43
21
|
/**
|
|
44
22
|
* Shared validation for a `fields{}` override map (collection- or block-level):
|
|
@@ -125,28 +103,16 @@ export function validateBlockAdminConfigs(blockAdmins, collections) {
|
|
|
125
103
|
// between same-blockType declarations is possible, so keys validate against
|
|
126
104
|
// the union of sites.
|
|
127
105
|
const blocksByType = new Map();
|
|
128
|
-
const walkBlock = (block) => {
|
|
129
|
-
let sites = blocksByType.get(block.blockType);
|
|
130
|
-
if (sites == null) {
|
|
131
|
-
sites = [];
|
|
132
|
-
blocksByType.set(block.blockType, sites);
|
|
133
|
-
}
|
|
134
|
-
sites.push(block);
|
|
135
|
-
walkFields(block.fields);
|
|
136
|
-
};
|
|
137
|
-
const walkFields = (fields) => {
|
|
138
|
-
for (const field of fields) {
|
|
139
|
-
if (field.type === 'blocks') {
|
|
140
|
-
for (const block of field.blocks)
|
|
141
|
-
walkBlock(block);
|
|
142
|
-
}
|
|
143
|
-
else if ('fields' in field && Array.isArray(field.fields)) {
|
|
144
|
-
walkFields(field.fields);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
};
|
|
148
106
|
for (const collection of collections) {
|
|
149
|
-
|
|
107
|
+
walkFieldDeclarations(collection.fields, () => { }, {
|
|
108
|
+
onBlock: (block) => {
|
|
109
|
+
const sites = blocksByType.get(block.blockType);
|
|
110
|
+
if (sites == null)
|
|
111
|
+
blocksByType.set(block.blockType, [block]);
|
|
112
|
+
else
|
|
113
|
+
sites.push(block);
|
|
114
|
+
},
|
|
115
|
+
});
|
|
150
116
|
}
|
|
151
117
|
const seen = new Set();
|
|
152
118
|
for (const entry of blockAdmins) {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { formatDeclarationPath, walkFieldDeclarations } from '../paths/index.js';
|
|
8
9
|
import { fieldTypeToStore } from '../storage/field-store-map.js';
|
|
9
10
|
/**
|
|
10
11
|
* Field names that cannot be declared in a collection schema because they
|
|
@@ -61,37 +62,27 @@ function hasLocalizedField(fields) {
|
|
|
61
62
|
});
|
|
62
63
|
return found;
|
|
63
64
|
}
|
|
65
|
+
/** Every field declaration, for checks that don't care where it sits. */
|
|
64
66
|
function walkFields(fields, visit) {
|
|
65
|
-
|
|
67
|
+
walkFieldDeclarations(fields, (field) => {
|
|
66
68
|
visit(field);
|
|
67
|
-
|
|
68
|
-
walkFields(field.fields, visit);
|
|
69
|
-
}
|
|
70
|
-
else if (field.type === 'blocks') {
|
|
71
|
-
for (const block of field.blocks) {
|
|
72
|
-
walkFields(block.fields, visit);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
69
|
+
});
|
|
76
70
|
}
|
|
77
71
|
/**
|
|
78
|
-
* Path-aware variant of `walkFields` — visits every field with its
|
|
79
|
-
*
|
|
72
|
+
* Path-aware variant of `walkFields` — visits every field with its
|
|
73
|
+
* declaration path (e.g. `files.filesGroup.generateThumbnail`) so validation
|
|
80
74
|
* errors can point at the exact declaration site.
|
|
75
|
+
*
|
|
76
|
+
* Delegates to the shared grammar's canonical walk. It previously descended
|
|
77
|
+
* into blocks without recording the block type, which made the emitted path
|
|
78
|
+
* ambiguous whenever two blocks in one field declared the same field name —
|
|
79
|
+
* both rendered as `content.alt`, and the message could not say which was at
|
|
80
|
+
* fault. `walkFieldDeclarations` carries the block type through.
|
|
81
81
|
*/
|
|
82
|
-
function walkFieldsWithPath(fields, visit
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (field.type === 'group' || field.type === 'array') {
|
|
87
|
-
walkFieldsWithPath(field.fields, visit, fieldPath);
|
|
88
|
-
}
|
|
89
|
-
else if (field.type === 'blocks') {
|
|
90
|
-
for (const block of field.blocks) {
|
|
91
|
-
walkFieldsWithPath(block.fields, visit, fieldPath);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
82
|
+
function walkFieldsWithPath(fields, visit) {
|
|
83
|
+
walkFieldDeclarations(fields, (field, segments) => {
|
|
84
|
+
visit(field, formatDeclarationPath(segments));
|
|
85
|
+
});
|
|
95
86
|
}
|
|
96
87
|
/**
|
|
97
88
|
* Top-level field names a collection's `search` config references (body +
|
|
@@ -114,6 +105,52 @@ function searchReferencedFieldNames(collection) {
|
|
|
114
105
|
}
|
|
115
106
|
return names;
|
|
116
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Enforce that every field named by a collection's `search` config resolves.
|
|
110
|
+
*
|
|
111
|
+
* `buildSearchDocument` looks each name up among the collection's **top-level**
|
|
112
|
+
* fields and silently skips anything it cannot resolve, so an unrecognised or
|
|
113
|
+
* dotted name produced a collection that indexed less than its author thought
|
|
114
|
+
* — with no error at boot and no signal at index time.
|
|
115
|
+
*
|
|
116
|
+
* Nested content is reachable, but by naming its top-level *container*: a
|
|
117
|
+
* `group` / `array` / `blocks` field named in `search.body` is walked
|
|
118
|
+
* recursively and every nested text leaf is flattened into the body. What is
|
|
119
|
+
* not supported is addressing one specific nested declaration, which is why a
|
|
120
|
+
* dotted path is rejected outright rather than accepted and ignored.
|
|
121
|
+
*/
|
|
122
|
+
function validateSearchFields(collection) {
|
|
123
|
+
const search = collection.search;
|
|
124
|
+
if (search == null)
|
|
125
|
+
return;
|
|
126
|
+
const topLevel = (name) => collection.fields.find((f) => 'name' in f && f.name === name);
|
|
127
|
+
const check = (name, option) => {
|
|
128
|
+
if (name.includes('.')) {
|
|
129
|
+
throw new Error(`Collection "${collection.path}" names '${name}' in \`search.${option}\` but search config addresses top-level fields only. To index content nested inside a \`group\` / \`array\` / \`blocks\` field, name the top-level container — it is walked recursively and every nested text leaf is flattened into the body.`);
|
|
130
|
+
}
|
|
131
|
+
const field = topLevel(name);
|
|
132
|
+
if (field == null) {
|
|
133
|
+
throw new Error(`Collection "${collection.path}" names '${name}' in \`search.${option}\` but no top-level field with that name exists.`);
|
|
134
|
+
}
|
|
135
|
+
return field;
|
|
136
|
+
};
|
|
137
|
+
for (const decl of search.body ?? []) {
|
|
138
|
+
check(typeof decl === 'string' ? decl : decl.field, 'body');
|
|
139
|
+
}
|
|
140
|
+
for (const decl of search.facets ?? []) {
|
|
141
|
+
const name = typeof decl === 'string' ? decl : decl.field;
|
|
142
|
+
const field = check(name, 'facets');
|
|
143
|
+
if (field.type !== 'relation') {
|
|
144
|
+
throw new Error(`Collection "${collection.path}" names '${name}' in \`search.facets\` but field "${name}" has type "${field.type}". Facets are controlled-vocabulary references — name a \`relation\` field.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const name of search.filters ?? []) {
|
|
148
|
+
const field = check(name, 'filters');
|
|
149
|
+
if (field.type === 'group' || field.type === 'array' || field.type === 'blocks') {
|
|
150
|
+
throw new Error(`Collection "${collection.path}" names '${name}' in \`search.filters\` but field "${name}" has type "${field.type}". Filters project a single scalar value — name a value field.`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
117
154
|
/**
|
|
118
155
|
* Enforce the `virtual` field constraints for one collection. See
|
|
119
156
|
* `BaseField.virtual` in field-types.ts for the contract these rules back:
|
|
@@ -236,6 +273,7 @@ export function validateCollections(collections) {
|
|
|
236
273
|
if (collection.tree === true && collection.orderable === true) {
|
|
237
274
|
throw new Error(`Collection "${collection.path}" sets both \`tree: true\` and \`orderable: true\`. A document-tree collection owns ordering on the tree edge (per-parent), so \`byline_documents.order_key\` is inert — set only \`tree: true\`.`);
|
|
238
275
|
}
|
|
276
|
+
validateSearchFields(collection);
|
|
239
277
|
validateVirtualFields(collection);
|
|
240
278
|
validateUploadLocations(collection);
|
|
241
279
|
}
|
|
@@ -510,6 +510,68 @@ describe('validateCollections', () => {
|
|
|
510
510
|
},
|
|
511
511
|
],
|
|
512
512
|
};
|
|
513
|
-
|
|
513
|
+
// The path names the block type, so the message identifies which block's
|
|
514
|
+
// `file` declaration is at fault rather than just `content.file`.
|
|
515
|
+
expect(() => validateCollections([collection])).toThrow(/content\.attachmentsBlock\.file.*must not contain duplicate slashes/s);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// search config field resolution
|
|
520
|
+
//
|
|
521
|
+
// `buildSearchDocument` resolves each name among the collection's top-level
|
|
522
|
+
// fields and silently skips what it cannot resolve, so an unrecognised name
|
|
523
|
+
// used to produce a collection that indexed less than its author intended,
|
|
524
|
+
// with no signal at boot or at index time.
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
describe('validateCollections — search config fields', () => {
|
|
527
|
+
const searchable = (search) => ({
|
|
528
|
+
...baseCollection,
|
|
529
|
+
search,
|
|
530
|
+
fields: [
|
|
531
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
532
|
+
{ name: 'summary', label: 'Summary', type: 'text' },
|
|
533
|
+
{ name: 'featured', label: 'Featured', type: 'checkbox' },
|
|
534
|
+
{ name: 'topics', label: 'Topics', type: 'relation', targetCollection: 'topics' },
|
|
535
|
+
{ name: 'format', label: 'Format', type: 'select', options: [] },
|
|
536
|
+
{
|
|
537
|
+
name: 'files',
|
|
538
|
+
label: 'Files',
|
|
539
|
+
type: 'array',
|
|
540
|
+
fields: [
|
|
541
|
+
{
|
|
542
|
+
name: 'filesGroup',
|
|
543
|
+
type: 'group',
|
|
544
|
+
fields: [{ name: 'caption', label: 'Caption', type: 'text' }],
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
});
|
|
550
|
+
it('accepts top-level names, including a container in body', () => {
|
|
551
|
+
expect(() => validateCollections([
|
|
552
|
+
searchable({
|
|
553
|
+
body: [{ field: 'title', boost: 2 }, 'summary', 'files'],
|
|
554
|
+
facets: ['topics'],
|
|
555
|
+
filters: ['featured'],
|
|
556
|
+
}),
|
|
557
|
+
])).not.toThrow();
|
|
558
|
+
});
|
|
559
|
+
it('rejects a body name that does not exist', () => {
|
|
560
|
+
expect(() => validateCollections([searchable({ body: ['titel'] })])).toThrow(/no top-level field with that name exists/);
|
|
561
|
+
});
|
|
562
|
+
it('rejects a dotted path and explains what to name instead', () => {
|
|
563
|
+
// Accepting it would be worse than rejecting: the indexer would resolve
|
|
564
|
+
// nothing and silently index an empty body.
|
|
565
|
+
expect(() => validateCollections([searchable({ body: ['files.filesGroup.caption'] })])).toThrow(/top-level fields only.*name the top-level container/s);
|
|
566
|
+
});
|
|
567
|
+
it('rejects a facet that is not a relation', () => {
|
|
568
|
+
expect(() => validateCollections([searchable({ facets: ['format'] })])).toThrow(/Facets are controlled-vocabulary references/);
|
|
569
|
+
});
|
|
570
|
+
it('rejects a filter naming a container field', () => {
|
|
571
|
+
expect(() => validateCollections([searchable({ filters: ['files'] })])).toThrow(/Filters project a single scalar value/);
|
|
572
|
+
});
|
|
573
|
+
it('checks facets and filters too, not only body', () => {
|
|
574
|
+
expect(() => validateCollections([searchable({ facets: ['nope'] })])).toThrow(/no top-level field with that name exists/);
|
|
575
|
+
expect(() => validateCollections([searchable({ filters: ['nope'] })])).toThrow(/no top-level field with that name exists/);
|
|
514
576
|
});
|
|
515
577
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './l
|
|
|
12
12
|
export { type BylineLogger, getLogger } from './lib/logger.js';
|
|
13
13
|
export { AsyncRegistry, type RegisteredServices, Registry } from './lib/registry.js';
|
|
14
14
|
export * from './patches/index.js';
|
|
15
|
+
export * from './paths/index.js';
|
|
15
16
|
export { mergePredicates, type ParseContext, type ParsedSort, type ParsedWhere, parsePredicateFilters, parseSort, parseWhere, } from './query/parse-where.js';
|
|
16
17
|
export { getCollectionSchemasForPath } from './schemas/zod/cache.js';
|
|
17
18
|
export * from './services/index.js';
|
package/dist/index.js
CHANGED
|
@@ -28,6 +28,7 @@ export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './l
|
|
|
28
28
|
export { getLogger } from './lib/logger.js';
|
|
29
29
|
export { AsyncRegistry, Registry } from './lib/registry.js';
|
|
30
30
|
export * from './patches/index.js';
|
|
31
|
+
export * from './paths/index.js';
|
|
31
32
|
export { mergePredicates, parsePredicateFilters, parseSort, parseWhere, } from './query/parse-where.js';
|
|
32
33
|
export { getCollectionSchemasForPath } from './schemas/zod/cache.js';
|
|
33
34
|
export * from './services/index.js';
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// Core implementation of the patch engine
|
|
2
2
|
import { isBlocksField, isStructureField } from '../@types/field-types.js';
|
|
3
|
-
|
|
3
|
+
import { parseInstancePath } from '../paths/parse-path.js';
|
|
4
|
+
// Instance-path grammar — see `@byline/core` `paths/`. Patch paths address
|
|
5
|
+
// items, so they carry selectors and no block type (the item holds `_type`).
|
|
6
|
+
//
|
|
7
|
+
// Historical shapes, unchanged:
|
|
4
8
|
// - "foo.bar" -> [{field: 'foo'}, {field: 'bar'}]
|
|
5
9
|
// - "reviews[0].rating" -> [{field: 'reviews'}, {index: 0}, {field: 'rating'}]
|
|
6
10
|
// - "reviews[id=abc].rating" -> [{field: 'reviews'}, {id: 'abc'}, {field: 'rating'}]
|
|
@@ -8,33 +12,19 @@ import { isBlocksField, isStructureField } from '../@types/field-types.js';
|
|
|
8
12
|
export function parsePatchPath(path) {
|
|
9
13
|
if (!path)
|
|
10
14
|
return [];
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
while (true) {
|
|
25
|
-
match = bracketRegex.exec(part);
|
|
26
|
-
if (match === null)
|
|
27
|
-
break;
|
|
28
|
-
const token = match[1];
|
|
29
|
-
if (/^\d+$/.test(token)) {
|
|
30
|
-
segments.push({ kind: 'index', index: Number.parseInt(token, 10) });
|
|
31
|
-
}
|
|
32
|
-
else if (token.startsWith('id=')) {
|
|
33
|
-
segments.push({ kind: 'id', id: token.slice(3) });
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return segments;
|
|
15
|
+
const parsed = parseInstancePath(String(path));
|
|
16
|
+
// An unparseable path yields no segments, so callers reject the patch
|
|
17
|
+
// rather than applying it somewhere else. The previous hand-rolled parser
|
|
18
|
+
// was lenient — `"a["` produced `[field: 'a']`, and the patch was then
|
|
19
|
+
// applied at `a`, a path the client never addressed. Silent misapplication
|
|
20
|
+
// is a worse failure than a rejected patch.
|
|
21
|
+
if (!parsed.ok)
|
|
22
|
+
return [];
|
|
23
|
+
return parsed.segments.map((segment) => segment.kind === 'field'
|
|
24
|
+
? // The grammar names this `name`; the patch resolver has always called
|
|
25
|
+
// it `key`. Renamed at the boundary rather than churning consumers.
|
|
26
|
+
{ kind: 'field', key: segment.name }
|
|
27
|
+
: segment);
|
|
38
28
|
}
|
|
39
29
|
// Very small, best-effort resolver that walks the CollectionDefinition to find
|
|
40
30
|
// the field targeted by a patch path. This is intentionally conservative and
|
|
@@ -187,7 +177,8 @@ function ensurePath(root, segments) {
|
|
|
187
177
|
function applyFieldPatch(doc, patch) {
|
|
188
178
|
const segments = parsePatchPath(patch.path);
|
|
189
179
|
if (segments.length === 0) {
|
|
190
|
-
|
|
180
|
+
// Covers both an empty path and one that did not parse.
|
|
181
|
+
throw new Error(`Empty or unparseable path for field patch: ${JSON.stringify(patch.path ?? '')}`);
|
|
191
182
|
}
|
|
192
183
|
if (patch.kind === 'field.set') {
|
|
193
184
|
const { parent, key } = ensurePath(doc, segments);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { applyPatches } from './apply-patches.js';
|
|
2
|
+
import { applyPatches, parsePatchPath } from './apply-patches.js';
|
|
3
3
|
const DocsDefinition = {
|
|
4
4
|
path: 'docs',
|
|
5
5
|
labels: {
|
|
@@ -270,3 +270,51 @@ describe('applyPatches', () => {
|
|
|
270
270
|
expect(updated.content[0]?.constrainedWidth).toBe(true);
|
|
271
271
|
});
|
|
272
272
|
});
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Patch paths are instance paths
|
|
275
|
+
//
|
|
276
|
+
// `parsePatchPath` delegates to the shared grammar's `parseInstancePath`
|
|
277
|
+
// (`@byline/core` `paths/`). The two agree on every well-formed path; they
|
|
278
|
+
// differ only in that a malformed one is now rejected rather than partially
|
|
279
|
+
// parsed.
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
describe('parsePatchPath — shared instance-path grammar', () => {
|
|
282
|
+
it('parses positional and id selectors', () => {
|
|
283
|
+
expect(parsePatchPath('content[0].gallery[1].alt')).toEqual([
|
|
284
|
+
{ kind: 'field', key: 'content' },
|
|
285
|
+
{ kind: 'index', index: 0 },
|
|
286
|
+
{ kind: 'field', key: 'gallery' },
|
|
287
|
+
{ kind: 'index', index: 1 },
|
|
288
|
+
{ kind: 'field', key: 'alt' },
|
|
289
|
+
]);
|
|
290
|
+
expect(parsePatchPath('content[id=abc].alt')).toEqual([
|
|
291
|
+
{ kind: 'field', key: 'content' },
|
|
292
|
+
{ kind: 'id', id: 'abc' },
|
|
293
|
+
{ kind: 'field', key: 'alt' },
|
|
294
|
+
]);
|
|
295
|
+
});
|
|
296
|
+
it('carries no block-type segment — the addressed item holds its own `_type`', () => {
|
|
297
|
+
expect(parsePatchPath('content[0].alt')).not.toContainEqual({
|
|
298
|
+
kind: 'field',
|
|
299
|
+
key: 'photoBlock',
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
it('rejects a malformed path instead of parsing it partially', () => {
|
|
303
|
+
// Previously `'a['` yielded `[{ kind: 'field', key: 'a' }]`, so a patch
|
|
304
|
+
// was applied at `a` — a path the client never addressed. Returning no
|
|
305
|
+
// segments makes callers reject the patch instead.
|
|
306
|
+
for (const malformed of ['a[', 'a[]', 'a[x]', 'a]b', 'a..b']) {
|
|
307
|
+
expect(parsePatchPath(malformed)).toEqual([]);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
it('surfaces a malformed path as a patch error rather than a silent write', () => {
|
|
311
|
+
const original = { title: 'original' };
|
|
312
|
+
const { doc, errors } = applyPatches(DocsDefinition, original, [
|
|
313
|
+
{ kind: 'field.set', path: 'title[', value: 'injected' },
|
|
314
|
+
]);
|
|
315
|
+
expect(errors).toHaveLength(1);
|
|
316
|
+
expect(errors[0]?.message).toMatch(/unparseable path/i);
|
|
317
|
+
// The truncated path `title` is never written.
|
|
318
|
+
expect(doc.title).toBe('original');
|
|
319
|
+
});
|
|
320
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
* Field path grammar — one AST, two serialisations.
|
|
10
|
+
*
|
|
11
|
+
* See `path-types.ts` for the instance/declaration model, and
|
|
12
|
+
* `docs/04-collections/01-fields.md` for the author-facing account.
|
|
13
|
+
*/
|
|
14
|
+
export { formatDeclarationPath, formatInstancePath, parseDeclarationPath, parseInstancePath, toDeclarationSegments, } from './parse-path.js';
|
|
15
|
+
export { resolveDeclarationPath, walkFieldDeclarations } from './resolve-path.js';
|
|
16
|
+
export type { PathParseFailure, PathParseResult, PathResolution, PathSegment, ResolveOptions, } from './path-types.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
* Field path grammar — one AST, two serialisations.
|
|
10
|
+
*
|
|
11
|
+
* See `path-types.ts` for the instance/declaration model, and
|
|
12
|
+
* `docs/04-collections/01-fields.md` for the author-facing account.
|
|
13
|
+
*/
|
|
14
|
+
export { formatDeclarationPath, formatInstancePath, parseDeclarationPath, parseInstancePath, toDeclarationSegments, } from './parse-path.js';
|
|
15
|
+
export { resolveDeclarationPath, walkFieldDeclarations } from './resolve-path.js';
|
|
@@ -0,0 +1,44 @@
|
|
|
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 { PathParseResult, PathSegment } from './path-types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Parse a declaration path: dotted, index-free, block types written as plain
|
|
11
|
+
* segments (`content.photoBlock.gallery.alt`).
|
|
12
|
+
*
|
|
13
|
+
* Every segment comes back as `kind: 'field'`. Item indices are a parse
|
|
14
|
+
* error rather than something silently dropped — a caller writing
|
|
15
|
+
* `files[0].caption` where a declaration is expected has made a category
|
|
16
|
+
* mistake, and saying so is more useful than quietly addressing every item.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseDeclarationPath(path: string): PathParseResult;
|
|
19
|
+
/**
|
|
20
|
+
* Parse an instance path: dotted field names with bracket item selectors,
|
|
21
|
+
* either positional (`gallery[1]`) or by stable id (`gallery[id=abc]`).
|
|
22
|
+
*
|
|
23
|
+
* Blocks carry no discriminator here — the addressed item resolves its own
|
|
24
|
+
* `_type` — so a block hop is just the blocks field name followed by an
|
|
25
|
+
* item selector.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseInstancePath(path: string): PathParseResult;
|
|
28
|
+
/**
|
|
29
|
+
* Serialise segments as a declaration path. Index and id segments are
|
|
30
|
+
* dropped, so this doubles as the instance → declaration projection when
|
|
31
|
+
* applied to instance segments.
|
|
32
|
+
*/
|
|
33
|
+
export declare function formatDeclarationPath(segments: readonly PathSegment[]): string;
|
|
34
|
+
/** Serialise segments as an instance path, rendering selectors in brackets. */
|
|
35
|
+
export declare function formatInstancePath(segments: readonly PathSegment[]): string;
|
|
36
|
+
/**
|
|
37
|
+
* Drop the item selectors from a path, leaving the declaration it addresses.
|
|
38
|
+
*
|
|
39
|
+
* This is the projection several call sites hand-roll with a regex today.
|
|
40
|
+
* Doing it over segments rather than raw text is what makes it safe: a field
|
|
41
|
+
* legitimately named `0` is a `field` segment and survives, where a
|
|
42
|
+
* string-level `/^\d+$/` filter would silently delete it.
|
|
43
|
+
*/
|
|
44
|
+
export declare function toDeclarationSegments(segments: readonly PathSegment[]): readonly PathSegment[];
|