@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.
@@ -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 today and `preview.url` can default to
1157
- * `buildDocumentPath` in a later pass.
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
- const walk = (fields, prefix) => {
101
- for (const field of fields) {
102
- assertDotFreeSegment(field.name, `field name "${field.name}" in collection "${collection.path}"`);
103
- const path = [...prefix, field.name];
104
- if ((field.type === 'file' || field.type === 'image') && field.upload !== undefined) {
105
- const canonical = [collection.path, ...path].join('.');
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.`);
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
- walk(collection.fields, []);
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 dotted schema path (`faq.answer`, `files.filesGroup.publicationFile`)
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
- * - `'ok'` — the path resolves to a field declaration;
15
- * - `'blocks'` — the path tries to traverse a `type: 'blocks'` field
16
- * (blocks resolve their own admin config from the
17
- * blockType-keyed registry, so this is always an error);
18
- * - `'unresolved'` a segment doesn't name a field, or a value field
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
- const segments = path.split('.');
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
- walkFields(collection.fields);
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
- for (const field of fields) {
67
+ walkFieldDeclarations(fields, (field) => {
66
68
  visit(field);
67
- if (field.type === 'group' || field.type === 'array') {
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 dotted
79
- * schema path (e.g. `files.filesGroup.generateThumbnail`) so validation
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, prefix = '') {
83
- for (const field of fields) {
84
- const fieldPath = prefix === '' ? field.name : `${prefix}.${field.name}`;
85
- visit(field, fieldPath);
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
- expect(() => validateCollections([collection])).toThrow(/content\.file.*must not contain duplicate slashes/s);
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
- // Very small path grammar:
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 segments = [];
12
- const parts = String(path).split('.');
13
- for (const part of parts) {
14
- const fieldMatch = part.match(/^([^[]+)/);
15
- if (!fieldMatch) {
16
- continue;
17
- }
18
- const field = fieldMatch[1];
19
- segments.push({ kind: 'field', key: field });
20
- const bracketRegex = /\[([^\]]+)\]/g;
21
- let match;
22
- // Biome: avoid assignment in the while condition
23
- // eslint-disable-next-line no-constant-condition
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
- throw new Error('Empty path for field patch');
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[];